From 9d45bf9b08fb6985518f671849db7c686b90bebd Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:40:46 -0700 Subject: [PATCH 1/4] test(proxy): capture turnEnded terminal failure modes Split the ambiguous turnEnded fixture into deterministic kept-open and transport-close sequences before changing proxy behavior. The smoke coverage shows that turnEnded is currently a no-op, leaving semantic watchdog or transport-close handling to finish the response.\n\nChanges:\n- Add text-plus-turnEnded scenarios with kept-open and closed HTTP/2 transports\n- Name terminal-less transport closure and assert the explicit finishTerminal cascade\n- Bound the kept-open reproduction with test-controlled semantic watchdog settings --- test/fixtures/fake-cursor-server.ts | 36 ++++++++++++++++++++++------- test/smoke.ts | 30 +++++++++++++++++------- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts index d6fdcc5..2fe677a 100644 --- a/test/fixtures/fake-cursor-server.ts +++ b/test/fixtures/fake-cursor-server.ts @@ -29,9 +29,10 @@ export type CursorScenario = | "semantic-progress-trickle" | "heartbeat-trickle" | "heartbeat-trickle-kept-open" - | "turn-ended-kept-open" + | "turn-ended-semantic-stall" + | "turn-ended-transport-close" | "checkpoint-trickle" - | "abrupt-session-close" + | "transport-close-without-terminal" | "frame-error" | "goaway" | "stream-aborted" @@ -56,9 +57,10 @@ export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ "semantic-progress-trickle", "heartbeat-trickle", "heartbeat-trickle-kept-open", - "turn-ended-kept-open", + "turn-ended-semantic-stall", + "turn-ended-transport-close", "checkpoint-trickle", - "abrupt-session-close", + "transport-close-without-terminal", "frame-error", "goaway", "stream-aborted", @@ -388,14 +390,32 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = case "heartbeat-trickle-kept-open": trickleUntilClosed(heartbeat()); break; - case "turn-ended-kept-open": - stream.write(frameConnectMessage(turnEnded())); + case "turn-ended-semantic-stall": + // Reproduces Cursor's normal-looking completion shape: text and + // turnEnded arrive, but the HTTP/2 response remains open with no + // Connect end-stream. The proxy's current turnEnded no-op must leave + // the semantic-progress watchdog as the only terminal owner. + stream.write(Buffer.concat([ + frameConnectMessage(textDelta("before-turn-ended-stall")), + frameConnectMessage(turnEnded()), + ])); + break; + case "turn-ended-transport-close": + // The present no-op for turnEnded defers termination to the transport + // close cascade rather than treating this as a clean completion. + stream.write(Buffer.concat([ + frameConnectMessage(textDelta("before-turn-ended-close")), + frameConnectMessage(turnEnded()), + ])); + setTimeout(() => stream.session.destroy(), 10); break; case "checkpoint-trickle": trickle(checkpoint()); break; - case "abrupt-session-close": - stream.write(frameConnectMessage(textDelta("before-close"))); + case "transport-close-without-terminal": + // No turnEnded or Connect end-stream: this is an abnormal transport + // failure and must reach proxy.ts finishTerminal's explicit-error path. + stream.write(frameConnectMessage(textDelta("before-terminal-less-close"))); setTimeout(() => stream.session.destroy(), 10); break; case "frame-error": diff --git a/test/smoke.ts b/test/smoke.ts index 68658fa..c8f2972 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -640,10 +640,11 @@ async function testStreamingTermination() { assertEqual(doneCount(malformedMessage.body), 1, "Malformed protobuf payloads must emit exactly one [DONE]"); console.log("[test] Malformed protobuf payloads terminate explicitly exactly once"); - const abruptClose = await observeProxyScenario("abrupt-session-close"); - assert(abruptClose.body.includes("[Error:"), "Abrupt HTTP/2 closure must be surfaced as an error"); - assertEqual(doneCount(abruptClose.body), 1, "Abrupt HTTP/2 closure must emit exactly one [DONE]"); - console.log("[test] Abrupt HTTP/2 closure is an explicit error exactly once"); + const terminalLessClose = await observeProxyScenario("transport-close-without-terminal"); + assert(terminalLessClose.body.includes("before-terminal-less-close"), "Terminal-less closure must preserve preceding text"); + assert(terminalLessClose.body.includes("[Error:"), "Closure without turnEnded or Connect end-stream must be surfaced as an error"); + assertEqual(doneCount(terminalLessClose.body), 1, "Closure without a terminal signal must emit exactly one [DONE]"); + console.log("[test] Terminal-less transport closure reaches finishTerminal as an explicit error exactly once"); const frameError = await observeProxyScenario("frame-error"); assert(frameError.body.includes("[Error:"), "HTTP/2 frame errors must be surfaced as an error"); @@ -687,10 +688,23 @@ async function testStreamingTermination() { assertEqual(doneCount(firstProgressStall.body), 1, "First-progress stalls must emit exactly one [DONE]"); console.log("[test] First-progress stalls are bounded independently of transport liveness"); - const turnEnded = await observeProxyScenario("turn-ended-kept-open"); - assert(turnEnded.body.includes("[Error:"), "turnEnded transport closure must not become a clean success"); - assertEqual(doneCount(turnEnded.body), 1, "turnEnded transport closure must emit exactly one [DONE]"); - console.log("[test] turnEnded transport closure is an explicit error exactly once"); + const turnEndedStall = await observeProxyScenario("turn-ended-semantic-stall", 250, { + CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS: "1000", + CURSOR_PROXY_SEMANTIC_PROGRESS_TIMEOUT_MS: "25", + CURSOR_BRIDGE_SESSION_TIMEOUT_MS: "1000", + CURSOR_BRIDGE_PING_INTERVAL_MS: "1000", + }); + assert(!turnEndedStall.timedOut, "turnEnded without Connect end-stream must be bounded by the semantic watchdog"); + assert(turnEndedStall.body.includes("before-turn-ended-stall"), "turnEnded stall fixture must deliver the preceding text delta"); + assert(turnEndedStall.body.includes("[Error: Cursor response stalled waiting for semantic progress]"), "Current turnEnded no-op must not terminate before the test-controlled semantic watchdog"); + assertEqual(doneCount(turnEndedStall.body), 1, "Semantic watchdog termination after turnEnded must emit exactly one [DONE]"); + console.log("[test] turnEnded is currently a no-op; only the semantic watchdog reaches finishTerminal"); + + const turnEndedClose = await observeProxyScenario("turn-ended-transport-close"); + assert(turnEndedClose.body.includes("before-turn-ended-close"), "turnEnded transport-close fixture must deliver the preceding text delta"); + assert(turnEndedClose.body.includes("[Error:"), "Current turnEnded no-op must defer terminal ownership to the close cascade"); + assertEqual(doneCount(turnEndedClose.body), 1, "turnEnded followed by transport closure must emit exactly one [DONE]"); + console.log("[test] turnEnded followed by transport closure reaches finishTerminal as an explicit error exactly once"); const slowProgress = await observeProxyScenario("slow-semantic-progress", 500); assert(!slowProgress.timedOut, "Slow semantic progress exceeded its bounded completion window"); From f11d5429191c4953cde0ac39197406246596ddfd Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:46:48 -0700 Subject: [PATCH 2/4] fix(proxy): finish streams on Cursor turnEnded Cursor can signal normal completion with InteractionUpdate.turnEnded while leaving the HTTP/2 response open. Route that signal through the existing idempotent terminal owner so SSE clients receive stop, usage, and exactly one completion marker without waiting for the semantic watchdog.\n\nPreserve strict errors for terminal-less transport closure and protect the clean result from a later close. Adapted from tanushshukla/opencode-cursor@a067e099. --- src/proxy.ts | 38 +++++++++++++++++------------ test/fixtures/fake-cursor-server.ts | 8 +++--- test/smoke.ts | 21 +++++++++++----- 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index abb03da..345754e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1202,6 +1202,7 @@ export function processServerMessage( onMcpExec: (exec: PendingExec) => void, onCheckpoint?: (checkpointBytes: Uint8Array) => void, onProtocolError?: (error: Error) => void, + onTurnEnded?: () => void, ): void { const markSemanticProgress = () => { state.semanticProgressCount += 1; @@ -1210,7 +1211,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); @@ -1253,6 +1254,7 @@ function handleInteractionUpdate( state: StreamState, onText: (text: string, isThinking?: boolean) => void, markSemanticProgress: () => void, + onTurnEnded?: () => void, ): void { switch (update.message.case) { case "textDelta": { @@ -1291,9 +1293,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; @@ -1665,6 +1671,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; @@ -1676,6 +1689,7 @@ function createBridgeStreamResponse( terminal = true; semanticWatchdog.clear(); clearInterval(heartbeatTimer); + persistLiveConversation(); for (const active of activeBridges.values()) { if (active.bridge === bridge) removeActiveBridge(active); } @@ -1787,16 +1801,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" @@ -1835,12 +1847,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 @@ -2105,8 +2112,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 diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts index 2fe677a..1f2bd1b 100644 --- a/test/fixtures/fake-cursor-server.ts +++ b/test/fixtures/fake-cursor-server.ts @@ -401,13 +401,15 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = ])); break; case "turn-ended-transport-close": - // The present no-op for turnEnded defers termination to the transport - // close cascade rather than treating this as a clean completion. + // A close after Cursor's completion signal must not change the already + // clean terminal outcome. Retain the session before the proxy releases + // the bridge so this scenario can still deliver the late close. + const session = stream.session; stream.write(Buffer.concat([ frameConnectMessage(textDelta("before-turn-ended-close")), frameConnectMessage(turnEnded()), ])); - setTimeout(() => stream.session.destroy(), 10); + setTimeout(() => session.destroy(), 10); break; case "checkpoint-trickle": trickle(checkpoint()); diff --git a/test/smoke.ts b/test/smoke.ts index c8f2972..40ae5b2 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -548,6 +548,10 @@ function doneCount(body: string): number { return body.split("data: [DONE]").length - 1; } +function errorCount(body: string): number { + return body.split("[Error:").length - 1; +} + async function observeProxyScenario( scenario: CursorScenario, deadlineMs = 125, @@ -643,6 +647,7 @@ async function testStreamingTermination() { const terminalLessClose = await observeProxyScenario("transport-close-without-terminal"); assert(terminalLessClose.body.includes("before-terminal-less-close"), "Terminal-less closure must preserve preceding text"); assert(terminalLessClose.body.includes("[Error:"), "Closure without turnEnded or Connect end-stream must be surfaced as an error"); + assertEqual(errorCount(terminalLessClose.body), 1, "Closure without a terminal signal must emit exactly one explicit error"); assertEqual(doneCount(terminalLessClose.body), 1, "Closure without a terminal signal must emit exactly one [DONE]"); console.log("[test] Terminal-less transport closure reaches finishTerminal as an explicit error exactly once"); @@ -694,17 +699,21 @@ async function testStreamingTermination() { CURSOR_BRIDGE_SESSION_TIMEOUT_MS: "1000", CURSOR_BRIDGE_PING_INTERVAL_MS: "1000", }); - assert(!turnEndedStall.timedOut, "turnEnded without Connect end-stream must be bounded by the semantic watchdog"); + assert(!turnEndedStall.timedOut, "turnEnded without Connect end-stream must terminate promptly"); assert(turnEndedStall.body.includes("before-turn-ended-stall"), "turnEnded stall fixture must deliver the preceding text delta"); - assert(turnEndedStall.body.includes("[Error: Cursor response stalled waiting for semantic progress]"), "Current turnEnded no-op must not terminate before the test-controlled semantic watchdog"); - assertEqual(doneCount(turnEndedStall.body), 1, "Semantic watchdog termination after turnEnded must emit exactly one [DONE]"); - console.log("[test] turnEnded is currently a no-op; only the semantic watchdog reaches finishTerminal"); + assertEqual(errorCount(turnEndedStall.body), 0, "turnEnded must complete cleanly before the semantic watchdog"); + assert(turnEndedStall.body.includes('"finish_reason":"stop"'), "turnEnded must emit a stop terminal chunk"); + assert(turnEndedStall.body.includes('"usage":'), "turnEnded must emit a usage terminal chunk"); + assertEqual(doneCount(turnEndedStall.body), 1, "turnEnded must emit exactly one [DONE]"); + console.log("[test] turnEnded completes the open response before the semantic watchdog"); const turnEndedClose = await observeProxyScenario("turn-ended-transport-close"); assert(turnEndedClose.body.includes("before-turn-ended-close"), "turnEnded transport-close fixture must deliver the preceding text delta"); - assert(turnEndedClose.body.includes("[Error:"), "Current turnEnded no-op must defer terminal ownership to the close cascade"); + assertEqual(errorCount(turnEndedClose.body), 0, "Transport closure after turnEnded must stay clean and idempotent"); + assert(turnEndedClose.body.includes('"finish_reason":"stop"'), "turnEnded transport-close must emit a stop terminal chunk"); + assert(turnEndedClose.body.includes('"usage":'), "turnEnded transport-close must emit a usage terminal chunk"); assertEqual(doneCount(turnEndedClose.body), 1, "turnEnded followed by transport closure must emit exactly one [DONE]"); - console.log("[test] turnEnded followed by transport closure reaches finishTerminal as an explicit error exactly once"); + console.log("[test] turnEnded followed by transport closure remains clean exactly once"); const slowProgress = await observeProxyScenario("slow-semantic-progress", 500); assert(!slowProgress.timedOut, "Slow semantic progress exceeded its bounded completion window"); From ee042bf640f5f08f6d982f70b8e3ee7b4d4ced61 Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:53:48 -0700 Subject: [PATCH 3/4] fix(proxy): refuse supported Cursor interaction queries Reply with typed protocol rejections for Cursor queries whose generated responses support a negative result, allowing the server to continue the same turn. Keep VM setup and server controls fail-fast rather than falsely acknowledging unperformed privileged operations.\n\nChanges:\n- encode typed rejections for web, question, mode, Exa, and plan queries\n- cover continued turnEnded completion and explicit control failure\n- document the protocol disposition and tanushshukla/opencode-cursor@a067e099 attribution --- docs/cursor-hang-root-cause.md | 20 ++++++ src/proxy.ts | 104 +++++++++++++++++++++++++++- test/fixtures/fake-cursor-server.ts | 70 ++++++++++++++++++- test/smoke.ts | 37 +++++++++- 4 files changed, 224 insertions(+), 7 deletions(-) diff --git a/docs/cursor-hang-root-cause.md b/docs/cursor-hang-root-cause.md index 3176556..1d8ed28 100644 --- a/docs/cursor-hang-root-cause.md +++ b/docs/cursor-hang-root-cause.md @@ -170,6 +170,26 @@ backpressure from stalling the bridge. ## Evidence sources +## 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. + - Field notes: unhandled server-message families / raw-byte timer resets (leading hang hypothesis); every stream-termination path; timers/watchdogs and reset semantics; error-swallowing inventory; root-cause synthesis of the five defects; no-timeout provider diff --git a/src/proxy.ts b/src/proxy.ts index 345754e..2b27c13 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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, @@ -54,6 +65,8 @@ import { RequestContextSuccessSchema, ResumeActionSchema, SetBlobResultSchema, + SwitchModeRequestResponse_RejectedSchema, + SwitchModeRequestResponseSchema, ShellRejectedSchema, ShellResultSchema, UserMessageActionSchema, @@ -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, @@ -1232,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 @@ -1249,6 +1264,89 @@ 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, diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts index 1f2bd1b..b338e36 100644 --- a/test/fixtures/fake-cursor-server.ts +++ b/test/fixtures/fake-cursor-server.ts @@ -5,9 +5,11 @@ import { AgentClientMessageSchema, AgentServerMessageSchema, ConversationStateStructureSchema, + ExecServerControlMessageSchema, ExecServerMessageSchema, HeartbeatUpdateSchema, InteractionUpdateSchema, + InteractionQuerySchema, McpArgsSchema, TextDeltaUpdateSchema, TurnEndedUpdateSchema, @@ -41,7 +43,9 @@ export type CursorScenario = | "transport-death" | "tool-pause-resume" | "tool-pause-transport-death" - | "tool-pause-clean-exit"; + | "tool-pause-clean-exit" + | "interaction-query-continue" + | "exec-server-control"; export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ "clean-end-stream", @@ -70,6 +74,8 @@ export const CURSOR_SCENARIOS: readonly CursorScenario[] = [ "tool-pause-resume", "tool-pause-transport-death", "tool-pause-clean-exit", + "interaction-query-continue", + "exec-server-control", ] as const; export interface FakeCursorServer { @@ -146,6 +152,45 @@ function turnEnded(): Buffer { }); } +function webSearchQuery(): Buffer { + return serverMessage({ + message: { + case: "interactionQuery", + value: create(InteractionQuerySchema, { + id: 41, + query: { case: "webSearchRequestQuery", value: {} }, + }), + }, + }); +} + +function execServerControl(): Buffer { + return serverMessage({ + message: { + case: "execServerControlMessage", + value: create(ExecServerControlMessageSchema, { + message: { case: "abort", value: { id: 77 } }, + }), + }, + }); +} + +function hasRejectedWebSearchResponse(chunk: Buffer): boolean { + if (chunk.length < 5 || chunk.readUInt32BE(1) !== chunk.length - 5) return false; + try { + const message = fromBinary(AgentClientMessageSchema, chunk.subarray(5)); + if (message.message.case !== "interactionResponse") return false; + const response = message.message.value; + return response.id === 41 && + response.result.case === "webSearchRequestResponse" && + response.result.value.result.case === "rejected" && + response.result.value.result.value.reason === + "Not available via the OpenCode Cursor bridge. Use the provided MCP tools instead."; + } catch { + return false; + } +} + function checkpoint(): Buffer { return serverMessage({ message: { @@ -306,6 +351,26 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = return; } + if (scenario === "interaction-query-continue") { + let receivedInitialRequest = false; + stream.on("data", (chunk) => { + if (!receivedInitialRequest) { + receivedInitialRequest = true; + stream.write(frameConnectMessage(webSearchQuery())); + return; + } + stream.end(Buffer.concat([ + frameConnectMessage(textDelta( + hasRejectedWebSearchResponse(Buffer.from(chunk)) + ? "after-query-refusal" + : "unexpected-query-response", + )), + frameConnectMessage(turnEnded()), + ])); + }); + return; + } + const endWith = (body: Uint8Array) => stream.end(frameConnectEndStream(body)); const trickle = (payload: Buffer) => { let sent = 0; @@ -411,6 +476,9 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = ])); setTimeout(() => session.destroy(), 10); break; + case "exec-server-control": + stream.write(frameConnectMessage(execServerControl())); + break; case "checkpoint-trickle": trickle(checkpoint()); break; diff --git a/test/smoke.ts b/test/smoke.ts index 40ae5b2..0b08cbd 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -14,6 +14,7 @@ import { KvServerMessageSchema, ModelDetailsSchema, TextDeltaUpdateSchema, + SetupVmEnvironmentArgsSchema, ThinkingDetailsSchema, TurnEndedUpdateSchema, } from "../src/proto/agent_pb"; @@ -715,6 +716,19 @@ async function testStreamingTermination() { assertEqual(doneCount(turnEndedClose.body), 1, "turnEnded followed by transport closure must emit exactly one [DONE]"); console.log("[test] turnEnded followed by transport closure remains clean exactly once"); + const refusedQuery = await observeProxyScenario("interaction-query-continue"); + assert(!refusedQuery.timedOut, "A safely refused interaction query must let the same turn continue"); + assert(refusedQuery.body.includes("after-query-refusal"), "The server must continue after its typed query rejection"); + assertEqual(errorCount(refusedQuery.body), 0, "A safely refused query must not produce an SSE error"); + assertEqual(doneCount(refusedQuery.body), 1, "A safely refused query followed by turnEnded must emit exactly one [DONE]"); + console.log("[test] Typed interaction-query refusal continues to a clean turnEnded terminal"); + + const unsupportedControl = await observeProxyScenario("exec-server-control"); + assert(unsupportedControl.body.includes("Unsupported Cursor exec control message: abort"), "Unsupported server controls must surface an explicit error"); + assertEqual(errorCount(unsupportedControl.body), 1, "Unsupported server controls must emit exactly one explicit error"); + assertEqual(doneCount(unsupportedControl.body), 1, "Unsupported server controls must emit exactly one [DONE]"); + console.log("[test] Unsupported server controls terminate with one explicit error and one [DONE]"); + const slowProgress = await observeProxyScenario("slow-semantic-progress", 500); assert(!slowProgress.timedOut, "Slow semantic progress exceeded its bounded completion window"); assert(!slowProgress.body.includes("[Error:"), "Slow semantic progress must not emit an error"); @@ -813,11 +827,28 @@ async function testServerMessageClassification(modules: TestModules) { assertEqual(state.semanticProgressCount, 1, "Text output must count as semantic progress"); let unsupportedError: Error | undefined; + const safeQueryCases = [ + "webSearchRequestQuery", + "askQuestionInteractionQuery", + "switchModeRequestQuery", + "exaSearchRequestQuery", + "exaFetchRequestQuery", + "createPlanRequestQuery", + ] as const; + for (const queryCase of safeQueryCases) { + let responseBytes: Uint8Array | undefined; + dispatch({ message: { case: "interactionQuery", value: create(InteractionQuerySchema, { + id: 1, + query: { case: queryCase, value: {} }, + }) } }, (error) => { unsupportedError = error; }, (bytes) => { responseBytes = bytes; }); + assert(!unsupportedError, `${queryCase} must be safely refused rather than terminal`); + assert(responseBytes && responseBytes.length > 5, `${queryCase} must emit a typed interaction response`); + } dispatch({ message: { case: "interactionQuery", value: create(InteractionQuerySchema, { id: 1, - query: { case: "webSearchRequestQuery", value: {} }, + query: { case: "setupVmEnvironmentArgs", value: create(SetupVmEnvironmentArgsSchema, {}) }, }) } }, (error) => { unsupportedError = error; }); - assert(unsupportedError?.message.includes("Unsupported Cursor interaction query"), "Unsupported blocking queries must trigger an explicit terminal error"); + assertEqual(unsupportedError?.message, "Unsupported Cursor interaction query: setupVmEnvironmentArgs", "VM setup must remain an explicit terminal error because its response has no rejection arm"); unsupportedError = undefined; dispatch({ message: { case: "execServerControlMessage", value: create(ExecServerControlMessageSchema, { message: { case: "abort", value: { id: 1 } }, @@ -851,7 +882,7 @@ async function testServerMessageClassification(modules: TestModules) { "Failed to write native tool result to Cursor bridge", "A dead bridge must explicitly reject native tool-result writes", ); - console.log("[test] Liveness traffic is non-progress and unsupported queries terminate explicitly"); + console.log("[test] Liveness traffic is non-progress; safe queries reject-and-continue while controls fail explicitly"); } async function runScenarioSubprocess( From f4e3e4211ada226763281becb341b17a01f03a1d Mon Sep 17 00:00:00 2001 From: hffmnnj <52758545+hffmnnj@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:00:45 -0700 Subject: [PATCH 4/4] docs(root-cause): reorder interaction-query section before evidence sources --- docs/cursor-hang-root-cause.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cursor-hang-root-cause.md b/docs/cursor-hang-root-cause.md index 1d8ed28..2b3c82e 100644 --- a/docs/cursor-hang-root-cause.md +++ b/docs/cursor-hang-root-cause.md @@ -168,8 +168,6 @@ 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. -## Evidence sources - ## Interaction-query disposition Adapted with attribution to `tanushshukla/opencode-cursor@a067e099`: an unanswered @@ -190,6 +188,8 @@ clean `turnEnded` terminal. It also proves `execServerControlMessage` produces e 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 hypothesis); every stream-termination path; timers/watchdogs and reset semantics; error-swallowing inventory; root-cause synthesis of the five defects; no-timeout provider