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.
- package/DISCLAIMER.md +9 -0
- package/README.md +38 -8
- 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 +435 -63
- package/dist/models.d.ts +29 -4
- package/dist/models.js +134 -17
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +145 -43
- 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 +421 -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 +35 -12
- package/dist/protocol/tools.js +179 -74
- package/dist/session.d.ts +26 -1
- package/dist/session.js +2 -2
- package/dist/shared.d.ts +4 -0
- package/dist/shared.js +4 -0
- package/dist/transport/connect.js +11 -1
- package/package.json +3 -2
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, } 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
|
-
|
|
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,11 +250,24 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
183
250
|
apiBaseURL: resolveApiBaseURL(options),
|
|
184
251
|
telemetryEnabled: resolveTelemetryEnabled(options),
|
|
185
252
|
}));
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
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}
|
|
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
|
-
`
|
|
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:
|
|
535
|
+
total: est.inputTokens,
|
|
430
536
|
noCache: undefined,
|
|
431
|
-
cacheRead:
|
|
432
|
-
cacheWrite:
|
|
537
|
+
cacheRead: est.cacheRead,
|
|
538
|
+
cacheWrite: est.cacheWrite,
|
|
433
539
|
},
|
|
434
540
|
outputTokens: {
|
|
435
|
-
total:
|
|
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
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
652
|
-
//
|
|
653
|
-
//
|
|
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
|
-
*
|
|
748
|
-
*
|
|
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
|
|
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
|
|
758
|
-
|
|
759
|
-
return crypto.randomUUID();
|
|
1096
|
+
if (typeof raw === "string" && raw.trim().length > 0)
|
|
1097
|
+
return raw.trim();
|
|
1098
|
+
return undefined;
|
|
760
1099
|
}
|
|
761
|
-
/**
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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 ".";
|