cursor-opencode-provider 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,20 +5,53 @@ import { trace } from "./debug.js";
5
5
  import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
6
6
  import { decodeFramePayload } from "./protocol/framing.js";
7
7
  import { decodeMessage } from "./protocol/messages.js";
8
- import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, parseExecIdFromToolCallId, toolsToDescriptors, detectExecVariantField, buildRawEmptyExecReply, buildRequestContextResult, REQUEST_CONTEXT_RESULT_FIELD, } from "./protocol/tools.js";
8
+ import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, } from "./protocol/tools.js";
9
+ import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
9
10
  import { handleKvServerMessage } from "./protocol/kv.js";
11
+ import { handleInteractionQuery, inspectInteractionQueryWire } from "./protocol/interactions.js";
10
12
  import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
11
13
  import { conversationBlobCount } from "./protocol/blob-store.js";
14
+ import { bindConversationId, } from "./protocol/conversation-bind.js";
12
15
  import { sessionManager } from "./session.js";
13
16
  import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
14
17
  import { buildRequestContext } from "./context/build.js";
15
18
  import { opencodeGlobalCacheDir } from "./context/paths.js";
16
19
  import { resolveAgentUrl } from "./agent-url.js";
17
- import { CURSOR_API_HOST } from "./shared.js";
20
+ import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
18
21
  let _availableModels;
19
22
  // mtime of the cache file the last time we loaded it. Compared on each call
20
23
  // so discoverModels' background refresh is picked up without a process restart.
21
24
  let _availableModelsMtimeMs = -1;
25
+ // OpenCode omits tools from compaction calls. Keep the last real catalog per
26
+ // session so the new Cursor conversation is still born with tool definitions;
27
+ // execution remains disabled for the summary turn itself.
28
+ const toolCatalogBySession = new Map();
29
+ // A compaction Run uses its own summary-agent system prompt. Its opaque Cursor
30
+ // checkpoint must never become the base for the resumed normal agent: doing so
31
+ // suppresses OpenCode's compacted prompt/system seed and makes Cursor narrate
32
+ // tool use instead of emitting exec requests. Rebase once on the next turn.
33
+ const postCompactionRebaseBySession = new Set();
34
+ export const MAX_TURN_STATE_SESSIONS = 256;
35
+ function rememberToolCatalog(sessionKey, tools) {
36
+ toolCatalogBySession.delete(sessionKey);
37
+ toolCatalogBySession.set(sessionKey, tools);
38
+ while (toolCatalogBySession.size > MAX_TURN_STATE_SESSIONS) {
39
+ const oldest = toolCatalogBySession.keys().next().value;
40
+ if (!oldest)
41
+ break;
42
+ toolCatalogBySession.delete(oldest);
43
+ }
44
+ }
45
+ function rememberPostCompactionRebase(sessionKey) {
46
+ postCompactionRebaseBySession.delete(sessionKey);
47
+ postCompactionRebaseBySession.add(sessionKey);
48
+ while (postCompactionRebaseBySession.size > MAX_TURN_STATE_SESSIONS) {
49
+ const oldest = postCompactionRebaseBySession.values().next().value;
50
+ if (!oldest)
51
+ break;
52
+ postCompactionRebaseBySession.delete(oldest);
53
+ }
54
+ }
22
55
  export function createCursorLanguageModel(modelId, providerId, options) {
23
56
  return {
24
57
  specificationVersion: "v3",
@@ -73,6 +106,14 @@ async function doStreamImpl(modelId, options, callOptions) {
73
106
  const pending = session.pending.get(r.execId);
74
107
  if (!pending)
75
108
  continue;
109
+ if (pending.bridged) {
110
+ // Display-only Cursor tool — OpenCode already ran it; nothing to write
111
+ // back on the Run stream.
112
+ session.usageEstimate.inputTokens += estimateTokens(r.output.length);
113
+ trace(`continuation: bridged tool result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
114
+ sessionManager.resolve(session.sessionId, r.execId);
115
+ continue;
116
+ }
76
117
  try {
77
118
  for (const frame of buildExecClientMessages({
78
119
  execId: r.execId,
@@ -83,6 +124,8 @@ async function doStreamImpl(modelId, options, callOptions) {
83
124
  })) {
84
125
  session.stream.write(frame);
85
126
  }
127
+ // Tool results enlarge Cursor's context for the rest of this Run.
128
+ session.usageEstimate.inputTokens += estimateTokens(r.output.length);
86
129
  trace(`continuation: wrote exec result execId=${r.execId} field=${pending.resultField} outLen=${r.output.length}`);
87
130
  // Only resolve after the write succeeded — if it threw, the server never
88
131
  // got the result and would block on heartbeats forever otherwise.
@@ -166,14 +209,38 @@ async function doStreamImpl(modelId, options, callOptions) {
166
209
  }
167
210
  async function startSession(modelId, token, callOptions, options) {
168
211
  const prompt = callOptions.prompt;
169
- // Cursor stores server-side history keyed by conversation_id. Minting a new
170
- // UUID every turn made each OpenCode user message look like a brand-new chat.
171
- // OpenCode sends X-Session-Id / x-session-affinity on every call — derive a
172
- // stable UUID from that so follow-ups hit the same server conversation.
173
- const conversationId = resolveConversationId(callOptions);
212
+ const incomingTools = extractTools(callOptions);
213
+ const sessionKey = opencodeSessionKey(callOptions);
214
+ const providerOptions = callOptions.providerOptions?.cursor;
215
+ // The classic plugin marks OpenCode's agent="compaction" through chat.params.
216
+ // Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
217
+ const isCompaction = providerOptions?.[CURSOR_COMPACTION_OPTION] === true;
218
+ const toolState = resolveTurnToolState({
219
+ sessionKey,
220
+ incomingTools,
221
+ toolChoice: callOptions.toolChoice,
222
+ isCompaction,
223
+ });
224
+ const tools = toolState.advertisedTools;
225
+ const allowTools = toolState.allowTools;
226
+ const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
227
+ // Compaction must not reuse the prior conversation; its first normal turn
228
+ // must also rebase so the summary-agent checkpoint cannot replace the normal
229
+ // system prompt and OpenCode's newly compacted history.
230
+ const bound = bindConversationId(sessionKey, { reset: resetState.reset });
231
+ const conversationId = bound.conversationId;
232
+ if (bound.reset) {
233
+ trace(`conversation reset: reason=${resetState.reason ?? "unknown"} ` +
234
+ `sessionKey=${sessionKey ?? "(none)"} ` +
235
+ `previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
236
+ }
174
237
  const userText = extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".";
175
- const systemPrompt = extractSystemPrompt(prompt);
176
- const tools = extractTools(callOptions);
238
+ const baseSystemPrompt = extractSystemPrompt(prompt);
239
+ const interactionGuidance = buildOpenCodeInteractionGuidance(tools, isCompaction);
240
+ const systemPrompt = interactionGuidance
241
+ ? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n")
242
+ : baseSystemPrompt;
243
+ const history = extractPromptHistory(prompt);
177
244
  await loadAvailableModels();
178
245
  // Resolve the region-specific Run stream origin once per process (memoized
179
246
  // in agent-url.ts). Explicit agent host overrides skip GetServerConfig but
@@ -183,7 +250,6 @@ async function startSession(modelId, token, callOptions, options) {
183
250
  apiBaseURL: resolveApiBaseURL(options),
184
251
  telemetryEnabled: resolveTelemetryEnabled(options),
185
252
  }));
186
- const providerOptions = callOptions.providerOptions?.cursor;
187
253
  // OpenCode merges model, agent, and selected-variant options before placing
188
254
  // them under providerOptions.cursor. Read only the plugin's dedicated nested
189
255
  // payload so unrelated options never become requested_model.parameters.
@@ -209,27 +275,28 @@ async function startSession(modelId, token, callOptions, options) {
209
275
  baseURL: agentBaseUrl,
210
276
  headers: options.headers,
211
277
  });
212
- // Build the tool descriptors once — advertised in AgentRunRequest #4 mcp_tools
213
- // AND echoed into the request_context reply (server turn-setup probe).
214
- const toolDescriptors = tools.length > 0 ? toolsToDescriptors(tools) : [];
215
- // Compaction/summary turns pass tools:{} (and may set toolChoice "none").
216
- // Cursor still has native Grep/etc.; allowTools gates emitting tool-call parts.
217
- const allowTools = computeAllowTools(toolDescriptors.length, callOptions.toolChoice);
218
278
  const workspaceRoot = options.workspaceRoot || process.cwd();
219
279
  const requestContext = await buildRequestContext({ workspaceRoot, tools });
280
+ // Resolve descriptors once from the merged OpenCode config so MCP identity is
281
+ // consistent across AgentRunRequest and both request_context reply paths.
282
+ const toolDescriptors = Array.isArray(requestContext.tools)
283
+ ? requestContext.tools
284
+ : [];
220
285
  // CLI parity: echo the last conversation_checkpoint_update as conversation_state.
221
- // Turn 1 (no checkpoint) seeds system prompt only; live user text is current-only.
222
- const conversationState = getCheckpoint(conversationId);
286
+ // After compaction reset there is no checkpoint seed from OpenCode history.
287
+ const conversationState = bound.reset ? undefined : getCheckpoint(conversationId);
223
288
  const reqBytes = buildRunRequest({
224
289
  text: userText,
225
290
  modelId: cursorModelId,
226
291
  conversationId,
227
292
  systemPrompt: conversationState ? undefined : systemPrompt,
293
+ history: conversationState ? undefined : history,
228
294
  conversationState,
229
295
  parameterValues,
230
296
  maxMode,
231
297
  availableModels: _availableModels,
232
298
  tools,
299
+ toolDescriptors,
233
300
  requestContext,
234
301
  });
235
302
  // Content hashes — Cursor content-addresses large payloads; logging these lets
@@ -241,12 +308,22 @@ async function startSession(modelId, token, callOptions, options) {
241
308
  const hooksCtx = typeof requestContext.hooks_additional_context === "string"
242
309
  ? requestContext.hooks_additional_context
243
310
  : "";
311
+ const historyChars = history.reduce((n, m) => n + m.content.length, 0);
312
+ const usageEstimate = {
313
+ inputTokens: estimateTokens((systemPrompt?.length ?? 0) + userText.length + historyChars + (conversationState?.length ?? 0)),
314
+ outputTokens: 0,
315
+ cacheRead: 0,
316
+ cacheWrite: 0,
317
+ };
244
318
  trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId} conversationId=${conversationId} ` +
245
319
  `params=${JSON.stringify(parameterValues ?? [])} ` +
246
- `maxMode=${maxMode} systemPromptLen=${systemPrompt?.length ?? 0} tools=${tools.length} ` +
320
+ `maxMode=${maxMode} systemPromptLen=${systemPrompt?.length ?? 0} ` +
321
+ `tools=${tools.length} incomingTools=${incomingTools.length} compaction=${isCompaction} ` +
247
322
  `skills=${skillsCount} hooks=${hooksCtx ? hooksCtx.split("\n").length : 0} ` +
248
323
  `availableModels=${_availableModels?.length ?? 0} userTextLen=${userText.length} ` +
249
- `checkpointLen=${conversationState?.length ?? 0} runRequestBytes=${reqBytes.length}`);
324
+ `historyMsgs=${history.length} historyChars=${historyChars} ` +
325
+ `checkpointLen=${conversationState?.length ?? 0} reset=${bound.reset} ` +
326
+ `usageEstimateIn=${usageEstimate.inputTokens} runRequestBytes=${reqBytes.length}`);
250
327
  if (hooksCtx)
251
328
  trace(`outbound Run hooks_additional_context: ${hooksCtx}`);
252
329
  trace(`hash run_request sha256=${sha(reqBytes)}`);
@@ -261,10 +338,13 @@ async function startSession(modelId, token, callOptions, options) {
261
338
  stream,
262
339
  frames: stream.frames()[Symbol.asyncIterator](),
263
340
  pending: new Map(),
341
+ displayToolCalls: new Map(),
342
+ nextBridgedExecId: 900_000,
264
343
  blobs: new Map(),
265
344
  toolDescriptors,
266
345
  requestContext,
267
346
  allowTools,
347
+ usageEstimate,
268
348
  pumpActive: false,
269
349
  heartbeat: null,
270
350
  expiresAt: Date.now() + 300_000,
@@ -372,7 +452,7 @@ function isTruthyEnv(value) {
372
452
  * and KEEP the session open for the result on the next doStream call;
373
453
  * - turn_ended / stream end → finish "stop" and close the session.
374
454
  */
375
- async function pump(session, controller, ids, abortSignal) {
455
+ export async function pump(session, controller, ids, abortSignal) {
376
456
  const { textId, reasoningId } = ids;
377
457
  let textStarted = false;
378
458
  let reasoningStarted = false;
@@ -425,6 +505,7 @@ async function pump(session, controller, ids, abortSignal) {
425
505
  safeEnqueue({ type: "text-start", id: textId });
426
506
  textStarted = true;
427
507
  }
508
+ session.usageEstimate.outputTokens += estimateTokens(text.length);
428
509
  safeEnqueue({ type: "text-delta", id: textId, delta: text });
429
510
  };
430
511
  const emitReasoning = (text) => {
@@ -434,23 +515,41 @@ async function pump(session, controller, ids, abortSignal) {
434
515
  safeEnqueue({ type: "reasoning-start", id: reasoningId });
435
516
  reasoningStarted = true;
436
517
  }
518
+ session.usageEstimate.outputTokens += estimateTokens(text.length);
437
519
  safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text });
438
520
  };
439
521
  const emitFinish = (te, reason) => {
440
522
  closeOpenSpans();
523
+ if (te) {
524
+ // Authoritative TurnEnded counts — replace the running estimate.
525
+ session.usageEstimate = {
526
+ inputTokens: Number(te.input_tokens ?? 0) || 0,
527
+ outputTokens: Number(te.output_tokens ?? 0) || 0,
528
+ cacheRead: Number(te.cache_read ?? 0) || 0,
529
+ cacheWrite: Number(te.cache_write ?? 0) || 0,
530
+ };
531
+ }
532
+ const est = session.usageEstimate;
441
533
  const usage = {
442
534
  inputTokens: {
443
- total: te?.input_tokens ?? 0,
535
+ total: est.inputTokens,
444
536
  noCache: undefined,
445
- cacheRead: te?.cache_read ?? 0,
446
- cacheWrite: te?.cache_write ?? 0,
537
+ cacheRead: est.cacheRead,
538
+ cacheWrite: est.cacheWrite,
447
539
  },
448
540
  outputTokens: {
449
- total: te?.output_tokens ?? 0,
541
+ total: est.outputTokens,
450
542
  text: undefined,
451
543
  reasoning: undefined,
452
544
  },
453
545
  };
546
+ const reasonLabel = typeof reason === "object" && reason && "unified" in reason
547
+ ? String(reason.unified ?? "unknown")
548
+ : String(reason);
549
+ trace(`finish: reason=${reasonLabel} ` +
550
+ `in=${est.inputTokens} out=${est.outputTokens} ` +
551
+ `cacheRead=${est.cacheRead} cacheWrite=${est.cacheWrite} ` +
552
+ `source=${te ? "turn_ended" : "estimate"}`);
454
553
  safeEnqueue({ type: "finish", usage, finishReason: reason });
455
554
  };
456
555
  while (true) {
@@ -523,12 +622,14 @@ async function pump(session, controller, ids, abortSignal) {
523
622
  const iu = asm.interaction_update;
524
623
  const esm = asm.exec_server_message;
525
624
  const kv = asm.kv_server_message;
625
+ const interactionQuery = asm.interaction_query;
526
626
  const checkpointRaw = asm.conversation_checkpoint_update;
527
627
  const topField = payload.length > 0 ? payload[0] >> 3 : 0;
528
628
  {
529
629
  const iuKind = iu ? Object.keys(iu).find((k) => iu[k]) : undefined;
530
630
  trace(`pump frame: topField=${topField} interaction_update=${iuKind ?? "-"} ` +
531
631
  `exec=${esm ? "yes" : "no"} kv=${kv ? "yes" : "no"} ` +
632
+ `interaction_query=${interactionQuery ? "yes" : "no"} ` +
532
633
  `checkpoint=${checkpointRaw ? "yes" : "no"}`);
533
634
  }
534
635
  try {
@@ -552,6 +653,82 @@ async function pump(session, controller, ids, abortSignal) {
552
653
  sessionManager.close(session);
553
654
  return;
554
655
  }
656
+ else if (iu?.tool_call_started) {
657
+ // Stash Cursor display ToolCall until exec claims it, or completed bridges it.
658
+ const started = iu.tool_call_started;
659
+ const callId = typeof started.call_id === "string" ? started.call_id : "";
660
+ const toolCall = started.tool_call;
661
+ if (callId && toolCall) {
662
+ session.displayToolCalls.set(callId, toolCall);
663
+ const variant = Object.keys(toolCall).find((k) => k.endsWith("_tool_call")) ?? "?";
664
+ const callIdLog = callId.replace(/\r?\n/g, "\\n");
665
+ let wireFields = "";
666
+ if (variant === "?") {
667
+ const toolBytes = extractProtobufSubmessage(payload, [1, 2, 2]);
668
+ if (toolBytes) {
669
+ wireFields = ` wireFields=[${listProtobufFieldNumbers(toolBytes).join(",")}]`;
670
+ }
671
+ }
672
+ trace(`display tool_call_started: callId=${callIdLog} variant=${variant}${wireFields}`);
673
+ }
674
+ }
675
+ else if (iu?.tool_call_completed) {
676
+ const completed = iu.tool_call_completed;
677
+ const callId = typeof completed.call_id === "string" ? completed.call_id : "";
678
+ // If exec already claimed this call_id, display map entry is gone — skip.
679
+ if (!callId || !session.displayToolCalls.has(callId)) {
680
+ if (callId) {
681
+ trace(`display tool_call_completed: ignore (exec-handled or unknown) callId=${callId}`);
682
+ }
683
+ }
684
+ else {
685
+ const stored = session.displayToolCalls.get(callId);
686
+ session.displayToolCalls.delete(callId);
687
+ const toolCall = completed.tool_call ?? stored;
688
+ if (!session.allowTools) {
689
+ trace(`display tool_call_completed: SKIPPED (allowTools=false) callId=${callId}`);
690
+ }
691
+ else {
692
+ const display = parseDisplayToolCall(callId, toolCall);
693
+ const advertised = advertisedToolNamesFromDescriptors(session.toolDescriptors);
694
+ const bridged = display
695
+ ? resolveBridgedOpenCodeToolCall(display, advertised)
696
+ : undefined;
697
+ if (!display) {
698
+ const callIdLog = callId.replace(/\r?\n/g, "\\n");
699
+ // AgentServerMessage.interaction_update(1).tool_call_completed(3).tool_call(2)
700
+ const toolBytes = extractProtobufSubmessage(payload, [1, 3, 2]);
701
+ const wire = toolBytes
702
+ ? ` wireFields=[${listProtobufFieldNumbers(toolBytes).join(",")}]`
703
+ : "";
704
+ trace(`display tool_call_completed: unparsed callId=${callIdLog} ` +
705
+ `keys=[${Object.keys(toolCall).join(",")}]${wire}`);
706
+ }
707
+ else if (!bridged) {
708
+ trace(`display tool_call_completed: no advertised OpenCode tool ` +
709
+ `callId=${callId} variant=${display.variant} preferred=${display.preferredToolName} ` +
710
+ `advertised=[${advertised.join(",")}]`);
711
+ }
712
+ else {
713
+ const execId = session.nextBridgedExecId++;
714
+ sessionManager.registerPending(execId, session, "bridged", bridged.toolName, true);
715
+ const toolCallId = `cursor_${session.sessionId}_${execId}`;
716
+ const input = JSON.stringify(bridged.args ?? {});
717
+ trace(`display BRIDGED tool-call toolCallId=${toolCallId} toolName=${bridged.toolName} ` +
718
+ `variant=${bridged.variant} callId=${callId} inputLen=${input.length}`);
719
+ closeOpenSpans();
720
+ safeEnqueue({
721
+ type: "tool-call",
722
+ toolCallId,
723
+ toolName: bridged.toolName,
724
+ input,
725
+ });
726
+ emitFinish(undefined, { unified: "tool-calls", raw: undefined });
727
+ return;
728
+ }
729
+ }
730
+ }
731
+ }
555
732
  else if (esm) {
556
733
  const esmId = esm.id ?? 0;
557
734
  if (esm.request_context_args) {
@@ -574,10 +751,57 @@ async function pump(session, controller, ids, abortSignal) {
574
751
  trace(`exec request_context: write FAILED ${e.message}`);
575
752
  }
576
753
  }
754
+ else if (esm.mcp_state_exec_args) {
755
+ // MCP-backed writes/reads can be preceded by this control-plane probe.
756
+ // Confirm the virtual servers from the already-advertised context, then
757
+ // keep pumping until Cursor emits the actual mcp_args tool request.
758
+ const stateArgs = esm.mcp_state_exec_args;
759
+ const requested = Array.isArray(stateArgs.server_identifiers)
760
+ ? stateArgs.server_identifiers.join(",")
761
+ : "";
762
+ try {
763
+ session.stream.write(buildMcpStateResult(esmId, stateArgs, session.requestContext));
764
+ trace(`exec mcp_state: replied id=${esmId} requested=[${requested}]`);
765
+ }
766
+ catch (e) {
767
+ const error = new Error(`Failed to answer Cursor MCP state probe: ${e.message}`);
768
+ trace(`exec mcp_state: write FAILED ${error.message}`);
769
+ safeError(error);
770
+ sessionManager.close(session);
771
+ return;
772
+ }
773
+ }
577
774
  else {
578
775
  const parsed = parseExecServerMessage(esm);
776
+ const displayCallId = extractExecDisplayCallId(esm);
777
+ if (displayCallId) {
778
+ session.displayToolCalls.delete(displayCallId);
779
+ trace(`exec: claimed display callId=${displayCallId}`);
780
+ }
579
781
  trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
580
782
  if (parsed) {
783
+ if (parsed.localError) {
784
+ trace(`exec: REFUSED invalid mapping toolName=${parsed.toolName} error=${parsed.localError}`);
785
+ try {
786
+ for (const frame of buildExecClientMessages({
787
+ execId: parsed.id,
788
+ resultField: parsed.resultField,
789
+ output: "",
790
+ error: parsed.localError,
791
+ toolName: parsed.toolName,
792
+ })) {
793
+ session.stream.write(frame);
794
+ }
795
+ }
796
+ catch (e) {
797
+ const error = new Error(`Failed to reject unsupported Cursor tool request: ${e.message}`);
798
+ trace(`exec: invalid-mapping reply FAILED ${error.message}`);
799
+ safeError(error);
800
+ sessionManager.close(session);
801
+ return;
802
+ }
803
+ continue;
804
+ }
581
805
  // OpenCode throws "Tool call not allowed while generating summary"
582
806
  // when assistantMessage.summary is set. Compaction/summary turns
583
807
  // advertise no tools — refuse on the Cursor channel and keep
@@ -617,24 +841,39 @@ async function pump(session, controller, ids, abortSignal) {
617
841
  emitFinish(undefined, { unified: "tool-calls", raw: undefined });
618
842
  return;
619
843
  }
620
- // Unmapped exec variant (diagnostics/smart-mode-classifier/etc.). We
621
- // MUST still reply or the server blocks. Emit an empty result at the
622
- // variant's field number (request/result share the number) and keep
623
- // pumping. The hex dump records exactly which variant it was.
844
+ // Never guess a response type for an unknown exec variant. Request and
845
+ // result field numbers are not universally identical; a structurally
846
+ // wrong reply recreates the heartbeat-only deadlock. Fail promptly so
847
+ // schema drift is actionable.
624
848
  const variantField = detectExecVariantField(payload);
625
849
  const hex = Array.from(payload.subarray(0, 48))
626
850
  .map((x) => x.toString(16).padStart(2, "0"))
627
851
  .join("");
628
852
  trace(`exec UNMAPPED: id=${esmId} variantField=${variantField} keys=[${Object.keys(esm).join(",")}] hex=${hex}`);
629
- if (variantField && variantField !== REQUEST_CONTEXT_RESULT_FIELD) {
630
- try {
631
- session.stream.write(buildRawEmptyExecReply(esmId, variantField));
632
- trace(`exec UNMAPPED: replied empty result field=${variantField}`);
633
- }
634
- catch (e) {
635
- trace(`exec UNMAPPED: write FAILED field=${variantField} err=${e.message}`);
636
- }
637
- }
853
+ const err = new Error(`Unsupported Cursor exec variant field #${variantField ?? "unknown"} (id=${esmId})`);
854
+ safeError(err);
855
+ sessionManager.close(session);
856
+ return;
857
+ }
858
+ }
859
+ else if (interactionQuery) {
860
+ // InteractionQuery is a must-reply channel, just like exec and KV. AI
861
+ // SDK has no Cursor-specific UI callback, so answer immediately with the
862
+ // conservative headless policy from protocol/interactions.ts.
863
+ try {
864
+ const handled = handleInteractionQuery(interactionQuery, payload);
865
+ session.stream.write(handled.reply);
866
+ trace(`interaction_query: replied id=${handled.id} variant=${handled.variantName} ` +
867
+ `field=${handled.variantField} outcome=${handled.outcome}`);
868
+ }
869
+ catch (e) {
870
+ const info = inspectInteractionQueryWire(payload);
871
+ const err = e instanceof Error ? e : new Error(String(e));
872
+ trace(`interaction_query: FAILED id=${info.id ?? "?"} ` +
873
+ `variantField=${info.variantField ?? "?"} err=${err.message}`);
874
+ safeError(err);
875
+ sessionManager.close(session);
876
+ return;
638
877
  }
639
878
  }
640
879
  else if (kv) {
@@ -662,9 +901,9 @@ async function pump(session, controller, ids, abortSignal) {
662
901
  // exec/args decode) must not abort the whole turn — log and skip.
663
902
  trace(`frame dispatch FAILED (skipping): topField=${topField} err=${e.message}`);
664
903
  }
665
- // heartbeat / step / partial_tool_call / interaction_query
666
- // ignore (partial args are display-only; the exec channel is authoritative.
667
- // Checkpoints are handled above.)
904
+ // heartbeat / step / partial_tool_call ignore (partial args are
905
+ // display-only; the exec channel is authoritative. Checkpoints and
906
+ // interaction queries are handled above.)
668
907
  }
669
908
  }
670
909
  /** Normalize protobufjs bytes / Buffer / number[] into a Uint8Array. */
@@ -758,29 +997,135 @@ function extractSystemPrompt(prompt) {
758
997
  return parts.length > 0 ? parts.join("\n\n") : undefined;
759
998
  }
760
999
  /**
761
- * Map OpenCode's session id header to a stable Cursor conversation_id (UUID).
762
- * Falls back to a random UUID when headers are absent (direct SDK use).
1000
+ * Cursor's native UI interactions cannot be surfaced through the AI SDK.
1001
+ * Redirect only to OpenCode tools that are genuinely advertised this turn;
1002
+ * compaction keeps its dedicated summary prompt unchanged.
763
1003
  */
764
- export function resolveConversationId(callOptions) {
1004
+ export function buildOpenCodeInteractionGuidance(tools, isCompaction) {
1005
+ if (isCompaction)
1006
+ return undefined;
1007
+ const names = new Set(tools.map((tool) => tool.name));
1008
+ const instructions = [];
1009
+ if (names.has("question")) {
1010
+ instructions.push("- When user input is required, call the OpenCode `question` tool; do not use Cursor's native AskQuestion interaction.");
1011
+ }
1012
+ if (names.has("plan_enter")) {
1013
+ instructions.push("- To enter plan mode, call the OpenCode `plan_enter` tool; do not use Cursor's native SwitchMode or CreatePlan interactions.");
1014
+ }
1015
+ else if (names.has("todowrite")) {
1016
+ instructions.push("- For planning, call the OpenCode `todowrite` tool and explain the plan in normal text; do not use Cursor's native SwitchMode or CreatePlan interactions.");
1017
+ }
1018
+ if (names.has("plan_exit")) {
1019
+ instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool.");
1020
+ }
1021
+ if (names.has("webfetch")) {
1022
+ instructions.push("- To fetch a known URL, call the OpenCode `webfetch` tool; do not use Cursor's native WebFetch interaction.");
1023
+ }
1024
+ if (instructions.length === 0)
1025
+ return undefined;
1026
+ return [
1027
+ "OpenCode owns interactive workflows and tool execution for this session. Use the advertised OpenCode tools below instead of equivalent Cursor-native UI interactions:",
1028
+ ...instructions,
1029
+ "Emit the actual tool call and wait for its result; never merely claim or summarize that a tool was used.",
1030
+ ].join("\n");
1031
+ }
1032
+ /** Rough char→token estimate for mid-turn usage before TurnEnded arrives. */
1033
+ export function estimateTokens(chars) {
1034
+ if (!Number.isFinite(chars) || chars <= 0)
1035
+ return 0;
1036
+ return Math.ceil(chars / 4);
1037
+ }
1038
+ /**
1039
+ * Prior prompt turns for a seed ConversationStateStructure after compaction
1040
+ * reset. Drops the trailing user message (live action), but preserves tool
1041
+ * result payloads as labeled assistant observations for Cursor's text history.
1042
+ */
1043
+ export function extractPromptHistory(prompt) {
1044
+ const out = [];
1045
+ for (const m of prompt) {
1046
+ if (m.role === "system") {
1047
+ if (typeof m.content === "string" && m.content.length > 0) {
1048
+ out.push({ role: "system", content: m.content });
1049
+ }
1050
+ continue;
1051
+ }
1052
+ if (m.role === "user") {
1053
+ const text = extractUserText(m);
1054
+ if (text && text !== ".")
1055
+ out.push({ role: "user", content: text });
1056
+ continue;
1057
+ }
1058
+ if (m.role === "assistant") {
1059
+ const text = extractAssistantHistoryText(m);
1060
+ if (text)
1061
+ appendSeedHistory(out, "assistant", text);
1062
+ continue;
1063
+ }
1064
+ if (m.role === "tool" && Array.isArray(m.content)) {
1065
+ const results = [];
1066
+ for (const part of m.content) {
1067
+ const p = part;
1068
+ if (p.type !== "tool-result")
1069
+ continue;
1070
+ const toolName = typeof p.toolName === "string" && p.toolName ? p.toolName : "tool";
1071
+ const result = toolResultOutputToText(p.output);
1072
+ const label = result.isError ? "Tool error" : "Tool result";
1073
+ results.push(`${label} (${toolName}):\n${result.text}`);
1074
+ }
1075
+ if (results.length > 0)
1076
+ appendSeedHistory(out, "assistant", results.join("\n\n"));
1077
+ }
1078
+ }
1079
+ // Live user message is the Run action, not seed history.
1080
+ if (out.length > 0 && out[out.length - 1].role === "user")
1081
+ out.pop();
1082
+ return out;
1083
+ }
1084
+ function extractAssistantHistoryText(msg) {
1085
+ const content = msg.content;
1086
+ if (typeof content === "string")
1087
+ return content;
1088
+ if (!Array.isArray(content))
1089
+ return "";
1090
+ const texts = [];
1091
+ for (const part of content) {
1092
+ const p = part;
1093
+ if (p.type === "text" && typeof p.text === "string" && p.text.length > 0) {
1094
+ texts.push(p.text);
1095
+ }
1096
+ }
1097
+ return texts.join("\n");
1098
+ }
1099
+ function appendSeedHistory(out, role, content) {
1100
+ if (!content)
1101
+ return;
1102
+ const last = out[out.length - 1];
1103
+ if (last?.role === role) {
1104
+ last.content += `\n\n${content}`;
1105
+ return;
1106
+ }
1107
+ out.push({ role, content });
1108
+ }
1109
+ /** OpenCode session id header, if present. */
1110
+ export function opencodeSessionKey(callOptions) {
765
1111
  const h = callOptions.headers ?? {};
766
1112
  const raw = h["x-session-id"] ??
767
1113
  h["X-Session-Id"] ??
768
1114
  h["x-session-affinity"] ??
769
1115
  h["x-opencode-session"];
770
- if (typeof raw === "string" && raw.trim().length > 0) {
771
- return sessionIdToUuid(raw.trim());
772
- }
773
- return crypto.randomUUID();
1116
+ if (typeof raw === "string" && raw.trim().length > 0)
1117
+ return raw.trim();
1118
+ return undefined;
774
1119
  }
775
- /** Deterministic UUID (version-4 shape) from an arbitrary session key. */
776
- export function sessionIdToUuid(sessionId) {
777
- const hash = createHash("sha256").update(`cursor-opencode-provider:conv:${sessionId}`).digest();
778
- const bytes = Buffer.from(hash.subarray(0, 16));
779
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
780
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
781
- const hex = bytes.toString("hex");
782
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
1120
+ /**
1121
+ * Map OpenCode's session id header to the active Cursor conversation_id.
1122
+ * Compaction resets remint via bindConversationId; otherwise the binding is
1123
+ * sticky for the OpenCode session. Falls back to a random UUID with no header.
1124
+ */
1125
+ export function resolveConversationId(callOptions) {
1126
+ return bindConversationId(opencodeSessionKey(callOptions)).conversationId;
783
1127
  }
1128
+ export { sessionIdToUuid } from "./protocol/conversation-bind.js";
784
1129
  function extractTools(callOptions) {
785
1130
  const tools = callOptions.tools;
786
1131
  if (!tools || tools.length === 0) {
@@ -814,6 +1159,39 @@ export function spanEndParts(opts) {
814
1159
  export function computeAllowTools(toolCount, toolChoice) {
815
1160
  return toolCount > 0 && toolChoice?.type !== "none";
816
1161
  }
1162
+ export function resolveTurnToolState(input) {
1163
+ const { sessionKey, incomingTools, isCompaction } = input;
1164
+ if (sessionKey && incomingTools.length > 0) {
1165
+ rememberToolCatalog(sessionKey, incomingTools.map((tool) => ({ ...tool })));
1166
+ }
1167
+ const cached = sessionKey ? toolCatalogBySession.get(sessionKey) : undefined;
1168
+ if (sessionKey && cached && incomingTools.length === 0) {
1169
+ rememberToolCatalog(sessionKey, cached);
1170
+ }
1171
+ const advertisedTools = isCompaction && incomingTools.length === 0
1172
+ ? (cached?.map((tool) => ({ ...tool })) ?? [])
1173
+ : incomingTools;
1174
+ return {
1175
+ advertisedTools,
1176
+ allowTools: !isCompaction && computeAllowTools(incomingTools.length, input.toolChoice),
1177
+ };
1178
+ }
1179
+ export function resolveTurnConversationReset(input) {
1180
+ const { sessionKey, isCompaction } = input;
1181
+ if (isCompaction) {
1182
+ if (sessionKey)
1183
+ rememberPostCompactionRebase(sessionKey);
1184
+ return { reset: true, reason: "compaction" };
1185
+ }
1186
+ if (sessionKey && postCompactionRebaseBySession.delete(sessionKey)) {
1187
+ return { reset: true, reason: "post-compaction-rebase" };
1188
+ }
1189
+ return { reset: false };
1190
+ }
1191
+ export function resetTurnStateForTests() {
1192
+ toolCatalogBySession.clear();
1193
+ postCompactionRebaseBySession.clear();
1194
+ }
817
1195
  function extractUserText(lastUser) {
818
1196
  if (!lastUser)
819
1197
  return ".";