cursor-opencode-provider 0.4.3 → 0.6.0
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/README.md +33 -23
- package/dist/context/agents.d.ts +1 -1
- package/dist/context/agents.js +1 -1
- package/dist/context/auth-store.js +2 -2
- package/dist/context/build.d.ts +11 -1
- package/dist/context/build.js +76 -25
- package/dist/context/frozen.d.ts +29 -0
- package/dist/context/frozen.js +168 -0
- package/dist/context/index.d.ts +3 -2
- package/dist/context/index.js +3 -2
- package/dist/context/paths.d.ts +34 -53
- package/dist/context/paths.js +54 -118
- package/dist/context/rules.d.ts +1 -0
- package/dist/context/rules.js +2 -2
- package/dist/image-save-tool.d.ts +19 -0
- package/dist/image-save-tool.js +25 -0
- package/dist/image-save.d.ts +58 -0
- package/dist/image-save.js +189 -0
- package/dist/image-staging.d.ts +59 -0
- package/dist/image-staging.js +91 -0
- package/dist/index.d.ts +1 -1
- package/dist/language-model.d.ts +13 -11
- package/dist/language-model.js +957 -150
- package/dist/plan-execution-kickoff.d.ts +55 -0
- package/dist/plan-execution-kickoff.js +162 -0
- package/dist/plugin-opencode2.js +52 -3
- package/dist/plugin-v2.d.ts +11 -9
- package/dist/plugin-v2.js +6 -13
- package/dist/plugin.d.ts +9 -0
- package/dist/plugin.js +61 -5
- package/dist/pricing-data.d.ts +16 -4
- package/dist/pricing-data.js +16 -4
- package/dist/protocol/ask-question.d.ts +133 -0
- package/dist/protocol/ask-question.js +320 -0
- package/dist/protocol/blob-reachability.d.ts +15 -0
- package/dist/protocol/blob-reachability.js +140 -0
- package/dist/protocol/blob-store.d.ts +37 -0
- package/dist/protocol/blob-store.js +115 -0
- package/dist/protocol/conversation-bind.d.ts +13 -0
- package/dist/protocol/conversation-bind.js +27 -1
- package/dist/protocol/conversation-persistence.d.ts +32 -0
- package/dist/protocol/conversation-persistence.js +504 -0
- package/dist/protocol/conversation-state.d.ts +16 -0
- package/dist/protocol/conversation-state.js +62 -0
- package/dist/protocol/create-plan.d.ts +141 -0
- package/dist/protocol/create-plan.js +373 -0
- package/dist/protocol/generate-image.d.ts +54 -0
- package/dist/protocol/generate-image.js +101 -0
- package/dist/protocol/interactions.d.ts +110 -8
- package/dist/protocol/interactions.js +295 -35
- package/dist/protocol/kv.d.ts +1 -0
- package/dist/protocol/kv.js +2 -0
- package/dist/protocol/messages.d.ts +2 -0
- package/dist/protocol/messages.js +148 -8
- package/dist/protocol/request.d.ts +2 -0
- package/dist/protocol/request.js +2 -0
- package/dist/protocol/switch-mode.d.ts +178 -0
- package/dist/protocol/switch-mode.js +417 -0
- package/dist/protocol/token-details.d.ts +33 -0
- package/dist/protocol/token-details.js +155 -0
- package/dist/protocol/tool-call-bridge.js +10 -5
- package/dist/protocol/tools.d.ts +44 -11
- package/dist/protocol/tools.js +171 -41
- package/dist/session.d.ts +39 -11
- package/dist/shared.d.ts +3 -0
- package/dist/shared.js +3 -0
- package/dist/usage.d.ts +42 -37
- package/dist/usage.js +201 -70
- package/dist/web-search-tool.d.ts +22 -26
- package/dist/web-search-tool.js +25 -15
- package/package.json +7 -4
package/dist/language-model.js
CHANGED
|
@@ -5,21 +5,31 @@ import { bidiRunStream, CursorRunInterruptedError, normalizeAgentRunOrigin, } fr
|
|
|
5
5
|
import { trace, traceRequestContextPaths } from "./debug.js";
|
|
6
6
|
import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
|
|
7
7
|
import { decodeFramePayload } from "./protocol/framing.js";
|
|
8
|
-
import { debugWalkTurnEnded, decodeMessage } from "./protocol/messages.js";
|
|
9
|
-
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_TOOL, } from "./protocol/tools.js";
|
|
8
|
+
import { debugWalkTurnEnded, decodeMessage, encodeMessage } from "./protocol/messages.js";
|
|
9
|
+
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, buildCompleteEditReadMessages, rejectPartialReadMutation, binaryWritePayload, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_TOOL, } from "./protocol/tools.js";
|
|
10
10
|
import { describeCursorExecVariant } from "./protocol/exec-variants.js";
|
|
11
11
|
import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
|
|
12
12
|
import { handleKvServerMessage } from "./protocol/kv.js";
|
|
13
|
-
import { handleInteractionQuery } from "./protocol/interactions.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
13
|
+
import { buildAskQuestionInteractionReply, buildAsyncAskQuestionCompletion, buildCreatePlanInteractionReply, buildSwitchModeInteractionReply, handleInteractionQuery, } from "./protocol/interactions.js";
|
|
14
|
+
import { ASK_QUESTION_RESULT_FIELD, askQuestionResultFromToolOutput, askQuestionToolInput, } from "./protocol/ask-question.js";
|
|
15
|
+
import { SWITCH_MODE_RESULT_FIELD, getActiveCursorMode, isBridgedCursorPlanModeActive, isCursorPlanModeActive, setActiveCursorMode, switchModeResultFromQuestionOutput, switchModeResultFromToolOutput, switchModeToolInput, takeActiveCursorModeReminder, } from "./protocol/switch-mode.js";
|
|
16
|
+
import { CREATE_PLAN_NOT_APPROVED_REASON, CREATE_PLAN_RESULT_FIELD, CURSOR_PLAN_STAGE_TOOL, createPlanApproved, createPlanStageInput, } from "./protocol/create-plan.js";
|
|
17
|
+
import { flushPlanExecutionKickoff, formatPlanKickoffPath, hasPlanExecutionKickoff, planExecutionKickoffState, planPathFromUri, queuePlanExecutionKickoff, takePlanExecutionKickoffWarning, } from "./plan-execution-kickoff.js";
|
|
18
|
+
import { CURSOR_IMAGE_SAVE_TOOL, imageMimeForPath, remapCursorImageWritePath, } from "./protocol/generate-image.js";
|
|
19
|
+
import { stageCursorImage } from "./image-staging.js";
|
|
20
|
+
import { IMAGE_PERMISSION_DENIED_PREFIX } from "./image-save.js";
|
|
21
|
+
import { clearCheckpoint, getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
|
|
22
|
+
import { cursorContextUsageMetadata, decodeConversationTokenDetails, } from "./protocol/token-details.js";
|
|
23
|
+
import { clearConversationBlobs, conversationBlobCount, inspectConversationBlobGraph, } from "./protocol/blob-store.js";
|
|
24
|
+
import { bindConversationId, resolveConversationGroupId, } from "./protocol/conversation-bind.js";
|
|
25
|
+
import { clearPersistedConversationState, hydrateConversationState, persistConversationState, } from "./protocol/conversation-state.js";
|
|
26
|
+
import { initializeConversationPersistence } from "./protocol/conversation-persistence.js";
|
|
17
27
|
import { resolveContinuationPolicy, sessionManager, } from "./session.js";
|
|
18
28
|
import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
|
|
19
29
|
import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
|
|
20
|
-
import {
|
|
30
|
+
import { getOrBuildRequestContext } from "./context/frozen.js";
|
|
21
31
|
import { workspaceRootFromRequestContext } from "./context/env.js";
|
|
22
|
-
import { opencodeGlobalCacheDir, setHostCacheDirOverride } from "./context/paths.js";
|
|
32
|
+
import { ensureOpencodeProjectDir, opencodeGlobalCacheDir, setHostCacheDirOverride, } from "./context/paths.js";
|
|
23
33
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
24
34
|
import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
|
|
25
35
|
import { isCompactionSession } from "./compaction-marker.js";
|
|
@@ -29,7 +39,7 @@ import { resolveCursorModelSupportsImages } from "./model-metadata.js";
|
|
|
29
39
|
import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
|
|
30
40
|
import { analyzeReplayFrame, AttemptReplaySafety } from "./replay-safety.js";
|
|
31
41
|
import { readAllFieldsStrict } from "./protocol/struct.js";
|
|
32
|
-
import {
|
|
42
|
+
import { buildLanguageModelV3UsageFromCounters, cursorUsageCountersFromTurnEnded, emptyLanguageModelV3Usage, formatCursorCacheDiagnostics, formatCursorTokenCategories, formatTurnUsageValidation, turnEndedCounter, } from "./usage.js";
|
|
33
43
|
let _availableModels;
|
|
34
44
|
// mtime of the cache file the last time we loaded it. Compared on each call
|
|
35
45
|
// so discoverModels' background refresh is picked up without a process restart.
|
|
@@ -56,6 +66,11 @@ const MAX_RETRY_DELAY_MS = 30_000;
|
|
|
56
66
|
const RUN_REQUEST_DECODE_FAILED = "CURSOR_RUN_REQUEST_DECODE_FAILED";
|
|
57
67
|
const RUN_REQUEST_UNSUPPORTED = "CURSOR_RUN_REQUEST_UNSUPPORTED";
|
|
58
68
|
const RUN_REPLY_FAILED = "CURSOR_RUN_REPLY_FAILED";
|
|
69
|
+
/** Cursor's own conversation export path treats state above 100 MiB as large. */
|
|
70
|
+
export const MAX_CHECKPOINT_BLOB_GRAPH_BYTES = 100 * 1024 * 1024;
|
|
71
|
+
export function checkpointBlobGraphRequiresRebase(stats, maxBytes = MAX_CHECKPOINT_BLOB_GRAPH_BYTES) {
|
|
72
|
+
return !stats.complete || stats.bytes > maxBytes;
|
|
73
|
+
}
|
|
59
74
|
const RESPONSE_REQUIRED_CHANNEL_BY_FIELD = new Map([
|
|
60
75
|
[2, "exec"],
|
|
61
76
|
[4, "kv"],
|
|
@@ -235,9 +250,61 @@ export function connectFrameError(payload) {
|
|
|
235
250
|
return new CursorProtocolError("Cursor returned a malformed Connect error envelope");
|
|
236
251
|
}
|
|
237
252
|
}
|
|
253
|
+
const toolCatalogWaitersBySession = new Map();
|
|
254
|
+
function publishToolCatalogWaiters(sessionKey, tools) {
|
|
255
|
+
const waiters = toolCatalogWaitersBySession.get(sessionKey);
|
|
256
|
+
if (!waiters || waiters.size === 0)
|
|
257
|
+
return;
|
|
258
|
+
toolCatalogWaitersBySession.delete(sessionKey);
|
|
259
|
+
for (const waiter of waiters)
|
|
260
|
+
waiter.resolve(tools);
|
|
261
|
+
}
|
|
262
|
+
function waitForSiblingToolCatalog(sessionKey, signal) {
|
|
263
|
+
const existing = toolCatalogBySession.get(sessionKey);
|
|
264
|
+
if (existing && existing.length > 0) {
|
|
265
|
+
return Promise.resolve(structuredClone(existing));
|
|
266
|
+
}
|
|
267
|
+
if (signal?.aborted) {
|
|
268
|
+
return Promise.reject(new CursorLocalCancellationError("Cursor tool-catalog wait cancelled"));
|
|
269
|
+
}
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
let settled = false;
|
|
272
|
+
let set = toolCatalogWaitersBySession.get(sessionKey);
|
|
273
|
+
const finish = (tools, error) => {
|
|
274
|
+
if (settled)
|
|
275
|
+
return;
|
|
276
|
+
settled = true;
|
|
277
|
+
set?.delete(waiter);
|
|
278
|
+
if (set?.size === 0)
|
|
279
|
+
toolCatalogWaitersBySession.delete(sessionKey);
|
|
280
|
+
signal?.removeEventListener("abort", onAbort);
|
|
281
|
+
if (error)
|
|
282
|
+
reject(error);
|
|
283
|
+
else
|
|
284
|
+
resolve(structuredClone(tools));
|
|
285
|
+
};
|
|
286
|
+
const onAbort = () => finish(undefined, new CursorLocalCancellationError("Cursor tool-catalog wait cancelled"));
|
|
287
|
+
const waiter = {
|
|
288
|
+
resolve: (tools) => finish(tools),
|
|
289
|
+
cancel: onAbort,
|
|
290
|
+
};
|
|
291
|
+
if (!set) {
|
|
292
|
+
set = new Set();
|
|
293
|
+
toolCatalogWaitersBySession.set(sessionKey, set);
|
|
294
|
+
}
|
|
295
|
+
set.add(waiter);
|
|
296
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
297
|
+
// Re-check after enqueue: a sibling may have published between the initial
|
|
298
|
+
// map lookup and waiter registration.
|
|
299
|
+
const raced = toolCatalogBySession.get(sessionKey);
|
|
300
|
+
if (raced && raced.length > 0)
|
|
301
|
+
finish(raced);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
238
304
|
function rememberToolCatalog(sessionKey, tools) {
|
|
239
305
|
toolCatalogBySession.delete(sessionKey);
|
|
240
|
-
toolCatalogBySession.set(sessionKey, tools);
|
|
306
|
+
toolCatalogBySession.set(sessionKey, structuredClone(tools));
|
|
307
|
+
publishToolCatalogWaiters(sessionKey, tools);
|
|
241
308
|
while (toolCatalogBySession.size > MAX_TURN_STATE_SESSIONS) {
|
|
242
309
|
const oldest = toolCatalogBySession.keys().next().value;
|
|
243
310
|
if (!oldest)
|
|
@@ -245,6 +312,17 @@ function rememberToolCatalog(sessionKey, tools) {
|
|
|
245
312
|
toolCatalogBySession.delete(oldest);
|
|
246
313
|
}
|
|
247
314
|
}
|
|
315
|
+
/** Restore the last real catalog solely for lifecycle turns such as compaction. */
|
|
316
|
+
export function restoreTurnToolCatalog(sessionKey, tools) {
|
|
317
|
+
if (!sessionKey || tools.length === 0)
|
|
318
|
+
return;
|
|
319
|
+
rememberToolCatalog(sessionKey, tools);
|
|
320
|
+
}
|
|
321
|
+
function snapshotToolCatalog(sessionKey) {
|
|
322
|
+
if (!sessionKey)
|
|
323
|
+
return [];
|
|
324
|
+
return structuredClone(toolCatalogBySession.get(sessionKey) ?? []);
|
|
325
|
+
}
|
|
248
326
|
function rememberPostCompactionRebase(sessionKey) {
|
|
249
327
|
postCompactionRebaseBySession.delete(sessionKey);
|
|
250
328
|
postCompactionRebaseBySession.add(sessionKey);
|
|
@@ -292,6 +370,11 @@ export function createCursorLanguageModel(modelId, providerId, options) {
|
|
|
292
370
|
// Host Path.cache / explicit override wins over XDG heuristics for this process.
|
|
293
371
|
if (options.cacheDir)
|
|
294
372
|
setHostCacheDirOverride(options.cacheDir);
|
|
373
|
+
// Start loading and pruning restart state as soon as the provider is built.
|
|
374
|
+
// startSession awaits this same per-cache-root initialization before binding.
|
|
375
|
+
void initializeConversationPersistence(opencodeGlobalCacheDir()).catch((error) => {
|
|
376
|
+
trace(`conversation persistence: startup load failed: ${String(error)}`);
|
|
377
|
+
});
|
|
295
378
|
return {
|
|
296
379
|
specificationVersion: "v3",
|
|
297
380
|
provider: providerId,
|
|
@@ -327,7 +410,6 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
327
410
|
baseUrl: resolveApiBaseURL(options),
|
|
328
411
|
});
|
|
329
412
|
const prompt = callOptions.prompt;
|
|
330
|
-
const promptTokens = estimatePromptTokens(prompt);
|
|
331
413
|
const retryPolicy = resolveRetryPolicy(options.retry);
|
|
332
414
|
// pumpWithRecovery owns the complete per-turn attempt budget. Opening a
|
|
333
415
|
// replacement session here must be a single attempt; otherwise setup retry
|
|
@@ -389,7 +471,6 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
389
471
|
initialSession: activeSession,
|
|
390
472
|
controller,
|
|
391
473
|
abortSignal: callOptions.abortSignal,
|
|
392
|
-
promptTokens,
|
|
393
474
|
retryPolicy,
|
|
394
475
|
recover: (recovery) => openSession({ recovery }),
|
|
395
476
|
onSession: (next) => { activeSession = next; },
|
|
@@ -400,6 +481,23 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
400
481
|
catch (e) {
|
|
401
482
|
trace(`pull: close failed (already closed/cancelled) err=${e.message}`);
|
|
402
483
|
}
|
|
484
|
+
// pumpWithRecovery has returned only after the Run reached terminal
|
|
485
|
+
// turn_ended and endPump cleared ownership. This is the first safe
|
|
486
|
+
// point to start a second OpenCode generation. A failed prior handoff
|
|
487
|
+
// is retried only because this explicit new provider turn reached its
|
|
488
|
+
// own terminal boundary — never from a timer or tight loop.
|
|
489
|
+
const kickoff = await flushPlanExecutionKickoff(activeSession.openCodeSessionId, {
|
|
490
|
+
cursorSessionID: activeSession.sessionId,
|
|
491
|
+
terminal: activeSession.closed,
|
|
492
|
+
pumpActive: activeSession.pumpActive || activeSession.pumpOwner != null,
|
|
493
|
+
pendingExecs: activeSession.pending.size,
|
|
494
|
+
});
|
|
495
|
+
if (!kickoff) {
|
|
496
|
+
const state = planExecutionKickoffState(activeSession.openCodeSessionId);
|
|
497
|
+
if (state?.status === "failed") {
|
|
498
|
+
setActiveCursorMode(activeSession.openCodeSessionId, "plan");
|
|
499
|
+
}
|
|
500
|
+
}
|
|
403
501
|
}
|
|
404
502
|
catch (e) {
|
|
405
503
|
activeSession.pumpActive = false;
|
|
@@ -429,7 +527,6 @@ export async function pumpWithRecovery(input) {
|
|
|
429
527
|
maxAttempts: (input.maxRecoveries ?? 1) + 1,
|
|
430
528
|
};
|
|
431
529
|
const maxRecoveries = retryPolicy.maxAttempts - 1;
|
|
432
|
-
const requestUsage = { outputChars: 0 };
|
|
433
530
|
input.onSession?.(session);
|
|
434
531
|
for (let attempt = 0;; attempt++) {
|
|
435
532
|
const pumpedSession = session;
|
|
@@ -439,8 +536,6 @@ export async function pumpWithRecovery(input) {
|
|
|
439
536
|
await pump(pumpedSession, input.controller, {
|
|
440
537
|
textId: crypto.randomUUID(),
|
|
441
538
|
reasoningId: crypto.randomUUID(),
|
|
442
|
-
promptTokens: input.promptTokens ?? 0,
|
|
443
|
-
requestUsage,
|
|
444
539
|
}, input.abortSignal);
|
|
445
540
|
return session;
|
|
446
541
|
}
|
|
@@ -473,7 +568,6 @@ export async function pumpWithRecovery(input) {
|
|
|
473
568
|
: { kind: "rebase" };
|
|
474
569
|
session = await input.recover(recovery);
|
|
475
570
|
if (recovery.kind === "resume") {
|
|
476
|
-
session.hadToolCallBoundary = pumpedSession.hadToolCallBoundary;
|
|
477
571
|
session.usageEstimate = { ...pumpedSession.usageEstimate };
|
|
478
572
|
session.editToolCalls = new Map(pumpedSession.editToolCalls);
|
|
479
573
|
}
|
|
@@ -489,17 +583,29 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
489
583
|
const prompt = callOptions.prompt;
|
|
490
584
|
const incomingTools = extractTools(callOptions);
|
|
491
585
|
const sessionKey = opencodeSessionKey(callOptions);
|
|
586
|
+
const cacheDir = opencodeGlobalCacheDir();
|
|
587
|
+
if (sessionKey) {
|
|
588
|
+
const restored = await hydrateConversationState(cacheDir, sessionKey).catch((error) => {
|
|
589
|
+
trace(`conversation persistence: restore failed sessionKey=${sessionKey}: ${String(error)}`);
|
|
590
|
+
return undefined;
|
|
591
|
+
});
|
|
592
|
+
if (restored?.postCompactionRebase)
|
|
593
|
+
rememberPostCompactionRebase(sessionKey);
|
|
594
|
+
if (restored?.toolCatalog.length)
|
|
595
|
+
restoreTurnToolCatalog(sessionKey, restored.toolCatalog);
|
|
596
|
+
}
|
|
492
597
|
const providerOptions = callOptions.providerOptions?.cursor;
|
|
493
598
|
// The classic plugin marks OpenCode's agent="compaction" through chat.params.
|
|
494
599
|
// OpenCode 2.0 removed that hook, so its plugin records the same fact against
|
|
495
600
|
// the session id from session.hook("context") instead; consult both.
|
|
496
601
|
// Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
|
|
497
602
|
const isCompaction = providerOptions?.[CURSOR_COMPACTION_OPTION] === true || isCompactionSession(sessionKey);
|
|
498
|
-
const toolState = resolveTurnToolState({
|
|
603
|
+
const toolState = await resolveTurnToolState({
|
|
499
604
|
sessionKey,
|
|
500
605
|
incomingTools,
|
|
501
606
|
toolChoice: callOptions.toolChoice,
|
|
502
607
|
isCompaction,
|
|
608
|
+
abortSignal: callOptions.abortSignal,
|
|
503
609
|
});
|
|
504
610
|
const tools = toolState.advertisedTools;
|
|
505
611
|
const webToolAliases = buildCustomWebToolAliases(tools);
|
|
@@ -513,17 +619,55 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
513
619
|
const allowTools = toolState.allowTools;
|
|
514
620
|
const discoveredSubagentCatalog = extractHostSubagentCatalog(cursorTools);
|
|
515
621
|
const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
|
|
516
|
-
|
|
517
|
-
|
|
622
|
+
let recovery = startOptions?.recovery;
|
|
623
|
+
let resumeRecovery = recovery?.kind === "resume" ? recovery : undefined;
|
|
624
|
+
let resuming = !!resumeRecovery;
|
|
518
625
|
// Compaction must not reuse the prior conversation; its first normal turn
|
|
519
626
|
// must also rebase so the summary-agent checkpoint cannot replace the normal
|
|
520
627
|
// system prompt and OpenCode's newly compacted history.
|
|
521
|
-
|
|
522
|
-
? { conversationId:
|
|
628
|
+
let bound = resuming
|
|
629
|
+
? { conversationId: resumeRecovery.conversationId, reset: false, previousId: undefined }
|
|
523
630
|
: bindConversationId(sessionKey, { reset: resetState.reset || recovery?.kind === "rebase" });
|
|
631
|
+
let conversationState = resuming
|
|
632
|
+
? resumeRecovery.checkpoint
|
|
633
|
+
: (bound.reset ? undefined : getCheckpoint(bound.conversationId));
|
|
634
|
+
let checkpointGraph = conversationState
|
|
635
|
+
? inspectConversationBlobGraph(bound.conversationId, conversationState)
|
|
636
|
+
: { count: 0, bytes: 0, complete: true };
|
|
637
|
+
let forcedResetReason;
|
|
638
|
+
if (conversationState && checkpointBlobGraphRequiresRebase(checkpointGraph)) {
|
|
639
|
+
const previousConversationId = bound.conversationId;
|
|
640
|
+
forcedResetReason = checkpointGraph.complete
|
|
641
|
+
? "oversized-checkpoint-graph"
|
|
642
|
+
: "incomplete-checkpoint-graph";
|
|
643
|
+
trace(`checkpoint state rejected: reason=${forcedResetReason} ` +
|
|
644
|
+
`conversationId=${previousConversationId} checkpointBytes=${conversationState.length} ` +
|
|
645
|
+
`blobCount=${checkpointGraph.count} blobBytes=${checkpointGraph.bytes} ` +
|
|
646
|
+
`limitBytes=${MAX_CHECKPOINT_BLOB_GRAPH_BYTES} ` +
|
|
647
|
+
`detail=${checkpointGraph.fallbackReason ?? "-"} action=rebase`);
|
|
648
|
+
// A recovery resume must become a history rebase so the interrupted user
|
|
649
|
+
// request remains in the seed. An ordinary turn keeps its normal user text.
|
|
650
|
+
if (recovery?.kind === "resume")
|
|
651
|
+
recovery = { kind: "rebase" };
|
|
652
|
+
resumeRecovery = undefined;
|
|
653
|
+
resuming = false;
|
|
654
|
+
if (!sessionKey) {
|
|
655
|
+
clearCheckpoint(previousConversationId);
|
|
656
|
+
clearConversationBlobs(previousConversationId);
|
|
657
|
+
}
|
|
658
|
+
bound = bindConversationId(sessionKey, { reset: true });
|
|
659
|
+
conversationState = undefined;
|
|
660
|
+
checkpointGraph = { count: 0, bytes: 0, complete: true };
|
|
661
|
+
}
|
|
524
662
|
const conversationId = bound.conversationId;
|
|
663
|
+
const conversationGroupId = resolveConversationGroupId(sessionKey, conversationId);
|
|
525
664
|
if (bound.reset) {
|
|
526
|
-
|
|
665
|
+
if (sessionKey) {
|
|
666
|
+
await clearPersistedConversationState(cacheDir, sessionKey, bound.previousId).catch((error) => {
|
|
667
|
+
trace(`conversation persistence: reset cleanup failed sessionKey=${sessionKey}: ${String(error)}`);
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
trace(`conversation reset: reason=${forcedResetReason ?? (recovery?.kind === "rebase" ? "interrupted-run" : (resetState.reason ?? "unknown"))} ` +
|
|
527
671
|
`sessionKey=${sessionKey ?? "(none)"} ` +
|
|
528
672
|
`previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
|
|
529
673
|
}
|
|
@@ -539,9 +683,24 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
539
683
|
const workspaceRoot = path.resolve(getSessionDirectory(sessionKey) ?? (options.workspaceRoot || process.cwd()));
|
|
540
684
|
const baseSystemPrompt = extractSystemPrompt(prompt);
|
|
541
685
|
const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
686
|
+
// After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
|
|
687
|
+
// (same <system_reminder> contract the CLI uses after flipping unifiedMode).
|
|
688
|
+
const modeReminder = isCompaction
|
|
689
|
+
? undefined
|
|
690
|
+
: takeActiveCursorModeReminder(sessionKey, {
|
|
691
|
+
advertisedTools: cursorTools.map((tool) => tool.name),
|
|
692
|
+
});
|
|
693
|
+
const kickoffWarning = isCompaction
|
|
694
|
+
? undefined
|
|
695
|
+
: takePlanExecutionKickoffWarning(sessionKey);
|
|
696
|
+
const systemPrompt = [
|
|
697
|
+
baseSystemPrompt,
|
|
698
|
+
interactionGuidance,
|
|
699
|
+
modeReminder,
|
|
700
|
+
kickoffWarning ? `<system_reminder>${kickoffWarning}</system_reminder>` : undefined,
|
|
701
|
+
]
|
|
702
|
+
.filter(Boolean)
|
|
703
|
+
.join("\n\n");
|
|
545
704
|
const history = extractPromptHistory(prompt, {
|
|
546
705
|
preserveTrailingUser: recovery?.kind === "rebase",
|
|
547
706
|
toolResults: isCompaction ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
|
|
@@ -604,7 +763,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
604
763
|
baseURL: agentBaseUrl,
|
|
605
764
|
headers: options.headers,
|
|
606
765
|
});
|
|
607
|
-
|
|
766
|
+
// Freeze RequestContext per conversation_id (CLI parity). Rebuilding every
|
|
767
|
+
// Run mutates volatile slices (git porcelain, layout) and breaks prompt cache.
|
|
768
|
+
const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools });
|
|
608
769
|
const contextSubagents = Array.isArray(requestContext.custom_subagents)
|
|
609
770
|
? requestContext.custom_subagents
|
|
610
771
|
.map((agent) => agent && typeof agent === "object" && typeof agent.name === "string"
|
|
@@ -628,15 +789,14 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
628
789
|
? requestContext.tools
|
|
629
790
|
: [];
|
|
630
791
|
// CLI parity: echo the last conversation_checkpoint_update as conversation_state.
|
|
631
|
-
// After compaction reset there is no checkpoint —
|
|
632
|
-
|
|
633
|
-
? recovery.checkpoint
|
|
634
|
-
: (bound.reset ? undefined : getCheckpoint(conversationId));
|
|
792
|
+
// After compaction or an unsafe checkpoint reset there is no checkpoint —
|
|
793
|
+
// seed a new Cursor conversation from OpenCode's authoritative history.
|
|
635
794
|
const reqBytes = buildRunRequest({
|
|
636
795
|
text: userText,
|
|
637
796
|
images,
|
|
638
797
|
modelId: cursorModelId,
|
|
639
798
|
conversationId,
|
|
799
|
+
conversationGroupId,
|
|
640
800
|
systemPrompt: conversationState ? undefined : systemPrompt,
|
|
641
801
|
history: conversationState ? undefined : history,
|
|
642
802
|
conversationState,
|
|
@@ -657,14 +817,31 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
657
817
|
? requestContext.hooks_additional_context
|
|
658
818
|
: "";
|
|
659
819
|
const historyChars = history.reduce((n, m) => n + m.content.length, 0);
|
|
820
|
+
const seedChars = conversationState
|
|
821
|
+
? 0
|
|
822
|
+
: (systemPrompt?.length ?? 0) + userText.length + historyChars;
|
|
823
|
+
const seedEstimateIn = estimateTokens(seedChars);
|
|
824
|
+
const checkpointEnvelopeEstimateIn = conversationState
|
|
825
|
+
? estimateTokens(userText.length + conversationState.length)
|
|
826
|
+
: 0;
|
|
827
|
+
const runRequestWireEstimateIn = estimateTokens(reqBytes.length);
|
|
828
|
+
const encodedRequestContext = encodeMessage("RequestContext", requestContext);
|
|
829
|
+
const priorTokenDetails = decodeConversationTokenDetails(conversationState);
|
|
830
|
+
const requestContextHash = sha(encodedRequestContext);
|
|
831
|
+
const systemPromptHash = systemPrompt ? sha(systemPrompt) : undefined;
|
|
660
832
|
const usageEstimate = {
|
|
661
|
-
|
|
833
|
+
// This is used only before Cursor supplies authoritative TurnEnded usage.
|
|
834
|
+
// Estimate the actual protobuf request, not history/system text omitted by
|
|
835
|
+
// checkpoint Runs. The opaque remote interpretation of KV blobs is logged
|
|
836
|
+
// separately and deliberately is not mislabeled as a token count.
|
|
837
|
+
inputTokens: runRequestWireEstimateIn,
|
|
662
838
|
outputTokens: 0,
|
|
663
839
|
cacheRead: 0,
|
|
664
840
|
cacheWrite: 0,
|
|
665
841
|
reasoningTokens: 0,
|
|
666
842
|
};
|
|
667
|
-
trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId}
|
|
843
|
+
trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId} ` +
|
|
844
|
+
`conversationId=${conversationId} conversationGroupId=${conversationGroupId} ` +
|
|
668
845
|
`params=${JSON.stringify(parameterValues ?? [])} ` +
|
|
669
846
|
`maxMode=${maxMode} systemPromptLen=${systemPrompt?.length ?? 0} ` +
|
|
670
847
|
`tools=${tools.length} incomingTools=${incomingTools.length} compaction=${isCompaction} ` +
|
|
@@ -672,14 +849,20 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
672
849
|
`availableModels=${_availableModels?.length ?? 0} userTextLen=${userText.length} ` +
|
|
673
850
|
`images=${images.length} imageBytes=${images.reduce((total, image) => total + image.data.length, 0)} ` +
|
|
674
851
|
`historyMsgs=${history.length} historyChars=${historyChars} ` +
|
|
675
|
-
`checkpointLen=${conversationState?.length ?? 0}
|
|
676
|
-
`
|
|
677
|
-
`
|
|
852
|
+
`checkpointLen=${conversationState?.length ?? 0} checkpointBlobCount=${checkpointGraph.count} ` +
|
|
853
|
+
`checkpointBlobBytes=${checkpointGraph.bytes} checkpointGraphComplete=${checkpointGraph.complete} ` +
|
|
854
|
+
`seedChars=${seedChars} seedEstimateIn=${seedEstimateIn} ` +
|
|
855
|
+
`checkpointEnvelopeEstimateIn=${checkpointEnvelopeEstimateIn} ` +
|
|
856
|
+
`reset=${bound.reset} ` +
|
|
857
|
+
`resume=${resuming} requestContextReused=${requestContextReused} ` +
|
|
858
|
+
`usageEstimateMode=run-request-wire usageEstimateIn=${usageEstimate.inputTokens} ` +
|
|
859
|
+
`runRequestBytes=${reqBytes.length} requestContextBytes=${encodedRequestContext.length}`);
|
|
678
860
|
if (hooksCtx)
|
|
679
861
|
trace(`outbound Run hooks_additional_context: ${hooksCtx}`);
|
|
680
862
|
trace(`hash run_request sha256=${sha(reqBytes)}`);
|
|
681
863
|
if (systemPrompt)
|
|
682
864
|
trace(`hash systemPrompt sha256=${sha(systemPrompt)}`);
|
|
865
|
+
trace(`hash requestContext sha256=${requestContextHash}`);
|
|
683
866
|
if (conversationState)
|
|
684
867
|
trace(`hash checkpoint sha256=${sha(conversationState)}`);
|
|
685
868
|
try {
|
|
@@ -693,8 +876,31 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
693
876
|
const session = {
|
|
694
877
|
sessionId: crypto.randomUUID(),
|
|
695
878
|
conversationId,
|
|
879
|
+
cacheDir,
|
|
696
880
|
resumeCheckpoint: undefined,
|
|
881
|
+
tokenDetails: priorTokenDetails,
|
|
882
|
+
tokenDetailsFresh: false,
|
|
883
|
+
cacheDiagnostics: {
|
|
884
|
+
sessionKey,
|
|
885
|
+
conversationId,
|
|
886
|
+
conversationGroupId,
|
|
887
|
+
modelId: cursorModelId,
|
|
888
|
+
priorTokenDetails,
|
|
889
|
+
startedWithCheckpoint: !!conversationState,
|
|
890
|
+
requestContextReused,
|
|
891
|
+
requestContextHash,
|
|
892
|
+
systemPromptHash,
|
|
893
|
+
checkpointUpdates: 0,
|
|
894
|
+
tokenDetailUpdates: 0,
|
|
895
|
+
pumpPasses: 0,
|
|
896
|
+
stepStarts: 0,
|
|
897
|
+
stepCompletes: 0,
|
|
898
|
+
displayToolCalls: 0,
|
|
899
|
+
execRequests: 0,
|
|
900
|
+
},
|
|
697
901
|
openCodeSessionId: sessionKey,
|
|
902
|
+
postCompactionRebase: isCompaction,
|
|
903
|
+
toolCatalog: snapshotToolCatalog(sessionKey),
|
|
698
904
|
stream,
|
|
699
905
|
frames: stream.frames()[Symbol.asyncIterator](),
|
|
700
906
|
pending: new Map(),
|
|
@@ -730,11 +936,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
730
936
|
if (session.closed)
|
|
731
937
|
return;
|
|
732
938
|
if (heartbeatWritePending) {
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
}));
|
|
939
|
+
// The pending write has its own bounded drain wait and will close the
|
|
940
|
+
// session with the actual failing operation. Do not race it with another
|
|
941
|
+
// heartbeat or misattribute an existing KV backlog to the heartbeat.
|
|
942
|
+
trace(`heartbeat skipped: prior heartbeat write is still pending sessionId=${session.sessionId}`);
|
|
738
943
|
return;
|
|
739
944
|
}
|
|
740
945
|
heartbeatWritePending = true;
|
|
@@ -773,6 +978,124 @@ export function findContinuationSession(toolResults) {
|
|
|
773
978
|
}
|
|
774
979
|
return undefined;
|
|
775
980
|
}
|
|
981
|
+
/**
|
|
982
|
+
* Turn an OpenCode `question` tool result into the frame Cursor is waiting for.
|
|
983
|
+
*
|
|
984
|
+
* A synchronous AskQuestion still has its InteractionQuery open, so the answer
|
|
985
|
+
* goes back as the InteractionResponse for that query id. A `run_async` one was
|
|
986
|
+
* already released with `async{}`, so — exactly as Cursor CLI does — the answer
|
|
987
|
+
* travels as a ConversationAction keyed by the originating tool call id, with
|
|
988
|
+
* the server's own AskQuestionArgs bytes echoed back untouched.
|
|
989
|
+
*/
|
|
990
|
+
function buildAskQuestionContinuationFrame(pending, result) {
|
|
991
|
+
const metadata = pending.resultMetadata ?? {};
|
|
992
|
+
const args = metadata.askQuestionArgs;
|
|
993
|
+
if (!args)
|
|
994
|
+
throw new CursorProtocolError("Bridged AskQuestion lost its decoded arguments");
|
|
995
|
+
const answer = askQuestionResultFromToolOutput(args, result.output, result.error !== undefined);
|
|
996
|
+
if (!args.runAsync) {
|
|
997
|
+
const interactionId = metadata.interactionId;
|
|
998
|
+
if (typeof interactionId !== "number") {
|
|
999
|
+
throw new CursorProtocolError("Bridged AskQuestion lost its interaction id");
|
|
1000
|
+
}
|
|
1001
|
+
return buildAskQuestionInteractionReply(interactionId, answer);
|
|
1002
|
+
}
|
|
1003
|
+
const rawArgs = metadata.askQuestionRawArgs;
|
|
1004
|
+
if (!(rawArgs instanceof Uint8Array)) {
|
|
1005
|
+
throw new CursorProtocolError("Bridged async AskQuestion lost its original arguments");
|
|
1006
|
+
}
|
|
1007
|
+
const toolCallId = metadata.askQuestionToolCallId;
|
|
1008
|
+
return buildAsyncAskQuestionCompletion(typeof toolCallId === "string" ? toolCallId : "", rawArgs, answer);
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Turn the host's plan-approval outcome into the CreatePlan reply Cursor is
|
|
1012
|
+
* still blocking on. One contract across every channel: `success` means the
|
|
1013
|
+
* user approved execution, `error` means the plan was written but not accepted,
|
|
1014
|
+
* so the model keeps planning instead of starting work.
|
|
1015
|
+
*
|
|
1016
|
+
* A host plan-stage tool reports that outcome as tool success/failure. The
|
|
1017
|
+
* emulated path asks through `question`, so only an explicit "Yes" approves.
|
|
1018
|
+
* On approval the session leaves plan mode, which arms the agent-mode
|
|
1019
|
+
* `<system_reminder>` for the next Run.
|
|
1020
|
+
*/
|
|
1021
|
+
function buildCreatePlanContinuationFrame(pending, result, sessionKey, cursorSessionID) {
|
|
1022
|
+
const metadata = pending.resultMetadata ?? {};
|
|
1023
|
+
const interactionId = metadata.interactionId;
|
|
1024
|
+
if (typeof interactionId !== "number") {
|
|
1025
|
+
throw new CursorProtocolError("Bridged CreatePlan lost its interaction id");
|
|
1026
|
+
}
|
|
1027
|
+
const planUri = metadata.planUri;
|
|
1028
|
+
if (typeof planUri !== "string" || !planUri.trim()) {
|
|
1029
|
+
throw new CursorProtocolError("Bridged CreatePlan lost its plan URI");
|
|
1030
|
+
}
|
|
1031
|
+
const approved = metadata.createPlanBridgeKind === "approve"
|
|
1032
|
+
? createPlanApproved(result.output, result.error !== undefined, typeof metadata.createPlanQuestion === "string" ? metadata.createPlanQuestion : "")
|
|
1033
|
+
: result.error === undefined;
|
|
1034
|
+
if (!approved) {
|
|
1035
|
+
const reason = result.error?.trim();
|
|
1036
|
+
return buildCreatePlanInteractionReply(interactionId, {
|
|
1037
|
+
error: { error: reason || CREATE_PLAN_NOT_APPROVED_REASON },
|
|
1038
|
+
plan_uri: "",
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
// The question-emulated path can report execution approval only when a host
|
|
1042
|
+
// kickoff exists. Otherwise keep planning mode active and return an error.
|
|
1043
|
+
if (metadata.createPlanBridgeKind === "approve" && !hasPlanExecutionKickoff()) {
|
|
1044
|
+
return buildCreatePlanInteractionReply(interactionId, {
|
|
1045
|
+
error: { error: "The plan was approved, but this host cannot start its execution turn." },
|
|
1046
|
+
plan_uri: planUri,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
// Approved: planning is over for this plan, so drop the plan-mode reminder
|
|
1050
|
+
// and hand the next Run the agent-mode contract, exactly as an approved
|
|
1051
|
+
// plan_exit does.
|
|
1052
|
+
setActiveCursorMode(sessionKey, "agent");
|
|
1053
|
+
// Only the question-emulated path needs the provider's synthetic build turn.
|
|
1054
|
+
// A host stage tool owns both approval and execution handoff itself.
|
|
1055
|
+
if (metadata.createPlanBridgeKind === "approve" && sessionKey) {
|
|
1056
|
+
const absolute = typeof metadata.planPath === "string" && metadata.planPath.trim()
|
|
1057
|
+
? metadata.planPath.trim()
|
|
1058
|
+
: planPathFromUri(planUri);
|
|
1059
|
+
const workspaceRoot = typeof metadata.workspaceRoot === "string" ? metadata.workspaceRoot : undefined;
|
|
1060
|
+
if (!queuePlanExecutionKickoff({
|
|
1061
|
+
sessionID: sessionKey,
|
|
1062
|
+
planPath: formatPlanKickoffPath(absolute, workspaceRoot),
|
|
1063
|
+
cursorSessionID,
|
|
1064
|
+
})) {
|
|
1065
|
+
return buildCreatePlanInteractionReply(interactionId, {
|
|
1066
|
+
error: { error: "The plan was approved, but this host cannot start its execution turn." },
|
|
1067
|
+
plan_uri: planUri,
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return buildCreatePlanInteractionReply(interactionId, {
|
|
1072
|
+
success: {},
|
|
1073
|
+
plan_uri: planUri,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
function buildSwitchModeContinuationFrame(pending, result, sessionKey) {
|
|
1077
|
+
const metadata = pending.resultMetadata ?? {};
|
|
1078
|
+
const interactionId = metadata.interactionId;
|
|
1079
|
+
if (typeof interactionId !== "number") {
|
|
1080
|
+
throw new CursorProtocolError("Bridged SwitchMode lost its interaction id");
|
|
1081
|
+
}
|
|
1082
|
+
// A question-emulated exit carries the user's Yes/No as prose, not a plan
|
|
1083
|
+
// tool's success/failure, so it needs the answer parser rather than the
|
|
1084
|
+
// error-shaped mapping.
|
|
1085
|
+
const answer = metadata.switchModeBridgeKind === "question"
|
|
1086
|
+
? switchModeResultFromQuestionOutput(result.output, result.error !== undefined)
|
|
1087
|
+
: switchModeResultFromToolOutput(result.output, result.error !== undefined);
|
|
1088
|
+
if ("approved" in answer) {
|
|
1089
|
+
const target = metadata.switchModeTarget;
|
|
1090
|
+
if (typeof target === "string" && target.trim()) {
|
|
1091
|
+
const normalized = target.trim().toLowerCase();
|
|
1092
|
+
setActiveCursorMode(sessionKey, target, {
|
|
1093
|
+
bridgedPlanEntered: normalized === "plan" || normalized === "spec",
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return buildSwitchModeInteractionReply(interactionId, answer);
|
|
1098
|
+
}
|
|
776
1099
|
/**
|
|
777
1100
|
* Deliver trailing tool results onto a live continuation session.
|
|
778
1101
|
* Returns the same session when writes succeed (or only bridged results were
|
|
@@ -799,22 +1122,131 @@ export function deliverContinuationResults(session, trailingToolResults) {
|
|
|
799
1122
|
}
|
|
800
1123
|
const pending = claim.pending;
|
|
801
1124
|
let frames = [];
|
|
802
|
-
if (
|
|
1125
|
+
if (pending.resultField === ASK_QUESTION_RESULT_FIELD) {
|
|
1126
|
+
// A bridged Cursor AskQuestion. The host tool result carries the user's
|
|
1127
|
+
// choices; translate them back into the Cursor result the interaction is
|
|
1128
|
+
// waiting for (or into the deferred completion, when Cursor was already
|
|
1129
|
+
// released with `async`).
|
|
1130
|
+
try {
|
|
1131
|
+
frames = [buildAskQuestionContinuationFrame(pending, r)];
|
|
1132
|
+
}
|
|
1133
|
+
catch (error) {
|
|
1134
|
+
trace(`continuation: ask_question encode FAILED execId=${r.execId} err=${error.message}`);
|
|
1135
|
+
sessionManager.close(session, "result-write-failed");
|
|
1136
|
+
return undefined;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
else if (pending.resultField === SWITCH_MODE_RESULT_FIELD) {
|
|
1140
|
+
// Bridged SwitchMode: host plan_enter / plan_exit outcome becomes
|
|
1141
|
+
// approved{} or rejected{reason} on the still-open InteractionQuery.
|
|
1142
|
+
// Approval also arms the CLI-shaped mode reminder for the next Run.
|
|
1143
|
+
try {
|
|
1144
|
+
frames = [buildSwitchModeContinuationFrame(pending, r, session.openCodeSessionId)];
|
|
1145
|
+
}
|
|
1146
|
+
catch (error) {
|
|
1147
|
+
trace(`continuation: switch_mode encode FAILED execId=${r.execId} err=${error.message}`);
|
|
1148
|
+
sessionManager.close(session, "result-write-failed");
|
|
1149
|
+
return undefined;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
else if (pending.resultField === CREATE_PLAN_RESULT_FIELD) {
|
|
1153
|
+
try {
|
|
1154
|
+
frames = [buildCreatePlanContinuationFrame(pending, r, session.openCodeSessionId, session.sessionId)];
|
|
1155
|
+
}
|
|
1156
|
+
catch (error) {
|
|
1157
|
+
trace(`continuation: create_plan encode FAILED execId=${r.execId} err=${error.message}`);
|
|
1158
|
+
sessionManager.close(session, "result-write-failed");
|
|
1159
|
+
return undefined;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
else if (!pending.bridged) {
|
|
803
1163
|
try {
|
|
804
1164
|
const shellResult = pending.resultField === "shell_stream"
|
|
805
1165
|
|| pending.resultField === "background_shell_spawn_result"
|
|
806
1166
|
? consumeCursorShellResult(r.toolCallId, r.output)
|
|
807
1167
|
: undefined;
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1168
|
+
const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
|
|
1169
|
+
const correlatedEditCallId = pending.resultMetadata?.correlatedEditCallId;
|
|
1170
|
+
const requestedPath = pending.resultMetadata?.path;
|
|
1171
|
+
const correlatedEdit = !r.error
|
|
1172
|
+
&& pending.resultField === "read_result"
|
|
1173
|
+
&& pending.toolName === "read"
|
|
1174
|
+
&& typeof correlatedEditCallId === "string"
|
|
1175
|
+
&& typeof requestedPath === "string"
|
|
1176
|
+
? session.editToolCalls?.get(correlatedEditCallId)
|
|
1177
|
+
: undefined;
|
|
1178
|
+
if (correlatedEdit) {
|
|
1179
|
+
const absolutePath = path.resolve(workspaceRoot, requestedPath);
|
|
1180
|
+
if (absolutePath === path.resolve(workspaceRoot, correlatedEdit.path)) {
|
|
1181
|
+
frames = buildCompleteEditReadMessages(r.execId, absolutePath, requestedPath) ?? [];
|
|
1182
|
+
if (frames.length > 0) {
|
|
1183
|
+
correlatedEdit.completeRead = true;
|
|
1184
|
+
trace(`continuation: upgraded authorized correlated edit read execId=${r.execId} ` +
|
|
1185
|
+
`path=${JSON.stringify(requestedPath)}`);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
// A refused image commit is Cursor's own `permission_denied` variant,
|
|
1190
|
+
// not a generic write error: its agent branches on that case. The
|
|
1191
|
+
// marker is set by this provider's own tool, so matching it is a
|
|
1192
|
+
// contract rather than a guess at someone else's message text.
|
|
1193
|
+
if (pending.toolName === CURSOR_IMAGE_SAVE_TOOL
|
|
1194
|
+
&& pending.resultField === "write_result"
|
|
1195
|
+
&& r.error?.includes(IMAGE_PERMISSION_DENIED_PREFIX)) {
|
|
1196
|
+
const deniedPath = typeof pending.resultMetadata?.path === "string"
|
|
1197
|
+
? pending.resultMetadata.path
|
|
1198
|
+
: "";
|
|
1199
|
+
frames = [encodeMessage("AgentClientMessage", {
|
|
1200
|
+
exec_client_message: {
|
|
1201
|
+
id: r.execId,
|
|
1202
|
+
write_result: {
|
|
1203
|
+
permission_denied: {
|
|
1204
|
+
path: deniedPath,
|
|
1205
|
+
directory: deniedPath ? path.dirname(deniedPath) : "",
|
|
1206
|
+
operation: "write",
|
|
1207
|
+
error: r.error.replace(`${IMAGE_PERMISSION_DENIED_PREFIX} `, ""),
|
|
1208
|
+
is_readonly: false,
|
|
1209
|
+
},
|
|
1210
|
+
},
|
|
1211
|
+
},
|
|
1212
|
+
})];
|
|
1213
|
+
}
|
|
1214
|
+
// A committed image reports the file Cursor asked about, not the tool's
|
|
1215
|
+
// prose. `buildExecClientMessages` would otherwise put the message's
|
|
1216
|
+
// character count in `WriteSuccess.file_size`.
|
|
1217
|
+
if (frames.length === 0
|
|
1218
|
+
&& !r.error
|
|
1219
|
+
&& pending.toolName === CURSOR_IMAGE_SAVE_TOOL
|
|
1220
|
+
&& pending.resultField === "write_result") {
|
|
1221
|
+
frames = [encodeMessage("AgentClientMessage", {
|
|
1222
|
+
exec_client_message: {
|
|
1223
|
+
id: r.execId,
|
|
1224
|
+
write_result: {
|
|
1225
|
+
success: {
|
|
1226
|
+
path: typeof pending.resultMetadata?.path === "string"
|
|
1227
|
+
? pending.resultMetadata.path
|
|
1228
|
+
: "",
|
|
1229
|
+
lines_created: 0,
|
|
1230
|
+
file_size: typeof pending.resultMetadata?.imageByteLength === "number"
|
|
1231
|
+
? pending.resultMetadata.imageByteLength
|
|
1232
|
+
: 0,
|
|
1233
|
+
},
|
|
1234
|
+
},
|
|
1235
|
+
},
|
|
1236
|
+
})];
|
|
1237
|
+
}
|
|
1238
|
+
if (frames.length === 0) {
|
|
1239
|
+
frames = buildExecClientMessages({
|
|
1240
|
+
execId: r.execId,
|
|
1241
|
+
resultField: pending.resultField,
|
|
1242
|
+
output: shellResult?.output ?? r.output,
|
|
1243
|
+
error: r.error,
|
|
1244
|
+
toolName: pending.toolName ?? r.toolName,
|
|
1245
|
+
resultMetadata: pending.resultMetadata,
|
|
1246
|
+
shellOutcome: shellResult?.outcome,
|
|
1247
|
+
workspaceRoot,
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
818
1250
|
}
|
|
819
1251
|
catch (error) {
|
|
820
1252
|
trace(`continuation: result encode FAILED execId=${r.execId} err=${error.message}`);
|
|
@@ -834,7 +1266,14 @@ export function deliverContinuationResults(session, trailingToolResults) {
|
|
|
834
1266
|
trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
|
|
835
1267
|
continue;
|
|
836
1268
|
}
|
|
837
|
-
|
|
1269
|
+
const resultKind = pending.resultField === ASK_QUESTION_RESULT_FIELD
|
|
1270
|
+
? "ask_question answer"
|
|
1271
|
+
: pending.resultField === SWITCH_MODE_RESULT_FIELD
|
|
1272
|
+
? "switch_mode answer"
|
|
1273
|
+
: pending.resultField === CREATE_PLAN_RESULT_FIELD
|
|
1274
|
+
? "create_plan answer"
|
|
1275
|
+
: "exec result";
|
|
1276
|
+
trace(`continuation: wrote ${resultKind} execId=${r.execId} field=${pending.resultField} ` +
|
|
838
1277
|
`frames=${outcome.framesWritten} outLen=${r.output.length}`);
|
|
839
1278
|
}
|
|
840
1279
|
return session;
|
|
@@ -879,7 +1318,27 @@ function resolveExplicitAgentBaseURL(options) {
|
|
|
879
1318
|
function isTruthyEnv(value) {
|
|
880
1319
|
return value === "1" || value === "true";
|
|
881
1320
|
}
|
|
1321
|
+
const streamWriteChains = new WeakMap();
|
|
1322
|
+
/**
|
|
1323
|
+
* Keep awaited protocol writes ordered per Run stream. In particular, a large
|
|
1324
|
+
* KV get_blob reply must drain before another KV reply or heartbeat is queued;
|
|
1325
|
+
* otherwise one heartbeat observes the backlog created by dozens of ignored
|
|
1326
|
+
* false write() results and reports the wrong operation as the failure.
|
|
1327
|
+
*/
|
|
882
1328
|
async function writeWithBackpressure(stream, message, operation) {
|
|
1329
|
+
const previous = streamWriteChains.get(stream);
|
|
1330
|
+
const current = (previous ? previous.catch(() => undefined) : Promise.resolve())
|
|
1331
|
+
.then(() => writeWithBackpressureNow(stream, message, operation));
|
|
1332
|
+
streamWriteChains.set(stream, current);
|
|
1333
|
+
try {
|
|
1334
|
+
await current;
|
|
1335
|
+
}
|
|
1336
|
+
finally {
|
|
1337
|
+
if (streamWriteChains.get(stream) === current)
|
|
1338
|
+
streamWriteChains.delete(stream);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
async function writeWithBackpressureNow(stream, message, operation) {
|
|
883
1342
|
let accepted;
|
|
884
1343
|
try {
|
|
885
1344
|
accepted = stream.write(message);
|
|
@@ -892,6 +1351,7 @@ async function writeWithBackpressure(stream, message, operation) {
|
|
|
892
1351
|
}
|
|
893
1352
|
if (accepted !== false)
|
|
894
1353
|
return;
|
|
1354
|
+
trace(`stream write backpressured: operation=${operation} bytes=${message.length}`);
|
|
895
1355
|
if (!stream.waitForDrain) {
|
|
896
1356
|
throw new CursorTransportError(`Cursor ${operation} write was backpressured`, {
|
|
897
1357
|
transient: false,
|
|
@@ -901,6 +1361,7 @@ async function writeWithBackpressure(stream, message, operation) {
|
|
|
901
1361
|
}
|
|
902
1362
|
try {
|
|
903
1363
|
await stream.waitForDrain(5_000);
|
|
1364
|
+
trace(`stream write drained: operation=${operation} bytes=${message.length}`);
|
|
904
1365
|
}
|
|
905
1366
|
catch (cause) {
|
|
906
1367
|
throw toCursorProviderError(cause, {
|
|
@@ -947,13 +1408,27 @@ async function nextFrameWithSemanticDeadline(session) {
|
|
|
947
1408
|
*/
|
|
948
1409
|
export async function pump(session, controller, ids, abortSignal) {
|
|
949
1410
|
sessionManager.registerSession(session);
|
|
1411
|
+
const cacheDiagnostics = session.cacheDiagnostics ??= {
|
|
1412
|
+
sessionKey: session.openCodeSessionId,
|
|
1413
|
+
conversationId: session.conversationId,
|
|
1414
|
+
priorTokenDetails: session.tokenDetails,
|
|
1415
|
+
startedWithCheckpoint: !!session.tokenDetails,
|
|
1416
|
+
requestContextReused: false,
|
|
1417
|
+
requestContextHash: "unavailable",
|
|
1418
|
+
checkpointUpdates: 0,
|
|
1419
|
+
tokenDetailUpdates: 0,
|
|
1420
|
+
pumpPasses: 0,
|
|
1421
|
+
stepStarts: 0,
|
|
1422
|
+
stepCompletes: 0,
|
|
1423
|
+
displayToolCalls: 0,
|
|
1424
|
+
execRequests: 0,
|
|
1425
|
+
};
|
|
1426
|
+
cacheDiagnostics.pumpPasses++;
|
|
950
1427
|
const { textId, reasoningId } = ids;
|
|
951
|
-
const promptTokens = ids.promptTokens ?? 0;
|
|
952
1428
|
const advertisedToolNames = advertisedToolNamesFromDescriptors(session.toolDescriptors);
|
|
953
1429
|
const advertisedToolNameSet = new Set(advertisedToolNames.map((name) => resolveCustomWebToolAlias(name, session.toolAliases)));
|
|
954
1430
|
let textStarted = false;
|
|
955
1431
|
let reasoningStarted = false;
|
|
956
|
-
const requestUsage = ids.requestUsage ?? { outputChars: 0 };
|
|
957
1432
|
const replaySafety = new AttemptReplaySafety(session.sessionId);
|
|
958
1433
|
const failRunProtocol = (message, code) => {
|
|
959
1434
|
replaySafety.markBarrier("unknown-or-malformed-frame");
|
|
@@ -961,6 +1436,10 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
961
1436
|
sessionManager.close(session, "remote-error", error);
|
|
962
1437
|
throw error;
|
|
963
1438
|
};
|
|
1439
|
+
const rethrowTransportWriteFailure = (error) => {
|
|
1440
|
+
if (error instanceof CursorProviderError && error.origin !== "protocol")
|
|
1441
|
+
throw error;
|
|
1442
|
+
};
|
|
964
1443
|
// OpenCode cancels the ReadableStream between turns (see the cancel handler
|
|
965
1444
|
// in doStreamImpl). The frames iterator can still yield a final `done` after
|
|
966
1445
|
// the cancel lands — controller.enqueue on a cancelled controller throws.
|
|
@@ -1016,13 +1495,17 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1016
1495
|
}
|
|
1017
1496
|
};
|
|
1018
1497
|
/**
|
|
1019
|
-
* Cursor's streamed edit handshake
|
|
1020
|
-
* replacement through write_args.
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1498
|
+
* Cursor's streamed edit handshake needs the complete target before it sends
|
|
1499
|
+
* the replacement through write_args. OpenCode's ordinary read tool caps at
|
|
1500
|
+
* 50 KB, so forwarding this private prerequisite read teaches Cursor's editor
|
|
1501
|
+
* that a large file ends at the cap and produces a destructive replacement.
|
|
1502
|
+
*
|
|
1503
|
+
* Answer a correlated workspace-file read directly up to Cursor's own 50 MB
|
|
1504
|
+
* edit limit. Missing targets remain empty-file successes so creation can
|
|
1505
|
+
* continue. External and otherwise ineligible reads retain OpenCode's normal
|
|
1506
|
+
* permission-aware path.
|
|
1024
1507
|
*/
|
|
1025
|
-
const
|
|
1508
|
+
const recoverCorrelatedEditRead = (parsed, displayCallId) => {
|
|
1026
1509
|
if (!displayCallId ||
|
|
1027
1510
|
parsed.resultField !== "read_result" ||
|
|
1028
1511
|
parsed.toolName !== "read" ||
|
|
@@ -1043,8 +1526,54 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1043
1526
|
const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
|
|
1044
1527
|
const resolvePath = (value) => path.resolve(workspaceRoot, value);
|
|
1045
1528
|
const absolutePath = resolvePath(requestedPath);
|
|
1046
|
-
if (absolutePath !== resolvePath(editPath)
|
|
1529
|
+
if (absolutePath !== resolvePath(editPath))
|
|
1047
1530
|
return false;
|
|
1531
|
+
let exists = true;
|
|
1532
|
+
try {
|
|
1533
|
+
fs.lstatSync(absolutePath);
|
|
1534
|
+
}
|
|
1535
|
+
catch (error) {
|
|
1536
|
+
const code = error && typeof error === "object" && "code" in error
|
|
1537
|
+
? error.code
|
|
1538
|
+
: undefined;
|
|
1539
|
+
if (code !== "ENOENT")
|
|
1540
|
+
return false;
|
|
1541
|
+
exists = false;
|
|
1542
|
+
}
|
|
1543
|
+
if (exists) {
|
|
1544
|
+
// Do not bypass OpenCode's external-directory permission boundary, even
|
|
1545
|
+
// through a symlink rooted in the workspace.
|
|
1546
|
+
try {
|
|
1547
|
+
const realRoot = fs.realpathSync(workspaceRoot);
|
|
1548
|
+
const realTarget = fs.realpathSync(absolutePath);
|
|
1549
|
+
const relative = path.relative(realRoot, realTarget);
|
|
1550
|
+
if (relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
1551
|
+
parsed.resultMetadata = {
|
|
1552
|
+
...parsed.resultMetadata,
|
|
1553
|
+
correlatedEditCallId: displayCallId,
|
|
1554
|
+
};
|
|
1555
|
+
return false;
|
|
1556
|
+
}
|
|
1557
|
+
const frames = buildCompleteEditReadMessages(parsed.id, absolutePath, requestedPath);
|
|
1558
|
+
if (!frames)
|
|
1559
|
+
return false;
|
|
1560
|
+
for (const frame of frames)
|
|
1561
|
+
session.stream.write(frame);
|
|
1562
|
+
const editCall = session.editToolCalls?.get(displayCallId);
|
|
1563
|
+
if (editCall)
|
|
1564
|
+
editCall.completeRead = true;
|
|
1565
|
+
trace(`exec: correlated edit read completed directly id=${parsed.id} ` +
|
|
1566
|
+
`path=${JSON.stringify(requestedPath)}; awaiting write_args`);
|
|
1567
|
+
return true;
|
|
1568
|
+
}
|
|
1569
|
+
catch {
|
|
1570
|
+
parsed.resultMetadata = {
|
|
1571
|
+
...parsed.resultMetadata,
|
|
1572
|
+
correlatedEditCallId: displayCallId,
|
|
1573
|
+
};
|
|
1574
|
+
return false;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1048
1577
|
try {
|
|
1049
1578
|
for (const frame of buildExecClientMessages({
|
|
1050
1579
|
execId: parsed.id,
|
|
@@ -1061,7 +1590,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1061
1590
|
return true;
|
|
1062
1591
|
}
|
|
1063
1592
|
catch (e) {
|
|
1064
|
-
const error = new Error(`Failed to recover Cursor edit read
|
|
1593
|
+
const error = new Error(`Failed to recover Cursor edit read: ${e.message}`);
|
|
1065
1594
|
trace(`exec: edit read recovery FAILED ${error.message}`);
|
|
1066
1595
|
safeError(error);
|
|
1067
1596
|
sessionManager.close(session);
|
|
@@ -1079,7 +1608,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1079
1608
|
*
|
|
1080
1609
|
* Read-only by construction — write/edit targets are never checked, so
|
|
1081
1610
|
* legitimate file creation (path resolves, file absent) is never denied. Runs
|
|
1082
|
-
* after
|
|
1611
|
+
* after recoverCorrelatedEditRead, which has already converted the edit
|
|
1083
1612
|
* handshake read into an empty-file success. EACCES/EPERM and other stat
|
|
1084
1613
|
* errors fall through to OpenCode so a genuine permission decision stands.
|
|
1085
1614
|
*/
|
|
@@ -1089,10 +1618,9 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1089
1618
|
const requested = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
|
|
1090
1619
|
if (!requested)
|
|
1091
1620
|
return false;
|
|
1092
|
-
// A scheme-addressed target is the host's to resolve,
|
|
1093
|
-
// stat. Rejecting it here
|
|
1094
|
-
//
|
|
1095
|
-
// permanently unreachable through this provider.
|
|
1621
|
+
// A scheme-addressed target is the advertised host executor's to resolve,
|
|
1622
|
+
// not a local file to stat. Rejecting it here would make any URI-backed
|
|
1623
|
+
// capability permanently unreachable through this provider.
|
|
1096
1624
|
if (isUriReadTarget(requested)) {
|
|
1097
1625
|
trace(`exec: forwarding URI read target id=${parsed.id} target=${JSON.stringify(requested)}`);
|
|
1098
1626
|
return false;
|
|
@@ -1141,9 +1669,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1141
1669
|
textStarted = true;
|
|
1142
1670
|
}
|
|
1143
1671
|
session.usageEstimate.outputTokens += estimateTokens(text.length);
|
|
1144
|
-
|
|
1145
|
-
requestUsage.outputChars += text.length;
|
|
1146
|
-
}
|
|
1672
|
+
safeEnqueue({ type: "text-delta", id: textId, delta: text });
|
|
1147
1673
|
};
|
|
1148
1674
|
const emitReasoning = (text) => {
|
|
1149
1675
|
if (!text)
|
|
@@ -1154,38 +1680,32 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1154
1680
|
reasoningStarted = true;
|
|
1155
1681
|
}
|
|
1156
1682
|
session.usageEstimate.outputTokens += estimateTokens(text.length);
|
|
1157
|
-
|
|
1158
|
-
requestUsage.outputChars += text.length;
|
|
1159
|
-
}
|
|
1683
|
+
safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text });
|
|
1160
1684
|
};
|
|
1161
|
-
const emitFinish = (te, reason) => {
|
|
1685
|
+
const emitFinish = (te, reason, settledUsage, settledSource) => {
|
|
1162
1686
|
closeOpenSpans();
|
|
1163
|
-
// TurnEnded is request-local only when this Run did not previously stop at
|
|
1164
|
-
// a tool boundary. On a held multi-step Run Cursor aggregates every model
|
|
1165
|
-
// invocation into these counters; treating that billed total as the final
|
|
1166
|
-
// request's prompt size makes OpenCode compact after ordinary tasks.
|
|
1167
|
-
const cumulativeByBoundary = !!te && session.hadToolCallBoundary === true;
|
|
1168
|
-
const cumulativeByBudget = !!te && !cumulativeByBoundary && exceedsRequestLocalBudget(te, {
|
|
1169
|
-
inputTokens: session.usageEstimate.inputTokens,
|
|
1170
|
-
outputTokens: session.usageEstimate.outputTokens,
|
|
1171
|
-
});
|
|
1172
|
-
const cumulativeTurnEnded = cumulativeByBoundary || cumulativeByBudget;
|
|
1173
|
-
if (te && !cumulativeTurnEnded) {
|
|
1174
|
-
session.usageEstimate = {
|
|
1175
|
-
inputTokens: turnEndedCounter(te, "input_tokens"),
|
|
1176
|
-
outputTokens: turnEndedCounter(te, "output_tokens"),
|
|
1177
|
-
cacheRead: turnEndedCounter(te, "cache_read"),
|
|
1178
|
-
cacheWrite: turnEndedCounter(te, "cache_write"),
|
|
1179
|
-
reasoningTokens: turnEndedCounter(te, "reasoning_tokens"),
|
|
1180
|
-
};
|
|
1181
|
-
}
|
|
1182
1687
|
const est = session.usageEstimate;
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1688
|
+
// OpenCode persists and sums every AI SDK finish. Cursor does not expose a
|
|
1689
|
+
// final context total at tool boundaries, so charging the growing estimate
|
|
1690
|
+
// there multiplies one held Run across all of its steps. Emit zero at
|
|
1691
|
+
// intermediate boundaries, then prefer checkpoint tokenDetails exactly once
|
|
1692
|
+
// when TurnEnded closes the held Run. Cursor CLI keeps context occupancy in
|
|
1693
|
+
// agentStore tokenDetails; it does not reinterpret TurnEnded as occupancy.
|
|
1694
|
+
const tokenDetails = te ? session.tokenDetails : undefined;
|
|
1695
|
+
const contextSource = tokenDetails
|
|
1696
|
+
? session.tokenDetailsFresh
|
|
1697
|
+
? "checkpoint-current-run"
|
|
1698
|
+
: "checkpoint-previous-turn"
|
|
1699
|
+
: undefined;
|
|
1700
|
+
const counters = te ? cursorUsageCountersFromTurnEnded(te) : undefined;
|
|
1701
|
+
const usage = settledUsage ?? (te
|
|
1702
|
+
? tokenDetails
|
|
1703
|
+
? buildLanguageModelV3UsageFromCounters(counters, { contextTotalTokens: tokenDetails.usedTokens })
|
|
1704
|
+
: emptyLanguageModelV3Usage()
|
|
1705
|
+
: emptyLanguageModelV3Usage());
|
|
1706
|
+
const providerMetadata = te
|
|
1707
|
+
? cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource)
|
|
1708
|
+
: undefined;
|
|
1189
1709
|
const reasonLabel = typeof reason === "object" && reason && "unified" in reason
|
|
1190
1710
|
? String(reason.unified ?? "unknown")
|
|
1191
1711
|
: String(reason);
|
|
@@ -1199,8 +1719,13 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1199
1719
|
`rawOut=${te ? turnEndedCounter(te, "output_tokens") : est.outputTokens} ` +
|
|
1200
1720
|
`rawCacheRead=${te ? turnEndedCounter(te, "cache_read") : est.cacheRead} ` +
|
|
1201
1721
|
`rawCacheWrite=${te ? turnEndedCounter(te, "cache_write") : est.cacheWrite} ` +
|
|
1202
|
-
`
|
|
1203
|
-
|
|
1722
|
+
`source=${te
|
|
1723
|
+
? settledSource ?? (contextSource ?? "unavailable")
|
|
1724
|
+
: "intermediate-zero"}`);
|
|
1725
|
+
if (counters) {
|
|
1726
|
+
trace(formatTurnUsageValidation(counters, usage, tokenDetails, contextSource));
|
|
1727
|
+
trace(formatCursorCacheDiagnostics(counters, tokenDetails, cacheDiagnostics.priorTokenDetails, cacheDiagnostics));
|
|
1728
|
+
}
|
|
1204
1729
|
safeEnqueue({
|
|
1205
1730
|
type: "finish",
|
|
1206
1731
|
usage,
|
|
@@ -1331,9 +1856,20 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1331
1856
|
if (checkpointRaw != null) {
|
|
1332
1857
|
const bytes = normalizeCheckpointBytes(checkpointRaw);
|
|
1333
1858
|
if (bytes && bytes.length > 0) {
|
|
1859
|
+
cacheDiagnostics.checkpointUpdates++;
|
|
1334
1860
|
setCheckpoint(session.conversationId, bytes);
|
|
1335
1861
|
session.resumeCheckpoint = Uint8Array.from(bytes);
|
|
1336
|
-
|
|
1862
|
+
const tokenDetails = decodeConversationTokenDetails(bytes);
|
|
1863
|
+
if (tokenDetails) {
|
|
1864
|
+
cacheDiagnostics.tokenDetailUpdates++;
|
|
1865
|
+
session.tokenDetails = tokenDetails;
|
|
1866
|
+
session.tokenDetailsFresh = true;
|
|
1867
|
+
}
|
|
1868
|
+
const context = tokenDetails
|
|
1869
|
+
? ` context=${tokenDetails.usedTokens}/${tokenDetails.maxTokens} ` +
|
|
1870
|
+
`categories=${formatCursorTokenCategories(tokenDetails)}`
|
|
1871
|
+
: " context=unavailable";
|
|
1872
|
+
trace(`checkpoint: stored ${bytes.length}B for conversationId=${session.conversationId}${context}`);
|
|
1337
1873
|
}
|
|
1338
1874
|
}
|
|
1339
1875
|
if (iu?.text_delta) {
|
|
@@ -1344,11 +1880,25 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1344
1880
|
}
|
|
1345
1881
|
else if (iu?.turn_ended) {
|
|
1346
1882
|
trace(`turn_ended raw wire fields: ${debugWalkTurnEnded(payload)}`);
|
|
1347
|
-
|
|
1883
|
+
const turnEnded = iu.turn_ended;
|
|
1884
|
+
if (session.openCodeSessionId) {
|
|
1885
|
+
await persistConversationState(session.cacheDir ?? opencodeGlobalCacheDir(), {
|
|
1886
|
+
sessionKey: session.openCodeSessionId,
|
|
1887
|
+
conversationId: session.conversationId,
|
|
1888
|
+
requestContext: session.requestContext,
|
|
1889
|
+
toolCatalog: session.toolCatalog ?? [],
|
|
1890
|
+
postCompactionRebase: session.postCompactionRebase,
|
|
1891
|
+
}).catch((error) => {
|
|
1892
|
+
trace(`conversation persistence: TurnEnded save failed ` +
|
|
1893
|
+
`sessionKey=${session.openCodeSessionId}: ${String(error)}`);
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
emitFinish(turnEnded, { unified: "stop", raw: undefined });
|
|
1348
1897
|
sessionManager.close(session);
|
|
1349
1898
|
return;
|
|
1350
1899
|
}
|
|
1351
1900
|
else if (iu?.tool_call_started) {
|
|
1901
|
+
cacheDiagnostics.displayToolCalls++;
|
|
1352
1902
|
// Stash Cursor display ToolCall until exec claims it, or completed bridges it.
|
|
1353
1903
|
const started = iu.tool_call_started;
|
|
1354
1904
|
const callId = typeof started.call_id === "string" ? started.call_id : "";
|
|
@@ -1435,7 +1985,14 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1435
1985
|
}
|
|
1436
1986
|
}
|
|
1437
1987
|
}
|
|
1988
|
+
else if (iu?.step_started) {
|
|
1989
|
+
cacheDiagnostics.stepStarts++;
|
|
1990
|
+
}
|
|
1991
|
+
else if (iu?.step_completed) {
|
|
1992
|
+
cacheDiagnostics.stepCompletes++;
|
|
1993
|
+
}
|
|
1438
1994
|
else if (esm) {
|
|
1995
|
+
cacheDiagnostics.execRequests++;
|
|
1439
1996
|
const esmId = esm.id ?? 0;
|
|
1440
1997
|
if (esm.request_context_args) {
|
|
1441
1998
|
// Server turn-setup probe (#10). Reply with full OpenCode-sourced context.
|
|
@@ -1451,10 +2008,11 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1451
2008
|
}
|
|
1452
2009
|
try {
|
|
1453
2010
|
traceRequestContextPaths(`exec request_context reply id=${esmId}`, session.requestContext);
|
|
1454
|
-
session.stream
|
|
2011
|
+
await writeWithBackpressure(session.stream, buildRequestContextResult(esmId, session.requestContext), `request-context reply id=${esmId}`);
|
|
1455
2012
|
trace(`exec request_context: replied`);
|
|
1456
2013
|
}
|
|
1457
|
-
catch {
|
|
2014
|
+
catch (error) {
|
|
2015
|
+
rethrowTransportWriteFailure(error);
|
|
1458
2016
|
failRunProtocol("Cursor request-context reply failed", RUN_REPLY_FAILED);
|
|
1459
2017
|
}
|
|
1460
2018
|
}
|
|
@@ -1467,10 +2025,11 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1467
2025
|
? stateArgs.server_identifiers.join(",")
|
|
1468
2026
|
: "";
|
|
1469
2027
|
try {
|
|
1470
|
-
session.stream
|
|
2028
|
+
await writeWithBackpressure(session.stream, buildMcpStateResult(esmId, stateArgs, session.requestContext), `MCP-state reply id=${esmId}`);
|
|
1471
2029
|
trace(`exec mcp_state: replied id=${esmId} requested=[${requested}]`);
|
|
1472
2030
|
}
|
|
1473
|
-
catch {
|
|
2031
|
+
catch (error) {
|
|
2032
|
+
rethrowTransportWriteFailure(error);
|
|
1474
2033
|
failRunProtocol("Cursor MCP-state reply failed", RUN_REPLY_FAILED);
|
|
1475
2034
|
}
|
|
1476
2035
|
}
|
|
@@ -1481,10 +2040,11 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1481
2040
|
const args = esm.list_mcp_resources_exec_args;
|
|
1482
2041
|
const server = typeof args.server === "string" ? args.server : "";
|
|
1483
2042
|
try {
|
|
1484
|
-
session.stream
|
|
2043
|
+
await writeWithBackpressure(session.stream, buildListMcpResourcesFallback(esmId), `list-MCP-resources reply id=${esmId}`);
|
|
1485
2044
|
trace(`exec list_mcp_resources: replied id=${esmId} server=${server || "(all)"} success{resources:[]}`);
|
|
1486
2045
|
}
|
|
1487
|
-
catch {
|
|
2046
|
+
catch (error) {
|
|
2047
|
+
rethrowTransportWriteFailure(error);
|
|
1488
2048
|
failRunProtocol("Cursor list_mcp_resources reply failed", RUN_REPLY_FAILED);
|
|
1489
2049
|
}
|
|
1490
2050
|
}
|
|
@@ -1494,10 +2054,11 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1494
2054
|
const server = typeof args.server === "string" ? args.server : "";
|
|
1495
2055
|
const uri = typeof args.uri === "string" ? args.uri : "";
|
|
1496
2056
|
try {
|
|
1497
|
-
session.stream
|
|
2057
|
+
await writeWithBackpressure(session.stream, buildReadMcpResourceFallback(esmId, server, uri), `read-MCP-resource reply id=${esmId}`);
|
|
1498
2058
|
trace(`exec read_mcp_resource: replied id=${esmId} server=${server} uri=${uri} error{server not found}`);
|
|
1499
2059
|
}
|
|
1500
|
-
catch {
|
|
2060
|
+
catch (error) {
|
|
2061
|
+
rethrowTransportWriteFailure(error);
|
|
1501
2062
|
failRunProtocol("Cursor read_mcp_resource reply failed", RUN_REPLY_FAILED);
|
|
1502
2063
|
}
|
|
1503
2064
|
}
|
|
@@ -1515,6 +2076,11 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1515
2076
|
const editCall = displayCallId
|
|
1516
2077
|
? session.editToolCalls?.get(displayCallId)
|
|
1517
2078
|
: undefined;
|
|
2079
|
+
// A complete correlated edit read is trusted provenance: unlike an
|
|
2080
|
+
// ordinary capped read it cannot have appended our warning marker,
|
|
2081
|
+
// and the source itself may legitimately contain the marker text.
|
|
2082
|
+
if (!editCall?.completeRead)
|
|
2083
|
+
rejectPartialReadMutation(parsed);
|
|
1518
2084
|
if (editCall) {
|
|
1519
2085
|
const remapped = remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNameSet, editCall.path, workspaceRootFromRequestContext(session.requestContext));
|
|
1520
2086
|
if (parsed.resultField === "write_result" && displayCallId) {
|
|
@@ -1547,6 +2113,63 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1547
2113
|
return;
|
|
1548
2114
|
continue;
|
|
1549
2115
|
}
|
|
2116
|
+
// Cursor writes a generated image with an ordinary write exec whose
|
|
2117
|
+
// `file_bytes` are binary (Cursor CLI's agent does exactly this, then
|
|
2118
|
+
// reads the client's WriteResult back). OpenCode's `write` takes a
|
|
2119
|
+
// string, so those bytes are staged and committed by the host tool
|
|
2120
|
+
// that can raise the `edit` permission before anything hits disk.
|
|
2121
|
+
const binaryWrite = binaryWritePayload(parsed);
|
|
2122
|
+
if (binaryWrite) {
|
|
2123
|
+
if (!advertisedToolNameSet.has(CURSOR_IMAGE_SAVE_TOOL)) {
|
|
2124
|
+
const reason = "This OpenCode agent cannot write binary file content. "
|
|
2125
|
+
+ "Do not retry this write with the same bytes.";
|
|
2126
|
+
if (!rejectExec(parsed, reason, "binary write unsupported"))
|
|
2127
|
+
return;
|
|
2128
|
+
continue;
|
|
2129
|
+
}
|
|
2130
|
+
const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
|
|
2131
|
+
const projectDir = ensureOpencodeProjectDir(workspaceRoot);
|
|
2132
|
+
const target = remapCursorImageWritePath(binaryWrite.path, {
|
|
2133
|
+
workspaceRoot,
|
|
2134
|
+
projectDir,
|
|
2135
|
+
});
|
|
2136
|
+
let imageId;
|
|
2137
|
+
try {
|
|
2138
|
+
imageId = stageCursorImage({
|
|
2139
|
+
path: target,
|
|
2140
|
+
projectDir,
|
|
2141
|
+
mime: imageMimeForPath(target),
|
|
2142
|
+
data: binaryWrite.data,
|
|
2143
|
+
sessionId: session.openCodeSessionId,
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
2146
|
+
catch (error) {
|
|
2147
|
+
if (!rejectExec(parsed, error.message, "binary write too large"))
|
|
2148
|
+
return;
|
|
2149
|
+
continue;
|
|
2150
|
+
}
|
|
2151
|
+
if (displayCallId)
|
|
2152
|
+
session.displayToolCalls.delete(displayCallId);
|
|
2153
|
+
sessionManager.registerPending(parsed.id, session, parsed.resultField, CURSOR_IMAGE_SAVE_TOOL, false, {
|
|
2154
|
+
...parsed.resultMetadata,
|
|
2155
|
+
path: target,
|
|
2156
|
+
binaryWriteBytes: undefined,
|
|
2157
|
+
imageByteLength: binaryWrite.data.length,
|
|
2158
|
+
});
|
|
2159
|
+
const toolCallId = `cursor_${session.sessionId}_${parsed.id}`;
|
|
2160
|
+
trace(`exec: binary write STAGED toolCallId=${toolCallId} ` +
|
|
2161
|
+
`requested=${JSON.stringify(binaryWrite.path)} target=${JSON.stringify(target)} ` +
|
|
2162
|
+
`bytes=${binaryWrite.data.length}`);
|
|
2163
|
+
closeOpenSpans();
|
|
2164
|
+
safeEnqueue({
|
|
2165
|
+
type: "tool-call",
|
|
2166
|
+
toolCallId,
|
|
2167
|
+
toolName: CURSOR_IMAGE_SAVE_TOOL,
|
|
2168
|
+
input: JSON.stringify({ image_id: imageId }),
|
|
2169
|
+
});
|
|
2170
|
+
emitFinish(undefined, { unified: "tool-calls", raw: undefined });
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
1550
2173
|
// Cursor has native capabilities (Task, filesystem, shell, etc.) in
|
|
1551
2174
|
// addition to the MCP descriptors sent by this provider. The model
|
|
1552
2175
|
// can request one even when the current OpenCode agent omitted its
|
|
@@ -1566,7 +2189,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1566
2189
|
return;
|
|
1567
2190
|
continue;
|
|
1568
2191
|
}
|
|
1569
|
-
if (
|
|
2192
|
+
if (recoverCorrelatedEditRead(parsed, displayCallId))
|
|
1570
2193
|
continue;
|
|
1571
2194
|
if (rejectMissingReadTarget(parsed)) {
|
|
1572
2195
|
if (displayCallId)
|
|
@@ -1613,11 +2236,25 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1613
2236
|
else if (interactionQuery) {
|
|
1614
2237
|
// InteractionQuery is a must-reply channel, just like exec and KV. AI
|
|
1615
2238
|
// SDK has no Cursor-specific UI callback, so answer immediately with the
|
|
1616
|
-
//
|
|
1617
|
-
//
|
|
2239
|
+
// policy from protocol/interactions.ts (AskQuestion bridge, CreatePlan
|
|
2240
|
+
// write under OpenCode defaults, GenerateImage approve when saveable).
|
|
1618
2241
|
const handled = (() => {
|
|
1619
2242
|
try {
|
|
1620
|
-
return handleInteractionQuery(interactionQuery, payload
|
|
2243
|
+
return handleInteractionQuery(interactionQuery, payload, {
|
|
2244
|
+
canBridgeAskQuestion: session.allowTools && advertisedToolNameSet.has("question"),
|
|
2245
|
+
allowTools: session.allowTools,
|
|
2246
|
+
advertisedTools: advertisedToolNameSet,
|
|
2247
|
+
canSaveGeneratedImage: session.allowTools && advertisedToolNameSet.has(CURSOR_IMAGE_SAVE_TOOL),
|
|
2248
|
+
canBridgeCreatePlan: session.allowTools
|
|
2249
|
+
&& isBridgedCursorPlanModeActive(session.openCodeSessionId)
|
|
2250
|
+
&& advertisedToolNameSet.has(CURSOR_PLAN_STAGE_TOOL)
|
|
2251
|
+
&& advertisedToolNameSet.has("write"),
|
|
2252
|
+
planModeActive: isCursorPlanModeActive(session.openCodeSessionId),
|
|
2253
|
+
...(getActiveCursorMode(session.openCodeSessionId)
|
|
2254
|
+
? { activeCursorModeId: getActiveCursorMode(session.openCodeSessionId) }
|
|
2255
|
+
: {}),
|
|
2256
|
+
workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
|
|
2257
|
+
});
|
|
1621
2258
|
}
|
|
1622
2259
|
catch {
|
|
1623
2260
|
return failRunProtocol("Cursor interaction request could not be handled", RUN_REQUEST_UNSUPPORTED);
|
|
@@ -1627,13 +2264,139 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1627
2264
|
if (handled.outcome === "acknowledged") {
|
|
1628
2265
|
replaySafety.markBarrier("stateful-interaction");
|
|
1629
2266
|
}
|
|
1630
|
-
|
|
2267
|
+
if (handled.reply) {
|
|
2268
|
+
await writeWithBackpressure(session.stream, handled.reply, `interaction reply id=${handled.id}`);
|
|
2269
|
+
}
|
|
1631
2270
|
trace(`interaction_query: replied id=${handled.id} variant=${handled.variantName} ` +
|
|
1632
|
-
`field=${handled.variantField} outcome=${handled.outcome}`
|
|
2271
|
+
`field=${handled.variantField} outcome=${handled.outcome}` +
|
|
2272
|
+
(handled.reply ? "" : " (deferred to host tool result)"));
|
|
2273
|
+
if (handled.generateImage) {
|
|
2274
|
+
trace(`interaction_query: approved generate_image target=` +
|
|
2275
|
+
`${JSON.stringify(handled.generateImage.filePath || "(unspecified)")} ` +
|
|
2276
|
+
`refs=${handled.generateImage.referenceImagePaths.length} ` +
|
|
2277
|
+
`cursorToolCallId=${handled.generateImage.toolCallId || "(none)"}`);
|
|
2278
|
+
}
|
|
1633
2279
|
}
|
|
1634
|
-
catch {
|
|
2280
|
+
catch (error) {
|
|
2281
|
+
rethrowTransportWriteFailure(error);
|
|
1635
2282
|
failRunProtocol("Cursor interaction reply failed", RUN_REPLY_FAILED);
|
|
1636
2283
|
}
|
|
2284
|
+
if (handled.outcome === "bridged" && handled.askQuestion) {
|
|
2285
|
+
// Cursor raised AskQuestion; OpenCode owns the tool loop, so the prompt
|
|
2286
|
+
// leaves as a `question` tool call and this doStream ends. The Run stays
|
|
2287
|
+
// open on the pending entry, and the answer is written back on the next
|
|
2288
|
+
// continuation (InteractionResponse when Cursor is blocking on it,
|
|
2289
|
+
// ConversationAction when it was already acknowledged with `async`).
|
|
2290
|
+
const ask = handled.askQuestion;
|
|
2291
|
+
const execId = session.nextBridgedExecId++;
|
|
2292
|
+
sessionManager.registerPending(execId, session, ASK_QUESTION_RESULT_FIELD, "question", false, {
|
|
2293
|
+
interactionId: handled.id,
|
|
2294
|
+
askQuestionArgs: ask.args,
|
|
2295
|
+
askQuestionRawArgs: ask.rawArgs,
|
|
2296
|
+
askQuestionToolCallId: ask.toolCallId,
|
|
2297
|
+
});
|
|
2298
|
+
const toolCallId = `cursor_${session.sessionId}_${execId}`;
|
|
2299
|
+
const input = JSON.stringify(askQuestionToolInput(ask.args));
|
|
2300
|
+
trace(`interaction_query: BRIDGED ask_question id=${handled.id} toolCallId=${toolCallId} ` +
|
|
2301
|
+
`questions=${ask.args.questions.length} runAsync=${ask.args.runAsync} ` +
|
|
2302
|
+
`cursorToolCallId=${ask.toolCallId || "(none)"}`);
|
|
2303
|
+
closeOpenSpans();
|
|
2304
|
+
safeEnqueue({
|
|
2305
|
+
type: "tool-call",
|
|
2306
|
+
toolCallId,
|
|
2307
|
+
toolName: "question",
|
|
2308
|
+
input,
|
|
2309
|
+
});
|
|
2310
|
+
emitFinish(undefined, { unified: "tool-calls", raw: undefined });
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
if (handled.outcome === "approved" && handled.switchMode) {
|
|
2314
|
+
// Entering plan mode without a host plan tool: Cursor was already
|
|
2315
|
+
// released with approved{} above, and the behavioural contract travels
|
|
2316
|
+
// as the <system_reminder> injected on subsequent Runs. Nothing is
|
|
2317
|
+
// pending, so keep pumping this Run rather than ending doStream.
|
|
2318
|
+
const sw = handled.switchMode;
|
|
2319
|
+
setActiveCursorMode(session.openCodeSessionId, sw.args.targetModeId, {
|
|
2320
|
+
bridgedPlanEntered: false,
|
|
2321
|
+
});
|
|
2322
|
+
trace(`interaction_query: APPROVED switch_mode id=${handled.id} ` +
|
|
2323
|
+
`target=${JSON.stringify(sw.args.targetModeId)} (no host plan tool; ` +
|
|
2324
|
+
`provider-owned mode) cursorToolCallId=${sw.toolCallId || "(none)"}`);
|
|
2325
|
+
continue;
|
|
2326
|
+
}
|
|
2327
|
+
if (handled.outcome === "bridged" && handled.switchMode) {
|
|
2328
|
+
// Cursor raised SwitchMode; the host owns the outcome, so the switch
|
|
2329
|
+
// leaves as a tool call (native plan_enter / plan_exit, or the emulated
|
|
2330
|
+
// `question` approval prompt) and this doStream ends. The Run stays open
|
|
2331
|
+
// on the pending entry; approved/rejected is written back on the next
|
|
2332
|
+
// continuation while Cursor is still blocking on the query.
|
|
2333
|
+
const sw = handled.switchMode;
|
|
2334
|
+
const bridgeKind = sw.bridge.kind;
|
|
2335
|
+
const toolName = sw.toolName ?? "plan_exit";
|
|
2336
|
+
const execId = session.nextBridgedExecId++;
|
|
2337
|
+
sessionManager.registerPending(execId, session, SWITCH_MODE_RESULT_FIELD, toolName, false, {
|
|
2338
|
+
interactionId: handled.id,
|
|
2339
|
+
switchModeTarget: sw.args.targetModeId,
|
|
2340
|
+
switchModeToolCallId: sw.toolCallId,
|
|
2341
|
+
switchModeBridgeKind: bridgeKind,
|
|
2342
|
+
});
|
|
2343
|
+
const toolCallId = `cursor_${session.sessionId}_${execId}`;
|
|
2344
|
+
const input = JSON.stringify(sw.bridge.kind === "question" ? sw.bridge.input : switchModeToolInput());
|
|
2345
|
+
trace(`interaction_query: BRIDGED switch_mode id=${handled.id} toolCallId=${toolCallId} ` +
|
|
2346
|
+
`bridge=${bridgeKind} hostTool=${toolName} ` +
|
|
2347
|
+
`target=${JSON.stringify(sw.args.targetModeId)} ` +
|
|
2348
|
+
`cursorToolCallId=${sw.toolCallId || "(none)"}`);
|
|
2349
|
+
closeOpenSpans();
|
|
2350
|
+
safeEnqueue({
|
|
2351
|
+
type: "tool-call",
|
|
2352
|
+
toolCallId,
|
|
2353
|
+
toolName,
|
|
2354
|
+
input,
|
|
2355
|
+
});
|
|
2356
|
+
emitFinish(undefined, { unified: "tool-calls", raw: undefined });
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
if (handled.outcome === "bridged" && handled.createPlan) {
|
|
2360
|
+
// Either a host plan-stage tool (writes the plan and runs its own
|
|
2361
|
+
// approval UI) or the emulated prompt after the provider already wrote
|
|
2362
|
+
// the plan. Both hold Cursor's query open until the user decides.
|
|
2363
|
+
const plan = handled.createPlan;
|
|
2364
|
+
const staged = plan.bridge.kind === "stage" ? createPlanStageInput(plan.args) : undefined;
|
|
2365
|
+
const planUri = staged?.plan_uri ?? plan.planUri ?? "";
|
|
2366
|
+
const input = staged ?? plan.questionInput ?? {};
|
|
2367
|
+
const stageExecId = session.nextBridgedExecId++;
|
|
2368
|
+
sessionManager.registerPending(stageExecId, session, CREATE_PLAN_RESULT_FIELD, plan.toolName, false, {
|
|
2369
|
+
interactionId: handled.id,
|
|
2370
|
+
createPlanToolCallId: plan.toolCallId,
|
|
2371
|
+
createPlanBridgeKind: plan.bridge.kind,
|
|
2372
|
+
// The host echoes the prompt verbatim; it anchors the answer parse.
|
|
2373
|
+
createPlanQuestion: plan.questionInput?.questions[0]?.question ?? "",
|
|
2374
|
+
planUri,
|
|
2375
|
+
// Absolute path for the post-Yes OpenCode kickoff (plan_exit shape).
|
|
2376
|
+
...(typeof plan.planPath === "string" && plan.planPath.trim()
|
|
2377
|
+
? { planPath: plan.planPath.trim() }
|
|
2378
|
+
: {}),
|
|
2379
|
+
workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
|
|
2380
|
+
});
|
|
2381
|
+
const stageToolCallId = `cursor_${session.sessionId}_${stageExecId}`;
|
|
2382
|
+
trace(`interaction_query: BRIDGED create_plan id=${handled.id} bridge=${plan.bridge.kind} ` +
|
|
2383
|
+
`hostTool=${plan.toolName} stageToolCallId=${stageToolCallId} ` +
|
|
2384
|
+
`planUri=${planUri || "(none)"} cursorToolCallId=${plan.toolCallId || "(none)"}`);
|
|
2385
|
+
// Cursor sends the plan body through the interaction query, not the
|
|
2386
|
+
// text stream, so nothing has shown it yet. Put it in the transcript
|
|
2387
|
+
// before asking — approving a plan you cannot read is not approval.
|
|
2388
|
+
if (plan.planReview)
|
|
2389
|
+
emitText(plan.planReview);
|
|
2390
|
+
closeOpenSpans();
|
|
2391
|
+
safeEnqueue({
|
|
2392
|
+
type: "tool-call",
|
|
2393
|
+
toolCallId: stageToolCallId,
|
|
2394
|
+
toolName: plan.toolName,
|
|
2395
|
+
input: JSON.stringify(input),
|
|
2396
|
+
});
|
|
2397
|
+
emitFinish(undefined, { unified: "tool-calls", raw: undefined });
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
1637
2400
|
}
|
|
1638
2401
|
else if (kv) {
|
|
1639
2402
|
// KV blob channel: ack set_blob / answer get_blob, then keep pumping.
|
|
@@ -1646,12 +2409,14 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1646
2409
|
const handled = handleKvServerMessage(kv, session);
|
|
1647
2410
|
if (handled) {
|
|
1648
2411
|
try {
|
|
1649
|
-
session.stream.
|
|
2412
|
+
await writeWithBackpressure(session.stream, handled.reply, `KV ${handled.kind}_blob reply id=${handled.id}`);
|
|
1650
2413
|
trace(`kv replied: kind=${handled.kind} id=${handled.id} blobId=${handled.blobIdHex.slice(0, 16)}… ` +
|
|
1651
2414
|
`found=${handled.found} echoed=${!!handled.echoed} ` +
|
|
2415
|
+
`replyBytes=${handled.reply.length} replyBlobBytes=${handled.replyBlobBytes} ` +
|
|
1652
2416
|
`sessionBlobs=${session.blobs.size} convBlobs=${conversationBlobCount(session.conversationId)}`);
|
|
1653
2417
|
}
|
|
1654
|
-
catch {
|
|
2418
|
+
catch (error) {
|
|
2419
|
+
rethrowTransportWriteFailure(error);
|
|
1655
2420
|
failRunProtocol("Cursor KV reply failed", RUN_REPLY_FAILED);
|
|
1656
2421
|
}
|
|
1657
2422
|
}
|
|
@@ -1661,7 +2426,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1661
2426
|
}
|
|
1662
2427
|
}
|
|
1663
2428
|
catch (e) {
|
|
1664
|
-
if (e instanceof
|
|
2429
|
+
if (e instanceof CursorProviderError)
|
|
1665
2430
|
throw e;
|
|
1666
2431
|
if (requiredChannel) {
|
|
1667
2432
|
failRunProtocol(`Cursor ${requiredChannel} request could not be handled`, RUN_REQUEST_UNSUPPORTED);
|
|
@@ -1783,16 +2548,26 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
1783
2548
|
const instructions = [];
|
|
1784
2549
|
const subagents = extractHostSubagentCatalog(tools);
|
|
1785
2550
|
if (names.has("question")) {
|
|
1786
|
-
|
|
1787
|
-
|
|
2551
|
+
// Cursor-native AskQuestion is translated into this tool (see
|
|
2552
|
+
// protocol/ask-question.ts), so both routes reach the user. Saying so stops
|
|
2553
|
+
// the model from treating a question as impossible when it reaches for its
|
|
2554
|
+
// native interaction first.
|
|
2555
|
+
instructions.push("- When user input is required, call the OpenCode `question` tool. Cursor-native AskQuestion requests are also accepted and answered through it.");
|
|
2556
|
+
}
|
|
2557
|
+
// CreatePlan is a Cursor InteractionQuery (#7), not an OpenCode/MCP catalog
|
|
2558
|
+
// tool. Always say so when we advertise any tools — otherwise the model sees
|
|
2559
|
+
// CreatePlan in Cursor's function definitions, notices it is absent from the
|
|
2560
|
+
// OpenCode list, and narrates "No CreatePlan MCP tool is available" even
|
|
2561
|
+
// though the provider bridges the native interaction.
|
|
2562
|
+
instructions.push("- Cursor-native CreatePlan is accepted as a Cursor interaction (not an OpenCode or MCP catalog tool). Raise it normally; the provider writes the plan under the host's calculated plans directory and handles execution approval. Do not narrate that CreatePlan is missing, unavailable, or not an MCP tool.");
|
|
1788
2563
|
if (names.has("plan_enter")) {
|
|
1789
|
-
instructions.push("- To enter plan mode, call the OpenCode `plan_enter` tool
|
|
2564
|
+
instructions.push("- To enter plan mode, call the OpenCode `plan_enter` tool. Cursor-native SwitchMode requests for plan/spec are also accepted and answered through it.");
|
|
1790
2565
|
}
|
|
1791
2566
|
else if (names.has("todowrite")) {
|
|
1792
|
-
instructions.push("- For planning, call the OpenCode `todowrite` tool and
|
|
2567
|
+
instructions.push("- For planning task lists, call the OpenCode `todowrite` tool and/or write the plan as normal markdown.");
|
|
1793
2568
|
}
|
|
1794
2569
|
if (names.has("plan_exit")) {
|
|
1795
|
-
instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool.");
|
|
2570
|
+
instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool. Cursor-native SwitchMode for any non-plan target (agent, build, chat, debug, edit, background, multitask, triage, project, …) is also accepted and answered through it; the provider then injects the Cursor CLI-shaped mode reminder for that target.");
|
|
1796
2571
|
}
|
|
1797
2572
|
if (names.has(CUSTOM_WEBSEARCH_TOOL)) {
|
|
1798
2573
|
instructions.push(`- For web searches, call \`${CUSTOM_WEBSEARCH_TOOL}\`; do not use Cursor's native WebSearch interaction.`);
|
|
@@ -1806,8 +2581,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
1806
2581
|
if (names.has(CUSTOM_READ_MCP_RESOURCE_TOOL)) {
|
|
1807
2582
|
instructions.push(`- To read an MCP resource, call \`${CUSTOM_READ_MCP_RESOURCE_TOOL}\`; do not use Cursor's native resource-reading interaction.`);
|
|
1808
2583
|
}
|
|
1809
|
-
if (names.has("task")
|
|
1810
|
-
const target =
|
|
2584
|
+
if (names.has("task")) {
|
|
2585
|
+
const target = "`task`";
|
|
1811
2586
|
const available = subagents.agents.map((agent) => `\`${agent.name}\``).join(", ");
|
|
1812
2587
|
instructions.push(`- Native Cursor Task/subagent requests are executed through OpenCode ${target}. ` +
|
|
1813
2588
|
"Advertised custom subagent names are used exactly; otherwise `unspecified` and `generalPurpose` select host `general`, " +
|
|
@@ -1835,8 +2610,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
1835
2610
|
`OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
|
|
1836
2611
|
`Workspace root: ${JSON.stringify(workspaceRoot)}. Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.`,
|
|
1837
2612
|
subagents.executor
|
|
1838
|
-
? "Call only tools in that exact list. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed
|
|
1839
|
-
: "Call only tools in that exact list. Cursor
|
|
2613
|
+
? "Call only tools in that exact list for ordinary host execution. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing."
|
|
2614
|
+
: "Call only tools in that exact list for ordinary host execution. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing. Other unlisted Cursor-native tools are not bridged; complete the work with the listed tools or explain the limitation without claiming a missing MCP tool.",
|
|
1840
2615
|
...(instructions.length > 0
|
|
1841
2616
|
? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
|
|
1842
2617
|
: []),
|
|
@@ -1850,25 +2625,19 @@ export function estimateTokens(chars) {
|
|
|
1850
2625
|
return 0;
|
|
1851
2626
|
return Math.ceil(chars / 4);
|
|
1852
2627
|
}
|
|
1853
|
-
/**
|
|
1854
|
-
export function
|
|
1855
|
-
const serializedContent = prompt
|
|
1856
|
-
.map((message) => typeof message.content === "string"
|
|
1857
|
-
? message.content
|
|
1858
|
-
: (JSON.stringify(message.content) ?? ""))
|
|
1859
|
-
.join("\n");
|
|
1860
|
-
return estimateTokens(serializedContent.length);
|
|
1861
|
-
}
|
|
1862
|
-
/** Preserve exact Cursor counters as diagnostics when they are cumulative across a multi-step Run. */
|
|
1863
|
-
export function cursorTurnEndedProviderMetadata(te) {
|
|
2628
|
+
/** Preserve exact request-local Cursor counters as diagnostics. */
|
|
2629
|
+
export function cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource) {
|
|
1864
2630
|
return {
|
|
1865
2631
|
cursor: {
|
|
1866
|
-
usageVersion:
|
|
2632
|
+
usageVersion: 3,
|
|
1867
2633
|
inputTokensRaw: turnEndedCounter(te, "input_tokens"),
|
|
1868
2634
|
outputTokensRaw: turnEndedCounter(te, "output_tokens"),
|
|
1869
2635
|
cacheReadRaw: turnEndedCounter(te, "cache_read"),
|
|
1870
2636
|
cacheWriteRaw: turnEndedCounter(te, "cache_write"),
|
|
1871
2637
|
reasoningTokensRaw: turnEndedCounter(te, "reasoning_tokens"),
|
|
2638
|
+
...(tokenDetails
|
|
2639
|
+
? { context: cursorContextUsageMetadata(tokenDetails, contextSource) }
|
|
2640
|
+
: {}),
|
|
1872
2641
|
},
|
|
1873
2642
|
};
|
|
1874
2643
|
}
|
|
@@ -2025,18 +2794,43 @@ export function spanEndParts(opts) {
|
|
|
2025
2794
|
export function computeAllowTools(toolCount, toolChoice) {
|
|
2026
2795
|
return toolCount > 0 && toolChoice?.type !== "none";
|
|
2027
2796
|
}
|
|
2028
|
-
export function resolveTurnToolState(input) {
|
|
2797
|
+
export async function resolveTurnToolState(input) {
|
|
2029
2798
|
const { sessionKey, incomingTools, isCompaction } = input;
|
|
2030
2799
|
if (sessionKey && incomingTools.length > 0) {
|
|
2031
2800
|
rememberToolCatalog(sessionKey, incomingTools.map((tool) => ({ ...tool })));
|
|
2032
2801
|
}
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2802
|
+
// Advertisement and permission are deliberately independent.
|
|
2803
|
+
//
|
|
2804
|
+
// Advertisement must stay byte-stable across every Run of a conversation or
|
|
2805
|
+
// the RequestContext changes shape and Cursor's prompt cache goes cold. A
|
|
2806
|
+
// zero-tool call is never a smaller catalog — it is a lifecycle turn
|
|
2807
|
+
// (compaction, title generation, summarization) that OpenCode opens alongside
|
|
2808
|
+
// the real one — so it re-advertises the last real catalog rather than
|
|
2809
|
+
// collapsing the context to tools=0. A genuinely restricted turn still sends
|
|
2810
|
+
// a non-empty set and is advertised verbatim.
|
|
2811
|
+
//
|
|
2812
|
+
// On cold start the lifecycle Run may arrive before any catalog exists. For a
|
|
2813
|
+
// valid session key, wait until a sibling doStream publishes the first real
|
|
2814
|
+
// catalog; cancellation is the only escape. Never time out to tools=[] and
|
|
2815
|
+
// never invent or filter the enabled set.
|
|
2816
|
+
//
|
|
2817
|
+
// Permission is computed from what actually arrived, so those lifecycle turns
|
|
2818
|
+
// still refuse execution and cannot duplicate the real turn's side effects.
|
|
2819
|
+
let advertisedTools;
|
|
2820
|
+
if (incomingTools.length > 0) {
|
|
2821
|
+
advertisedTools = incomingTools;
|
|
2822
|
+
}
|
|
2823
|
+
else if (sessionKey) {
|
|
2824
|
+
const cached = toolCatalogBySession.get(sessionKey)
|
|
2825
|
+
?? await waitForSiblingToolCatalog(sessionKey, input.abortSignal);
|
|
2826
|
+
advertisedTools = cached.map((tool) => ({ ...tool }));
|
|
2827
|
+
}
|
|
2828
|
+
else {
|
|
2829
|
+
// Without a session key there is no safe sibling-correlation key. Preserve
|
|
2830
|
+
// the host's literal no-tool call rather than borrowing another session's
|
|
2831
|
+
// catalog.
|
|
2832
|
+
advertisedTools = [];
|
|
2036
2833
|
}
|
|
2037
|
-
const advertisedTools = isCompaction && incomingTools.length === 0
|
|
2038
|
-
? (cached?.map((tool) => ({ ...tool })) ?? [])
|
|
2039
|
-
: incomingTools;
|
|
2040
2834
|
return {
|
|
2041
2835
|
advertisedTools,
|
|
2042
2836
|
allowTools: !isCompaction && computeAllowTools(incomingTools.length, input.toolChoice),
|
|
@@ -2055,6 +2849,11 @@ export function resolveTurnConversationReset(input) {
|
|
|
2055
2849
|
return { reset: false };
|
|
2056
2850
|
}
|
|
2057
2851
|
export function resetTurnStateForTests() {
|
|
2852
|
+
for (const waiters of toolCatalogWaitersBySession.values()) {
|
|
2853
|
+
for (const waiter of waiters)
|
|
2854
|
+
waiter.cancel();
|
|
2855
|
+
}
|
|
2856
|
+
toolCatalogWaitersBySession.clear();
|
|
2058
2857
|
toolCatalogBySession.clear();
|
|
2059
2858
|
postCompactionRebaseBySession.clear();
|
|
2060
2859
|
}
|
|
@@ -2081,6 +2880,7 @@ function foldStreamParts(parts) {
|
|
|
2081
2880
|
let reasoning = "";
|
|
2082
2881
|
const content = [];
|
|
2083
2882
|
let finishReason = { unified: "stop", raw: undefined };
|
|
2883
|
+
let providerMetadata;
|
|
2084
2884
|
let usage = {
|
|
2085
2885
|
inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
|
2086
2886
|
outputTokens: { total: undefined, text: undefined, reasoning: undefined },
|
|
@@ -2101,11 +2901,18 @@ function foldStreamParts(parts) {
|
|
|
2101
2901
|
else if (part.type === "finish") {
|
|
2102
2902
|
finishReason = part.finishReason;
|
|
2103
2903
|
usage = part.usage;
|
|
2904
|
+
providerMetadata = part.providerMetadata;
|
|
2104
2905
|
}
|
|
2105
2906
|
}
|
|
2106
2907
|
if (reasoning)
|
|
2107
2908
|
content.unshift({ type: "reasoning", text: reasoning });
|
|
2108
2909
|
if (text)
|
|
2109
2910
|
content.unshift({ type: "text", text });
|
|
2110
|
-
return {
|
|
2911
|
+
return {
|
|
2912
|
+
content,
|
|
2913
|
+
finishReason,
|
|
2914
|
+
usage,
|
|
2915
|
+
warnings: [],
|
|
2916
|
+
...(providerMetadata ? { providerMetadata } : {}),
|
|
2917
|
+
};
|
|
2111
2918
|
}
|