cursor-opencode-provider 0.7.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/dist/context/agents.js +1 -1
- package/dist/context/build.d.ts +2 -0
- package/dist/context/build.js +22 -10
- package/dist/context/epoch.d.ts +59 -0
- package/dist/context/epoch.js +224 -0
- package/dist/context/frozen.d.ts +4 -2
- package/dist/context/frozen.js +31 -11
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/overlay.d.ts +44 -0
- package/dist/context/overlay.js +96 -0
- package/dist/debug.d.ts +9 -1
- package/dist/debug.js +46 -5
- package/dist/host-event-bridge.d.ts +22 -0
- package/dist/host-event-bridge.js +22 -0
- package/dist/image-input.js +36 -11
- package/dist/index.d.ts +3 -0
- package/dist/language-model.d.ts +51 -3
- package/dist/language-model.js +569 -140
- package/dist/model-config.js +5 -1
- package/dist/plugin-opencode2.js +10 -4
- package/dist/plugin.js +26 -15
- package/dist/pricing-data.d.ts +40 -0
- package/dist/pricing-data.js +40 -0
- package/dist/protocol/ask-question.d.ts +8 -9
- package/dist/protocol/ask-question.js +8 -9
- package/dist/protocol/conversation-bind.js +18 -8
- package/dist/protocol/create-plan.d.ts +6 -7
- package/dist/protocol/create-plan.js +4 -5
- package/dist/protocol/messages.js +16 -1
- package/dist/protocol/struct.js +1 -1
- package/dist/protocol/switch-mode.d.ts +3 -4
- package/dist/protocol/switch-mode.js +21 -12
- package/dist/protocol/tool-call-bridge.d.ts +4 -1
- package/dist/protocol/tool-call-bridge.js +27 -5
- package/dist/protocol/tools.d.ts +4 -2
- package/dist/protocol/tools.js +247 -51
- package/dist/session-directory.d.ts +23 -4
- package/dist/session-directory.js +36 -4
- package/dist/session.d.ts +23 -3
- package/dist/session.js +68 -8
- package/dist/shared.d.ts +2 -0
- package/dist/shared.js +2 -0
- package/dist/transport/connect.js +78 -10
- package/dist/transport/https-proxy.d.ts +56 -0
- package/dist/transport/https-proxy.js +348 -0
- package/dist/usage.d.ts +11 -0
- package/dist/usage.js +33 -7
- package/package.json +1 -1
package/dist/language-model.js
CHANGED
|
@@ -11,7 +11,7 @@ import { buildGitDiffExecMessages } from "./protocol/git-diff.js";
|
|
|
11
11
|
import { cursorExecVariantByRequestField, describeCursorExecVariant } from "./protocol/exec-variants.js";
|
|
12
12
|
import { appendCheckpointUserGrounding, appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
|
|
13
13
|
import { progressOnlyContinuationPrompt, shouldContinueProgressOnlyTurn, } from "./protocol/progress-continuation.js";
|
|
14
|
-
import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, snapshotMirroredTodos, snapshotMirroredTodosFromReadOutput, } from "./protocol/tool-call-bridge.js";
|
|
14
|
+
import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, isNativeDisplayToolCall, snapshotMirroredTodos, snapshotMirroredTodosFromReadOutput, } from "./protocol/tool-call-bridge.js";
|
|
15
15
|
import { handleKvServerMessage } from "./protocol/kv.js";
|
|
16
16
|
import { buildAskQuestionInteractionReply, buildAsyncAskQuestionCompletion, buildCreatePlanInteractionReply, buildSwitchModeInteractionReply, handleInteractionQuery, } from "./protocol/interactions.js";
|
|
17
17
|
import { ASK_QUESTION_RESULT_FIELD, askQuestionResultFromToolOutput, askQuestionToolInput, } from "./protocol/ask-question.js";
|
|
@@ -22,9 +22,9 @@ import { flushHostAgentModeSwitch, queueHostAgentModeSwitch, } from "./host-agen
|
|
|
22
22
|
import { CURSOR_IMAGE_SAVE_TOOL, imageMimeForPath, remapCursorImageWritePath, } from "./protocol/generate-image.js";
|
|
23
23
|
import { stageCursorImage } from "./image-staging.js";
|
|
24
24
|
import { IMAGE_PERMISSION_DENIED_PREFIX } from "./image-save.js";
|
|
25
|
-
import {
|
|
25
|
+
import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
|
|
26
26
|
import { cursorContextUsageMetadata, decodeConversationTokenDetails, } from "./protocol/token-details.js";
|
|
27
|
-
import {
|
|
27
|
+
import { conversationBlobCount, inspectConversationBlobGraph, } from "./protocol/blob-store.js";
|
|
28
28
|
import { bindConversationId, resolveConversationGroupId, } from "./protocol/conversation-bind.js";
|
|
29
29
|
import { clearPersistedConversationState, hydrateConversationState, persistConversationState, } from "./protocol/conversation-state.js";
|
|
30
30
|
import { initializeConversationPersistence } from "./protocol/conversation-persistence.js";
|
|
@@ -32,18 +32,19 @@ import { resolveContinuationPolicy, sessionManager, } from "./session.js";
|
|
|
32
32
|
import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
|
|
33
33
|
import { readCache, cacheFilePath, resolveVariantParameters, resolveVariantMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
|
|
34
34
|
import { getOrBuildRequestContext } from "./context/frozen.js";
|
|
35
|
+
import { admitContextEpoch, appendMidConversationMessage, resetContextEpochsForTests, } from "./context/epoch.js";
|
|
35
36
|
import { workspaceRootFromRequestContext } from "./context/env.js";
|
|
36
37
|
import { ensureOpencodeProjectDir, opencodeGlobalCacheDir, setHostCacheDirOverride, } from "./context/paths.js";
|
|
37
38
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
38
|
-
import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION, CURSOR_HOST_AGENT_OPTION, } from "./shared.js";
|
|
39
|
+
import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION, CURSOR_HISTORY_REWRITE_OPTION, CURSOR_HOST_AGENT_OPTION, } from "./shared.js";
|
|
39
40
|
import { isCompactionSession } from "./compaction-marker.js";
|
|
40
|
-
import {
|
|
41
|
+
import { resolveSessionWorkspaceRoot } from "./session-directory.js";
|
|
41
42
|
import { assertCursorUserImageSupport, extractCursorPromptImages } from "./image-input.js";
|
|
42
43
|
import { resolveCursorModelSupportsImages } from "./model-metadata.js";
|
|
43
44
|
import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
|
|
44
45
|
import { analyzeReplayFrame, AttemptReplaySafety } from "./replay-safety.js";
|
|
45
46
|
import { readAllFieldsStrict } from "./protocol/struct.js";
|
|
46
|
-
import {
|
|
47
|
+
import { cursorUsageCountersFromTurnEnded, emptyLanguageModelV3Usage, formatCursorCacheDiagnostics, formatCursorTokenCategories, formatTurnUsageValidation, occupancyUsageFromTokenDetails, occupancyValidationCounters, OPENCODE_DISPLAY_ONLY_COST_METADATA, turnEndedCounter, } from "./usage.js";
|
|
47
48
|
let _availableModels;
|
|
48
49
|
// mtime of the cache file the last time we loaded it. Compared on each call
|
|
49
50
|
// so discoverModels' background refresh is picked up without a process restart.
|
|
@@ -71,10 +72,23 @@ const MAX_RETRY_DELAY_MS = 30_000;
|
|
|
71
72
|
const RUN_REQUEST_DECODE_FAILED = "CURSOR_RUN_REQUEST_DECODE_FAILED";
|
|
72
73
|
const RUN_REQUEST_UNSUPPORTED = "CURSOR_RUN_REQUEST_UNSUPPORTED";
|
|
73
74
|
const RUN_REPLY_FAILED = "CURSOR_RUN_REPLY_FAILED";
|
|
74
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Cursor CLI's conversation **export / transfer-to-cloud** path treats state
|
|
77
|
+
* above 100 MiB as large (`conversation-export.ts` default `104857600`). That
|
|
78
|
+
* budget must not remint a sticky conversation on resume — CLI soft-reuses.
|
|
79
|
+
*/
|
|
75
80
|
export const MAX_CHECKPOINT_BLOB_GRAPH_BYTES = 100 * 1024 * 1024;
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
/** @deprecated Never remints; kept for call-site/tests. Always false. */
|
|
82
|
+
export function checkpointBlobGraphRequiresRebase(_stats, _maxBytes = MAX_CHECKPOINT_BLOB_GRAPH_BYTES) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
/** Warn-only concern for incomplete / oversized checkpoint graphs (no remint). */
|
|
86
|
+
export function checkpointBlobGraphConcern(stats, maxBytes = MAX_CHECKPOINT_BLOB_GRAPH_BYTES) {
|
|
87
|
+
if (!stats.complete)
|
|
88
|
+
return "incomplete-checkpoint-graph";
|
|
89
|
+
if (stats.bytes > maxBytes)
|
|
90
|
+
return "oversized-checkpoint-graph";
|
|
91
|
+
return undefined;
|
|
78
92
|
}
|
|
79
93
|
const RESPONSE_REQUIRED_CHANNEL_BY_FIELD = new Map([
|
|
80
94
|
[2, "exec"],
|
|
@@ -396,13 +410,13 @@ function rememberPromptIdentity(sessionKey, value) {
|
|
|
396
410
|
promptIdentityBySession.delete(oldest);
|
|
397
411
|
}
|
|
398
412
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
413
|
+
/**
|
|
414
|
+
* Prompt identity is diagnostics / persistence only and never drives remint.
|
|
415
|
+
* Kept as a named helper so call sites / tests that still mention identity
|
|
416
|
+
* churn have a single no-op definition.
|
|
417
|
+
*/
|
|
418
|
+
export function promptIdentityWouldRemint(_previous, _current) {
|
|
419
|
+
return false;
|
|
406
420
|
}
|
|
407
421
|
function sentHistoryImageHashes(sessionKey) {
|
|
408
422
|
if (!sessionKey)
|
|
@@ -501,7 +515,8 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
501
515
|
session = deliverContinuationResults(session, trailingToolResults);
|
|
502
516
|
}
|
|
503
517
|
if (!session) {
|
|
504
|
-
|
|
518
|
+
const sessionKey = opencodeSessionKey(callOptions);
|
|
519
|
+
if (!session && trailingToolResults.length > 0) {
|
|
505
520
|
// True continuation (prompt ends with tool results) but the held-open Run
|
|
506
521
|
// is gone (or its write path just failed). Rebase the complete OpenCode
|
|
507
522
|
// prompt onto a fresh conversation: its seed history includes the
|
|
@@ -518,6 +533,18 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
518
533
|
if (historical > 0) {
|
|
519
534
|
trace(`fresh turn: ignoring ${historical} historical tool result(s) (not trailing)`);
|
|
520
535
|
}
|
|
536
|
+
// Host may start a new user turn while the prior Run still has pending
|
|
537
|
+
// tools. Finish that Run on the same conversation (cancel stranded execs,
|
|
538
|
+
// drain turn_ended) before opening the new Run so the checkpoint prefix
|
|
539
|
+
// is preserved. registerSession will not close a prior Run that still
|
|
540
|
+
// has real pending execs; a failed prepare leaves that Run held.
|
|
541
|
+
try {
|
|
542
|
+
await preparePriorSessionForFreshTurn(sessionKey);
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
trace(`fresh turn: prepare-prior failed — opening the new Run and leaving ` +
|
|
546
|
+
`any still-pending prior held — ${error.message}`);
|
|
547
|
+
}
|
|
521
548
|
session = await openSession();
|
|
522
549
|
}
|
|
523
550
|
}
|
|
@@ -773,21 +800,23 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
773
800
|
const lifecycle = !allowTools && !isCompaction && !recovery;
|
|
774
801
|
// v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
|
|
775
802
|
// one plugin instance per project). OpenCode 2.0 runs one daemon across many
|
|
776
|
-
// projects, so its static option is only a fallback
|
|
777
|
-
//
|
|
778
|
-
|
|
803
|
+
// projects, so its static option is only a last-resort fallback. Prefer the
|
|
804
|
+
// per-request `x-opencode-directory` header, then the session mark recorded
|
|
805
|
+
// from `session.hook("context")` via `getSessionDirectory`.
|
|
806
|
+
const workspaceRoot = resolveSessionWorkspaceRoot({
|
|
807
|
+
sessionKey,
|
|
808
|
+
headers: callOptions.headers,
|
|
809
|
+
workspaceRoot: options.workspaceRoot,
|
|
810
|
+
});
|
|
779
811
|
const baseSystemPrompt = extractSystemPrompt(prompt);
|
|
780
812
|
const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
: undefined;
|
|
813
|
+
// Prompt-identity diagnostics are filled after Context Epoch admission below
|
|
814
|
+
// (baseline hash is the epoch baseline, not a per-turn host hash).
|
|
815
|
+
let frozenSystemPromptHash;
|
|
785
816
|
const resetState = resolveTurnConversationReset({
|
|
786
817
|
sessionKey,
|
|
787
818
|
isCompaction,
|
|
788
|
-
|
|
789
|
-
? {}
|
|
790
|
-
: { promptIdentity: { hostAgent, systemPromptHash: stableSystemPromptHash } }),
|
|
819
|
+
historyRewrite: providerOptions?.[CURSOR_HISTORY_REWRITE_OPTION] === true,
|
|
791
820
|
});
|
|
792
821
|
// Compaction must not reuse the prior conversation; its first normal turn
|
|
793
822
|
// must also rebase so the summary-agent checkpoint cannot replace the normal
|
|
@@ -806,30 +835,18 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
806
835
|
let checkpointGraph = conversationState
|
|
807
836
|
? inspectConversationBlobGraph(bound.conversationId, conversationState)
|
|
808
837
|
: { count: 0, bytes: 0, complete: true };
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
// request remains in the seed. An ordinary turn keeps its normal user text.
|
|
822
|
-
if (recovery?.kind === "resume")
|
|
823
|
-
recovery = { kind: "rebase" };
|
|
824
|
-
resumeRecovery = undefined;
|
|
825
|
-
resuming = false;
|
|
826
|
-
if (!sessionKey) {
|
|
827
|
-
clearCheckpoint(previousConversationId);
|
|
828
|
-
clearConversationBlobs(previousConversationId);
|
|
829
|
-
}
|
|
830
|
-
bound = bindConversationId(sessionKey, { reset: true });
|
|
831
|
-
conversationState = undefined;
|
|
832
|
-
checkpointGraph = { count: 0, bytes: 0, complete: true };
|
|
838
|
+
const forcedResetReason = undefined;
|
|
839
|
+
// CLI soft-reuses incomplete / oversized graphs (100 MiB is export-only).
|
|
840
|
+
// Never remint — warn and keep the sticky conversation + checkpoint.
|
|
841
|
+
if (conversationState) {
|
|
842
|
+
const concern = checkpointBlobGraphConcern(checkpointGraph);
|
|
843
|
+
if (concern) {
|
|
844
|
+
trace(`checkpoint state warning: reason=${concern} ` +
|
|
845
|
+
`conversationId=${bound.conversationId} checkpointBytes=${conversationState.length} ` +
|
|
846
|
+
`blobCount=${checkpointGraph.count} blobBytes=${checkpointGraph.bytes} ` +
|
|
847
|
+
`limitBytes=${MAX_CHECKPOINT_BLOB_GRAPH_BYTES} ` +
|
|
848
|
+
`detail=${checkpointGraph.fallbackReason ?? "-"} action=warm-reuse`);
|
|
849
|
+
}
|
|
833
850
|
}
|
|
834
851
|
const conversationId = bound.conversationId;
|
|
835
852
|
const conversationGroupId = resolveConversationGroupId(sessionKey, conversationId);
|
|
@@ -856,22 +873,53 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
856
873
|
const activeMode = getActiveCursorMode(sessionKey);
|
|
857
874
|
const nativePlanPromptOwnsMode = hostAgent === "plan"
|
|
858
875
|
&& (activeMode === "plan" || activeMode === "spec");
|
|
859
|
-
|
|
876
|
+
// Mode/kickoff are chronological Mid-Conversation updates (V2), including on
|
|
877
|
+
// checkpoint Turns — never folded into the frozen system baseline.
|
|
878
|
+
const modeReminder = isCompaction || lifecycle || nativePlanPromptOwnsMode
|
|
860
879
|
? undefined
|
|
861
880
|
: takeActiveCursorModeReminder(sessionKey, {
|
|
862
881
|
advertisedTools: cursorTools.map((tool) => tool.name),
|
|
863
882
|
});
|
|
864
|
-
const kickoffWarning = isCompaction ||
|
|
883
|
+
const kickoffWarning = isCompaction || lifecycle
|
|
865
884
|
? undefined
|
|
866
885
|
: takePlanExecutionKickoffWarning(sessionKey);
|
|
867
|
-
const
|
|
868
|
-
baseSystemPrompt,
|
|
869
|
-
interactionGuidance,
|
|
886
|
+
const oneShotReminders = [
|
|
870
887
|
modeReminder,
|
|
871
888
|
kickoffWarning ? `<system_reminder>${kickoffWarning}</system_reminder>` : undefined,
|
|
872
|
-
]
|
|
873
|
-
|
|
874
|
-
|
|
889
|
+
].filter((part) => !!part);
|
|
890
|
+
let systemPrompt;
|
|
891
|
+
if (isCompaction || lifecycle) {
|
|
892
|
+
// Ephemeral summary/title Runs — do not initialize a sticky Context Epoch.
|
|
893
|
+
systemPrompt = startedWithCheckpoint
|
|
894
|
+
? undefined
|
|
895
|
+
: [baseSystemPrompt, interactionGuidance, ...oneShotReminders].filter(Boolean).join("\n\n");
|
|
896
|
+
if (startedWithCheckpoint && oneShotReminders.length) {
|
|
897
|
+
userText = appendMidConversationMessage(userText, oneShotReminders.join("\n\n"));
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
else {
|
|
901
|
+
const admitted = admitContextEpoch({
|
|
902
|
+
conversationId,
|
|
903
|
+
hasCheckpoint: startedWithCheckpoint,
|
|
904
|
+
hostSystem: baseSystemPrompt,
|
|
905
|
+
guidance: interactionGuidance,
|
|
906
|
+
hostAgent,
|
|
907
|
+
workspaceRoot,
|
|
908
|
+
oneShotReminders,
|
|
909
|
+
});
|
|
910
|
+
systemPrompt = startedWithCheckpoint ? undefined : admitted.seedSystemPrompt;
|
|
911
|
+
userText = appendMidConversationMessage(userText, admitted.midConversationMessage);
|
|
912
|
+
// A recovered epoch has no baseline bytes. Keep the hash restored from
|
|
913
|
+
// the checkpoint snapshot so this turn's TurnEnded save does not drop it.
|
|
914
|
+
const previousIdentity = sessionKey ? promptIdentityBySession.get(sessionKey) : undefined;
|
|
915
|
+
frozenSystemPromptHash = admitted.epoch.baselineHash.trim() || previousIdentity?.systemPromptHash;
|
|
916
|
+
if (sessionKey) {
|
|
917
|
+
rememberPromptIdentity(sessionKey, {
|
|
918
|
+
hostAgent: hostAgent || previousIdentity?.hostAgent,
|
|
919
|
+
systemPromptHash: frozenSystemPromptHash,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
}
|
|
875
923
|
const history = extractPromptHistory(prompt, {
|
|
876
924
|
preserveTrailingUser: recovery?.kind === "rebase",
|
|
877
925
|
toolResults: isCompaction ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
|
|
@@ -1047,7 +1095,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1047
1095
|
stream.destroy();
|
|
1048
1096
|
throw error;
|
|
1049
1097
|
}
|
|
1050
|
-
const hostToolDialect = hostToolDialectFromTools(tools);
|
|
1098
|
+
const hostToolDialect = hostToolDialectFromTools(tools, options.defaultDialect);
|
|
1051
1099
|
trace(`host tool dialect: filePathKey=${hostToolDialect.filePathKey} shellTool=${hostToolDialect.shellTool} ` +
|
|
1052
1100
|
`tools=[${tools.map((t) => t.name).join(",")}]`);
|
|
1053
1101
|
const session = {
|
|
@@ -1074,10 +1122,12 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1074
1122
|
stepCompletes: 0,
|
|
1075
1123
|
displayToolCalls: 0,
|
|
1076
1124
|
execRequests: 0,
|
|
1125
|
+
createPlanInTurn: false,
|
|
1126
|
+
switchModeInTurn: false,
|
|
1077
1127
|
},
|
|
1078
1128
|
openCodeSessionId: lifecycle ? undefined : sessionKey,
|
|
1079
1129
|
hostAgent,
|
|
1080
|
-
stableSystemPromptHash,
|
|
1130
|
+
stableSystemPromptHash: frozenSystemPromptHash,
|
|
1081
1131
|
postCompactionRebase: isCompaction,
|
|
1082
1132
|
toolCatalog: snapshotToolCatalog(sessionKey),
|
|
1083
1133
|
stream,
|
|
@@ -1097,6 +1147,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1097
1147
|
subagentCatalog,
|
|
1098
1148
|
requestContext,
|
|
1099
1149
|
allowTools,
|
|
1150
|
+
permittedToolNames: new Set(allowTools ? incomingTools.map((tool) => tool.name).filter((name) => !!name) : []),
|
|
1100
1151
|
usageEstimate,
|
|
1101
1152
|
pumpActive: false,
|
|
1102
1153
|
pumpOwner: null,
|
|
@@ -1191,6 +1242,295 @@ export function findContinuationSession(toolResults) {
|
|
|
1191
1242
|
}
|
|
1192
1243
|
return undefined;
|
|
1193
1244
|
}
|
|
1245
|
+
/** How long a fresh-turn drain may wait for Cursor `turn_ended` after bridged settle. */
|
|
1246
|
+
export const FRESH_TURN_DRAIN_TIMEOUT_MS = 8_000;
|
|
1247
|
+
/**
|
|
1248
|
+
* Error text written onto Cursor execs that the host abandoned by starting a
|
|
1249
|
+
* new user turn before returning the tool result. Completing the held Run with
|
|
1250
|
+
* this cancel (then draining to `turn_ended`) keeps the same conversation
|
|
1251
|
+
* prefix instead of `superseded-by-new-run` mid-exec.
|
|
1252
|
+
*/
|
|
1253
|
+
export const FRESH_TURN_PENDING_CANCEL_REASON = "Host started a new user turn before this tool result was delivered";
|
|
1254
|
+
/**
|
|
1255
|
+
* Before opening a new user-turn Run, finish the prior held-open Run cleanly:
|
|
1256
|
+
* settle display-only bridged pendings, cancel any real execs still waiting on
|
|
1257
|
+
* the host, then drain for `turn_ended`. That preserves the same
|
|
1258
|
+
* conversation_id / checkpoint prefix instead of aborting mid-tool.
|
|
1259
|
+
*/
|
|
1260
|
+
export async function preparePriorSessionForFreshTurn(openCodeSessionId, opts) {
|
|
1261
|
+
const prior = sessionManager.findOpenByOpenCodeSessionId(openCodeSessionId);
|
|
1262
|
+
if (!prior)
|
|
1263
|
+
return "none";
|
|
1264
|
+
const settledBridged = sessionManager.settleBridgedPending(prior);
|
|
1265
|
+
if (settledBridged > 0) {
|
|
1266
|
+
trace(`fresh turn: settled ${settledBridged} bridged pending on prior session ${prior.sessionId} ` +
|
|
1267
|
+
`remainingPending=${prior.pending.size}`);
|
|
1268
|
+
}
|
|
1269
|
+
if (sessionManager.isActivelyPumping(prior)) {
|
|
1270
|
+
// A live pull owns the stream; do not race cancel writes against it.
|
|
1271
|
+
// Wait briefly for the pump to finish, then cancel + drain.
|
|
1272
|
+
const waited = await waitUntilNotPumping(prior, {
|
|
1273
|
+
timeoutMs: opts?.timeoutMs ?? FRESH_TURN_DRAIN_TIMEOUT_MS,
|
|
1274
|
+
});
|
|
1275
|
+
if (!waited) {
|
|
1276
|
+
trace(`fresh turn: prior session ${prior.sessionId} is still pumping after wait — ` +
|
|
1277
|
+
`leaving that Run held; registerSession will not close it`);
|
|
1278
|
+
return "busy";
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
if (prior.closed)
|
|
1282
|
+
return "none";
|
|
1283
|
+
const cancelled = cancelPendingExecsForFreshTurn(prior);
|
|
1284
|
+
if (cancelled > 0) {
|
|
1285
|
+
trace(`fresh turn: cancelled ${cancelled} pending exec(s) on prior session ${prior.sessionId} ` +
|
|
1286
|
+
`remainingPending=${prior.pending.size}`);
|
|
1287
|
+
}
|
|
1288
|
+
if (prior.closed) {
|
|
1289
|
+
// Cancel path closed the session (encode/write failure). Caller opens a
|
|
1290
|
+
// fresh Run on the same conversation_id — better than mid-exec supersede
|
|
1291
|
+
// with stranded pending, but turn_ended may be missing.
|
|
1292
|
+
return cancelled > 0 || settledBridged > 0 ? "settled-only" : "none";
|
|
1293
|
+
}
|
|
1294
|
+
if (prior.pending.size > 0) {
|
|
1295
|
+
trace(`fresh turn: prior session ${prior.sessionId} still has ${prior.pending.size} ` +
|
|
1296
|
+
`pending after cancel — leaving that Run held; registerSession will not close it`);
|
|
1297
|
+
return "busy";
|
|
1298
|
+
}
|
|
1299
|
+
const outcome = await drainSessionUntilTurnEnded(prior, {
|
|
1300
|
+
timeoutMs: opts?.timeoutMs ?? FRESH_TURN_DRAIN_TIMEOUT_MS,
|
|
1301
|
+
});
|
|
1302
|
+
trace(`fresh turn: prior session ${prior.sessionId} drain outcome=${outcome}`);
|
|
1303
|
+
if (outcome === "turn-ended")
|
|
1304
|
+
return "drained";
|
|
1305
|
+
if (cancelled > 0 || settledBridged > 0)
|
|
1306
|
+
return "settled-only";
|
|
1307
|
+
return outcome === "timeout" || outcome === "interrupted" ? "settled-only" : "busy";
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Write error/reject results for every still-open non-bridged pending on the
|
|
1311
|
+
* held Run so Cursor can finish the agent turn instead of being superseded.
|
|
1312
|
+
*
|
|
1313
|
+
* @returns number of pendings successfully cancelled
|
|
1314
|
+
*/
|
|
1315
|
+
export function cancelPendingExecsForFreshTurn(session) {
|
|
1316
|
+
if (session.closed || session.pending.size === 0)
|
|
1317
|
+
return 0;
|
|
1318
|
+
const synthetic = [];
|
|
1319
|
+
for (const [execId, pending] of session.pending.entries()) {
|
|
1320
|
+
if (pending.bridged)
|
|
1321
|
+
continue;
|
|
1322
|
+
if (pending.state !== "pending")
|
|
1323
|
+
continue;
|
|
1324
|
+
synthetic.push({
|
|
1325
|
+
toolCallId: `cursor_${session.sessionId}_${execId}`,
|
|
1326
|
+
sessionId: session.sessionId,
|
|
1327
|
+
execId,
|
|
1328
|
+
toolName: pending.toolName ?? "unknown",
|
|
1329
|
+
output: FRESH_TURN_PENDING_CANCEL_REASON,
|
|
1330
|
+
error: FRESH_TURN_PENDING_CANCEL_REASON,
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
if (synthetic.length === 0)
|
|
1334
|
+
return 0;
|
|
1335
|
+
const before = session.pending.size;
|
|
1336
|
+
const delivered = deliverContinuationResults(session, synthetic);
|
|
1337
|
+
if (!delivered || delivered.closed) {
|
|
1338
|
+
// deliverContinuationResults closes on encode/write failure after some
|
|
1339
|
+
// claims may already have succeeded.
|
|
1340
|
+
return Math.max(0, before - session.pending.size);
|
|
1341
|
+
}
|
|
1342
|
+
return Math.max(0, before - session.pending.size);
|
|
1343
|
+
}
|
|
1344
|
+
async function waitUntilNotPumping(session, opts) {
|
|
1345
|
+
if (!sessionManager.isActivelyPumping(session))
|
|
1346
|
+
return true;
|
|
1347
|
+
const deadlineAt = Date.now() + Math.max(1, opts.timeoutMs);
|
|
1348
|
+
while (Date.now() < deadlineAt) {
|
|
1349
|
+
if (session.closed)
|
|
1350
|
+
return false;
|
|
1351
|
+
if (!sessionManager.isActivelyPumping(session))
|
|
1352
|
+
return true;
|
|
1353
|
+
await new Promise((resolve) => {
|
|
1354
|
+
const timer = setTimeout(resolve, 25);
|
|
1355
|
+
timer.unref?.();
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
return !sessionManager.isActivelyPumping(session);
|
|
1359
|
+
}
|
|
1360
|
+
function nextFrameWithTimeout(frames, timeoutMs) {
|
|
1361
|
+
return new Promise((resolve, reject) => {
|
|
1362
|
+
let settled = false;
|
|
1363
|
+
const finish = (value, error) => {
|
|
1364
|
+
if (settled)
|
|
1365
|
+
return;
|
|
1366
|
+
settled = true;
|
|
1367
|
+
clearTimeout(timer);
|
|
1368
|
+
if (error !== undefined)
|
|
1369
|
+
reject(error);
|
|
1370
|
+
else
|
|
1371
|
+
resolve(value);
|
|
1372
|
+
};
|
|
1373
|
+
const timer = setTimeout(() => finish({ done: true, timedOut: true }), timeoutMs);
|
|
1374
|
+
timer.unref?.();
|
|
1375
|
+
void frames.next().then(value => finish(value), error => finish(undefined, error));
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* Read remaining frames on an idle held-open Run until `turn_ended`, a
|
|
1380
|
+
* response-requiring request we cannot answer without the host, or timeout.
|
|
1381
|
+
* Used only on the fresh-turn settle path — not a general pump substitute.
|
|
1382
|
+
*/
|
|
1383
|
+
export async function drainSessionUntilTurnEnded(session, opts) {
|
|
1384
|
+
if (session.closed)
|
|
1385
|
+
return "skipped";
|
|
1386
|
+
if (session.pending.size > 0 || sessionManager.isActivelyPumping(session))
|
|
1387
|
+
return "busy";
|
|
1388
|
+
const timeoutMs = Math.max(1, opts?.timeoutMs ?? FRESH_TURN_DRAIN_TIMEOUT_MS);
|
|
1389
|
+
const owner = Symbol(`fresh-turn-drain:${session.sessionId}`);
|
|
1390
|
+
sessionManager.beginPump(session, owner);
|
|
1391
|
+
const deadlineAt = Date.now() + timeoutMs;
|
|
1392
|
+
let outcome = "timeout";
|
|
1393
|
+
try {
|
|
1394
|
+
while (Date.now() < deadlineAt) {
|
|
1395
|
+
if (session.closed) {
|
|
1396
|
+
outcome = "interrupted";
|
|
1397
|
+
break;
|
|
1398
|
+
}
|
|
1399
|
+
const remainingMs = Math.max(1, deadlineAt - Date.now());
|
|
1400
|
+
let next;
|
|
1401
|
+
try {
|
|
1402
|
+
next = await nextFrameWithTimeout(session.frames, remainingMs);
|
|
1403
|
+
}
|
|
1404
|
+
catch (error) {
|
|
1405
|
+
trace(`fresh turn drain: frame wait failed sessionId=${session.sessionId} ` +
|
|
1406
|
+
`err=${error.message}`);
|
|
1407
|
+
outcome = "interrupted";
|
|
1408
|
+
break;
|
|
1409
|
+
}
|
|
1410
|
+
if ("timedOut" in next && next.timedOut) {
|
|
1411
|
+
outcome = "timeout";
|
|
1412
|
+
break;
|
|
1413
|
+
}
|
|
1414
|
+
if (next.done) {
|
|
1415
|
+
outcome = "interrupted";
|
|
1416
|
+
break;
|
|
1417
|
+
}
|
|
1418
|
+
const frame = next.value;
|
|
1419
|
+
if (frame.flags & 0x02) {
|
|
1420
|
+
outcome = "interrupted";
|
|
1421
|
+
break;
|
|
1422
|
+
}
|
|
1423
|
+
let payload;
|
|
1424
|
+
try {
|
|
1425
|
+
payload = decodeFramePayload(frame);
|
|
1426
|
+
}
|
|
1427
|
+
catch {
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
let asm;
|
|
1431
|
+
try {
|
|
1432
|
+
asm = decodeMessage("AgentServerMessage", payload);
|
|
1433
|
+
}
|
|
1434
|
+
catch {
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
const iu = asm.interaction_update;
|
|
1438
|
+
const kv = asm.kv_server_message;
|
|
1439
|
+
const esm = asm.exec_server_message;
|
|
1440
|
+
const interactionQuery = asm.interaction_query;
|
|
1441
|
+
const checkpointRaw = asm.conversation_checkpoint_update;
|
|
1442
|
+
const requiredChannel = responseRequiredChannel(payload);
|
|
1443
|
+
if (checkpointRaw != null) {
|
|
1444
|
+
const bytes = normalizeCheckpointBytes(checkpointRaw);
|
|
1445
|
+
if (bytes && bytes.length > 0) {
|
|
1446
|
+
if (session.cacheDiagnostics)
|
|
1447
|
+
session.cacheDiagnostics.checkpointUpdates++;
|
|
1448
|
+
setCheckpoint(session.conversationId, bytes);
|
|
1449
|
+
session.resumeCheckpoint = Uint8Array.from(bytes);
|
|
1450
|
+
const tokenDetails = decodeConversationTokenDetails(bytes);
|
|
1451
|
+
if (tokenDetails) {
|
|
1452
|
+
if (session.cacheDiagnostics)
|
|
1453
|
+
session.cacheDiagnostics.tokenDetailUpdates++;
|
|
1454
|
+
session.tokenDetails = tokenDetails;
|
|
1455
|
+
session.tokenDetailsFresh = true;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
if (iu?.turn_ended) {
|
|
1460
|
+
trace(`fresh turn drain: turn_ended raw wire fields: ${debugWalkTurnEnded(payload)}`);
|
|
1461
|
+
if (session.openCodeSessionId) {
|
|
1462
|
+
await persistConversationState(session.cacheDir ?? opencodeGlobalCacheDir(), {
|
|
1463
|
+
sessionKey: session.openCodeSessionId,
|
|
1464
|
+
conversationId: session.conversationId,
|
|
1465
|
+
requestContext: session.requestContext,
|
|
1466
|
+
toolCatalog: session.toolCatalog ?? [],
|
|
1467
|
+
postCompactionRebase: session.postCompactionRebase,
|
|
1468
|
+
hostAgent: session.hostAgent,
|
|
1469
|
+
systemPromptHash: session.stableSystemPromptHash,
|
|
1470
|
+
}).catch((error) => {
|
|
1471
|
+
trace(`fresh turn drain: TurnEnded save failed ` +
|
|
1472
|
+
`sessionKey=${session.openCodeSessionId}: ${String(error)}`);
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
// Record TurnEnded cache counters on diagnostics for the abandoned Run
|
|
1476
|
+
// even though no OpenCode stream consumer will see this finish.
|
|
1477
|
+
const turnEnded = iu.turn_ended;
|
|
1478
|
+
const counters = cursorUsageCountersFromTurnEnded(turnEnded);
|
|
1479
|
+
if (session.cacheDiagnostics) {
|
|
1480
|
+
trace(formatCursorCacheDiagnostics(counters, session.tokenDetails, session.cacheDiagnostics.priorTokenDetails, {
|
|
1481
|
+
...session.cacheDiagnostics,
|
|
1482
|
+
conversationGroupId: session.cacheDiagnostics.conversationGroupId
|
|
1483
|
+
?? resolveConversationGroupId(session.openCodeSessionId, session.conversationId),
|
|
1484
|
+
modelId: session.cacheDiagnostics.modelId,
|
|
1485
|
+
}));
|
|
1486
|
+
}
|
|
1487
|
+
else {
|
|
1488
|
+
trace(`fresh turn drain: turn_ended input=${counters.inputTokens} ` +
|
|
1489
|
+
`cacheRead=${counters.cacheRead} output=${counters.outputTokens}`);
|
|
1490
|
+
}
|
|
1491
|
+
sessionManager.close(session, "turn-ended");
|
|
1492
|
+
outcome = "turn-ended";
|
|
1493
|
+
break;
|
|
1494
|
+
}
|
|
1495
|
+
if (kv) {
|
|
1496
|
+
const handled = handleKvServerMessage(kv, session);
|
|
1497
|
+
if (!handled) {
|
|
1498
|
+
outcome = "busy";
|
|
1499
|
+
break;
|
|
1500
|
+
}
|
|
1501
|
+
try {
|
|
1502
|
+
await writeWithBackpressure(session.stream, handled.reply, `fresh-turn-drain KV ${handled.kind}_blob reply id=${handled.id}`);
|
|
1503
|
+
}
|
|
1504
|
+
catch (error) {
|
|
1505
|
+
trace(`fresh turn drain: KV reply failed sessionId=${session.sessionId} ` +
|
|
1506
|
+
`err=${error.message}`);
|
|
1507
|
+
outcome = "interrupted";
|
|
1508
|
+
break;
|
|
1509
|
+
}
|
|
1510
|
+
sessionManager.recordSemanticProgress(session);
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
// Text/thinking/heartbeat/partial display updates can be ignored while draining.
|
|
1514
|
+
if (iu?.text_delta || iu?.thinking_delta || iu?.heartbeat || iu?.partial_tool_call || iu?.step_started || iu?.step_completed) {
|
|
1515
|
+
sessionManager.recordSemanticProgress(session);
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
if (iu?.tool_call_started || iu?.tool_call_completed) {
|
|
1519
|
+
// A new display tool needs host mediation — stop draining.
|
|
1520
|
+
outcome = "busy";
|
|
1521
|
+
break;
|
|
1522
|
+
}
|
|
1523
|
+
if (esm || interactionQuery || requiredChannel === "exec" || requiredChannel === "interaction") {
|
|
1524
|
+
outcome = "busy";
|
|
1525
|
+
break;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
finally {
|
|
1530
|
+
sessionManager.endPump(session, owner);
|
|
1531
|
+
}
|
|
1532
|
+
return outcome;
|
|
1533
|
+
}
|
|
1194
1534
|
/**
|
|
1195
1535
|
* Turn an OpenCode `question` tool result into the frame Cursor is waiting for.
|
|
1196
1536
|
*
|
|
@@ -1672,6 +2012,8 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1672
2012
|
stepCompletes: 0,
|
|
1673
2013
|
displayToolCalls: 0,
|
|
1674
2014
|
execRequests: 0,
|
|
2015
|
+
createPlanInTurn: false,
|
|
2016
|
+
switchModeInTurn: false,
|
|
1675
2017
|
};
|
|
1676
2018
|
cacheDiagnostics.pumpPasses++;
|
|
1677
2019
|
const { textId, reasoningId } = ids;
|
|
@@ -1949,38 +2291,41 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1949
2291
|
// OpenCode TUI/GUI replace each assistant message's tokens (they do not
|
|
1950
2292
|
// sum occupancy) and the TUI footer requires tokens.output > 0. Cost is
|
|
1951
2293
|
// added per step-finish. Emit checkpoint occupancy snapshots at tool-call
|
|
1952
|
-
// boundaries with a $0 Copilot cost override, then
|
|
1953
|
-
// snapshot
|
|
2294
|
+
// boundaries with a $0 Copilot cost override, then one more occupancy
|
|
2295
|
+
// snapshot at TurnEnded/stop. Held-Run TurnEnded counters are cumulative
|
|
2296
|
+
// across every tool step, but this finish only spans the last generation
|
|
2297
|
+
// slice — putting output_tokens/reasoning there makes host tok/s
|
|
2298
|
+
// (generated/stepElapsed) absurd. Keep exact request counters under
|
|
2299
|
+
// providerMetadata.cursor.*Raw. Char/4 usageEstimate stays traces-only.
|
|
1954
2300
|
const tokenDetails = session.tokenDetails;
|
|
1955
|
-
const occupancyDetails =
|
|
2301
|
+
const occupancyDetails = tokenDetails && tokenDetails.usedTokens > 0 ? tokenDetails : undefined;
|
|
1956
2302
|
const contextSource = tokenDetails
|
|
1957
2303
|
? session.tokenDetailsFresh
|
|
1958
2304
|
? "checkpoint-current-run"
|
|
1959
2305
|
: "checkpoint-previous-turn"
|
|
1960
2306
|
: undefined;
|
|
2307
|
+
const usage = settledUsage ?? (occupancyDetails
|
|
2308
|
+
? occupancyUsageFromTokenDetails(occupancyDetails, session.cacheDiagnostics?.priorTokenDetails)
|
|
2309
|
+
: emptyLanguageModelV3Usage());
|
|
1961
2310
|
const counters = te ? cursorUsageCountersFromTurnEnded(te) : undefined;
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
contextTotalTokens: tokenDetails.usedTokens,
|
|
1966
|
-
priorContextTokens: session.cacheDiagnostics?.priorTokenDetails?.usedTokens,
|
|
1967
|
-
})
|
|
1968
|
-
: emptyLanguageModelV3Usage()
|
|
1969
|
-
: occupancyDetails
|
|
1970
|
-
? occupancyUsageFromTokenDetails(occupancyDetails, session.cacheDiagnostics?.priorTokenDetails)
|
|
1971
|
-
: emptyLanguageModelV3Usage());
|
|
2311
|
+
// TurnEnded stays a real (non-occupancyOnly) finish so hosts that collapse
|
|
2312
|
+
// tool-boundary occupancy still keep one context snapshot for the sidebar.
|
|
2313
|
+
// Copilot $0 avoids billing the occupancy-shaped counters as a new prompt.
|
|
1972
2314
|
const providerMetadata = te
|
|
1973
|
-
?
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
2315
|
+
? {
|
|
2316
|
+
...OPENCODE_DISPLAY_ONLY_COST_METADATA,
|
|
2317
|
+
...cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource),
|
|
2318
|
+
}
|
|
2319
|
+
: {
|
|
2320
|
+
...OPENCODE_DISPLAY_ONLY_COST_METADATA,
|
|
2321
|
+
cursor: {
|
|
2322
|
+
usageVersion: 3,
|
|
2323
|
+
occupancyOnly: true,
|
|
2324
|
+
...(occupancyDetails && contextSource
|
|
2325
|
+
? { context: cursorContextUsageMetadata(occupancyDetails, contextSource) }
|
|
2326
|
+
: {}),
|
|
2327
|
+
},
|
|
2328
|
+
};
|
|
1984
2329
|
const reasonLabel = typeof reason === "object" && reason && "unified" in reason
|
|
1985
2330
|
? String(reason.unified ?? "unknown")
|
|
1986
2331
|
: String(reason);
|
|
@@ -1989,29 +2334,48 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1989
2334
|
const occupancySource = occupancyDetails
|
|
1990
2335
|
? `occupancy-${contextSource ?? "unavailable"}`
|
|
1991
2336
|
: "intermediate-zero";
|
|
2337
|
+
// Occupancy finishes never see Cursor TurnEnded cache_read. usageEstimate.cacheRead
|
|
2338
|
+
// stays 0 for the whole Run, so logging it as rawCacheRead falsely reports 0% on
|
|
2339
|
+
// every tool-call step. Prefer the V3 occupancy partition (prior prefix → cacheRead)
|
|
2340
|
+
// and label the estimate separately from billed TurnEnded counters.
|
|
2341
|
+
const occupancyPrefixCache = occupancyDetails
|
|
2342
|
+
? (session.cacheDiagnostics?.priorTokenDetails?.usedTokens ?? 0)
|
|
2343
|
+
: undefined;
|
|
2344
|
+
const rawIn = te
|
|
2345
|
+
? turnEndedCounter(te, "input_tokens")
|
|
2346
|
+
: occupancyDetails
|
|
2347
|
+
? occupancyDetails.usedTokens
|
|
2348
|
+
: est.inputTokens;
|
|
2349
|
+
const rawOut = te
|
|
2350
|
+
? turnEndedCounter(te, "output_tokens")
|
|
2351
|
+
: occupancyDetails
|
|
2352
|
+
? 1
|
|
2353
|
+
: est.outputTokens;
|
|
2354
|
+
const rawCacheRead = te
|
|
2355
|
+
? turnEndedCounter(te, "cache_read")
|
|
2356
|
+
: occupancyPrefixCache ?? est.cacheRead;
|
|
2357
|
+
const rawCacheWrite = te ? turnEndedCounter(te, "cache_write") : est.cacheWrite;
|
|
1992
2358
|
trace(`finish: reason=${reasonLabel} ` +
|
|
1993
2359
|
`v3In=${inTotal} v3Out=${outTotal} ` +
|
|
1994
2360
|
`v3CacheRead=${usage.inputTokens?.cacheRead ?? 0} v3CacheWrite=${usage.inputTokens?.cacheWrite ?? 0} ` +
|
|
1995
2361
|
`v3Reasoning=${usage.outputTokens?.reasoning ?? 0} ` +
|
|
1996
|
-
`rawIn=${
|
|
1997
|
-
`
|
|
1998
|
-
`rawCacheRead=${te ? turnEndedCounter(te, "cache_read") : est.cacheRead} ` +
|
|
1999
|
-
`rawCacheWrite=${te ? turnEndedCounter(te, "cache_write") : est.cacheWrite} ` +
|
|
2362
|
+
`rawIn=${rawIn} rawOut=${rawOut} rawCacheRead=${rawCacheRead} rawCacheWrite=${rawCacheWrite} ` +
|
|
2363
|
+
`${occupancyPrefixCache !== undefined ? `occupancyPrefixCache=${occupancyPrefixCache} ` : ""}` +
|
|
2000
2364
|
`source=${te
|
|
2001
2365
|
? settledSource ?? (contextSource ?? "unavailable")
|
|
2002
2366
|
: occupancySource}`);
|
|
2003
|
-
|
|
2367
|
+
// Validate the usage we actually send. Occupancy finishes (tool-call and
|
|
2368
|
+
// TurnEnded/stop) use prior-prefix cacheRead — never compare that against
|
|
2369
|
+
// aggregate TurnEnded request cache ratios (false mismatch). Raw request
|
|
2370
|
+
// counters stay on `finish:` and cache diagnosis only.
|
|
2371
|
+
if (occupancyDetails) {
|
|
2372
|
+
trace(formatTurnUsageValidation(occupancyValidationCounters(occupancyDetails, session.cacheDiagnostics?.priorTokenDetails), usage, occupancyDetails, contextSource));
|
|
2373
|
+
}
|
|
2374
|
+
else if (counters) {
|
|
2004
2375
|
trace(formatTurnUsageValidation(counters, usage, tokenDetails, contextSource));
|
|
2005
|
-
trace(formatCursorCacheDiagnostics(counters, tokenDetails, cacheDiagnostics.priorTokenDetails, cacheDiagnostics));
|
|
2006
2376
|
}
|
|
2007
|
-
|
|
2008
|
-
trace(
|
|
2009
|
-
inputTokens: occupancyDetails.usedTokens,
|
|
2010
|
-
outputTokens: 1,
|
|
2011
|
-
cacheRead: session.cacheDiagnostics?.priorTokenDetails?.usedTokens ?? 0,
|
|
2012
|
-
cacheWrite: 0,
|
|
2013
|
-
reasoningTokens: 0,
|
|
2014
|
-
}, usage, occupancyDetails, contextSource));
|
|
2377
|
+
if (counters) {
|
|
2378
|
+
trace(formatCursorCacheDiagnostics(counters, tokenDetails, cacheDiagnostics.priorTokenDetails, cacheDiagnostics));
|
|
2015
2379
|
}
|
|
2016
2380
|
safeEnqueue({
|
|
2017
2381
|
type: "finish",
|
|
@@ -2262,7 +2626,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2262
2626
|
const display = parseDisplayToolCall(callId, toolCall, session.mirroredTodos);
|
|
2263
2627
|
const advertised = advertisedToolNamesFromDescriptors(session.toolDescriptors);
|
|
2264
2628
|
const bridged = display
|
|
2265
|
-
? resolveBridgedOpenCodeToolCall(display, advertised)
|
|
2629
|
+
? resolveBridgedOpenCodeToolCall(display, advertised, session.hostToolDialect)
|
|
2266
2630
|
: undefined;
|
|
2267
2631
|
if (!display) {
|
|
2268
2632
|
const callIdLog = callId.replace(/\r?\n/g, "\\n");
|
|
@@ -2275,9 +2639,17 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2275
2639
|
`keys=[${Object.keys(toolCall).join(",")}]${wire}`);
|
|
2276
2640
|
}
|
|
2277
2641
|
else if (!bridged) {
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2642
|
+
// GetMcpTools / GetDynamicTools is executed by Cursor after mcp_state;
|
|
2643
|
+
// large catalogs then spill through write_args. Not a missing host tool.
|
|
2644
|
+
if (isNativeDisplayToolCall(display.variant)) {
|
|
2645
|
+
trace(`display tool_call_completed: native ${display.preferredToolName} callId=${callId} ` +
|
|
2646
|
+
`(server-side catalog; spill uses write_args)`);
|
|
2647
|
+
}
|
|
2648
|
+
else {
|
|
2649
|
+
trace(`display tool_call_completed: no advertised OpenCode tool ` +
|
|
2650
|
+
`callId=${callId} variant=${display.variant} preferred=${display.preferredToolName} ` +
|
|
2651
|
+
`advertised=[${advertised.join(",")}]`);
|
|
2652
|
+
}
|
|
2281
2653
|
}
|
|
2282
2654
|
else {
|
|
2283
2655
|
// Snapshot only Cursor todo writes (not create_plan's synthetic
|
|
@@ -2544,6 +2916,22 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2544
2916
|
return;
|
|
2545
2917
|
continue;
|
|
2546
2918
|
}
|
|
2919
|
+
// Epoch advertisement may keep tools the host filtered this turn
|
|
2920
|
+
// (plan denies edit). Refuse those here — do not emit to OpenCode.
|
|
2921
|
+
const permittedToolNames = session.permittedToolNames;
|
|
2922
|
+
if (permittedToolNames
|
|
2923
|
+
&& permittedToolNames.size > 0
|
|
2924
|
+
&& !permittedToolNames.has(parsed.toolName)) {
|
|
2925
|
+
const permitted = [...permittedToolNames].sort().join(", ");
|
|
2926
|
+
const reason = `OpenCode tool '${parsed.toolName}' is not permitted for the current agent this turn. ` +
|
|
2927
|
+
`Permitted tools: ${permitted || "none"}. Continue using only permitted tools; do not retry ` +
|
|
2928
|
+
`'${parsed.toolName}'.`;
|
|
2929
|
+
trace(`exec: not permitted this turn toolName=${parsed.toolName} ` +
|
|
2930
|
+
`permitted=[${[...permittedToolNames].sort().join(",")}]`);
|
|
2931
|
+
if (!await rejectExec(parsed, reason, "not permitted this turn"))
|
|
2932
|
+
return;
|
|
2933
|
+
continue;
|
|
2934
|
+
}
|
|
2547
2935
|
if (recoverCorrelatedEditRead(parsed, displayCallId))
|
|
2548
2936
|
continue;
|
|
2549
2937
|
if (rejectMissingReadTarget(parsed)) {
|
|
@@ -2662,6 +3050,15 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2662
3050
|
trace(`interaction_query: replied id=${handled.id} variant=${handled.variantName} ` +
|
|
2663
3051
|
`field=${handled.variantField} outcome=${handled.outcome}` +
|
|
2664
3052
|
(handled.reply ? "" : " (deferred to host tool result)"));
|
|
3053
|
+
// Tag the Run for cache diagnosis: a first CreatePlan/SwitchMode can
|
|
3054
|
+
// coincide with a one-time upstream tools-category expansion. Set on
|
|
3055
|
+
// any outcome (acknowledged/approved/bridged) via presence, not kind.
|
|
3056
|
+
if (handled.createPlan && session.cacheDiagnostics) {
|
|
3057
|
+
session.cacheDiagnostics.createPlanInTurn = true;
|
|
3058
|
+
}
|
|
3059
|
+
if (handled.switchMode && session.cacheDiagnostics) {
|
|
3060
|
+
session.cacheDiagnostics.switchModeInTurn = true;
|
|
3061
|
+
}
|
|
2665
3062
|
if (handled.generateImage) {
|
|
2666
3063
|
trace(`interaction_query: approved generate_image target=` +
|
|
2667
3064
|
`${JSON.stringify(handled.generateImage.filePath || "(unspecified)")} ` +
|
|
@@ -2974,7 +3371,9 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
2974
3371
|
// CreatePlan in Cursor's function definitions, notices it is absent from the
|
|
2975
3372
|
// OpenCode list, and narrates "No CreatePlan MCP tool is available" even
|
|
2976
3373
|
// though the provider bridges the native interaction.
|
|
2977
|
-
instructions.push(
|
|
3374
|
+
instructions.push(names.has("cursor_plan_stage")
|
|
3375
|
+
? "- Cursor-native CreatePlan is accepted as a Cursor interaction (not an OpenCode or MCP catalog tool). Raise it normally. The host stage tool waits for the host plan review and does not return until the user accepts or declines. Do not call `plan_exit` to submit or skip that review, and do not implement until that tool returns success. Do not narrate that CreatePlan is missing, unavailable, or not an MCP tool. When the task will need a user-approved plan, record the first version as soon as its shape is clear, then keep investigating: refining afterward is expected and follow-up plans cost nothing extra. Do not defer the first plan until investigation is complete."
|
|
3376
|
+
: "- 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. When the task will need a user-approved plan, record the first version as soon as its shape is clear, then keep investigating: refining afterward is expected and follow-up plans cost nothing extra. Do not defer the first plan until investigation is complete.");
|
|
2978
3377
|
if (names.has("plan_enter")) {
|
|
2979
3378
|
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.");
|
|
2980
3379
|
}
|
|
@@ -3007,6 +3406,7 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
3007
3406
|
instructions.push(shell
|
|
3008
3407
|
? `- OpenCode \`execute\` is Code Mode JavaScript (\`code\`); it is not a shell. For OS commands, call OpenCode ${shell}. Do not pass \`command\` to \`execute\`.`
|
|
3009
3408
|
: "- OpenCode `execute` is Code Mode JavaScript (`code`); it is not a shell. Do not pass `command` to `execute`.");
|
|
3409
|
+
instructions.push("- When the host's Code Mode catalog lists additional tools, including MCP server tools, call them inside `execute` through `tools`. Use only the exact paths and signatures in that catalog or returned by its `search` function. Call `execute` with `{ code }` to run them; do not request a Code Mode tool as a direct OpenCode tool call.");
|
|
3010
3410
|
}
|
|
3011
3411
|
if (names.has("task") || names.has("subagent")) {
|
|
3012
3412
|
const target = names.has("task") ? "`task`" : "`subagent`";
|
|
@@ -3037,11 +3437,11 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
3037
3437
|
instructions.push("- Never use a read result as complete file content when it says the output is capped, partial, or requires another offset. Read the remaining ranges first, or make a targeted edit/patch from complete context; do not pass a partial read back as a whole-file replacement.");
|
|
3038
3438
|
}
|
|
3039
3439
|
return [
|
|
3040
|
-
`OpenCode exposes
|
|
3440
|
+
`OpenCode exposes these direct tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
|
|
3041
3441
|
`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.`,
|
|
3042
3442
|
subagents.executor
|
|
3043
|
-
? "Call only tools in that
|
|
3044
|
-
: "Call only tools in that
|
|
3443
|
+
? "Call only tools in that direct OpenCode 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."
|
|
3444
|
+
: "Call only tools in that direct OpenCode 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.",
|
|
3045
3445
|
...(instructions.length > 0
|
|
3046
3446
|
? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
|
|
3047
3447
|
: []),
|
|
@@ -3203,12 +3603,13 @@ function extractTools(callOptions) {
|
|
|
3203
3603
|
const out = [];
|
|
3204
3604
|
for (const t of tools) {
|
|
3205
3605
|
// LanguageModelV3FunctionTool always has type:"function". Be defensive in
|
|
3206
|
-
// case a middleware strips it
|
|
3606
|
+
// case a middleware strips it or passes schema as parameters/schema.
|
|
3207
3607
|
const any = t;
|
|
3208
|
-
|
|
3608
|
+
const schema = any.inputSchema ?? any.parameters ?? any.schema;
|
|
3609
|
+
if (any.type === "function" || (any.name && schema !== undefined)) {
|
|
3209
3610
|
if (!any.name)
|
|
3210
3611
|
continue;
|
|
3211
|
-
out.push({ name: any.name, description: any.description, inputSchema:
|
|
3612
|
+
out.push({ name: any.name, description: any.description, inputSchema: schema });
|
|
3212
3613
|
}
|
|
3213
3614
|
}
|
|
3214
3615
|
trace(`extractTools: ${tools.length} incoming → ${out.length} advertised [${out.map((t) => t.name).join(",")}]`);
|
|
@@ -3223,8 +3624,15 @@ export function spanEndParts(opts) {
|
|
|
3223
3624
|
out.push({ type: "text-end", id: opts.textId });
|
|
3224
3625
|
return out;
|
|
3225
3626
|
}
|
|
3627
|
+
/** UTF-16 name order for the first catalog freeze and for newcomers only. */
|
|
3226
3628
|
function toolsInFixedOrder(tools) {
|
|
3227
|
-
return tools
|
|
3629
|
+
return tools
|
|
3630
|
+
.map((tool) => ({ ...tool }))
|
|
3631
|
+
.sort((left, right) => {
|
|
3632
|
+
const a = left.name ?? "";
|
|
3633
|
+
const b = right.name ?? "";
|
|
3634
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
3635
|
+
});
|
|
3228
3636
|
}
|
|
3229
3637
|
/** Exported for tests — false for compaction/summary (no tools) and toolChoice none. */
|
|
3230
3638
|
export function computeAllowTools(toolCount, toolChoice) {
|
|
@@ -3232,39 +3640,62 @@ export function computeAllowTools(toolCount, toolChoice) {
|
|
|
3232
3640
|
}
|
|
3233
3641
|
export async function resolveTurnToolState(input) {
|
|
3234
3642
|
const { sessionKey, incomingTools, isCompaction } = input;
|
|
3235
|
-
if (sessionKey && incomingTools.length > 0) {
|
|
3236
|
-
rememberToolCatalog(sessionKey, toolsInFixedOrder(incomingTools));
|
|
3237
|
-
}
|
|
3238
3643
|
// Advertisement and permission are deliberately independent.
|
|
3239
3644
|
//
|
|
3240
3645
|
// Advertisement must stay byte-stable across every Run of a conversation or
|
|
3241
|
-
// the RequestContext changes shape and Cursor's prompt cache goes cold.
|
|
3242
|
-
//
|
|
3243
|
-
//
|
|
3244
|
-
//
|
|
3245
|
-
//
|
|
3246
|
-
//
|
|
3646
|
+
// the RequestContext changes shape and Cursor's prompt cache goes cold.
|
|
3647
|
+
//
|
|
3648
|
+
// OpenCode V1 *does* shrink the catalog on plan (edit denied → tools dropped
|
|
3649
|
+
// in request.ts resolveTools). Copying that into Cursor RequestContext costs
|
|
3650
|
+
// the whole tools prefix. Prefer: keep the epoch's fullest catalog for
|
|
3651
|
+
// advertisement; compute allowTools from what actually arrived this turn.
|
|
3652
|
+
//
|
|
3653
|
+
// A zero-tool call is never a smaller catalog — it is a lifecycle turn
|
|
3654
|
+
// (compaction, title generation) that re-advertises the last real catalog.
|
|
3655
|
+
// New tool names (MCP connect) append at the tail without rewriting
|
|
3656
|
+
// descriptors already frozen. Equal name-sets and host shrinks keep the
|
|
3657
|
+
// frozen advertisement and its order — schema/description churn must not
|
|
3658
|
+
// retokenize tools, and inserting a name that sorts earlier than `z` must
|
|
3659
|
+
// not reshuffle the prefix.
|
|
3247
3660
|
//
|
|
3248
3661
|
// On cold start the lifecycle Run may arrive before any catalog exists. For a
|
|
3249
3662
|
// valid session key, wait until a sibling doStream publishes the first real
|
|
3250
|
-
// catalog; cancellation is the only escape.
|
|
3251
|
-
// never invent or filter the enabled set.
|
|
3252
|
-
//
|
|
3253
|
-
// Permission is computed from what actually arrived, so those lifecycle turns
|
|
3254
|
-
// still refuse execution and cannot duplicate the real turn's side effects.
|
|
3663
|
+
// catalog; cancellation is the only escape.
|
|
3255
3664
|
let advertisedTools;
|
|
3256
3665
|
if (incomingTools.length > 0) {
|
|
3257
|
-
|
|
3666
|
+
if (sessionKey) {
|
|
3667
|
+
const cached = toolCatalogBySession.get(sessionKey);
|
|
3668
|
+
if (cached && cached.length > 0) {
|
|
3669
|
+
const cachedNames = new Set(cached.map((tool) => tool.name));
|
|
3670
|
+
const incomingNames = new Set(incomingTools.map((tool) => tool.name));
|
|
3671
|
+
const hasNew = [...incomingNames].some((name) => !cachedNames.has(name));
|
|
3672
|
+
if (hasNew) {
|
|
3673
|
+
const newcomers = toolsInFixedOrder(incomingTools.filter((tool) => !cachedNames.has(tool.name)));
|
|
3674
|
+
const merged = [...cached, ...newcomers];
|
|
3675
|
+
rememberToolCatalog(sessionKey, merged);
|
|
3676
|
+
advertisedTools = merged;
|
|
3677
|
+
}
|
|
3678
|
+
else {
|
|
3679
|
+
rememberToolCatalog(sessionKey, cached);
|
|
3680
|
+
advertisedTools = cached;
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
else {
|
|
3684
|
+
const initial = toolsInFixedOrder(incomingTools);
|
|
3685
|
+
rememberToolCatalog(sessionKey, initial);
|
|
3686
|
+
advertisedTools = initial;
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
else {
|
|
3690
|
+
advertisedTools = toolsInFixedOrder(incomingTools);
|
|
3691
|
+
}
|
|
3258
3692
|
}
|
|
3259
3693
|
else if (sessionKey) {
|
|
3260
3694
|
const cached = toolCatalogBySession.get(sessionKey)
|
|
3261
3695
|
?? await waitForSiblingToolCatalog(sessionKey, input.abortSignal);
|
|
3262
|
-
advertisedTools =
|
|
3696
|
+
advertisedTools = cached;
|
|
3263
3697
|
}
|
|
3264
3698
|
else {
|
|
3265
|
-
// Without a session key there is no safe sibling-correlation key. Preserve
|
|
3266
|
-
// the host's literal no-tool call rather than borrowing another session's
|
|
3267
|
-
// catalog.
|
|
3268
3699
|
advertisedTools = [];
|
|
3269
3700
|
}
|
|
3270
3701
|
return {
|
|
@@ -3279,25 +3710,22 @@ export function resolveTurnConversationReset(input) {
|
|
|
3279
3710
|
rememberPostCompactionRebase(sessionKey);
|
|
3280
3711
|
return { reset: true, reason: "compaction" };
|
|
3281
3712
|
}
|
|
3282
|
-
|
|
3713
|
+
if (input.historyRewrite) {
|
|
3714
|
+
if (sessionKey)
|
|
3715
|
+
postCompactionRebaseBySession.delete(sessionKey);
|
|
3716
|
+
return { reset: true, reason: "history-rewrite" };
|
|
3717
|
+
}
|
|
3718
|
+
// Diagnostics only: accept promptIdentity for remember without reminting.
|
|
3283
3719
|
if (sessionKey && input.promptIdentity) {
|
|
3284
|
-
const current = normalizePromptIdentity(input.promptIdentity);
|
|
3285
3720
|
const previous = promptIdentityBySession.get(sessionKey);
|
|
3286
|
-
if (previous && promptIdentityChanged(previous, current)) {
|
|
3287
|
-
promptChange = current.hostAgent !== undefined && previous.hostAgent !== current.hostAgent
|
|
3288
|
-
? "agent-change"
|
|
3289
|
-
: "system-prompt-change";
|
|
3290
|
-
}
|
|
3291
3721
|
rememberPromptIdentity(sessionKey, {
|
|
3292
3722
|
...previous,
|
|
3293
|
-
...
|
|
3723
|
+
...normalizePromptIdentity(input.promptIdentity),
|
|
3294
3724
|
});
|
|
3295
3725
|
}
|
|
3296
3726
|
if (sessionKey && postCompactionRebaseBySession.delete(sessionKey)) {
|
|
3297
3727
|
return { reset: true, reason: "post-compaction-rebase" };
|
|
3298
3728
|
}
|
|
3299
|
-
if (promptChange)
|
|
3300
|
-
return { reset: true, reason: promptChange };
|
|
3301
3729
|
return { reset: false };
|
|
3302
3730
|
}
|
|
3303
3731
|
export function resetTurnStateForTests() {
|
|
@@ -3310,6 +3738,7 @@ export function resetTurnStateForTests() {
|
|
|
3310
3738
|
postCompactionRebaseBySession.clear();
|
|
3311
3739
|
promptIdentityBySession.clear();
|
|
3312
3740
|
mirroredTodosBySession.clear();
|
|
3741
|
+
resetContextEpochsForTests();
|
|
3313
3742
|
}
|
|
3314
3743
|
function extractUserText(lastUser) {
|
|
3315
3744
|
if (!lastUser)
|