diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index 87c849245..e47e509dd 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -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) ) { diff --git a/packages/coding-agent/test/agent-session-retry-cap.test.ts b/packages/coding-agent/test/agent-session-retry-cap.test.ts index 388e971a8..4021bd3ce 100644 --- a/packages/coding-agent/test/agent-session-retry-cap.test.ts +++ b/packages/coding-agent/test/agent-session-retry-cap.test.ts @@ -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");