From f32d68c3958fcb0f92919ad36403a48390d027d4 Mon Sep 17 00:00:00 2001 From: CasualDeveloper <10153929+CasualDeveloper@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:56:36 +0800 Subject: [PATCH] fix: track Cursor SDK 1.0.37 protocol and catalog Cursor marks its own notices, such as a safety-filter model fallback, with TextDeltaUpdate.is_server_notice. The adapter streamed them as assistant text, so the notice was saved and replayed as model output. Keep flagged text out of the reply and the continuation history, and report it as providerMetadata.cursor.serverNotice and a doGenerate warning. The flag identifies notices without matching model names or message wording. Ignore SDK 1.0.37 client-presentation updates (routed model, prompts, branch changes, feedback, comparisons, context state, nudges) and AgentServerMessage.ttft_breakdown latency telemetry. Each was previously an unsupported interaction or output after turnEnded, failing the Run. Decline the newer Cursor-hosted interaction queries (PR management, MCP authentication, image generation, environment replacement, and source control connection) in the reply layout the official SDK uses for local runs, instead of failing the Run. Drop a GetUsableModels family whose wire IDs already belong to an AvailableModels family under another id. Cursor listed a legacy Opus 4.6 alias that way, so the picker showed two "Claude Opus 4.6 1M" entries, one with the wrong context window. Report the Cursor CLI client version from one shared constant used by both workers and the capability probes. Keep it at cli-2026.01.09-231024f: Cursor varies server behavior by client version, so a bump needs a live end-to-end check first. Describe truncated streams in premature-eof errors. The transport reports the cut frame's declared and received sizes and the frame count, and the Run adds whether turnEnded and a final checkpoint arrived. Long conversations showed Cursor closing the stream mid-message before turnEnded; these details separate that from a missing end frame. Verified with npm test (97 tests) and npm run typecheck. Earlier live read and shell calls on Composer through the rebuilt plugin showed one Claude Opus 4.6 1M catalog entry; they predate the client version and diagnostics changes. Refs #31 --- scripts/check-package.mjs | 1 + scripts/copy-runtime.mjs | 2 +- scripts/cursor-capability/run.ts | 3 +- scripts/probe-opencode-v2-cursor.ts | 3 +- src/cursor-agent.ts | 83 ++++++++++++++++++++++------- src/cursor-client.d.mts | 4 ++ src/cursor-client.mjs | 6 +++ src/h2-unary.mjs | 4 +- src/h2-v2.mjs | 18 +++++-- src/models/publish.ts | 4 ++ src/opencode/language.ts | 11 ++++ test/model-normalizer.test.ts | 31 +++++++++++ test/v2-agent.test.ts | 76 +++++++++++++++++++------- 13 files changed, 198 insertions(+), 48 deletions(-) create mode 100644 src/cursor-client.d.mts create mode 100644 src/cursor-client.mjs diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 3865c65..0d45f97 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -27,6 +27,7 @@ try { for (const required of [ "dist/index.js", "dist/index.d.ts", + "dist/cursor-client.mjs", "dist/h2-v2.mjs", "dist/h2-unary.mjs", "LICENSE", diff --git a/scripts/copy-runtime.mjs b/scripts/copy-runtime.mjs index 5985308..8369cd6 100644 --- a/scripts/copy-runtime.mjs +++ b/scripts/copy-runtime.mjs @@ -1,7 +1,7 @@ import { cpSync, mkdirSync } from "node:fs"; import { join } from "node:path"; -const runtimeFiles = ["h2-unary.mjs", "h2-v2.mjs"]; +const runtimeFiles = ["cursor-client.mjs", "h2-unary.mjs", "h2-v2.mjs"]; mkdirSync("dist", { recursive: true }); diff --git a/scripts/cursor-capability/run.ts b/scripts/cursor-capability/run.ts index 5c701ff..42d11a0 100644 --- a/scripts/cursor-capability/run.ts +++ b/scripts/cursor-capability/run.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { gunzipSync } from "node:zlib"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import * as p from "../../src/proto/agent_pb.js"; +import { CURSOR_CLIENT_HEADERS } from "../../src/cursor-client.mjs"; import { buildRequest, decodeArgs, frame } from "./protocol.js"; export type ObservedCall = { id: string; name: string; args: ReturnType }; @@ -57,7 +58,7 @@ export async function runProbe(input: Parameters[0] & { ":method": "POST", ":path": "/agent.v1.AgentService/Run", "content-type": "application/connect+proto", "connect-protocol-version": "1", te: "trailers", authorization: `Bearer ${input.accessToken}`, "x-ghost-mode": "true", - "x-cursor-client-version": "cli-2026.01.09-231024f", "x-cursor-client-type": "cli", + ...CURSOR_CLIENT_HEADERS, "x-request-id": randomUUID(), }; if (!input.tools.length) { diff --git a/scripts/probe-opencode-v2-cursor.ts b/scripts/probe-opencode-v2-cursor.ts index 79719f0..d3379e7 100644 --- a/scripts/probe-opencode-v2-cursor.ts +++ b/scripts/probe-opencode-v2-cursor.ts @@ -3,6 +3,7 @@ import { parseArgs } from "node:util"; import { decodeCursorModelSelection, encodeCursorModelSelection, type CursorModelSelection } from "../src/model-selection.js"; import { contaminatedHistory, errorTrial, syntheticLongContext, tools, toolPrompt, toolTrial } from "./cursor-capability/cases.js"; import { runProbe, type Observation } from "./cursor-capability/run.js"; +import { CURSOR_CLIENT_HEADERS } from "../src/cursor-client.mjs"; async function main() { const { values } = parseArgs({ options: { @@ -59,7 +60,7 @@ async function main() { }; console.log(JSON.stringify({ type: "probe", version: 3, suite: values["follow-up"] ? "follow-up" : "matrix", startedAt: new Date().toISOString(), runBudget, timeoutMs, selections, - endpoint: "https://api2.cursor.sh", clientVersion: "cli-2026.01.09-231024f", + endpoint: "https://api2.cursor.sh", clientVersion: CURSOR_CLIENT_HEADERS["x-cursor-client-version"], })); const run = async (selection: CursorModelSelection, name: string, input: Omit[0], "accessToken" | "selection">) => { diff --git a/src/cursor-agent.ts b/src/cursor-agent.ts index 4a1e985..7fff74a 100644 --- a/src/cursor-agent.ts +++ b/src/cursor-agent.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; -import { BinaryWriter, WireType } from "@bufbuild/protobuf/wire"; +import { BinaryReader, BinaryWriter, WireType } from "@bufbuild/protobuf/wire"; import * as p from "./proto/agent_pb.js"; import { CURSOR_API_URL } from "./cursor-rpc.js"; import { resolveNodeExecutable } from "./node-runtime.js"; @@ -30,6 +30,7 @@ import { readTurnUsage, type CursorTokenUsage } from "./cursor-agent-usage.js"; export type CursorRunEvent = | { type: "text"; text: string } + | { type: "server-notice"; text: string } | { type: "reasoning"; text: string; id: string } | { type: "reasoning-metadata"; signatures: ReasoningSignature[] } | { type: "opaque-reasoning"; annotations: OpaqueReasoning[] } @@ -139,6 +140,33 @@ export function runCursorAgent( return run.open(input.abortSignal); } +const PRESENTATION_UPDATES = new Set([19, 20, 21, 22, 23, 24, 25, 26, 27]); + +// SDK 1.0.37 InteractionQuery fields newer than this descriptor, mapped to +// the reply's fail-closed result field and its string field 1. Web fetch +// keeps the generic hosted-access reason. +const NEWER_QUERIES = new Map([ + [9, [2]], // web fetch: rejected { reason } + [10, [2, "Cursor PR management is disabled."]], // error { error } + [11, [2, "Cursor MCP authentication is disabled."]], // rejected { reason } + [12, [2, "Cursor image generation is disabled."]], // rejected { reason } + [13, [2, "Cursor environment replacement is disabled."]], // failure { error_message } + [14, [2, "Cursor source-control connections are disabled."]], // rejected { reason } +]); + +// SDK 1.0.37: TextDeltaUpdate.is_server_notice = 2 (bool). It marks Cursor's +// own notices, such as a safety fallback, rather than model output. +function isServerNotice(update: p.TextDeltaUpdate): boolean { + return ( + update.$unknown?.some( + (field) => + field.no === 2 && + field.wireType === WireType.Varint && + new BinaryReader(field.data).bool(), + ) ?? false + ); +} + function extractToolOutput(raw: string): string { try { const parsed = JSON.parse(raw); @@ -175,6 +203,7 @@ class AgentRun { private sawTokenDelta = false; private checkpointSeenThisStep = false; private checkpointAfterTokenDelta = false; + private checkpointAfterTurnEnded = false; private traceCount = 0; private outputStarted = false; private resumed = false; @@ -234,6 +263,10 @@ class AgentRun { }, onEnd: (error) => { if (this.status === "closed") return; + if (error?.message.startsWith("Cursor transport: premature-eof")) + error = new Error( + `${error.message} (turnEnded=${this.turnEnded}, finalCheckpoint=${this.checkpointAfterTurnEnded})`, + ); if (error || !this.turnEnded) return this.dispose( error ?? new Error("Cursor Run ended without turnEnded"), @@ -466,7 +499,7 @@ class AgentRun { this.reasoningBlocks.set(event.id, content); appendEntry(this.expected, { role: "assistant", content: [content] }); } - } else + } else if (event.type === "text") appendEntry(this.expected, { role: "assistant", content: [ @@ -767,31 +800,31 @@ class AgentRun { }), }), }; - else if ( - q.case === undefined && - query.$unknown?.some((field) => field.no === 9) - ) - // Web fetch (field 9) postdates this descriptor. Its response has the - // other gates' layout: rejected = 2 { reason = 1 }. + else if (q.case === undefined) { + // Queries newer than this descriptor. Each reply mirrors the query + // field and fails closed as SDK 1.0.37 local runs do. + const field = query.$unknown?.find((item) => NEWER_QUERIES.has(item.no)); + if (!field) throw new Error("Unsupported Cursor interaction"); + const [result, reason] = NEWER_QUERIES.get(field.no)!; response.$unknown = [ { - no: 9, + no: field.no, wireType: WireType.LengthDelimited, data: new BinaryWriter() .bytes( new BinaryWriter() - .tag(2, WireType.LengthDelimited) + .tag(result, WireType.LengthDelimited) .fork() .tag(1, WireType.LengthDelimited) - .string(hosted) + .string(reason ?? hosted) .join() .finish(), ) .finish(), }, ]; - // A VM setup result can only claim success, and other queries have no - // known reply layout. + } + // A VM setup result can only claim success. else throw new Error("Unsupported Cursor interaction"); this.send({ case: "interactionResponse", value: response }); } @@ -1239,6 +1272,13 @@ class AgentRun { private receive(message: p.AgentServerMessage) { if (this.status === "closed") return; const item = message.message; + // SDK 1.0.37: AgentServerMessage.ttft_breakdown = 8 is latency telemetry. + if ( + item.case === undefined && + message.$unknown?.length && + message.$unknown.every((field) => field.no === 8) + ) + return; if (item.case === "interactionUpdate") { const update = item.value.message; if ( @@ -1255,13 +1295,15 @@ class AgentRun { result: record(result)?.case ?? "none", }); } - // SDK 1.0.31: field 21 is a feedback-form notification; 25 is an - // optional timestamp. Neither is model output or an execution request. - // The base AgentService descriptor retains these as unknown fields. + // SDK 1.0.37 adds client-presentation updates the base descriptor keeps + // as unknown fields: tool counts, prompts, branch changes, feedback, + // response comparisons, context state, routed model, and nudges (19-24, + // 26, 27), plus the optional timestamp (25). None is model output or an + // execution request. if ( update.case === undefined && - item.value.$unknown?.some((field) => field.no === 21) && - item.value.$unknown.every((field) => field.no === 21 || field.no === 25) + item.value.$unknown?.length && + item.value.$unknown.every((field) => PRESENTATION_UPDATES.has(field.no)) ) return; if (this.turnEnded && update.case !== "heartbeat") @@ -1270,7 +1312,9 @@ class AgentRun { if (update.value.text) this.emit( update.case === "textDelta" - ? { type: "text", text: update.value.text } + ? isServerNotice(update.value) + ? { type: "server-notice", text: update.value.text } + : { type: "text", text: update.value.text } : { type: "reasoning", text: update.value.text, @@ -1320,6 +1364,7 @@ class AgentRun { return; } if (item.case === "conversationCheckpointUpdate") { + if (this.turnEnded) this.checkpointAfterTurnEnded = true; // A checkpoint without token details must not erase known occupancy. if (item.value.tokenDetails?.usedTokens) { this.contextTokens = item.value.tokenDetails.usedTokens; diff --git a/src/cursor-client.d.mts b/src/cursor-client.d.mts new file mode 100644 index 0000000..d5e2a10 --- /dev/null +++ b/src/cursor-client.d.mts @@ -0,0 +1,4 @@ +export declare const CURSOR_CLIENT_HEADERS: { + readonly "x-cursor-client-version": string; + readonly "x-cursor-client-type": string; +}; diff --git a/src/cursor-client.mjs b/src/cursor-client.mjs new file mode 100644 index 0000000..5b5f0d9 --- /dev/null +++ b/src/cursor-client.mjs @@ -0,0 +1,6 @@ +// The Cursor CLI build this client presents as. Cursor varies server behavior +// by client version, so change it only after a live end-to-end check. +export const CURSOR_CLIENT_HEADERS = { + "x-cursor-client-version": "cli-2026.01.09-231024f", + "x-cursor-client-type": "cli", +}; diff --git a/src/h2-unary.mjs b/src/h2-unary.mjs index a00f95b..e694527 100644 --- a/src/h2-unary.mjs +++ b/src/h2-unary.mjs @@ -2,6 +2,7 @@ import http2 from "node:http2"; import { randomUUID } from "node:crypto"; import { gunzipSync } from "node:zlib"; +import { CURSOR_CLIENT_HEADERS } from "./cursor-client.mjs"; const LIMIT = 8 * 1024 * 1024; let input = Buffer.alloc(0); @@ -62,8 +63,7 @@ process.stdin.on("end", () => { te: "trailers", authorization: `Bearer ${config.accessToken}`, "x-ghost-mode": "true", - "x-cursor-client-version": "cli-2026.01.09-231024f", - "x-cursor-client-type": "cli", + ...CURSOR_CLIENT_HEADERS, "x-request-id": randomUUID(), }); let response = Buffer.alloc(0); diff --git a/src/h2-v2.mjs b/src/h2-v2.mjs index 3ada4ad..8d2d6fa 100644 --- a/src/h2-v2.mjs +++ b/src/h2-v2.mjs @@ -3,6 +3,7 @@ import http2 from "node:http2"; import { randomUUID } from "node:crypto"; import { gunzipSync } from "node:zlib"; +import { CURSOR_CLIENT_HEADERS } from "./cursor-client.mjs"; const LIMIT = 8 * 1024 * 1024; let client; @@ -11,6 +12,7 @@ let ended = false; let finished = false; let encoding = "identity"; let network = Buffer.alloc(0); +let frames = 0; let ipc = Buffer.alloc(0); function packet(type, data = Buffer.alloc(0)) { @@ -75,8 +77,7 @@ function open(config) { te: "trailers", authorization: `Bearer ${config.accessToken}`, "x-ghost-mode": "true", - "x-cursor-client-version": "cli-2026.01.09-231024f", - "x-cursor-client-type": "cli", + ...CURSOR_CLIENT_HEADERS, "x-request-id": randomUUID(), }; // Cursor's MCP tool family. Allowing only mcp_tool_call hides @@ -111,6 +112,7 @@ function open(config) { if (network.length < 5 + size) break; let bytes = network.subarray(5, 5 + size); network = network.subarray(5 + size); + frames++; if (flags & 1) { if (encoding !== "gzip") throw new Error("unsupported-compression"); bytes = gunzipSync(bytes, { maxOutputLength: LIMIT }); @@ -133,9 +135,15 @@ function open(config) { fail("invalid-connect-frame"); } }); - stream.on("end", () => - finish(ended && network.length === 0 ? undefined : "premature-eof"), - ); + stream.on("end", () => { + if (ended && network.length === 0) return finish(); + if (!network.length) return finish("premature-eof"); + // Sizes only: the cut frame's declared and received bytes. + const declared = network.length >= 5 ? network.readUInt32BE(1) : -1; + finish( + `premature-eof-partial-frame (declared=${declared}, received=${Math.max(0, network.length - 5)}, frames=${frames})`, + ); + }); stream.on("close", () => { if (!finished) fail("stream-closed"); }); diff --git a/src/models/publish.ts b/src/models/publish.ts index 8ca5143..ac3bbdc 100644 --- a/src/models/publish.ts +++ b/src/models/publish.ts @@ -17,6 +17,7 @@ export function publishCursorCatalog( const usableWires = usableModelIds(usableRaw); applyUsableWireIds(available, usableWires); const byId = new Map(available.map((model) => [model.id, model])); + const availableWires = new Set(available.flatMap(wireIds)); for (const model of usable) { const existing = byId.get(model.id); @@ -26,6 +27,9 @@ export function publishCursorCatalog( } continue; } + // A usable family sharing a wire ID with an AvailableModels family under + // another id is a legacy alias of it, not a separate model. + if (wireIds(model).some((id) => availableWires.has(id))) continue; byId.set(model.id, model); } diff --git a/src/opencode/language.ts b/src/opencode/language.ts index d4e3fce..59375d6 100644 --- a/src/opencode/language.ts +++ b/src/opencode/language.ts @@ -123,6 +123,7 @@ export function createCursorLanguageModel( ? { sessionID, observer: options.toolObserver } : undefined, }); + const serverNotices: string[] = []; let openBlock: { type: "text" | "reasoning"; id: string } | undefined; const closeBlock = ( controller: TransformStreamDefaultController, @@ -141,6 +142,10 @@ export function createCursorLanguageModel( controller.enqueue({ type: "stream-start", warnings }); }, transform(event, controller) { + if (event.type === "server-notice") { + serverNotices.push(event.text); + return; + } if (event.type === "text" || event.type === "reasoning") { if ( openBlock?.type !== event.type || @@ -230,6 +235,9 @@ export function createCursorLanguageModel( ...(event.turnUsage === undefined ? {} : { turnUsage: reportedCounts(event.turnUsage) }), + ...(serverNotices.length + ? { serverNotice: serverNotices.join("").trim() } + : {}), billedCost: "unavailable", }, }, @@ -291,6 +299,9 @@ export function createCursorLanguageModel( finalUsage = part.usage; finishReason = part.finishReason; providerMetadata = part.providerMetadata; + const notice = providerMetadata?.cursor?.serverNotice; + if (typeof notice === "string" && notice) + warnings = [...warnings, { type: "other", message: notice }]; } } return { diff --git a/test/model-normalizer.test.ts b/test/model-normalizer.test.ts index eda89b6..07f79dd 100644 --- a/test/model-normalizer.test.ts +++ b/test/model-normalizer.test.ts @@ -289,6 +289,37 @@ describe("Cursor model normalization", () => { expect(models[0]?.defaultSelection.publicId).toBe("gemini-3.7-flash"); }); + test("drops a usable family that aliases an AvailableModels family under another id", () => { + const models = publishCursorCatalog( + [{ + name: "claude-opus-9", + clientDisplayName: "Claude Opus 9", + serverModelName: "claude-opus-9", + parameterDefinitions: [{ + id: "effort", + parameterType: { + enumParameter: { values: [{ value: "high" }, { value: "max" }] }, + }, + }], + variants: ["high", "max"].map((effort) => ({ + parameterValues: [{ id: "effort", value: effort }], + legacySlug: `claude-9-opus-${effort}-thinking`, + })), + }], + [ + { modelId: "claude-9-opus-high", displayName: "Claude Opus 9 Legacy" }, + { modelId: "claude-9-opus-high-thinking", displayName: "Claude Opus 9 Legacy" }, + { modelId: "claude-9-opus-max-thinking", displayName: "Claude Opus 9 Legacy" }, + { modelId: "claude-fable-5.1", displayName: "Claude Fable 5.1" }, + ], + ); + + expect(models.map((model) => model.id)).toEqual([ + "claude-fable-5.1", + "claude-opus-9", + ]); + }); + test("keeps grok-4.6 stable and sends the usable wire id", () => { const models = publishCursorCatalog( [ diff --git a/test/v2-agent.test.ts b/test/v2-agent.test.ts index caf8168..d5245f1 100644 --- a/test/v2-agent.test.ts +++ b/test/v2-agent.test.ts @@ -788,8 +788,8 @@ test("drains final checkpoint/blob work after turnEnded before accepting Connect expect(result.providerMetadata?.cursor?.contextTokens).toBe(321); }); -test("ignores a post-turn feedback form but still rejects unknown terminal updates", async () => { - for (const field of [21, 22]) { +test("ignores post-turn presentation updates but still rejects unknown terminal updates", async () => { + for (const field of [21, 24, 99]) { const backend = await peer((message, stream) => { if (message.case !== "runRequest") return; stream.write(end()); @@ -799,14 +799,16 @@ test("ignores a post-turn feedback form but still rejects unknown terminal updat case: "interactionUpdate", value: fromBinary( p.InteractionUpdateSchema, - new Uint8Array([(((field << 3) | 2) & 127) | 128, 1, 0]), + new Uint8Array([(((field << 3) | 2) & 127) | 128, field >> 4, 0]), ), }), ), ); + // SDK 1.0.37 latency telemetry, AgentServerMessage field 8. + stream.write(frame(Uint8Array.of((8 << 3) | 2, 0))); stream.end(close()); }); - if (field === 21) + if (field !== 99) expect( (await model(backend.url).doGenerate({ prompt })).finishReason.unified, ).toBe("stop"); @@ -1690,6 +1692,36 @@ test("a matching final checkpoint uses Cursor input when output deltas omit gene expect(result.providerMetadata?.cursor?.turnUsage).toMatchObject({ input: 90, output: 13 }); }); +test("Cursor server notices become a warning and metadata, never assistant text", async () => { + const notice = "Switched to a fallback model after a safety filter."; + const flagged = create(p.TextDeltaUpdateSchema, { text: `\n\n${notice}` }); + flagged.$unknown = [ + { no: 2, wireType: WireType.Varint, data: Uint8Array.of(1) }, + ]; + const backend = await peer((message, stream) => { + if (message.case !== "runRequest") return; + stream.write(text("answer")); + stream.write( + frame( + encoded({ + case: "interactionUpdate", + value: create(p.InteractionUpdateSchema, { + message: { case: "textDelta", value: flagged }, + }), + }), + ), + ); + stream.write(end()); + stream.end(close()); + }); + const result = await model(backend.url).doGenerate({ prompt }); + expect(result.content).toEqual([ + { type: "text", text: "answer", providerMetadata: undefined }, + ]); + expect(result.warnings).toEqual([{ type: "other", message: notice }]); + expect(result.providerMetadata?.cursor?.serverNotice).toBe(notice); +}); + test("usage preserves explicit zeros and unknown fields, and rejects unsafe counters", () => { const decode = ( input: Parameters>[1], @@ -1873,14 +1905,18 @@ test("declines Cursor-hosted interaction queries so the Run continues with host value: create(p.CreatePlanRequestQuerySchema), }), ); - const webFetch = create(p.InteractionQuerySchema, { id: 7 }); - webFetch.$unknown = [ - { no: 9, wireType: WireType.LengthDelimited, data: Uint8Array.of(0) }, - ]; - stream.write(frame(encoded({ case: "interactionQuery", value: webFetch }))); + // SDK 1.0.37 queries newer than the descriptor: web fetch, PR + // management, MCP auth, image generation, env replacement, SCM. + for (const no of [9, 10, 11, 12, 13, 14]) { + const newer = create(p.InteractionQuerySchema, { id: no - 2 }); + newer.$unknown = [ + { no, wireType: WireType.LengthDelimited, data: Uint8Array.of(0) }, + ]; + stream.write(frame(encoded({ case: "interactionQuery", value: newer }))); + } } else if (message.case === "interactionResponse") { answers.set(message.value.id, message.value); - if (answers.size === 7) { + if (answers.size === 12) { stream.write(text("continued")); stream.write(end()); stream.end(close()); @@ -1902,15 +1938,17 @@ test("declines Cursor-hosted interaction queries so the Run continues with host ["switchModeRequestResponse", "rejected"], ["createPlanRequestResponse", "error"], ]); - const webFetch = answers.get(7)!; - expect(webFetch.result.case).toBeUndefined(); - const field = webFetch.$unknown?.find((item) => item.no === 9); - expect(field?.wireType).toBe(WireType.LengthDelimited); - const response = new BinaryReader(new BinaryReader(field!.data).bytes()); - expect(response.tag()).toEqual([2, WireType.LengthDelimited]); - const rejected = new BinaryReader(response.bytes()); - expect(rejected.tag()).toEqual([1, WireType.LengthDelimited]); - expect(rejected.string()).toContain("host tool"); + for (const no of [9, 10, 11, 12, 13, 14]) { + const answer = answers.get(no - 2)!; + expect(answer.result.case).toBeUndefined(); + const field = answer.$unknown?.find((item) => item.no === no); + expect(field?.wireType).toBe(WireType.LengthDelimited); + const response = new BinaryReader(new BinaryReader(field!.data).bytes()); + expect(response.tag()).toEqual([2, WireType.LengthDelimited]); + const declined = new BinaryReader(response.bytes()); + expect(declined.tag()).toEqual([1, WireType.LengthDelimited]); + expect(declined.string()).toMatch(no === 9 ? /host tool/ : /disabled/); + } }); test("declines Cursor's native edit so formatted host reads are never written back", async () => {