diff --git a/docs/cursor-hang-root-cause.md b/docs/cursor-hang-root-cause.md index 3176556..2b3c82e 100644 --- a/docs/cursor-hang-root-cause.md +++ b/docs/cursor-hang-root-cause.md @@ -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 diff --git a/src/proxy.ts b/src/proxy.ts index abb03da..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, @@ -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; @@ -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); @@ -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 @@ -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": { @@ -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; @@ -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; @@ -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); } @@ -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" @@ -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 @@ -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 diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts index d6fdcc5..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, @@ -29,9 +31,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" @@ -40,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", @@ -56,9 +61,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", @@ -68,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 { @@ -144,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: { @@ -304,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; @@ -388,14 +455,37 @@ 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": + // 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(() => session.destroy(), 10); + break; + case "exec-server-control": + stream.write(frameConnectMessage(execServerControl())); 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..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"; @@ -548,6 +549,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, @@ -640,10 +645,12 @@ 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(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"); const frameError = await observeProxyScenario("frame-error"); assert(frameError.body.includes("[Error:"), "HTTP/2 frame errors must be surfaced as an error"); @@ -687,10 +694,40 @@ 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 terminate promptly"); + assert(turnEndedStall.body.includes("before-turn-ended-stall"), "turnEnded stall fixture must deliver the preceding text delta"); + 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"); + 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 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"); @@ -790,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 } }, @@ -828,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(