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.
- package/DISCLAIMER.md +9 -0
- package/README.md +9 -4
- package/dist/context/build.js +3 -2
- package/dist/context/rules.d.ts +1 -0
- package/dist/context/rules.js +1 -0
- package/dist/language-model.d.ts +52 -6
- package/dist/language-model.js +434 -56
- package/dist/plugin.js +17 -2
- package/dist/protocol/blob-store.d.ts +2 -0
- package/dist/protocol/blob-store.js +4 -0
- package/dist/protocol/conversation-bind.d.ts +18 -0
- package/dist/protocol/conversation-bind.js +66 -0
- package/dist/protocol/interactions.d.ts +23 -0
- package/dist/protocol/interactions.js +136 -0
- package/dist/protocol/messages.js +454 -13
- package/dist/protocol/request.d.ts +30 -0
- package/dist/protocol/request.js +23 -8
- package/dist/protocol/stream.js +9 -3
- package/dist/protocol/tool-call-bridge.d.ts +44 -0
- package/dist/protocol/tool-call-bridge.js +480 -0
- package/dist/protocol/tools.d.ts +41 -12
- package/dist/protocol/tools.js +238 -74
- package/dist/session.d.ts +26 -1
- package/dist/session.js +2 -2
- package/dist/shared.d.ts +2 -0
- package/dist/shared.js +2 -0
- package/package.json +2 -1
package/dist/language-model.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
//
|
|
173
|
-
|
|
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
|
|
176
|
-
const
|
|
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
|
-
//
|
|
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}
|
|
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
|
-
`
|
|
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:
|
|
535
|
+
total: est.inputTokens,
|
|
444
536
|
noCache: undefined,
|
|
445
|
-
cacheRead:
|
|
446
|
-
cacheWrite:
|
|
537
|
+
cacheRead: est.cacheRead,
|
|
538
|
+
cacheWrite: est.cacheWrite,
|
|
447
539
|
},
|
|
448
540
|
outputTokens: {
|
|
449
|
-
total:
|
|
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
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
|
666
|
-
//
|
|
667
|
-
//
|
|
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
|
-
*
|
|
762
|
-
*
|
|
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
|
|
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
|
|
772
|
-
|
|
773
|
-
return crypto.randomUUID();
|
|
1116
|
+
if (typeof raw === "string" && raw.trim().length > 0)
|
|
1117
|
+
return raw.trim();
|
|
1118
|
+
return undefined;
|
|
774
1119
|
}
|
|
775
|
-
/**
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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 ".";
|