fix(session): resume Cursor idle stalls after unmarked MCP results

The idle watchdog aborts the request signal and cursor.ts closes
the Connect stream, so there is no in-flight server exec to race.
Unmarked MCP/todo blocks can continue once every emitted call has
a matching result, same as HTTP/2 RST.
This commit is contained in:
bnivanov
2026-08-18 14:48:45 +02:00
parent 271e7ba892
commit f5976d7129
5 changed files with 135 additions and 20 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ Current retryable categories include:
The normalized classifier recognizes the transient categories above from structured flags/status and provider-aware text patterns. Classifier refusals remain a separate typed `stopDetails` decision.
Beyond `isRetryableError(...)`, empty generic aborts may enter the same retry engine when no user, dispose, or streaming-edit-guard abort is in progress. An interrupted turn whose tool calls already have matching results can also be continued safely: the failed assistant/tool-result sequence is preserved so completed side effects are not replayed. Resolved stream stalls and HTTP/2 stream resets (`NGHTTP2_INTERNAL_ERROR`, `NGHTTP2_REFUSED_STREAM`, `HTTP2StreamReset`) use the same preserve-and-continue path. Cursor idle-stall recovery still requires the exec-resolved marker (the Connect stream may still be open); an HTTP/2 RST does not, because the stream is already dead.
Beyond `isRetryableError(...)`, empty generic aborts may enter the same retry engine when no user, dispose, or streaming-edit-guard abort is in progress. An interrupted turn whose tool calls already have matching results can also be continued safely: the failed assistant/tool-result sequence is preserved so completed side effects are not replayed. Resolved stream stalls and HTTP/2 stream resets (`NGHTTP2_INTERNAL_ERROR`, `NGHTTP2_REFUSED_STREAM`, `HTTP2StreamReset`) use the same preserve-and-continue path. Cursor idle-stall recovery continues after every emitted tool call has a result; the Connect stream is already closed by the idle abort. An HTTP/2 RST is the same: the stream is already dead.
Retry state is owned by `TurnRecovery`:
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Resume Cursor idle-stall turns after completed MCP/todo tool results. The watchdog already closes the Connect stream, so unmarked blocks no longer need the `exec-resolved` marker to continue.
## [17.3.7] - 2026-08-17
### Changed
@@ -20,7 +20,6 @@ import type {
} from "@oh-my-pi/pi-ai";
import { calculateRateLimitBackoffMs, parseRateLimitReason } from "@oh-my-pi/pi-ai";
import * as AIError from "@oh-my-pi/pi-ai/error";
import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols";
import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
import { extractRetryHint, logger, prompt } from "@oh-my-pi/pi-utils";
@@ -1177,25 +1176,15 @@ export class TurnRecovery {
if (!reasonlessAbort && !streamStall && !transportReset) return undefined;
if (reasonlessAbort && genericAbort) message.errorId = AIError.create(AIError.Flag.Abort);
// The Cursor server-execution marker gate applies only to the idle 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. An HTTP/2 RST_STREAM / NGHTTP2_* close also ends the Connect
// stream, so there is no in-flight server exec to race — unmarked MCP/todo
// blocks are safe to continue once every emitted call has a result.
// Idle stall and HTTP/2 RST both close the Cursor Connect stream:
// the lazy watchdog aborts the request signal, and cursor.ts then
// calls `h2Request.close()`. There is no in-flight server exec to
// race, so unmarked MCP/todo blocks can continue once every emitted
// call has a matching result. A reasonless abort ends the turn and
// the agent loop pairs leftover calls with `executed: false`.
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)
) {
return undefined;
}
resolvedToolCallIds.push(block.id);
}
if (resolvedToolCallIds.length === 0) return undefined;
@@ -1386,6 +1386,128 @@ describe("AgentSession retry delay cap", () => {
});
});
it("resumes a Cursor idle stall after an unmarked MCP tool result", async () => {
const stallMessage = "Provider stream stalled while waiting for the next event";
const model = createMockModel({
id: "composer-2.5",
provider: "cursor",
});
authStorage.setRuntimeApiKey("cursor", "cursor-test-key");
const toolCall: ToolCall = {
type: "toolCall",
id: "cursor-mcp-idle-1",
name: "mcp__databricks_production_execute_sql",
arguments: { query: "SELECT 1" },
};
const toolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [{ type: "text", text: "1" }],
isError: false,
timestamp: Date.now(),
};
let streamCalls = 0;
let resumedWithToolResult = false;
const agent = new Agent({
getApiKey: requestedModel => `${requestedModel.provider}-test-key`,
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
cursorOnToolResult: message => message,
streamFn: (_requestedModel, context, options) => {
streamCalls += 1;
if (streamCalls > 1) {
resumedWithToolResult = context.messages.some(
message => message.role === "toolResult" && message.toolCallId === toolCall.id,
);
model.push({ content: ["Recovered after Cursor idle stall"] });
return model.stream(model, context, options);
}
const stream = new AssistantMessageEventStream();
queueMicrotask(async () => {
await options?.cursorOnToolResult?.(toolResult);
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: "error",
error: {
...partial,
stopReason: "error",
errorMessage: stallMessage,
},
});
});
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("Run the query");
await session.waitForIdle();
expect(streamCalls).toBe(2);
expect(resumedWithToolResult).toBe(true);
expect(
session.agent.state.messages.some(
message => message.role === "toolResult" && message.toolCallId === toolCall.id,
),
).toBe(true);
expect(retryStartEvents).toHaveLength(1);
expect(retryEndEvents).toContainEqual(expect.objectContaining({ success: true, attempt: 1 }));
expect(lastAssistant(session).content).toContainEqual({
type: "text",
text: "Recovered after Cursor idle stall",
});
});
it("resumes a Cursor reasonless abort after an unmarked client-side tool call", async () => {
const model = createMockModel({
id: "composer-2.5",
@@ -603,10 +603,10 @@ describe("TurnRecovery replay-unsafe output classification", () => {
expect(recovery.classifyResolvedInterruptedToolTurn(message)).toBe("stream-stall");
});
it("does not continue a Cursor idle stall after an unmarked MCP call", () => {
it("continues a Cursor idle stall after an unmarked MCP call", () => {
const message = cursorMessage([mcpToolCall("mcp-1")], stallMessage);
const recovery = recoveryForReset(message, [realResult("mcp-1", "mcp__databricks_production_execute_sql")]);
expect(recovery.classifyResolvedInterruptedToolTurn(message)).toBeUndefined();
expect(recovery.classifyResolvedInterruptedToolTurn(message)).toBe("stream-stall");
});
it("does not continue an HTTP/2 reset whose tool call has no result", () => {