Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/cursor-hang-root-cause.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,26 @@ tokens or headers, cookies, prompt/message text, tool arguments/results, or blob
The parent always drains child stderr even while diagnostics are disabled, preventing pipe
backpressure from stalling the bridge.

## Interaction-query disposition

Adapted with attribution to `tanushshukla/opencode-cursor@a067e099`: an unanswered
`InteractionQuery` can make the Cursor server wait indefinitely while an otherwise valid turn
has already begun. The proxy sends a typed negative response and continues for every generated
query whose response schema has a refusal arm: `webSearchRequestQuery`,
`askQuestionInteractionQuery`, `switchModeRequestQuery`, `exaSearchRequestQuery`,
`exaFetchRequestQuery`, and `createPlanRequestQuery` (the last uses its typed error result).

`setupVmEnvironmentArgs` remains terminal because its generated response schema has only a
success arm: acknowledging it would falsely claim privileged environment setup completed.
`execServerControlMessage` also remains fail-fast because OpenCode has no safe acknowledgement
for Cursor's server-control contract. Exhaustive `never` defaults ensure future generated
variants cannot silently acquire a disposition.

The smoke fixture proves a rejected web-search query continues on the same turn to text and a
clean `turnEnded` terminal. It also proves `execServerControlMessage` produces exactly one
explicit error and one `[DONE]`; classification tests cover every generated safe refusal plus
the VM fail-fast boundary.

## Evidence sources

- Field notes: unhandled server-message families / raw-byte timer resets (leading hang
Expand Down
142 changes: 124 additions & 18 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,34 @@ import {
AgentClientMessageSchema,
AgentRunRequestSchema,
AgentServerMessageSchema,
AskQuestionInteractionResponseSchema,
AskQuestionRejectedSchema,
AskQuestionResultSchema,
ClientHeartbeatSchema,
ConversationActionSchema,
ConversationStateStructureSchema,
ConversationStepSchema,
AgentConversationTurnStructureSchema,
ConversationTurnStructureSchema,
CreatePlanErrorSchema,
CreatePlanRequestResponseSchema,
CreatePlanResultSchema,
AssistantMessageSchema,
BackgroundShellSpawnResultSchema,
DeleteResultSchema,
DeleteRejectedSchema,
DiagnosticsResultSchema,
ExaFetchRequestResponse_RejectedSchema,
ExaFetchRequestResponseSchema,
ExaSearchRequestResponse_RejectedSchema,
ExaSearchRequestResponseSchema,
ExecClientMessageSchema,
FetchErrorSchema,
FetchResultSchema,
GetBlobResultSchema,
GrepErrorSchema,
GrepResultSchema,
InteractionResponseSchema,
KvClientMessageSchema,
LsRejectedSchema,
LsResultSchema,
Expand All @@ -54,6 +65,8 @@ import {
RequestContextSuccessSchema,
ResumeActionSchema,
SetBlobResultSchema,
SwitchModeRequestResponse_RejectedSchema,
SwitchModeRequestResponseSchema,
ShellRejectedSchema,
ShellResultSchema,
UserMessageActionSchema,
Expand All @@ -62,9 +75,13 @@ import {
WriteResultSchema,
WriteShellStdinErrorSchema,
WriteShellStdinResultSchema,
WebSearchRequestResponse_RejectedSchema,
WebSearchRequestResponseSchema,
type AgentServerMessage,
type ConversationStateStructure,
type ExecServerMessage,
type InteractionQuery,
type InteractionResponse,
type InteractionUpdate,
type KvServerMessage,
type McpToolDefinition,
Expand Down Expand Up @@ -1202,6 +1219,7 @@ export function processServerMessage(
onMcpExec: (exec: PendingExec) => void,
onCheckpoint?: (checkpointBytes: Uint8Array) => void,
onProtocolError?: (error: Error) => void,
onTurnEnded?: () => void,
): void {
const markSemanticProgress = () => {
state.semanticProgressCount += 1;
Expand All @@ -1210,7 +1228,7 @@ export function processServerMessage(

switch (msg.message.case) {
case "interactionUpdate":
handleInteractionUpdate(msg.message.value, state, onText, markSemanticProgress);
handleInteractionUpdate(msg.message.value, state, onText, markSemanticProgress, onTurnEnded);
return;
case "kvServerMessage":
handleKvMessage(msg.message.value, blobStore, sendFrame);
Expand All @@ -1231,9 +1249,7 @@ export function processServerMessage(
onProtocolError?.(new Error(`Unsupported Cursor exec control message: ${msg.message.value.message.case ?? "unknown"}`));
return;
case "interactionQuery":
// Query answers may require user interaction or Cursor-specific capabilities that
// this proxy cannot provide. Never silently leave the server waiting for one.
onProtocolError?.(new Error(`Unsupported Cursor interaction query: ${msg.message.value.query.case ?? "unknown"}`));
handleInteractionQuery(msg.message.value, sendFrame, onProtocolError);
return;
case undefined:
// Protobuf decoders represent empty or forward-compatible messages as an
Expand All @@ -1248,11 +1264,95 @@ export function processServerMessage(
}
}

const INTERACTION_QUERY_REJECTION =
"Not available via the OpenCode Cursor bridge. Use the provided MCP tools instead.";

/** Refuse unprivileged IDE-native queries so Cursor can continue the current turn. */
function handleInteractionQuery(
query: InteractionQuery,
sendFrame: (data: Uint8Array) => void,
onProtocolError?: (error: Error) => void,
): void {
let result: InteractionResponse["result"];
switch (query.query.case) {
case "webSearchRequestQuery":
result = {
case: "webSearchRequestResponse",
value: create(WebSearchRequestResponseSchema, {
result: { case: "rejected", value: create(WebSearchRequestResponse_RejectedSchema, { reason: INTERACTION_QUERY_REJECTION }) },
}),
};
break;
case "askQuestionInteractionQuery":
result = {
case: "askQuestionInteractionResponse",
value: create(AskQuestionInteractionResponseSchema, {
result: create(AskQuestionResultSchema, {
result: { case: "rejected", value: create(AskQuestionRejectedSchema, { reason: INTERACTION_QUERY_REJECTION }) },
}),
}),
};
break;
case "switchModeRequestQuery":
result = {
case: "switchModeRequestResponse",
value: create(SwitchModeRequestResponseSchema, {
result: { case: "rejected", value: create(SwitchModeRequestResponse_RejectedSchema, { reason: INTERACTION_QUERY_REJECTION }) },
}),
};
break;
case "exaSearchRequestQuery":
result = {
case: "exaSearchRequestResponse",
value: create(ExaSearchRequestResponseSchema, {
result: { case: "rejected", value: create(ExaSearchRequestResponse_RejectedSchema, { reason: INTERACTION_QUERY_REJECTION }) },
}),
};
break;
case "exaFetchRequestQuery":
result = {
case: "exaFetchRequestResponse",
value: create(ExaFetchRequestResponseSchema, {
result: { case: "rejected", value: create(ExaFetchRequestResponse_RejectedSchema, { reason: INTERACTION_QUERY_REJECTION }) },
}),
};
break;
case "createPlanRequestQuery":
result = {
case: "createPlanRequestResponse",
value: create(CreatePlanRequestResponseSchema, {
result: create(CreatePlanResultSchema, {
result: { case: "error", value: create(CreatePlanErrorSchema, { error: INTERACTION_QUERY_REJECTION }) },
}),
}),
};
break;
// This response has no rejection arm. Claiming success would authorize work
// that this proxy did not perform.
case "setupVmEnvironmentArgs":
onProtocolError?.(new Error("Unsupported Cursor interaction query: setupVmEnvironmentArgs"));
return;
case undefined:
onProtocolError?.(new Error("Unsupported Cursor interaction query: unknown"));
return;
default: {
const unhandled: never = query.query;
throw new Error(`Unhandled Cursor interaction query: ${String(unhandled)}`);
}
}
const response = create(InteractionResponseSchema, { id: query.id, result });
const clientMessage = create(AgentClientMessageSchema, {
message: { case: "interactionResponse", value: response },
});
sendFrame(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
}

function handleInteractionUpdate(
update: InteractionUpdate,
state: StreamState,
onText: (text: string, isThinking?: boolean) => void,
markSemanticProgress: () => void,
onTurnEnded?: () => void,
): void {
switch (update.message.case) {
case "textDelta": {
Expand Down Expand Up @@ -1291,9 +1391,13 @@ function handleInteractionUpdate(
case "shellOutputDelta":
case "stepStarted":
case "stepCompleted":
// Cursor's turnEnded sequencing is unproven, so only a Connect end-stream may terminate.
case "heartbeat":
return;
// Cursor emits this after normal completion and can leave its HTTP/2 stream
// open without a Connect end-stream. The response owner supplies an
// idempotent terminal callback so a later transport close stays inert.
case "turnEnded":
onTurnEnded?.();
return;
case undefined:
return;
Expand Down Expand Up @@ -1665,6 +1769,13 @@ function createBridgeStreamResponse(
lastSemanticProgressAt: Date.now(),
};
const tagFilter = createThinkingTagFilter();
const persistLiveConversation = () => {
const stored = conversationStates.get(convKey);
if (!stored) return;
for (const [k, v] of blobStore) stored.blobStore.set(k, v);
stored.lastAccessMs = Date.now();
persistConversation(convKey, stored);
};

let mcpExecReceived = false;
let connectEndStreamReceived = false;
Expand All @@ -1676,6 +1787,7 @@ function createBridgeStreamResponse(
terminal = true;
semanticWatchdog.clear();
clearInterval(heartbeatTimer);
persistLiveConversation();
for (const active of activeBridges.values()) {
if (active.bridge === bridge) removeActiveBridge(active);
}
Expand Down Expand Up @@ -1787,16 +1899,14 @@ function createBridgeStreamResponse(
const stored = conversationStates.get(convKey);
if (stored) {
stored.checkpoint = checkpointBytes;
// Merge live blobs before persisting: the checkpoint may
// reference blobs set during this stream.
for (const [k, v] of blobStore) stored.blobStore.set(k, v);
stored.lastAccessMs = Date.now();
persistConversation(convKey, stored);
// The checkpoint may reference blobs set during this stream.
persistLiveConversation();
}
},
finishTerminal,
() => finishTerminal(),
);
semanticWatchdog.observe();
if (!terminal) semanticWatchdog.observe();
emitLifecycleDiagnostic("proxy", "message_dispatch", {
messageCase: serverMessage.message.case,
interactionCase: serverMessage.message.case === "interactionUpdate"
Expand Down Expand Up @@ -1835,12 +1945,7 @@ function createBridgeStreamResponse(
emitLifecycleDiagnostic("proxy", "terminal", { exitCode: code });
semanticWatchdog.clear();
clearInterval(heartbeatTimer);
const stored = conversationStates.get(convKey);
if (stored) {
for (const [k, v] of blobStore) stored.blobStore.set(k, v);
stored.lastAccessMs = Date.now();
persistConversation(convKey, stored);
}
persistLiveConversation();
if (terminal) return;
if (mcpExecReceived) {
// Tool pause deliberately owns neither terminal cleanup nor bridge
Expand Down Expand Up @@ -2105,8 +2210,9 @@ async function collectFullResponse(
(error) => {
finishCollection(error);
},
() => finishCollection(),
);
semanticWatchdog.observe();
if (!settled) semanticWatchdog.observe();
} catch (error) {
// A complete Connect frame with an invalid protobuf payload cannot be
// safely ignored in unary mode either: it may contain the only
Expand Down
Loading