cursor-opencode-provider 0.1.5 → 0.2.2

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, } 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
- import { readCache, cacheFilePath, resolveVariantParameters } from "./models.js";
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,11 +250,24 @@ 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
- const reasoningEffort = providerOptions?.reasoningEffort;
188
- const maxMode = !!(providerOptions?.maxMode ?? false);
189
- const modelInfo = _availableModels?.find((m) => m.id === modelId);
190
- const parameterValues = resolveVariantParameters(modelInfo, { reasoningEffort, maxMode });
253
+ // OpenCode merges model, agent, and selected-variant options before placing
254
+ // them under providerOptions.cursor. Read only the plugin's dedicated nested
255
+ // payload so unrelated options never become requested_model.parameters.
256
+ const picked = extractCursorVariantParameters(providerOptions);
257
+ const cursorModelId = resolveCursorWireModelId(providerOptions, modelId);
258
+ const reasoningEffort = typeof providerOptions?.reasoningEffort === "string"
259
+ ? providerOptions.reasoningEffort
260
+ : undefined;
261
+ const hintMaxMode = !!(providerOptions?.maxMode ?? false);
262
+ const modelInfo = _availableModels?.find((m) => m.id === cursorModelId);
263
+ const parameterValues = resolveVariantParameters(modelInfo, {
264
+ reasoningEffort,
265
+ maxMode: hintMaxMode,
266
+ picked,
267
+ });
268
+ // Wire max_mode from the hint *or* a 1m context pick — OpenCode's variant
269
+ // paramMap does not include a maxMode key when the user selects 1m.
270
+ const maxMode = hintMaxMode || paramsImplyMaxMode(parameterValues);
191
271
  // Do NOT pass callOptions.abortSignal into the h2 Run stream. OpenCode aborts
192
272
  // that signal when a turn ends with tool-calls; the Cursor stream must stay
193
273
  // open until we write the exec results on the next doStream.
@@ -195,27 +275,28 @@ async function startSession(modelId, token, callOptions, options) {
195
275
  baseURL: agentBaseUrl,
196
276
  headers: options.headers,
197
277
  });
198
- // Build the tool descriptors once — advertised in AgentRunRequest #4 mcp_tools
199
- // AND echoed into the request_context reply (server turn-setup probe).
200
- const toolDescriptors = tools.length > 0 ? toolsToDescriptors(tools) : [];
201
- // Compaction/summary turns pass tools:{} (and may set toolChoice "none").
202
- // Cursor still has native Grep/etc.; allowTools gates emitting tool-call parts.
203
- const allowTools = computeAllowTools(toolDescriptors.length, callOptions.toolChoice);
204
278
  const workspaceRoot = options.workspaceRoot || process.cwd();
205
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
+ : [];
206
285
  // CLI parity: echo the last conversation_checkpoint_update as conversation_state.
207
- // Turn 1 (no checkpoint) seeds system prompt only; live user text is current-only.
208
- const conversationState = getCheckpoint(conversationId);
286
+ // After compaction reset there is no checkpoint seed from OpenCode history.
287
+ const conversationState = bound.reset ? undefined : getCheckpoint(conversationId);
209
288
  const reqBytes = buildRunRequest({
210
289
  text: userText,
211
- modelId,
290
+ modelId: cursorModelId,
212
291
  conversationId,
213
292
  systemPrompt: conversationState ? undefined : systemPrompt,
293
+ history: conversationState ? undefined : history,
214
294
  conversationState,
215
295
  parameterValues,
216
296
  maxMode,
217
297
  availableModels: _availableModels,
218
298
  tools,
299
+ toolDescriptors,
219
300
  requestContext,
220
301
  });
221
302
  // Content hashes — Cursor content-addresses large payloads; logging these lets
@@ -227,12 +308,22 @@ async function startSession(modelId, token, callOptions, options) {
227
308
  const hooksCtx = typeof requestContext.hooks_additional_context === "string"
228
309
  ? requestContext.hooks_additional_context
229
310
  : "";
230
- trace(`outbound Run: model=${modelId} conversationId=${conversationId} ` +
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
+ };
318
+ trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId} conversationId=${conversationId} ` +
231
319
  `params=${JSON.stringify(parameterValues ?? [])} ` +
232
- `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} ` +
233
322
  `skills=${skillsCount} hooks=${hooksCtx ? hooksCtx.split("\n").length : 0} ` +
234
323
  `availableModels=${_availableModels?.length ?? 0} userTextLen=${userText.length} ` +
235
- `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}`);
236
327
  if (hooksCtx)
237
328
  trace(`outbound Run hooks_additional_context: ${hooksCtx}`);
238
329
  trace(`hash run_request sha256=${sha(reqBytes)}`);
@@ -247,10 +338,13 @@ async function startSession(modelId, token, callOptions, options) {
247
338
  stream,
248
339
  frames: stream.frames()[Symbol.asyncIterator](),
249
340
  pending: new Map(),
341
+ displayToolCalls: new Map(),
342
+ nextBridgedExecId: 900_000,
250
343
  blobs: new Map(),
251
344
  toolDescriptors,
252
345
  requestContext,
253
346
  allowTools,
347
+ usageEstimate,
254
348
  pumpActive: false,
255
349
  heartbeat: null,
256
350
  expiresAt: Date.now() + 300_000,
@@ -358,7 +452,7 @@ function isTruthyEnv(value) {
358
452
  * and KEEP the session open for the result on the next doStream call;
359
453
  * - turn_ended / stream end → finish "stop" and close the session.
360
454
  */
361
- async function pump(session, controller, ids, abortSignal) {
455
+ export async function pump(session, controller, ids, abortSignal) {
362
456
  const { textId, reasoningId } = ids;
363
457
  let textStarted = false;
364
458
  let reasoningStarted = false;
@@ -411,6 +505,7 @@ async function pump(session, controller, ids, abortSignal) {
411
505
  safeEnqueue({ type: "text-start", id: textId });
412
506
  textStarted = true;
413
507
  }
508
+ session.usageEstimate.outputTokens += estimateTokens(text.length);
414
509
  safeEnqueue({ type: "text-delta", id: textId, delta: text });
415
510
  };
416
511
  const emitReasoning = (text) => {
@@ -420,23 +515,41 @@ async function pump(session, controller, ids, abortSignal) {
420
515
  safeEnqueue({ type: "reasoning-start", id: reasoningId });
421
516
  reasoningStarted = true;
422
517
  }
518
+ session.usageEstimate.outputTokens += estimateTokens(text.length);
423
519
  safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text });
424
520
  };
425
521
  const emitFinish = (te, reason) => {
426
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;
427
533
  const usage = {
428
534
  inputTokens: {
429
- total: te?.input_tokens ?? 0,
535
+ total: est.inputTokens,
430
536
  noCache: undefined,
431
- cacheRead: te?.cache_read ?? 0,
432
- cacheWrite: te?.cache_write ?? 0,
537
+ cacheRead: est.cacheRead,
538
+ cacheWrite: est.cacheWrite,
433
539
  },
434
540
  outputTokens: {
435
- total: te?.output_tokens ?? 0,
541
+ total: est.outputTokens,
436
542
  text: undefined,
437
543
  reasoning: undefined,
438
544
  },
439
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"}`);
440
553
  safeEnqueue({ type: "finish", usage, finishReason: reason });
441
554
  };
442
555
  while (true) {
@@ -509,12 +622,14 @@ async function pump(session, controller, ids, abortSignal) {
509
622
  const iu = asm.interaction_update;
510
623
  const esm = asm.exec_server_message;
511
624
  const kv = asm.kv_server_message;
625
+ const interactionQuery = asm.interaction_query;
512
626
  const checkpointRaw = asm.conversation_checkpoint_update;
513
627
  const topField = payload.length > 0 ? payload[0] >> 3 : 0;
514
628
  {
515
629
  const iuKind = iu ? Object.keys(iu).find((k) => iu[k]) : undefined;
516
630
  trace(`pump frame: topField=${topField} interaction_update=${iuKind ?? "-"} ` +
517
631
  `exec=${esm ? "yes" : "no"} kv=${kv ? "yes" : "no"} ` +
632
+ `interaction_query=${interactionQuery ? "yes" : "no"} ` +
518
633
  `checkpoint=${checkpointRaw ? "yes" : "no"}`);
519
634
  }
520
635
  try {
@@ -538,6 +653,82 @@ async function pump(session, controller, ids, abortSignal) {
538
653
  sessionManager.close(session);
539
654
  return;
540
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
+ }
541
732
  else if (esm) {
542
733
  const esmId = esm.id ?? 0;
543
734
  if (esm.request_context_args) {
@@ -562,8 +753,35 @@ async function pump(session, controller, ids, abortSignal) {
562
753
  }
563
754
  else {
564
755
  const parsed = parseExecServerMessage(esm);
756
+ const displayCallId = extractExecDisplayCallId(esm);
757
+ if (displayCallId) {
758
+ session.displayToolCalls.delete(displayCallId);
759
+ trace(`exec: claimed display callId=${displayCallId}`);
760
+ }
565
761
  trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
566
762
  if (parsed) {
763
+ if (parsed.localError) {
764
+ trace(`exec: REFUSED invalid mapping toolName=${parsed.toolName} error=${parsed.localError}`);
765
+ try {
766
+ for (const frame of buildExecClientMessages({
767
+ execId: parsed.id,
768
+ resultField: parsed.resultField,
769
+ output: "",
770
+ error: parsed.localError,
771
+ toolName: parsed.toolName,
772
+ })) {
773
+ session.stream.write(frame);
774
+ }
775
+ }
776
+ catch (e) {
777
+ const error = new Error(`Failed to reject unsupported Cursor tool request: ${e.message}`);
778
+ trace(`exec: invalid-mapping reply FAILED ${error.message}`);
779
+ safeError(error);
780
+ sessionManager.close(session);
781
+ return;
782
+ }
783
+ continue;
784
+ }
567
785
  // OpenCode throws "Tool call not allowed while generating summary"
568
786
  // when assistantMessage.summary is set. Compaction/summary turns
569
787
  // advertise no tools — refuse on the Cursor channel and keep
@@ -603,24 +821,39 @@ async function pump(session, controller, ids, abortSignal) {
603
821
  emitFinish(undefined, { unified: "tool-calls", raw: undefined });
604
822
  return;
605
823
  }
606
- // Unmapped exec variant (diagnostics/smart-mode-classifier/etc.). We
607
- // MUST still reply or the server blocks. Emit an empty result at the
608
- // variant's field number (request/result share the number) and keep
609
- // pumping. The hex dump records exactly which variant it was.
824
+ // Never guess a response type for an unknown exec variant. Request and
825
+ // result field numbers are not universally identical; a structurally
826
+ // wrong reply recreates the heartbeat-only deadlock. Fail promptly so
827
+ // schema drift is actionable.
610
828
  const variantField = detectExecVariantField(payload);
611
829
  const hex = Array.from(payload.subarray(0, 48))
612
830
  .map((x) => x.toString(16).padStart(2, "0"))
613
831
  .join("");
614
832
  trace(`exec UNMAPPED: id=${esmId} variantField=${variantField} keys=[${Object.keys(esm).join(",")}] hex=${hex}`);
615
- if (variantField && variantField !== REQUEST_CONTEXT_RESULT_FIELD) {
616
- try {
617
- session.stream.write(buildRawEmptyExecReply(esmId, variantField));
618
- trace(`exec UNMAPPED: replied empty result field=${variantField}`);
619
- }
620
- catch (e) {
621
- trace(`exec UNMAPPED: write FAILED field=${variantField} err=${e.message}`);
622
- }
623
- }
833
+ const err = new Error(`Unsupported Cursor exec variant field #${variantField ?? "unknown"} (id=${esmId})`);
834
+ safeError(err);
835
+ sessionManager.close(session);
836
+ return;
837
+ }
838
+ }
839
+ else if (interactionQuery) {
840
+ // InteractionQuery is a must-reply channel, just like exec and KV. AI
841
+ // SDK has no Cursor-specific UI callback, so answer immediately with the
842
+ // conservative headless policy from protocol/interactions.ts.
843
+ try {
844
+ const handled = handleInteractionQuery(interactionQuery, payload);
845
+ session.stream.write(handled.reply);
846
+ trace(`interaction_query: replied id=${handled.id} variant=${handled.variantName} ` +
847
+ `field=${handled.variantField} outcome=${handled.outcome}`);
848
+ }
849
+ catch (e) {
850
+ const info = inspectInteractionQueryWire(payload);
851
+ const err = e instanceof Error ? e : new Error(String(e));
852
+ trace(`interaction_query: FAILED id=${info.id ?? "?"} ` +
853
+ `variantField=${info.variantField ?? "?"} err=${err.message}`);
854
+ safeError(err);
855
+ sessionManager.close(session);
856
+ return;
624
857
  }
625
858
  }
626
859
  else if (kv) {
@@ -648,9 +881,9 @@ async function pump(session, controller, ids, abortSignal) {
648
881
  // exec/args decode) must not abort the whole turn — log and skip.
649
882
  trace(`frame dispatch FAILED (skipping): topField=${topField} err=${e.message}`);
650
883
  }
651
- // heartbeat / step / partial_tool_call / interaction_query
652
- // ignore (partial args are display-only; the exec channel is authoritative.
653
- // Checkpoints are handled above.)
884
+ // heartbeat / step / partial_tool_call ignore (partial args are
885
+ // display-only; the exec channel is authoritative. Checkpoints and
886
+ // interaction queries are handled above.)
654
887
  }
655
888
  }
656
889
  /** Normalize protobufjs bytes / Buffer / number[] into a Uint8Array. */
@@ -744,29 +977,135 @@ function extractSystemPrompt(prompt) {
744
977
  return parts.length > 0 ? parts.join("\n\n") : undefined;
745
978
  }
746
979
  /**
747
- * Map OpenCode's session id header to a stable Cursor conversation_id (UUID).
748
- * Falls back to a random UUID when headers are absent (direct SDK use).
980
+ * Cursor's native UI interactions cannot be surfaced through the AI SDK.
981
+ * Redirect only to OpenCode tools that are genuinely advertised this turn;
982
+ * compaction keeps its dedicated summary prompt unchanged.
749
983
  */
750
- export function resolveConversationId(callOptions) {
984
+ export function buildOpenCodeInteractionGuidance(tools, isCompaction) {
985
+ if (isCompaction)
986
+ return undefined;
987
+ const names = new Set(tools.map((tool) => tool.name));
988
+ const instructions = [];
989
+ if (names.has("question")) {
990
+ instructions.push("- When user input is required, call the OpenCode `question` tool; do not use Cursor's native AskQuestion interaction.");
991
+ }
992
+ if (names.has("plan_enter")) {
993
+ instructions.push("- To enter plan mode, call the OpenCode `plan_enter` tool; do not use Cursor's native SwitchMode or CreatePlan interactions.");
994
+ }
995
+ else if (names.has("todowrite")) {
996
+ 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.");
997
+ }
998
+ if (names.has("plan_exit")) {
999
+ instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool.");
1000
+ }
1001
+ if (names.has("webfetch")) {
1002
+ instructions.push("- To fetch a known URL, call the OpenCode `webfetch` tool; do not use Cursor's native WebFetch interaction.");
1003
+ }
1004
+ if (instructions.length === 0)
1005
+ return undefined;
1006
+ return [
1007
+ "OpenCode owns interactive workflows and tool execution for this session. Use the advertised OpenCode tools below instead of equivalent Cursor-native UI interactions:",
1008
+ ...instructions,
1009
+ "Emit the actual tool call and wait for its result; never merely claim or summarize that a tool was used.",
1010
+ ].join("\n");
1011
+ }
1012
+ /** Rough char→token estimate for mid-turn usage before TurnEnded arrives. */
1013
+ export function estimateTokens(chars) {
1014
+ if (!Number.isFinite(chars) || chars <= 0)
1015
+ return 0;
1016
+ return Math.ceil(chars / 4);
1017
+ }
1018
+ /**
1019
+ * Prior prompt turns for a seed ConversationStateStructure after compaction
1020
+ * reset. Drops the trailing user message (live action), but preserves tool
1021
+ * result payloads as labeled assistant observations for Cursor's text history.
1022
+ */
1023
+ export function extractPromptHistory(prompt) {
1024
+ const out = [];
1025
+ for (const m of prompt) {
1026
+ if (m.role === "system") {
1027
+ if (typeof m.content === "string" && m.content.length > 0) {
1028
+ out.push({ role: "system", content: m.content });
1029
+ }
1030
+ continue;
1031
+ }
1032
+ if (m.role === "user") {
1033
+ const text = extractUserText(m);
1034
+ if (text && text !== ".")
1035
+ out.push({ role: "user", content: text });
1036
+ continue;
1037
+ }
1038
+ if (m.role === "assistant") {
1039
+ const text = extractAssistantHistoryText(m);
1040
+ if (text)
1041
+ appendSeedHistory(out, "assistant", text);
1042
+ continue;
1043
+ }
1044
+ if (m.role === "tool" && Array.isArray(m.content)) {
1045
+ const results = [];
1046
+ for (const part of m.content) {
1047
+ const p = part;
1048
+ if (p.type !== "tool-result")
1049
+ continue;
1050
+ const toolName = typeof p.toolName === "string" && p.toolName ? p.toolName : "tool";
1051
+ const result = toolResultOutputToText(p.output);
1052
+ const label = result.isError ? "Tool error" : "Tool result";
1053
+ results.push(`${label} (${toolName}):\n${result.text}`);
1054
+ }
1055
+ if (results.length > 0)
1056
+ appendSeedHistory(out, "assistant", results.join("\n\n"));
1057
+ }
1058
+ }
1059
+ // Live user message is the Run action, not seed history.
1060
+ if (out.length > 0 && out[out.length - 1].role === "user")
1061
+ out.pop();
1062
+ return out;
1063
+ }
1064
+ function extractAssistantHistoryText(msg) {
1065
+ const content = msg.content;
1066
+ if (typeof content === "string")
1067
+ return content;
1068
+ if (!Array.isArray(content))
1069
+ return "";
1070
+ const texts = [];
1071
+ for (const part of content) {
1072
+ const p = part;
1073
+ if (p.type === "text" && typeof p.text === "string" && p.text.length > 0) {
1074
+ texts.push(p.text);
1075
+ }
1076
+ }
1077
+ return texts.join("\n");
1078
+ }
1079
+ function appendSeedHistory(out, role, content) {
1080
+ if (!content)
1081
+ return;
1082
+ const last = out[out.length - 1];
1083
+ if (last?.role === role) {
1084
+ last.content += `\n\n${content}`;
1085
+ return;
1086
+ }
1087
+ out.push({ role, content });
1088
+ }
1089
+ /** OpenCode session id header, if present. */
1090
+ export function opencodeSessionKey(callOptions) {
751
1091
  const h = callOptions.headers ?? {};
752
1092
  const raw = h["x-session-id"] ??
753
1093
  h["X-Session-Id"] ??
754
1094
  h["x-session-affinity"] ??
755
1095
  h["x-opencode-session"];
756
- if (typeof raw === "string" && raw.trim().length > 0) {
757
- return sessionIdToUuid(raw.trim());
758
- }
759
- return crypto.randomUUID();
1096
+ if (typeof raw === "string" && raw.trim().length > 0)
1097
+ return raw.trim();
1098
+ return undefined;
760
1099
  }
761
- /** Deterministic UUID (version-4 shape) from an arbitrary session key. */
762
- export function sessionIdToUuid(sessionId) {
763
- const hash = createHash("sha256").update(`cursor-opencode-provider:conv:${sessionId}`).digest();
764
- const bytes = Buffer.from(hash.subarray(0, 16));
765
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
766
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
767
- const hex = bytes.toString("hex");
768
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
1100
+ /**
1101
+ * Map OpenCode's session id header to the active Cursor conversation_id.
1102
+ * Compaction resets remint via bindConversationId; otherwise the binding is
1103
+ * sticky for the OpenCode session. Falls back to a random UUID with no header.
1104
+ */
1105
+ export function resolveConversationId(callOptions) {
1106
+ return bindConversationId(opencodeSessionKey(callOptions)).conversationId;
769
1107
  }
1108
+ export { sessionIdToUuid } from "./protocol/conversation-bind.js";
770
1109
  function extractTools(callOptions) {
771
1110
  const tools = callOptions.tools;
772
1111
  if (!tools || tools.length === 0) {
@@ -800,6 +1139,39 @@ export function spanEndParts(opts) {
800
1139
  export function computeAllowTools(toolCount, toolChoice) {
801
1140
  return toolCount > 0 && toolChoice?.type !== "none";
802
1141
  }
1142
+ export function resolveTurnToolState(input) {
1143
+ const { sessionKey, incomingTools, isCompaction } = input;
1144
+ if (sessionKey && incomingTools.length > 0) {
1145
+ rememberToolCatalog(sessionKey, incomingTools.map((tool) => ({ ...tool })));
1146
+ }
1147
+ const cached = sessionKey ? toolCatalogBySession.get(sessionKey) : undefined;
1148
+ if (sessionKey && cached && incomingTools.length === 0) {
1149
+ rememberToolCatalog(sessionKey, cached);
1150
+ }
1151
+ const advertisedTools = isCompaction && incomingTools.length === 0
1152
+ ? (cached?.map((tool) => ({ ...tool })) ?? [])
1153
+ : incomingTools;
1154
+ return {
1155
+ advertisedTools,
1156
+ allowTools: !isCompaction && computeAllowTools(incomingTools.length, input.toolChoice),
1157
+ };
1158
+ }
1159
+ export function resolveTurnConversationReset(input) {
1160
+ const { sessionKey, isCompaction } = input;
1161
+ if (isCompaction) {
1162
+ if (sessionKey)
1163
+ rememberPostCompactionRebase(sessionKey);
1164
+ return { reset: true, reason: "compaction" };
1165
+ }
1166
+ if (sessionKey && postCompactionRebaseBySession.delete(sessionKey)) {
1167
+ return { reset: true, reason: "post-compaction-rebase" };
1168
+ }
1169
+ return { reset: false };
1170
+ }
1171
+ export function resetTurnStateForTests() {
1172
+ toolCatalogBySession.clear();
1173
+ postCompactionRebaseBySession.clear();
1174
+ }
803
1175
  function extractUserText(lastUser) {
804
1176
  if (!lastUser)
805
1177
  return ".";