cursor-opencode-provider 0.8.1 → 0.8.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/CHANGELOG.md +95 -0
- package/README.md +204 -37
- package/SECURITY.md +20 -8
- package/dist/activity.d.ts +73 -1
- package/dist/activity.js +231 -5
- package/dist/agent-url.js +37 -9
- package/dist/auth-renewal.d.ts +1 -1
- package/dist/auth-renewal.js +58 -26
- package/dist/billing.d.ts +105 -0
- package/dist/billing.js +212 -0
- package/dist/classic-tool-results.d.ts +1 -0
- package/dist/classic-tool-results.js +42 -0
- package/dist/context/build.d.ts +1 -0
- package/dist/context/build.js +32 -5
- package/dist/context/env.js +27 -3
- package/dist/context/epoch.js +9 -5
- package/dist/context/git.d.ts +5 -2
- package/dist/context/git.js +17 -7
- package/dist/context/paths.d.ts +1 -1
- package/dist/context/paths.js +7 -4
- package/dist/debug.js +13 -2
- package/dist/early-tool-rows.d.ts +60 -0
- package/dist/early-tool-rows.js +96 -0
- package/dist/errors.d.ts +49 -7
- package/dist/errors.js +163 -8
- package/dist/host-interrupt.d.ts +12 -0
- package/dist/host-interrupt.js +18 -0
- package/dist/host-steer.d.ts +71 -0
- package/dist/host-steer.js +232 -0
- package/dist/image-input.js +4 -3
- package/dist/index.d.ts +2 -2
- package/dist/language-model.d.ts +114 -23
- package/dist/language-model.js +2290 -469
- package/dist/lost-request.d.ts +39 -0
- package/dist/lost-request.js +111 -0
- package/dist/model-config.d.ts +3 -1
- package/dist/model-config.js +50 -29
- package/dist/models.d.ts +26 -3
- package/dist/models.js +101 -45
- package/dist/opencode2/tool-results.d.ts +18 -0
- package/dist/opencode2/tool-results.js +66 -0
- package/dist/plugin-opencode2.js +185 -25
- package/dist/plugin.js +28 -5
- package/dist/pricing-data.d.ts +26 -2
- package/dist/pricing-data.js +26 -2
- package/dist/pricing.d.ts +1 -1
- package/dist/pricing.js +7 -4
- package/dist/process-shared.d.ts +14 -0
- package/dist/process-shared.js +18 -0
- package/dist/protocol/apply-patch.d.ts +25 -13
- package/dist/protocol/apply-patch.js +79 -30
- package/dist/protocol/blob-store.d.ts +5 -1
- package/dist/protocol/blob-store.js +10 -6
- package/dist/protocol/client-version.js +6 -4
- package/dist/protocol/conversation-cache-header.d.ts +23 -0
- package/dist/protocol/conversation-cache-header.js +149 -0
- package/dist/protocol/conversation-persistence.d.ts +27 -1
- package/dist/protocol/conversation-persistence.js +628 -126
- package/dist/protocol/conversation-state.d.ts +8 -1
- package/dist/protocol/conversation-state.js +28 -7
- package/dist/protocol/host-notes.d.ts +31 -0
- package/dist/protocol/host-notes.js +85 -0
- package/dist/protocol/messages.js +91 -8
- package/dist/protocol/request.d.ts +35 -19
- package/dist/protocol/request.js +53 -28
- package/dist/protocol/sent-history-images.d.ts +49 -0
- package/dist/protocol/sent-history-images.js +102 -0
- package/dist/protocol/tool-call-bridge.d.ts +2 -0
- package/dist/protocol/tool-call-bridge.js +10 -0
- package/dist/protocol/tools.d.ts +74 -8
- package/dist/protocol/tools.js +431 -141
- package/dist/protocol/turn-provenance.d.ts +19 -4
- package/dist/protocol/turn-provenance.js +77 -20
- package/dist/replay-safety.d.ts +16 -4
- package/dist/replay-safety.js +34 -25
- package/dist/retry-budget.d.ts +21 -0
- package/dist/retry-budget.js +94 -0
- package/dist/runtime-files.d.ts +23 -0
- package/dist/runtime-files.js +581 -0
- package/dist/session.d.ts +94 -9
- package/dist/session.js +153 -34
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/dist/shell-timeout.d.ts +54 -10
- package/dist/shell-timeout.js +362 -92
- package/dist/stream-write.d.ts +20 -0
- package/dist/stream-write.js +87 -0
- package/dist/terminal-runner.d.ts +15 -0
- package/dist/terminal-runner.js +528 -0
- package/dist/transport/connect.d.ts +8 -1
- package/dist/transport/connect.js +103 -23
- package/dist/usage.d.ts +37 -17
- package/dist/usage.js +63 -29
- package/package.json +3 -2
- package/dist/protocol/thinking.d.ts +0 -15
- package/dist/protocol/thinking.js +0 -17
package/dist/language-model.js
CHANGED
|
@@ -2,20 +2,29 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { APICallError } from "@ai-sdk/provider";
|
|
5
|
+
import protobuf from "protobufjs";
|
|
6
|
+
import { newRetryBudget, turnRetryBudget, forgetRetryBudget, reserveRetry, resetRetryBudgetsForTests } from "./retry-budget.js";
|
|
5
7
|
import { PARALLEL_STEP_IDLE_MS, createParallelStep, isParallelStepProgressFrame, noteListedCallCount, noteParallelStepProgress, noteToolRequestsListedSeen, parallelStepCountMet, recordParallelStepEmission, resolveParallelStepCall, shouldGuardCloseParallelStep, shouldHoldParallelStep, } from "./parallel-step.js";
|
|
6
|
-
import { bidiRunStream, CursorRunInterruptedError, normalizeAgentRunOrigin, } from "./transport/connect.js";
|
|
8
|
+
import { bidiRunStream, CursorRunInterruptedError, frameArrival, lastFrameArrival, normalizeAgentRunOrigin, } from "./transport/connect.js";
|
|
9
|
+
import { isStreamWriteBlocked, StreamWriteDrainError, waitForStreamWrites, writeStreamMessage as writeWithBackpressure, } from "./stream-write.js";
|
|
10
|
+
import { clearUnansweredEarlySteers, forgetEarlySteerRecords, forgetUndeliveredEarlySteer, handOverEarlySteers, isEarlySteerTracked, needsHostHistory, listenForHostSteers, markEarlySteerDelivered, markEarlySteersCheckpointed, matchEarlySteers, recordEarlySteer, resetHostSteersForTests, settleEarlySteers, serializeEarlySteers, } from "./host-steer.js";
|
|
7
11
|
import { isDebugEnabled, trace, traceRequestContextPaths } from "./debug.js";
|
|
12
|
+
import { toolResultFailure } from "./opencode2/tool-results.js";
|
|
8
13
|
import { isExchangeableApiKey } from "./auth.js";
|
|
9
14
|
import { resolveBearerToken } from "./auth-renewal.js";
|
|
10
|
-
import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
|
|
15
|
+
import { buildRunRequest, buildHeartbeat, buildCancelAction, buildExecHeartbeat } from "./protocol/request.js";
|
|
16
|
+
import { onHostInterrupt } from "./host-interrupt.js";
|
|
11
17
|
import { decodeFramePayload } from "./protocol/framing.js";
|
|
12
18
|
import { debugWalkTurnEnded, decodeMessage, encodeMessage } from "./protocol/messages.js";
|
|
13
|
-
import { parseExecServerMessage, missingRequiredToolArguments, misplacedShellCommand, buildToolCallPart, buildExecClientMessages, execResultImages, buildReadRejectionMessages, buildUnsupportedExecDeny, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, toolsToDescriptors, resolveToolServerIdentity, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, preferCorrelatedTaskDescription, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, buildCompleteEditReadMessages, rejectPartialReadMutation, binaryWritePayload, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_TOOL, hostToolDialectFromTools, opencodePathArg, } from "./protocol/tools.js";
|
|
19
|
+
import { parseExecServerMessage, missingRequiredToolArguments, misplacedShellCommand, buildToolCallPart, buildExecClientMessages, attachHookAdditionalContexts, extractOpenCodeReadInstruction, execResultImages, buildReadRejectionMessages, buildUnsupportedExecDeny, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, toolsToDescriptors, resolveToolServerIdentity, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, preferCorrelatedTaskDescription, appendTaskAttachments, buildTaskAttachmentReadMessages, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, hostToolForCursorEdit, cursorEditReadAnswer, cursorEditReadReachesHost, buildCompleteEditReadMessages, rejectPartialReadMutation, binaryWritePayload, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_TOOL, hostToolDialectFromTools, opencodePathArg, } from "./protocol/tools.js";
|
|
20
|
+
import { hostPatchTool, isHostPatchTool } from "./protocol/apply-patch.js";
|
|
14
21
|
import { buildGitDiffExecMessages } from "./protocol/git-diff.js";
|
|
22
|
+
import { runtimeExecMessages, runtimeRootFromContext } from "./runtime-files.js";
|
|
23
|
+
import { createManagedTerminal } from "./terminal-runner.js";
|
|
15
24
|
import { cursorExecVariantByRequestField, describeCursorExecVariant } from "./protocol/exec-variants.js";
|
|
16
25
|
import { appendCheckpointUserGrounding, appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
|
|
17
26
|
import { progressOnlyContinuationPrompt, shouldContinueProgressOnlyTurn, } from "./protocol/progress-continuation.js";
|
|
18
|
-
import { advertisedToolNamesFromDescriptors, displayNativeDiscoveryError, displayMcpToolError, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, isNativeDisplayToolCall, snapshotMirroredTodos, snapshotMirroredTodosFromReadOutput, } from "./protocol/tool-call-bridge.js";
|
|
27
|
+
import { advertisedToolNamesFromDescriptors, displayNativeDiscoveryError, displayMcpToolError, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, taskToolCallAttachments, resolveBridgedOpenCodeToolCall, isNativeDisplayToolCall, snapshotMirroredTodos, snapshotMirroredTodosFromReadOutput, } from "./protocol/tool-call-bridge.js";
|
|
19
28
|
import { handleKvServerMessage } from "./protocol/kv.js";
|
|
20
29
|
import { buildAskQuestionInteractionReply, buildAsyncAskQuestionCompletion, buildCreatePlanInteractionReply, buildSwitchModeInteractionReply, handleInteractionQuery, } from "./protocol/interactions.js";
|
|
21
30
|
import { ASK_QUESTION_RESULT_FIELD, askQuestionResultFromToolOutput, askQuestionToolInput, } from "./protocol/ask-question.js";
|
|
@@ -27,15 +36,18 @@ import { CURSOR_IMAGE_SAVE_TOOL, imageMimeForPath, remapCursorImageWritePath, }
|
|
|
27
36
|
import { stageCursorImage } from "./image-staging.js";
|
|
28
37
|
import { IMAGE_PERMISSION_DENIED_PREFIX } from "./image-save.js";
|
|
29
38
|
import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
|
|
30
|
-
import {
|
|
39
|
+
import { EARLY_ROW_CLOSED, EarlyToolRows, cursorEditAnnouncement, earlyRowHandoff, earlyRowToolCallId, parseEarlyRowToolCallId, shouldOpenEarlyRow, } from "./early-tool-rows.js";
|
|
40
|
+
import { carryLostRequest, dropLostRequest, hasLostRequest, noteCheckpoint, rememberLostRequest, } from "./lost-request.js";
|
|
41
|
+
import { currentCursorTokenBreakdown, cursorContextUsageMetadata, decodeConversationTokenDetails, } from "./protocol/token-details.js";
|
|
31
42
|
import { conversationBlobCount, inspectConversationBlobGraph, } from "./protocol/blob-store.js";
|
|
32
43
|
import { bindConversationId, peekConversationId, resolveConversationGroupId, } from "./protocol/conversation-bind.js";
|
|
33
|
-
import { beginEmittedStep, detectForeignHistory, recordEmittedPart, trackTurnProvenance, } from "./protocol/turn-provenance.js";
|
|
44
|
+
import { beginEmittedStep, detectForeignHistory, finishEmittedStep, recordEmittedPart, resumeEmittedStep, trackTurnProvenance, } from "./protocol/turn-provenance.js";
|
|
34
45
|
import { clearPersistedConversationState, hydrateConversationState, hydrateTurnProvenance, persistConversationState, } from "./protocol/conversation-state.js";
|
|
46
|
+
import { confirmHistoryImagesAtTurnEnd, forgetHeldHistoryImages, heldHistoryImageHashes, noteHistoryImageCheckpoint, noteHistoryImagesSent, resetHeldHistoryImagesForTests, runHistoryImages, } from "./protocol/sent-history-images.js";
|
|
35
47
|
import { initializeConversationPersistence } from "./protocol/conversation-persistence.js";
|
|
36
|
-
import { resolveContinuationPolicy, readSessionFrame, sessionManager, } from "./session.js";
|
|
37
|
-
import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError,
|
|
38
|
-
import { readCache, cacheFilePath, resolveVariantParameters, resolveVariantMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
|
|
48
|
+
import { resolveContinuationPolicy, readSessionFrame, sessionManager, stoppedWith, } from "./session.js";
|
|
49
|
+
import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorServerError, CursorTransportError, hostFacingError, hostRetryError, isRejectedCredentialError, isTransientGrpcStatus, noProgressResumeError, retrySuppressedError, toCursorProviderError, } from "./errors.js";
|
|
50
|
+
import { readCache, cacheFilePath, resolveVariantParameters, resolveVariantMaxMode, extractCursorVariantParameters, resolveCursorWireModelId, parseCursorContextLimit } from "./models.js";
|
|
39
51
|
import { getFrozenRequestContext, getOrBuildRequestContext, resetFrozenRequestContextsForTests } from "./context/frozen.js";
|
|
40
52
|
import { systemInstructionsRuleText } from "./context/build.js";
|
|
41
53
|
import { loadMergedConfig } from "./context/rules.js";
|
|
@@ -49,10 +61,12 @@ import { isCompactionSession } from "./compaction-marker.js";
|
|
|
49
61
|
import { resolveSessionWorkspaceRoot } from "./session-directory.js";
|
|
50
62
|
import { assertCursorUserImageSupport, extractCursorPromptImages, extractCursorToolResultImages, MAX_CURSOR_IMAGE_INPUT_BYTES, } from "./image-input.js";
|
|
51
63
|
import { getDocumentedCursorModelContext, resolveCursorModelSupportsImages } from "./model-metadata.js";
|
|
52
|
-
import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
|
|
64
|
+
import { CURSOR_TIMEOUT_BACKGROUND, consumeCursorShellResult, registerCursorShellCall, resolveCursorShellPath, shellPolicyFromMetadata, } from "./shell-timeout.js";
|
|
65
|
+
import { decodePersistedHostNotes, encodePersistedHostNotes, hostNoteHookContexts, hostNoteText, wrapHostNotesForCursor, } from "./protocol/host-notes.js";
|
|
53
66
|
import { analyzeReplayFrame, AttemptReplaySafety, describeFrameLayout } from "./replay-safety.js";
|
|
54
67
|
import { readAllFieldsStrict } from "./protocol/struct.js";
|
|
55
|
-
import { cursorUsageCountersFromTurnEnded, emptyLanguageModelV3Usage, formatCursorCacheDiagnostics, formatCursorTokenCategories, formatTurnUsageValidation,
|
|
68
|
+
import { cursorUsageCountersFromTurnEnded, emptyLanguageModelV3Usage, formatCursorCacheDiagnostics, formatCursorTokenCategories, formatFinishTrace, formatTurnUsageValidation, missingRulesWarning, occupancyValidationCounters, turnEndedCounter, } from "./usage.js";
|
|
69
|
+
import { billingLedger, cursorRunCost, hostStepPrices, nanoAiuForUsd, shapeStepUsage, turnEndedCostUsd, } from "./billing.js";
|
|
56
70
|
/** Unknown frame layouts already traced in this process; each is logged once. */
|
|
57
71
|
const unknownFrameLayouts = new Set();
|
|
58
72
|
let _availableModels;
|
|
@@ -63,7 +77,17 @@ let _availableModelsMtimeMs = -1;
|
|
|
63
77
|
// session so the new Cursor conversation is still born with tool definitions;
|
|
64
78
|
// execution remains disabled for the summary turn itself.
|
|
65
79
|
const toolCatalogBySession = new Map();
|
|
66
|
-
|
|
80
|
+
/** Host note bodies a turn ended without delivering, for the session's next user turn. */
|
|
81
|
+
let nextHostNoteOrder = 0;
|
|
82
|
+
const undeliveredHostNotesBySession = new Map();
|
|
83
|
+
/**
|
|
84
|
+
* Host notes injected into a Run and not yet acknowledged, per OpenCode session
|
|
85
|
+
* and injection id. Cleared on `delivered`; anything else returns the notes to
|
|
86
|
+
* `undeliveredHostNotesBySession`, as Cursor SDK `Run.steer` reverts to a
|
|
87
|
+
* follow-up. The owning Run is kept so notes are released only once it can no
|
|
88
|
+
* longer deliver them.
|
|
89
|
+
*/
|
|
90
|
+
const hostNoteInjectionsBySession = new Map();
|
|
67
91
|
// A compaction Run uses its own summary-agent system prompt. Its opaque Cursor
|
|
68
92
|
// checkpoint must never become the base for the resumed normal agent: doing so
|
|
69
93
|
// suppresses OpenCode's compacted prompt/system seed and makes Cursor narrate
|
|
@@ -71,9 +95,7 @@ const sentHistoryImageHashesBySession = new Map();
|
|
|
71
95
|
const postCompactionRebaseBySession = new Set();
|
|
72
96
|
const promptIdentityBySession = new Map();
|
|
73
97
|
export const MAX_TURN_STATE_SESSIONS = 256;
|
|
74
|
-
const MAX_SENT_HISTORY_IMAGES_PER_SESSION = 256;
|
|
75
98
|
const DEFAULT_RETRY_POLICY = {
|
|
76
|
-
maxAttempts: 3,
|
|
77
99
|
baseDelayMs: 500,
|
|
78
100
|
maxDelayMs: 8_000,
|
|
79
101
|
};
|
|
@@ -162,10 +184,10 @@ export function resolveRetryPolicy(options) {
|
|
|
162
184
|
throw new CursorProtocolError(`Unknown Cursor retry option: ${key}`);
|
|
163
185
|
}
|
|
164
186
|
}
|
|
165
|
-
const maxAttempts = retryInteger("maxAttempts", options
|
|
187
|
+
const maxAttempts = options?.maxAttempts === undefined ? undefined : retryInteger("maxAttempts", options.maxAttempts, 1);
|
|
166
188
|
const baseDelayMs = retryInteger("baseDelayMs", options?.baseDelayMs, DEFAULT_RETRY_POLICY.baseDelayMs);
|
|
167
189
|
const maxDelayMs = retryInteger("maxDelayMs", options?.maxDelayMs, DEFAULT_RETRY_POLICY.maxDelayMs);
|
|
168
|
-
if (maxAttempts > MAX_RETRY_ATTEMPTS) {
|
|
190
|
+
if (maxAttempts !== undefined && maxAttempts > MAX_RETRY_ATTEMPTS) {
|
|
169
191
|
throw new CursorProtocolError(`Cursor retry maxAttempts must be no greater than ${MAX_RETRY_ATTEMPTS}`);
|
|
170
192
|
}
|
|
171
193
|
if (baseDelayMs > MAX_RETRY_DELAY_MS || maxDelayMs > MAX_RETRY_DELAY_MS) {
|
|
@@ -272,18 +294,97 @@ function retryInfoProtobufDelayMs(encoded) {
|
|
|
272
294
|
return undefined;
|
|
273
295
|
return Math.ceil(Number(seconds) * 1_000 + Number(nanos) / 1_000_000);
|
|
274
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Decode `aiserver.v1.ErrorDetails`: error #1 and details #2 (title #1,
|
|
299
|
+
* detail #2, is_retryable #4, analytics_metadata #10 { action_required #1 }).
|
|
300
|
+
*/
|
|
301
|
+
function decodeCursorErrorDetails(encoded) {
|
|
302
|
+
try {
|
|
303
|
+
const outer = protobuf.Reader.create(Buffer.from(encoded, "base64"));
|
|
304
|
+
const details = { error: 0 };
|
|
305
|
+
while (outer.pos < outer.len) {
|
|
306
|
+
const tag = outer.uint32();
|
|
307
|
+
if (tag === 0x08) {
|
|
308
|
+
details.error = outer.int32();
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (tag !== 0x12) {
|
|
312
|
+
outer.skipType(tag & 7);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const inner = protobuf.Reader.create(outer.bytes());
|
|
316
|
+
while (inner.pos < inner.len) {
|
|
317
|
+
const innerTag = inner.uint32();
|
|
318
|
+
if (innerTag === 0x0a)
|
|
319
|
+
details.title = inner.string().trim() || undefined;
|
|
320
|
+
else if (innerTag === 0x12)
|
|
321
|
+
details.detail = inner.string().trim() || undefined;
|
|
322
|
+
else if (innerTag === 0x20)
|
|
323
|
+
details.isRetryable = inner.bool();
|
|
324
|
+
else if (innerTag === 0x52) {
|
|
325
|
+
const analytics = protobuf.Reader.create(inner.bytes());
|
|
326
|
+
while (analytics.pos < analytics.len) {
|
|
327
|
+
const analyticsTag = analytics.uint32();
|
|
328
|
+
if (analyticsTag === 0x0a)
|
|
329
|
+
details.actionRequired = analytics.string() || undefined;
|
|
330
|
+
else
|
|
331
|
+
analytics.skipType(analyticsTag & 7);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
else
|
|
335
|
+
inner.skipType(innerTag & 7);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return details;
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// `ErrorDetails.Error` values Cursor CLI never retries: cancellation
|
|
345
|
+
// (USER_ABORTED_REQUEST, DEBOUNCED) and login, usage/rate limit, usage pricing
|
|
346
|
+
// and API-key/outdated-client actions.
|
|
347
|
+
const CURSOR_FINAL_ERRORS = new Set([
|
|
348
|
+
21, 35,
|
|
349
|
+
2, 18, 11, 12, 3, 38, 46, 47,
|
|
350
|
+
9, 7, 23, 10, 8, 50, 51, 22,
|
|
351
|
+
44, 45,
|
|
352
|
+
1, 42, 30,
|
|
353
|
+
]);
|
|
354
|
+
const CURSOR_ERROR_CUSTOM_MESSAGE = 29;
|
|
355
|
+
/**
|
|
356
|
+
* Cursor CLI's retry verdict for a Connect error (its `ErrorDetails`
|
|
357
|
+
* classifier): cancellation, a required user action, an explicit
|
|
358
|
+
* `isRetryable: false`, an unflagged custom message, or a bare
|
|
359
|
+
* `permission_denied` end the request; anything else may be retried.
|
|
360
|
+
*/
|
|
361
|
+
function cursorCliRetryable(code, details) {
|
|
362
|
+
if (code === "canceled" || code === "aborted")
|
|
363
|
+
return false;
|
|
364
|
+
if (details) {
|
|
365
|
+
if (CURSOR_FINAL_ERRORS.has(details.error) || details.actionRequired)
|
|
366
|
+
return false;
|
|
367
|
+
if (details.error === CURSOR_ERROR_CUSTOM_MESSAGE && details.isRetryable !== true)
|
|
368
|
+
return false;
|
|
369
|
+
if (details.isRetryable !== undefined)
|
|
370
|
+
return details.isRetryable;
|
|
371
|
+
}
|
|
372
|
+
if (code === "permission_denied")
|
|
373
|
+
return false;
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
275
376
|
export function connectFrameError(payload) {
|
|
276
377
|
try {
|
|
277
378
|
const envelope = JSON.parse(payload);
|
|
278
379
|
const code = typeof envelope.error?.code === "string" ? envelope.error.code : "unknown";
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
});
|
|
380
|
+
// Hosts classify retries from the error text, so Cursor's own message and
|
|
381
|
+
// details (the specific reason) stay in the log.
|
|
382
|
+
if (envelope.error?.message !== undefined || envelope.error?.details !== undefined) {
|
|
383
|
+
trace(`connect error: code=${code} message=${JSON.stringify(envelope.error.message)?.slice(0, 2_000)} details=${JSON.stringify(envelope.error.details)?.slice(0, 4_000)}`);
|
|
284
384
|
}
|
|
285
385
|
let retryAfterMs = retryDelayFromValue(envelope.error?.retryAfter ?? envelope.error?.retry_after);
|
|
286
386
|
let hasRetryInfo = false;
|
|
387
|
+
let cursorDetails;
|
|
287
388
|
if (Array.isArray(envelope.error?.details)) {
|
|
288
389
|
for (const detail of envelope.error.details) {
|
|
289
390
|
if (!detail || typeof detail !== "object")
|
|
@@ -294,10 +395,22 @@ export function connectFrameError(payload) {
|
|
|
294
395
|
hasRetryInfo = true;
|
|
295
396
|
retryAfterMs ??= retryDelayFromValue(record.retryDelay ?? record.retry_delay ?? record.value);
|
|
296
397
|
}
|
|
398
|
+
if (type === "aiserver.v1.ErrorDetails" && typeof record.value === "string") {
|
|
399
|
+
cursorDetails ??= decodeCursorErrorDetails(record.value);
|
|
400
|
+
}
|
|
297
401
|
}
|
|
298
402
|
}
|
|
299
|
-
|
|
300
|
-
|
|
403
|
+
// Cursor CLI shows `${title} ${detail}` and follows its own retry verdict.
|
|
404
|
+
const explanation = [cursorDetails?.title, cursorDetails?.detail].filter(Boolean).join(" ");
|
|
405
|
+
const retryable = cursorCliRetryable(code, cursorDetails);
|
|
406
|
+
// Cursor's final details take precedence over the Connect login code.
|
|
407
|
+
// Only a bare credential rejection is eligible for one forced renewal.
|
|
408
|
+
if (code === "unauthenticated" && retryable !== false) {
|
|
409
|
+
return new CursorAuthError(explanation || `Cursor authentication failed (${code}); reauthenticate with Cursor`, { code, replaySafe: true });
|
|
410
|
+
}
|
|
411
|
+
return new CursorServerError(`Cursor API error (code=${code})${explanation ? `: ${explanation}` : ""}`, {
|
|
412
|
+
transient: retryable ?? (isTransientGrpcStatus(code) || hasRetryInfo),
|
|
413
|
+
retryable,
|
|
301
414
|
replaySafe: true,
|
|
302
415
|
code,
|
|
303
416
|
retryAfterMs: retryAfterMs === undefined
|
|
@@ -406,62 +519,92 @@ function rememberPromptIdentity(sessionKey, value) {
|
|
|
406
519
|
export function promptIdentityWouldRemint(_previous, _current) {
|
|
407
520
|
return false;
|
|
408
521
|
}
|
|
409
|
-
function
|
|
410
|
-
if (!
|
|
411
|
-
return
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
522
|
+
function noteRunImageCheckpoint(session, checkpoint, frame) {
|
|
523
|
+
if (!session.historyImages || !session.openCodeSessionId)
|
|
524
|
+
return;
|
|
525
|
+
noteHistoryImageCheckpoint(session.historyImages, session.openCodeSessionId, session.conversationId, checkpoint, frameArrival(frame));
|
|
526
|
+
}
|
|
527
|
+
/** A resumed model step cannot replay a result or follow-up absent from its checkpoint. */
|
|
528
|
+
function invalidateResumeCheckpoint(session, afterFrame = lastFrameArrival(), hostResult = false) {
|
|
529
|
+
session.resumeCheckpoint = undefined;
|
|
530
|
+
// A later result cannot make an earlier uncheckpointed follow-up appear
|
|
531
|
+
// represented in this call's host history. Only a new checkpoint clears it.
|
|
532
|
+
session.checkpointRevokedByHostResult = hostResult
|
|
533
|
+
&& (!session.checkpointRevoked || session.checkpointRevokedByHostResult === true);
|
|
534
|
+
session.checkpointRevoked = true;
|
|
535
|
+
session.resumeCheckpointAfterFrame = afterFrame;
|
|
418
536
|
}
|
|
419
|
-
|
|
420
|
-
|
|
537
|
+
/** A queued checkpoint predating a delivered result still describes the old step. */
|
|
538
|
+
function acceptResumeCheckpoint(session, bytes, frame) {
|
|
539
|
+
const arrived = frameArrival(frame);
|
|
540
|
+
if (session.resumeCheckpointAfterFrame !== undefined && arrived !== undefined
|
|
541
|
+
&& arrived <= session.resumeCheckpointAfterFrame)
|
|
421
542
|
return;
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
while (remembered.size > MAX_SENT_HISTORY_IMAGES_PER_SESSION) {
|
|
427
|
-
const oldest = remembered.values().next().value;
|
|
428
|
-
if (!oldest)
|
|
429
|
-
break;
|
|
430
|
-
remembered.delete(oldest);
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
sentHistoryImageHashesBySession.delete(sessionKey);
|
|
434
|
-
sentHistoryImageHashesBySession.set(sessionKey, remembered);
|
|
435
|
-
while (sentHistoryImageHashesBySession.size > MAX_TURN_STATE_SESSIONS) {
|
|
436
|
-
const oldest = sentHistoryImageHashesBySession.keys().next().value;
|
|
437
|
-
if (!oldest)
|
|
438
|
-
break;
|
|
439
|
-
sentHistoryImageHashesBySession.delete(oldest);
|
|
440
|
-
}
|
|
543
|
+
session.resumeCheckpoint = Uint8Array.from(bytes);
|
|
544
|
+
session.checkpointRevoked = false;
|
|
545
|
+
session.checkpointRevokedByHostResult = false;
|
|
546
|
+
session.resumeCheckpointAfterFrame = undefined;
|
|
441
547
|
}
|
|
442
|
-
/**
|
|
443
|
-
|
|
444
|
-
|
|
548
|
+
/** `resumeCheckpoint` is set only by a checkpoint this Run received. */
|
|
549
|
+
function confirmRunImagesAtTurnEnd(session) {
|
|
550
|
+
if (!session.historyImages || !session.openCodeSessionId)
|
|
551
|
+
return;
|
|
552
|
+
confirmHistoryImagesAtTurnEnd(session.historyImages, session.openCodeSessionId, session.conversationId, session.resumeCheckpoint);
|
|
553
|
+
}
|
|
554
|
+
/** Test helper: the release `startSession` performs, then the host-note stores. */
|
|
555
|
+
export function releaseHostNoteInjectionsForTests(sessionKey) {
|
|
556
|
+
releaseHostNoteInjections(sessionKey, "run-closed");
|
|
557
|
+
return {
|
|
558
|
+
inFlight: [...(hostNoteInjectionsBySession.get(sessionKey)?.values() ?? [])].map((entry) => entry.notes),
|
|
559
|
+
undelivered: undeliveredHostNotes(sessionKey),
|
|
560
|
+
};
|
|
445
561
|
}
|
|
446
562
|
export function createCursorLanguageModel(modelId, providerId, options) {
|
|
447
563
|
// Host Path.cache / explicit override wins over XDG heuristics for this process.
|
|
448
564
|
if (options.cacheDir)
|
|
449
565
|
setHostCacheDirOverride(options.cacheDir);
|
|
450
|
-
//
|
|
451
|
-
//
|
|
566
|
+
// Prepare restart storage and schedule bounded background maintenance. Session
|
|
567
|
+
// hydration awaits directory readiness only, never the directory-wide sweep.
|
|
452
568
|
void initializeConversationPersistence(opencodeGlobalCacheDir()).catch((error) => {
|
|
453
569
|
trace(`conversation persistence: startup load failed: ${String(error)}`);
|
|
454
570
|
});
|
|
571
|
+
// Credential resolution and session setup can fail before a stream exists.
|
|
572
|
+
// Both public entrypoints must project those failures just like pump errors.
|
|
573
|
+
const doStream = async (callOptions) => {
|
|
574
|
+
const retryPolicy = resolveRetryPolicy(options.retry);
|
|
575
|
+
const retryBudget = turnRetryBudget(opencodeSessionKey(callOptions), retryTurnKey(callOptions.prompt), retryTurnKey(callOptions.prompt, true));
|
|
576
|
+
try {
|
|
577
|
+
return await doStreamImpl(modelId, options, callOptions, retryPolicy, retryBudget);
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
const failure = toCursorProviderError(error, { replaySafe: true, fallback: "Cursor Run setup failed" });
|
|
581
|
+
try {
|
|
582
|
+
if (callOptions.abortSignal?.aborted) {
|
|
583
|
+
forgetRetryBudget(opencodeSessionKey(callOptions), retryBudget);
|
|
584
|
+
throw new CursorLocalCancellationError("Cursor Run setup cancelled", error);
|
|
585
|
+
}
|
|
586
|
+
if (!failure.replaySafe && (failure.transient || failure.origin === "transport")) {
|
|
587
|
+
throw retrySuppressedError(failure, "while opening the Run", retryBudget.retries + 1, retryPolicy.maxAttempts ?? 14);
|
|
588
|
+
}
|
|
589
|
+
if (!failure.transient)
|
|
590
|
+
throw failure;
|
|
591
|
+
reserveRetry(retryBudget, failure, retryPolicy.maxAttempts);
|
|
592
|
+
retryBudget.hostRetryPending = true;
|
|
593
|
+
throw hostRetryError(failure, true);
|
|
594
|
+
}
|
|
595
|
+
catch (terminal) {
|
|
596
|
+
throw hostFacingError(terminal);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
};
|
|
455
600
|
return {
|
|
456
601
|
specificationVersion: "v3",
|
|
457
602
|
provider: providerId,
|
|
458
603
|
modelId,
|
|
459
604
|
supportedUrls: {},
|
|
460
|
-
|
|
461
|
-
return doStreamImpl(modelId, options, callOptions);
|
|
462
|
-
},
|
|
605
|
+
doStream,
|
|
463
606
|
async doGenerate(callOptions) {
|
|
464
|
-
const result = await
|
|
607
|
+
const result = await doStream(callOptions);
|
|
465
608
|
const parts = [];
|
|
466
609
|
const reader = result.stream.getReader();
|
|
467
610
|
while (true) {
|
|
@@ -484,9 +627,14 @@ function resolveRunBearerToken(options, forceRefresh = false) {
|
|
|
484
627
|
forceRefresh,
|
|
485
628
|
});
|
|
486
629
|
}
|
|
487
|
-
async function doStreamImpl(modelId, options, callOptions) {
|
|
630
|
+
async function doStreamImpl(modelId, options, callOptions, retryPolicy, retryBudget) {
|
|
631
|
+
await restoreSessionState(opencodeSessionKey(callOptions), opencodeGlobalCacheDir());
|
|
632
|
+
const withoutAnswered = await leaveOutAnsweredEarlySteers(callOptions.prompt, opencodeSessionKey(callOptions));
|
|
633
|
+
if (withoutAnswered === "all")
|
|
634
|
+
return { stream: answeredSteerStream() };
|
|
635
|
+
if (withoutAnswered)
|
|
636
|
+
callOptions = { ...callOptions, prompt: withoutAnswered };
|
|
488
637
|
const prompt = callOptions.prompt;
|
|
489
|
-
const retryPolicy = resolveRetryPolicy(options.retry);
|
|
490
638
|
// The Bearer token is resolved for every Run this call opens, including
|
|
491
639
|
// recovery Runs after a long-held tool: a host `getAccessToken` (or the
|
|
492
640
|
// apiKey exchange) renews it, so a Run never starts on a token that expired
|
|
@@ -497,6 +645,7 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
497
645
|
const credentialRenewable = Boolean(options.getAccessToken)
|
|
498
646
|
|| (!options.accessToken && options.apiKey !== undefined && isExchangeableApiKey(options.apiKey));
|
|
499
647
|
let prefetchedToken;
|
|
648
|
+
const requestedAt = Date.now();
|
|
500
649
|
// pumpWithRecovery owns the complete per-turn attempt budget. Opening a
|
|
501
650
|
// replacement session here must be a single attempt; otherwise setup retry
|
|
502
651
|
// loops nest inside recovery and `maxAttempts` no longer caps total Runs.
|
|
@@ -508,7 +657,9 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
508
657
|
const token = prefetched !== undefined && !forceRefresh
|
|
509
658
|
? prefetched
|
|
510
659
|
: await resolveRunBearerToken(options, forceRefresh);
|
|
511
|
-
|
|
660
|
+
const opened = await startSession(modelId, token, callOptions, options, { ...startOptions, requestedAt });
|
|
661
|
+
cancelIfHostStoppedSince(opened);
|
|
662
|
+
return opened;
|
|
512
663
|
};
|
|
513
664
|
// ── Continuation vs fresh turn ──
|
|
514
665
|
// OpenCode embeds *all* historical tool results in every prompt. Only the
|
|
@@ -516,8 +667,13 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
516
667
|
// live continuation. Treating mid-prompt history as continuation caused
|
|
517
668
|
// false "orphaned tool results" errors after Cursor turn_ended and OpenCode
|
|
518
669
|
// started the next step with old tools still in the prompt body.
|
|
519
|
-
|
|
670
|
+
let trailingToolResults = extractTrailingToolResults(prompt);
|
|
520
671
|
let session = findContinuationSession(trailingToolResults);
|
|
672
|
+
if (trailingToolResults.length === 0) {
|
|
673
|
+
const steered = continuationAfterEarlySteers(prompt, opencodeSessionKey(callOptions));
|
|
674
|
+
if (steered)
|
|
675
|
+
({ results: trailingToolResults, session } = steered);
|
|
676
|
+
}
|
|
521
677
|
// A call that must open a Run gets its credential before it changes any
|
|
522
678
|
// session state (plan mode, finishing the prior held Run), so a login that
|
|
523
679
|
// cannot be renewed fails the turn without side effects.
|
|
@@ -534,33 +690,39 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
534
690
|
// session and returns undefined so we fall through to history rebase
|
|
535
691
|
// instead of pumping a connection that can no longer accept writes.
|
|
536
692
|
const tailMedia = trailingToolResults.reduce((n, r) => n + (r.media?.length ?? 0), 0);
|
|
537
|
-
const roles = prompt.slice(Math.max(0, prompt.length - 4)).map((m) => {
|
|
538
|
-
if (!Array.isArray(m.content))
|
|
539
|
-
return `${m.role}:scalar`;
|
|
540
|
-
const kinds = m.content.map((p) => {
|
|
541
|
-
const part = p;
|
|
542
|
-
if (part.type === "tool-result") {
|
|
543
|
-
const out = part.output;
|
|
544
|
-
const value = out?.value;
|
|
545
|
-
const nonText = Array.isArray(value)
|
|
546
|
-
? value.filter((item) => item && typeof item === "object" && item.type !== "text").length
|
|
547
|
-
: 0;
|
|
548
|
-
return `tool-result(${part.toolName},${typeof out?.type === "string" ? out.type : "?"},mediaish=${nonText})`;
|
|
549
|
-
}
|
|
550
|
-
return String(part.type ?? "?");
|
|
551
|
-
});
|
|
552
|
-
return `${m.role}:[${kinds.join(",")}]`;
|
|
553
|
-
});
|
|
554
693
|
trace(`continuation: prompt-tail mediaParts=${tailMedia} supportsImages=${session.supportsImages} ` +
|
|
555
|
-
`roles=${
|
|
694
|
+
`roles=${describePromptTail(prompt)}`);
|
|
556
695
|
const results = session.supportsImages
|
|
557
696
|
? await decodeTrailingToolImages(session, trailingToolResults, callOptions.abortSignal)
|
|
558
697
|
: trailingToolResults;
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
698
|
+
const held = session;
|
|
699
|
+
const cancelled = () => new CursorLocalCancellationError("Cursor continuation upload cancelled");
|
|
700
|
+
const cancelUpload = () => {
|
|
701
|
+
dropLostRequest(held.openCodeSessionId);
|
|
702
|
+
sessionManager.close(held, "host-interrupted", cancelled());
|
|
703
|
+
};
|
|
704
|
+
const signal = callOptions.abortSignal;
|
|
705
|
+
signal?.addEventListener("abort", cancelUpload, { once: true });
|
|
706
|
+
try {
|
|
707
|
+
if (signal?.aborted) {
|
|
708
|
+
cancelUpload();
|
|
709
|
+
throw cancelled();
|
|
710
|
+
}
|
|
711
|
+
session = await deliverContinuationResults(held, results, {
|
|
712
|
+
hostAgent: hostAgentFromCallOptions(callOptions),
|
|
713
|
+
});
|
|
714
|
+
// Cancellation is not a failed delivery to rebase and upload again.
|
|
715
|
+
if (signal?.aborted)
|
|
716
|
+
throw cancelled();
|
|
717
|
+
}
|
|
718
|
+
finally {
|
|
719
|
+
signal?.removeEventListener("abort", cancelUpload);
|
|
720
|
+
}
|
|
562
721
|
if (session)
|
|
563
722
|
await refreshHeldSessionToolCatalog(session, callOptions);
|
|
723
|
+
// The rebase below seeds from host history, which holds OpenCode's copy of any promoted steer.
|
|
724
|
+
else
|
|
725
|
+
handOverEarlySteers(held.openCodeSessionId, held.sessionId, undefined);
|
|
564
726
|
}
|
|
565
727
|
if (!session) {
|
|
566
728
|
const sessionKey = opencodeSessionKey(callOptions);
|
|
@@ -570,8 +732,14 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
570
732
|
// prompt onto a fresh conversation: its seed history includes the
|
|
571
733
|
// completed tool result, so no result or advertised tool is lost and
|
|
572
734
|
// Cursor can continue instead of deadlocking.
|
|
735
|
+
// Resuming the last checkpoint cannot take these results: Cursor
|
|
736
|
+
// checkpoints only after tool results, so that checkpoint predates the
|
|
737
|
+
// outstanding calls, and `resume_action` re-runs the model step, which
|
|
738
|
+
// asks for the tool again under a new call id. Verified live 2026-10-09
|
|
739
|
+
// (claude-sonnet-5-5): the resumed Run re-requested the same read.
|
|
573
740
|
const ids = trailingToolResults.map((r) => `${r.sessionId}:${r.execId}`).join(",");
|
|
574
|
-
trace(`continuation: ${trailingToolResults.length} interrupted trailing tool result(s) [${ids}]
|
|
741
|
+
trace(`continuation: ${trailingToolResults.length} interrupted trailing tool result(s) [${ids}] ` +
|
|
742
|
+
`heldRun=${describeHeldRunLoss(trailingToolResults)} — rebasing fresh Run`);
|
|
575
743
|
session = await openSession({ recovery: { kind: "rebase" } });
|
|
576
744
|
}
|
|
577
745
|
else {
|
|
@@ -580,14 +748,25 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
580
748
|
const historicalResults = extractToolResults(prompt);
|
|
581
749
|
if (historicalResults.length > 0) {
|
|
582
750
|
trace(`fresh turn: reconciling ${historicalResults.length} historical tool result(s) with held pending calls`);
|
|
751
|
+
trace(`fresh turn: prompt-tail roles=${describePromptTail(prompt)}`);
|
|
583
752
|
}
|
|
584
753
|
// An in-session helper (title/memory/task child) that reuses the parent
|
|
585
754
|
// OpenCode session id with a strictly smaller catalog must not cancel or
|
|
586
755
|
// supersede the held parent Run — that remints (`interrupted-run`) when
|
|
587
756
|
// the trailing tool result returns. Isolate onto an ephemeral conversation.
|
|
588
757
|
const busyPrior = sessionManager.findOpenByOpenCodeSessionId(sessionKey);
|
|
589
|
-
|
|
590
|
-
|
|
758
|
+
const incomingTools = extractTools(callOptions);
|
|
759
|
+
// A tool-less call that is not compaction (title, summary) is a lifecycle
|
|
760
|
+
// Run: ephemeral and refused execution. Without a result for the parent's
|
|
761
|
+
// pending call, the host is still running that tool (OpenCode 2 sends one
|
|
762
|
+
// mid-tool), so the call must not cancel it. With that result, the host
|
|
763
|
+
// has moved past the tool and the parent Run is finished as usual.
|
|
764
|
+
const lifecycleCall = !computeAllowTools(incomingTools.length, callOptions.toolChoice)
|
|
765
|
+
&& !isCompactionCall(callOptions, sessionKey)
|
|
766
|
+
&& !historicalResults.some((result) => result.sessionId === busyPrior?.sessionId && busyPrior.pending.has(result.execId));
|
|
767
|
+
if (busyPrior
|
|
768
|
+
&& (lifecycleCall || shouldIsolateInSessionHelper(sessionKey, incomingTools, historicalResults))) {
|
|
769
|
+
trace(`fresh turn: isolating in-session ${lifecycleCall ? "tool-less lifecycle call" : "helper — incomingTools subset of"} ` +
|
|
591
770
|
`parentTools=${busyPrior.toolCatalog?.length ?? 0} ` +
|
|
592
771
|
`priorSession=${busyPrior.sessionId} pending=${busyPrior.pending.size}`);
|
|
593
772
|
session = await openSession({ isolate: true });
|
|
@@ -598,21 +777,40 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
598
777
|
// drain turn_ended) before opening the new Run so the checkpoint prefix
|
|
599
778
|
// is preserved. registerSession will not close a prior Run that still
|
|
600
779
|
// has real pending execs; a failed prepare leaves that Run held.
|
|
780
|
+
let unfinishedPrior = false;
|
|
601
781
|
try {
|
|
602
|
-
await preparePriorSessionForFreshTurn(sessionKey, {
|
|
782
|
+
const prepared = await preparePriorSessionForFreshTurn(sessionKey, {
|
|
603
783
|
toolResults: historicalResults,
|
|
604
784
|
hostAgent: hostAgentFromCallOptions(callOptions),
|
|
605
785
|
});
|
|
786
|
+
unfinishedPrior = prepared !== "drained" && prepared !== "none";
|
|
787
|
+
if (unfinishedPrior)
|
|
788
|
+
trace(`fresh turn: prior preparation=${prepared} — rebasing from host history`);
|
|
606
789
|
}
|
|
607
790
|
catch (error) {
|
|
608
|
-
|
|
791
|
+
unfinishedPrior = true;
|
|
792
|
+
trace(`fresh turn: prepare-prior failed — rebasing the new Run and leaving ` +
|
|
609
793
|
`any still-pending prior held — ${error.message}`);
|
|
610
794
|
}
|
|
611
|
-
|
|
795
|
+
if (!unfinishedPrior && needsHostHistory(sessionKey, sessionManager.findOpenByOpenCodeSessionId(sessionKey)?.sessionId)) {
|
|
796
|
+
unfinishedPrior = true;
|
|
797
|
+
trace("fresh turn: an early steer of an unfinished Run is not where the checkpoint has it — rebasing from host history");
|
|
798
|
+
}
|
|
799
|
+
// Without TurnEnded, the checkpoint may predate completed side effects.
|
|
800
|
+
// Include their host results instead of resuming that model step.
|
|
801
|
+
session = await openSession(unfinishedPrior
|
|
802
|
+
? { recovery: { kind: "rebase", reason: "unfinished-prior-run" } }
|
|
803
|
+
: undefined);
|
|
804
|
+
// A helper Run (title, lifecycle) or a compaction Run is not the user's next turn.
|
|
805
|
+
if (session.openCodeSessionId && !session.postCompactionRebase) {
|
|
806
|
+
clearUnansweredEarlySteers(sessionKey);
|
|
807
|
+
steerAnsweringRuns.delete(session.openCodeSessionId);
|
|
808
|
+
}
|
|
612
809
|
}
|
|
613
810
|
}
|
|
614
811
|
}
|
|
615
812
|
let activeSession = session;
|
|
813
|
+
let pumpingThisStream = false;
|
|
616
814
|
return {
|
|
617
815
|
stream: new ReadableStream({
|
|
618
816
|
async pull(controller) {
|
|
@@ -632,17 +830,37 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
632
830
|
if (activeSession.openCodeSessionId) {
|
|
633
831
|
beginEmittedStep(activeSession.openCodeSessionId, activeSession.conversationId);
|
|
634
832
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
833
|
+
pumpingThisStream = true;
|
|
834
|
+
try {
|
|
835
|
+
activeSession = await pumpWithRecovery({
|
|
836
|
+
initialSession: activeSession,
|
|
837
|
+
controller,
|
|
838
|
+
abortSignal: callOptions.abortSignal,
|
|
839
|
+
retryPolicy,
|
|
840
|
+
retryBudget,
|
|
841
|
+
canRebaseFromHistory: (failed) => {
|
|
842
|
+
if (failed !== session || trailingToolResults.length === 0
|
|
843
|
+
|| !failed.checkpointRevokedByHostResult || failed.pending.size > 0
|
|
844
|
+
|| failed.replaySafety?.historyRebaseUnsafe)
|
|
845
|
+
return false;
|
|
846
|
+
const completed = new Set(extractToolResults(prompt)
|
|
847
|
+
.map(result => `${result.sessionId}:${result.execId}`));
|
|
848
|
+
const execs = failed.replaySafety?.execs;
|
|
849
|
+
return !!execs?.length && execs.every(exec => completed.has(`${exec.sessionId}:${exec.id}`));
|
|
850
|
+
},
|
|
851
|
+
recover: (recovery) => openSession({ recovery }),
|
|
852
|
+
onSession: (next) => { activeSession = next; },
|
|
853
|
+
...(credentialRenewable
|
|
854
|
+
? { renewRejectedCredential: () => { forceCredentialRefresh = true; } }
|
|
855
|
+
: {}),
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
finally {
|
|
859
|
+
pumpingThisStream = false;
|
|
860
|
+
}
|
|
861
|
+
if (activeSession.closed && activeSession.pending.size === 0) {
|
|
862
|
+
forgetRetryBudget(opencodeSessionKey(callOptions), retryBudget);
|
|
863
|
+
}
|
|
646
864
|
try {
|
|
647
865
|
controller.close();
|
|
648
866
|
}
|
|
@@ -660,11 +878,22 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
660
878
|
});
|
|
661
879
|
}
|
|
662
880
|
catch (e) {
|
|
881
|
+
if (e instanceof CursorLocalCancellationError || callOptions.abortSignal?.aborted) {
|
|
882
|
+
forgetRetryBudget(opencodeSessionKey(callOptions), retryBudget);
|
|
883
|
+
}
|
|
663
884
|
activeSession.pumpActive = false;
|
|
664
885
|
trace(`pull: pump threw (cleaning up): ${e.message}`);
|
|
665
|
-
|
|
886
|
+
// A Run the host stopped is drained and closed by its cancel.
|
|
887
|
+
if (!activeSession.hostInterrupted) {
|
|
888
|
+
// Only the snapshot carries a request Cursor never checkpointed
|
|
889
|
+
// past a restart; a failed Run reaches no other save.
|
|
890
|
+
if (hasLostRequest(activeSession.openCodeSessionId)) {
|
|
891
|
+
await persistRunInProgress(activeSession, "failed Run");
|
|
892
|
+
}
|
|
893
|
+
sessionManager.close(activeSession);
|
|
894
|
+
}
|
|
666
895
|
try {
|
|
667
|
-
controller.error(
|
|
896
|
+
controller.error(hostFacingError(e));
|
|
668
897
|
}
|
|
669
898
|
catch {
|
|
670
899
|
/* controller already errored/closed */
|
|
@@ -674,8 +903,19 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
674
903
|
cancel() {
|
|
675
904
|
// OpenCode cancels the ReadableStream after "tool-calls"; keep the
|
|
676
905
|
// Cursor Run stream alive so the next doStream can write results.
|
|
906
|
+
// Mid-generation with nothing owed, the host stopped reading: OpenCode 2
|
|
907
|
+
// cancels the stream on Stop before it reports the interrupt. Stop the
|
|
908
|
+
// Run as Cursor CLI does, so Cursor ends it and its checkpoint is kept.
|
|
909
|
+
// A step stream cancelled after a continuation took over the Run is not a Stop.
|
|
910
|
+
if (pumpingThisStream && !activeSession.closed && !activeSession.hostInterrupted
|
|
911
|
+
&& activeSession.pending.size === 0 && sessionManager.isActivelyPumping(activeSession)) {
|
|
912
|
+
trace("ReadableStream cancel() mid-generation → cancelling the Run");
|
|
913
|
+
cancelForHostInterrupt(activeSession, "stream-cancelled");
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
677
916
|
trace("ReadableStream cancel() → closeUnlessPending");
|
|
678
|
-
|
|
917
|
+
if (!activeSession.hostInterrupted)
|
|
918
|
+
sessionManager.closeUnlessPending(activeSession);
|
|
679
919
|
},
|
|
680
920
|
}),
|
|
681
921
|
};
|
|
@@ -684,13 +924,20 @@ export async function pumpWithRecovery(input) {
|
|
|
684
924
|
let session = input.initialSession;
|
|
685
925
|
const retryPolicy = input.retryPolicy ?? {
|
|
686
926
|
...DEFAULT_RETRY_POLICY,
|
|
687
|
-
maxAttempts:
|
|
927
|
+
...(input.maxRecoveries === undefined ? {} : { maxAttempts: input.maxRecoveries + 1 }),
|
|
688
928
|
};
|
|
689
|
-
const
|
|
929
|
+
const retryBudget = input.retryBudget ?? newRetryBudget();
|
|
930
|
+
const maxRecoveries = retryPolicy.maxAttempts === undefined ? 13 : retryPolicy.maxAttempts - 1;
|
|
690
931
|
let credentialRenewed = false;
|
|
932
|
+
// As in Cursor CLI's Run retry loop: an attempt without a checkpoint of its
|
|
933
|
+
// own resumes the latest one this turn received, and two resumed attempts
|
|
934
|
+
// that streamed without saving a new checkpoint stop the automatic retries.
|
|
935
|
+
let turnCheckpoint;
|
|
936
|
+
let resumedAttempt = false;
|
|
937
|
+
let noProgressResumes = 0;
|
|
938
|
+
let openingFailure;
|
|
691
939
|
input.onSession?.(session);
|
|
692
|
-
const reopen = async (pumpedSession, failure) => {
|
|
693
|
-
const checkpoint = pumpedSession.resumeCheckpoint;
|
|
940
|
+
const reopen = async (pumpedSession, failure, checkpoint) => {
|
|
694
941
|
const recovery = failure.checkpointUnusable
|
|
695
942
|
? { kind: "rebase", reason: "checkpoint-unusable" }
|
|
696
943
|
: checkpoint
|
|
@@ -699,75 +946,213 @@ export async function pumpWithRecovery(input) {
|
|
|
699
946
|
conversationId: pumpedSession.conversationId,
|
|
700
947
|
checkpoint: Uint8Array.from(checkpoint),
|
|
701
948
|
}
|
|
702
|
-
: { kind: "rebase" };
|
|
703
|
-
|
|
949
|
+
: { kind: "rebase", ...(input.canRebaseFromHistory?.(pumpedSession) ? { reason: "completed-tool-results" } : {}) };
|
|
950
|
+
resumedAttempt = recovery.kind === "resume";
|
|
951
|
+
// A rebase opens another conversation; its checkpoints start over.
|
|
952
|
+
if (!resumedAttempt)
|
|
953
|
+
turnCheckpoint = undefined;
|
|
954
|
+
// Host notes the failed Run never acknowledged belong to this turn.
|
|
955
|
+
const unackedNotes = takeHostNoteInjections(pumpedSession);
|
|
956
|
+
const unackedNoteCount = unackedNotes.reduce((count, batch) => count + batch.notes.length, 0);
|
|
957
|
+
if (recovery.kind === "rebase" && pumpedSession.openCodeSessionId) {
|
|
958
|
+
// A replacement seed may omit old read outputs or have rewritten history.
|
|
959
|
+
// Restore notes before opening it; startSession removes only notes its
|
|
960
|
+
// actual seed contains. Initial user-action notes also need this transfer
|
|
961
|
+
// when the attempt failed before obtaining a checkpoint.
|
|
962
|
+
appendUndeliveredHostNotes(pumpedSession.openCodeSessionId, [
|
|
963
|
+
...(pumpedSession.userTurnHostNotes ?? []), ...unackedNotes,
|
|
964
|
+
]);
|
|
965
|
+
}
|
|
966
|
+
let next;
|
|
967
|
+
try {
|
|
968
|
+
next = await input.recover(recovery);
|
|
969
|
+
}
|
|
970
|
+
catch (error) {
|
|
971
|
+
if (recovery.kind === "resume" && unackedNotes.length > 0 && pumpedSession.openCodeSessionId) {
|
|
972
|
+
appendUndeliveredHostNotes(pumpedSession.openCodeSessionId, unackedNotes);
|
|
973
|
+
}
|
|
974
|
+
throw error;
|
|
975
|
+
}
|
|
976
|
+
handOverEarlySteers(pumpedSession.openCodeSessionId, pumpedSession.sessionId, recovery.kind === "resume" ? next.sessionId : undefined);
|
|
704
977
|
if (recovery.kind === "resume") {
|
|
978
|
+
next.replaySafety = {
|
|
979
|
+
...pumpedSession.replaySafety,
|
|
980
|
+
execs: pumpedSession.replaySafety?.execs?.slice(),
|
|
981
|
+
};
|
|
982
|
+
next.userTurnHostNotes = pumpedSession.userTurnHostNotes;
|
|
705
983
|
next.usageEstimate = { ...pumpedSession.usageEstimate };
|
|
706
984
|
next.editToolCalls = new Map(pumpedSession.editToolCalls);
|
|
707
985
|
// mirroredTodos rides along via rememberMirroredTodos (per-OpenCode-
|
|
708
986
|
// session, seeded in startSession) — no handoff needed here.
|
|
987
|
+
// The resumed Run continues the same turn, so the notes go to it.
|
|
988
|
+
// A failed write closes `next` and keeps the notes tracked against it,
|
|
989
|
+
// so the next recovery or user turn picks them up.
|
|
990
|
+
if (unackedNotes.length > 0) {
|
|
991
|
+
trace(`Run resume: re-injecting ${unackedNoteCount} unacknowledged host note(s)`);
|
|
992
|
+
for (const batch of unackedNotes) {
|
|
993
|
+
if (!await injectHostNotes(next, batch.notes, batch.order)) {
|
|
994
|
+
// A failed first batch must not discard later batches.
|
|
995
|
+
if (next.openCodeSessionId) {
|
|
996
|
+
appendUndeliveredHostNotes(next.openCodeSessionId, unackedNotes.filter((other) => other.order > batch.order));
|
|
997
|
+
}
|
|
998
|
+
throw next.closeError ?? new CursorTransportError("Cursor host note injection write failed", { transient: false, replaySafe: false });
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
else if (unackedNotes.length > 0) {
|
|
1004
|
+
trace(`Run rebase: ${unackedNoteCount} unacknowledged host note(s) transferred to the replacement seed`);
|
|
709
1005
|
}
|
|
710
1006
|
input.onSession?.(next);
|
|
711
1007
|
return next;
|
|
712
1008
|
};
|
|
1009
|
+
// A replacement can fail before pump() starts (for example while connecting).
|
|
1010
|
+
// Classify it in this same loop rather than exposing an unbudgeted host retry.
|
|
1011
|
+
const reopenAttempt = async (pumped, failure, checkpoint) => {
|
|
1012
|
+
try {
|
|
1013
|
+
session = await reopen(pumped, failure, checkpoint);
|
|
1014
|
+
}
|
|
1015
|
+
catch (error) {
|
|
1016
|
+
const failure = toCursorProviderError(error, { replaySafe: true, fallback: "Cursor recovery setup failed" });
|
|
1017
|
+
// The failed upload belongs to the replacement Run. The preceding Run's
|
|
1018
|
+
// checkpoint cannot prove that the replacement did not already start work.
|
|
1019
|
+
if (!failure.replaySafe && (failure.transient || failure.origin === "transport")) {
|
|
1020
|
+
throw retrySuppressedError(failure, "while opening the replacement Run", retryBudget.retries + 1, maxRecoveries + 1);
|
|
1021
|
+
}
|
|
1022
|
+
if (!failure.transient)
|
|
1023
|
+
throw error;
|
|
1024
|
+
openingFailure = failure;
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
713
1027
|
for (let attempt = 0;; attempt++) {
|
|
714
1028
|
const pumpedSession = session;
|
|
715
1029
|
const pumpOwner = Symbol("cursor-pump");
|
|
716
|
-
|
|
1030
|
+
if (!openingFailure)
|
|
1031
|
+
sessionManager.beginPump(pumpedSession, pumpOwner);
|
|
1032
|
+
// OpenCode 1.x stops a turn by aborting the signal, before the AI SDK cancels
|
|
1033
|
+
// the stream. Cancel at Cursor, as the stream's cancel() does, so Cursor ends
|
|
1034
|
+
// the Run on a checkpoint; a reply still waiting for drain would hold the
|
|
1035
|
+
// cancel back, so then reset the Run at once.
|
|
1036
|
+
const stopPumpedRun = () => {
|
|
1037
|
+
if (!sessionManager.isPumpOwner(pumpedSession, pumpOwner) || pumpedSession.pending.size > 0 || pumpedSession.hostInterrupted)
|
|
1038
|
+
return;
|
|
1039
|
+
if (!isStreamWriteBlocked(pumpedSession.stream)) {
|
|
1040
|
+
cancelForHostInterrupt(pumpedSession, "aborted");
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
dropLostRequest(pumpedSession.openCodeSessionId);
|
|
1044
|
+
sessionManager.close(pumpedSession, "host-interrupted", new CursorLocalCancellationError("Cursor Run cancelled locally"));
|
|
1045
|
+
};
|
|
1046
|
+
input.abortSignal?.addEventListener("abort", stopPumpedRun, { once: true });
|
|
1047
|
+
// A compaction Run keeps the session id, but a steer is for the agent, not the summarizer.
|
|
1048
|
+
const stopSteers = pumpedSession.openCodeSessionId && !pumpedSession.postCompactionRebase
|
|
1049
|
+
? listenForHostSteers(pumpedSession.openCodeSessionId, (steer) => injectHostSteer(pumpedSession, steer))
|
|
1050
|
+
: undefined;
|
|
717
1051
|
try {
|
|
1052
|
+
if (openingFailure) {
|
|
1053
|
+
const failure = openingFailure;
|
|
1054
|
+
openingFailure = undefined;
|
|
1055
|
+
throw failure;
|
|
1056
|
+
}
|
|
718
1057
|
await pump(pumpedSession, input.controller, {
|
|
719
1058
|
textId: crypto.randomUUID(),
|
|
720
1059
|
reasoningId: crypto.randomUUID(),
|
|
721
1060
|
}, input.abortSignal);
|
|
1061
|
+
if (pumpedSession.closed && pumpedSession.pending.size === 0) {
|
|
1062
|
+
forgetRetryBudget(pumpedSession.openCodeSessionId, retryBudget);
|
|
1063
|
+
}
|
|
722
1064
|
return session;
|
|
723
1065
|
}
|
|
724
1066
|
catch (error) {
|
|
1067
|
+
// A cancelled Run's stream fails or ends on purpose; recovering would restart the stopped turn.
|
|
1068
|
+
if (pumpedSession.hostInterrupted || input.abortSignal?.aborted) {
|
|
1069
|
+
forgetRetryBudget(pumpedSession.openCodeSessionId, retryBudget);
|
|
1070
|
+
if (error instanceof CursorLocalCancellationError)
|
|
1071
|
+
throw error;
|
|
1072
|
+
throw new CursorLocalCancellationError("Cursor Run cancelled: the host stopped the turn", error);
|
|
1073
|
+
}
|
|
725
1074
|
const failure = toCursorProviderError(error, {
|
|
726
1075
|
replaySafe: error instanceof CursorProviderError ? error.replaySafe : false,
|
|
727
1076
|
fallback: "Cursor Run interrupted",
|
|
728
1077
|
});
|
|
1078
|
+
// A renewable credential error may follow a checkpoint too. Retain it
|
|
1079
|
+
// before either recovery path so a later attempt can still resume it.
|
|
1080
|
+
if (pumpedSession.resumeCheckpoint) {
|
|
1081
|
+
turnCheckpoint = pumpedSession.resumeCheckpoint;
|
|
1082
|
+
noProgressResumes = 0;
|
|
1083
|
+
}
|
|
1084
|
+
else if (pumpedSession.checkpointRevoked) {
|
|
1085
|
+
turnCheckpoint = undefined;
|
|
1086
|
+
}
|
|
1087
|
+
const checkpoint = turnCheckpoint;
|
|
1088
|
+
// The inherited tool barrier forbids replaying the action, but an untouched
|
|
1089
|
+
// replacement may renew and resume its checkpoint without replaying tools.
|
|
1090
|
+
const untouchedCheckpointResume = resumedAttempt && checkpoint !== undefined
|
|
1091
|
+
&& failure.replayBarrier === undefined && !failure.checkpointUnusable;
|
|
729
1092
|
if (input.renewRejectedCredential
|
|
730
1093
|
&& !credentialRenewed
|
|
731
1094
|
&& isRejectedCredentialError(failure)
|
|
732
|
-
&& failure.replaySafe) {
|
|
1095
|
+
&& (failure.replaySafe || untouchedCheckpointResume)) {
|
|
733
1096
|
credentialRenewed = true;
|
|
734
1097
|
trace(`Run credential rejected: sessionId=${pumpedSession.sessionId} err=${failure.message} ` +
|
|
735
1098
|
`— renewing the token and reopening once`);
|
|
736
1099
|
sessionManager.close(pumpedSession, "remote-error", failure);
|
|
737
1100
|
input.renewRejectedCredential();
|
|
738
|
-
|
|
1101
|
+
await reopenAttempt(pumpedSession, failure, checkpoint);
|
|
739
1102
|
attempt--;
|
|
740
1103
|
continue;
|
|
741
1104
|
}
|
|
742
1105
|
if (!failure.transient)
|
|
743
1106
|
throw failure;
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1107
|
+
if (!pumpedSession.resumeCheckpoint && !pumpedSession.checkpointRevoked
|
|
1108
|
+
&& resumedAttempt && failure.replayBarrier) {
|
|
1109
|
+
noProgressResumes++;
|
|
1110
|
+
if (noProgressResumes >= 2) {
|
|
1111
|
+
trace(`Run resume made no progress: sessionId=${pumpedSession.sessionId} resumes=${noProgressResumes} ` +
|
|
1112
|
+
`err=${failure.message} — stopping automatic resume`);
|
|
1113
|
+
throw noProgressResumeError(failure, noProgressResumes);
|
|
1114
|
+
}
|
|
747
1115
|
}
|
|
748
|
-
|
|
749
|
-
|
|
1116
|
+
const historyRecovery = failure.replayBarrier === undefined && input.canRebaseFromHistory?.(pumpedSession);
|
|
1117
|
+
if (!failure.replaySafe && !checkpoint && !historyRecovery) {
|
|
1118
|
+
// Cursor CLI sends the same action again. Shown output keeps the
|
|
1119
|
+
// provider from doing so; the host can, unless a side effect would repeat.
|
|
1120
|
+
if (failure.replayBarrier === "output-only" && !pumpedSession.checkpointRevoked) {
|
|
1121
|
+
trace(`Run interrupted after output only: sessionId=${pumpedSession.sessionId} — leaving the retry to the host`);
|
|
1122
|
+
reserveRetry(retryBudget, failure, retryPolicy.maxAttempts);
|
|
1123
|
+
retryBudget.hostRetryPending = true;
|
|
1124
|
+
throw hostRetryError(failure);
|
|
1125
|
+
}
|
|
1126
|
+
throw retrySuppressedError(failure, "after visible output or stateful server activity", attempt + 1, maxRecoveries + 1);
|
|
750
1127
|
}
|
|
1128
|
+
reserveRetry(retryBudget, failure, retryPolicy.maxAttempts);
|
|
751
1129
|
trace(`Run interrupted: sessionId=${pumpedSession.sessionId} attempt=${attempt + 1}/${maxRecoveries} ` +
|
|
752
1130
|
`err=${failure.message} — ${checkpoint ? `resuming ${checkpoint.length}B checkpoint` : "rebasing fresh Run"}`);
|
|
753
1131
|
sessionManager.close(pumpedSession, "remote-error", failure);
|
|
754
1132
|
const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy);
|
|
755
1133
|
trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`);
|
|
756
1134
|
await sleepForRetry(delayMs, input.abortSignal);
|
|
757
|
-
|
|
1135
|
+
await reopenAttempt(pumpedSession, failure, checkpoint);
|
|
758
1136
|
}
|
|
759
1137
|
finally {
|
|
1138
|
+
input.abortSignal?.removeEventListener("abort", stopPumpedRun);
|
|
1139
|
+
stopSteers?.();
|
|
760
1140
|
// Cancellation cleanup inside pump() must keep an active owner alive.
|
|
761
1141
|
// Retry after releasing this owner so a stopped consumer with no pending
|
|
762
1142
|
// tools cannot leave its Run and heartbeat open. Pending tool turns and
|
|
763
|
-
// a newer pump owner still retain their session
|
|
764
|
-
|
|
765
|
-
|
|
1143
|
+
// a newer pump owner still retain their session, and a host interrupt
|
|
1144
|
+
// closes its Run itself after draining Cursor's checkpoint.
|
|
1145
|
+
if (sessionManager.endPump(pumpedSession, pumpOwner) && !pumpedSession.hostInterrupted) {
|
|
1146
|
+
// Held for the host's tools: keep answering what Cursor sends meanwhile.
|
|
1147
|
+
if (!sessionManager.closeUnlessPending(pumpedSession))
|
|
1148
|
+
readHeldRun(pumpedSession);
|
|
766
1149
|
}
|
|
767
1150
|
}
|
|
768
1151
|
}
|
|
769
1152
|
}
|
|
770
1153
|
const heartbeatWritePendingBySession = new WeakMap();
|
|
1154
|
+
/** Runs whose first current category breakdown was checked for missing rules. */
|
|
1155
|
+
const rulesCategoryChecked = new WeakSet();
|
|
771
1156
|
const heartbeatGenerationBySession = new WeakMap();
|
|
772
1157
|
function bumpHeartbeatGeneration(session) {
|
|
773
1158
|
const generation = (heartbeatGenerationBySession.get(session) ?? 0) + 1;
|
|
@@ -817,28 +1202,88 @@ export function attachSessionHeartbeat(session) {
|
|
|
817
1202
|
}, session.policy.heartbeatMs);
|
|
818
1203
|
interval.unref?.();
|
|
819
1204
|
session.heartbeat = interval;
|
|
1205
|
+
const execInterval = setInterval(() => {
|
|
1206
|
+
if (session.closed || !isCurrentHeartbeatGeneration(session, generation))
|
|
1207
|
+
return;
|
|
1208
|
+
if (execHeartbeatsPendingBySession.has(session))
|
|
1209
|
+
return;
|
|
1210
|
+
void writeExecHeartbeats(session, generation);
|
|
1211
|
+
}, EXEC_HEARTBEAT_MS);
|
|
1212
|
+
execInterval.unref?.();
|
|
820
1213
|
session.heartbeatCancel = () => {
|
|
821
1214
|
if (session.heartbeat)
|
|
822
1215
|
clearInterval(session.heartbeat);
|
|
823
1216
|
session.heartbeat = null;
|
|
1217
|
+
clearInterval(execInterval);
|
|
824
1218
|
if (isCurrentHeartbeatGeneration(session, generation))
|
|
825
1219
|
bumpHeartbeatGeneration(session);
|
|
826
1220
|
};
|
|
827
1221
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
1222
|
+
/** Cursor CLI's per-exec heartbeat period (`agent-exec` handler). */
|
|
1223
|
+
const EXEC_HEARTBEAT_MS = 3_000;
|
|
1224
|
+
const execHeartbeatsPendingBySession = new WeakSet();
|
|
1225
|
+
const execHeartbeatCount = new WeakMap();
|
|
1226
|
+
/** A Cursor exec the host still runs, as opposed to an interaction answer or a display-only mirror. */
|
|
1227
|
+
function isOwedCursorExec(pending) {
|
|
1228
|
+
return !pending.bridged
|
|
1229
|
+
&& pending.resultField !== ASK_QUESTION_RESULT_FIELD
|
|
1230
|
+
&& pending.resultField !== SWITCH_MODE_RESULT_FIELD
|
|
1231
|
+
&& pending.resultField !== CREATE_PLAN_RESULT_FIELD;
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Tell Cursor each exec the host is still running is alive, as Cursor CLI does
|
|
1235
|
+
* while its exec handler runs. A failed write is left to the session heartbeat,
|
|
1236
|
+
* which owns closing a broken stream; the CLI ignores it too.
|
|
1237
|
+
*/
|
|
1238
|
+
async function writeExecHeartbeats(session, generation) {
|
|
1239
|
+
execHeartbeatsPendingBySession.add(session);
|
|
1240
|
+
const stream = session.stream;
|
|
1241
|
+
try {
|
|
1242
|
+
for (const [execId, pending] of [...session.pending]) {
|
|
1243
|
+
if (!isOwedCursorExec(pending))
|
|
1244
|
+
continue;
|
|
1245
|
+
const stillRunning = () => !session.closed
|
|
1246
|
+
&& session.stream === stream
|
|
1247
|
+
&& isCurrentHeartbeatGeneration(session, generation)
|
|
1248
|
+
&& session.pending.get(execId) === pending
|
|
1249
|
+
&& pending.state === "pending"
|
|
1250
|
+
&& !pending.aborted;
|
|
1251
|
+
if (!stillRunning())
|
|
1252
|
+
continue;
|
|
1253
|
+
const written = await writeWithBackpressure(stream, buildExecHeartbeat(execId), `exec heartbeat id=${execId}`, stillRunning);
|
|
1254
|
+
if (!written)
|
|
1255
|
+
continue;
|
|
1256
|
+
const count = (execHeartbeatCount.get(pending) ?? 0) + 1;
|
|
1257
|
+
execHeartbeatCount.set(pending, count);
|
|
1258
|
+
trace(`exec heartbeat: id=${execId} tool=${pending.toolName ?? "?"} n=${count} sessionId=${session.sessionId}`);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
catch (error) {
|
|
1262
|
+
trace(`exec heartbeat write failed sessionId=${session.sessionId}: ${error.message}`);
|
|
1263
|
+
}
|
|
1264
|
+
finally {
|
|
1265
|
+
execHeartbeatsPendingBySession.delete(session);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
/** Restore receipts before matching a promoted copy, which may need no Run at all. */
|
|
1269
|
+
async function restoreSessionState(sessionKey, cacheDir) {
|
|
835
1270
|
if (sessionKey) {
|
|
836
1271
|
const restored = await hydrateConversationState(cacheDir, sessionKey).catch((error) => {
|
|
837
1272
|
trace(`conversation persistence: restore failed sessionKey=${sessionKey}: ${String(error)}`);
|
|
838
1273
|
return undefined;
|
|
839
1274
|
});
|
|
1275
|
+
if (restored && serializeEarlySteers(sessionKey)) {
|
|
1276
|
+
const requestContext = getFrozenRequestContext(restored.conversationId);
|
|
1277
|
+
if (requestContext)
|
|
1278
|
+
rememberSteerAnsweringRun(sessionKey, {
|
|
1279
|
+
openCodeSessionId: sessionKey, conversationId: restored.conversationId, cacheDir, requestContext,
|
|
1280
|
+
toolCatalog: restored.toolCatalog, postCompactionRebase: restored.postCompactionRebase,
|
|
1281
|
+
hostAgent: restored.hostAgent, stableSystemPromptHash: restored.systemPromptHash,
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
840
1284
|
if (restored?.postCompactionRebase)
|
|
841
1285
|
rememberPostCompactionRebase(sessionKey);
|
|
1286
|
+
restorePersistedHostNotes(sessionKey, restored?.hostNote);
|
|
842
1287
|
if (restored?.toolCatalog.length)
|
|
843
1288
|
restoreTurnToolCatalog(sessionKey, restored.toolCatalog);
|
|
844
1289
|
if (restored?.hostAgent || restored?.systemPromptHash) {
|
|
@@ -852,16 +1297,58 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
852
1297
|
trace(`turn provenance: restore failed sessionKey=${sessionKey}: ${String(error)}`);
|
|
853
1298
|
});
|
|
854
1299
|
}
|
|
1300
|
+
}
|
|
1301
|
+
/** Own cancellation until an opening Run has uploaded its request and can be registered. */
|
|
1302
|
+
async function openRunWithRequest(token, options, request, operation, owner) {
|
|
1303
|
+
let stream;
|
|
1304
|
+
let cancelled = false;
|
|
1305
|
+
const cancel = () => {
|
|
1306
|
+
cancelled = true;
|
|
1307
|
+
dropLostRequest(owner.stoppedWithSessionId);
|
|
1308
|
+
stream?.destroy();
|
|
1309
|
+
};
|
|
1310
|
+
// Only the opening phase follows this signal. The host's later tool-calls
|
|
1311
|
+
// abort must leave the registered Run alive for its continuation.
|
|
1312
|
+
owner.signal?.addEventListener("abort", cancel, { once: true });
|
|
1313
|
+
const unsubscribe = onHostInterrupt((sessionId, reason, at) => {
|
|
1314
|
+
if (reason !== "shutdown" && sessionId && sessionId === owner.stoppedWithSessionId && owner.requestedAt < at)
|
|
1315
|
+
cancel();
|
|
1316
|
+
});
|
|
1317
|
+
const previousStop = owner.stoppedWithSessionId ? hostInterrupts.get(owner.stoppedWithSessionId) : undefined;
|
|
1318
|
+
if (owner.signal?.aborted || (previousStop && owner.requestedAt < previousStop.at))
|
|
1319
|
+
cancel();
|
|
1320
|
+
try {
|
|
1321
|
+
if (cancelled)
|
|
1322
|
+
throw new CursorLocalCancellationError(`Cursor ${operation} cancelled`);
|
|
1323
|
+
stream = await bidiRunStream(token, options);
|
|
1324
|
+
if (cancelled)
|
|
1325
|
+
throw new CursorLocalCancellationError(`Cursor ${operation} cancelled`);
|
|
1326
|
+
await writeWithBackpressure(stream, request, operation);
|
|
1327
|
+
if (cancelled)
|
|
1328
|
+
throw new CursorLocalCancellationError(`Cursor ${operation} cancelled`);
|
|
1329
|
+
return stream;
|
|
1330
|
+
}
|
|
1331
|
+
catch (error) {
|
|
1332
|
+
stream?.destroy();
|
|
1333
|
+
if (cancelled)
|
|
1334
|
+
throw new CursorLocalCancellationError(`Cursor ${operation} cancelled`, error);
|
|
1335
|
+
throw error;
|
|
1336
|
+
}
|
|
1337
|
+
finally {
|
|
1338
|
+
owner.signal?.removeEventListener("abort", cancel);
|
|
1339
|
+
unsubscribe();
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
async function startSession(modelId, token, callOptions, options, startOptions) {
|
|
1343
|
+
const continuationPolicy = resolveContinuationPolicy(options.continuation);
|
|
1344
|
+
const prompt = callOptions.prompt;
|
|
1345
|
+
const incomingTools = extractTools(callOptions);
|
|
1346
|
+
const sessionKey = opencodeSessionKey(callOptions);
|
|
1347
|
+
const isolateHelper = startOptions?.isolate === true;
|
|
1348
|
+
const cacheDir = opencodeGlobalCacheDir();
|
|
855
1349
|
const providerOptions = callOptions.providerOptions?.cursor;
|
|
856
1350
|
const hostAgent = hostAgentFromCallOptions(callOptions);
|
|
857
|
-
|
|
858
|
-
// OpenCode 2.0 removed that hook, so its plugin writes the same request-local
|
|
859
|
-
// option from session hooks. The session marker is a fallback only when a
|
|
860
|
-
// host does not preserve mutable options; explicit false prevents concurrent
|
|
861
|
-
// title/generate calls from inheriting a compaction marker.
|
|
862
|
-
// Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
|
|
863
|
-
const compactionOption = providerOptions?.[CURSOR_COMPACTION_OPTION];
|
|
864
|
-
const isCompaction = compactionOption === true || (compactionOption === undefined && isCompactionSession(sessionKey));
|
|
1351
|
+
const isCompaction = isCompactionCall(callOptions, sessionKey);
|
|
865
1352
|
const toolState = await resolveTurnToolState({
|
|
866
1353
|
sessionKey,
|
|
867
1354
|
incomingTools,
|
|
@@ -881,9 +1368,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
881
1368
|
const allowTools = toolState.allowTools;
|
|
882
1369
|
const discoveredSubagentCatalog = extractHostSubagentCatalog(cursorTools);
|
|
883
1370
|
let recovery = startOptions?.recovery;
|
|
1371
|
+
const unfinishedPriorRun = recovery?.kind === "rebase" && recovery.reason === "unfinished-prior-run";
|
|
884
1372
|
let resumeRecovery = recovery?.kind === "resume" ? recovery : undefined;
|
|
885
1373
|
let resuming = !!resumeRecovery;
|
|
886
|
-
const lifecycle = !allowTools && !isCompaction && !recovery;
|
|
1374
|
+
const lifecycle = !allowTools && !isCompaction && (!recovery || unfinishedPriorRun);
|
|
887
1375
|
// Isolated helpers must not share the parent's openCodeSessionId or sticky
|
|
888
1376
|
// conversation: registerSession would otherwise supersede the held parent Run.
|
|
889
1377
|
const ephemeralRun = lifecycle || isolateHelper;
|
|
@@ -950,7 +1438,8 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
950
1438
|
: { count: 0, bytes: 0, complete: true };
|
|
951
1439
|
const forcedResetReason = foreignHistory
|
|
952
1440
|
? `foreign-history:${foreignHistory}`
|
|
953
|
-
: checkpointUnusable ? "checkpoint-unusable"
|
|
1441
|
+
: checkpointUnusable ? "checkpoint-unusable"
|
|
1442
|
+
: unfinishedPriorRun ? "unfinished-prior-run" : undefined;
|
|
954
1443
|
// CLI soft-reuses incomplete / oversized graphs (100 MiB is export-only).
|
|
955
1444
|
// Never remint — warn and keep the sticky conversation + checkpoint.
|
|
956
1445
|
if (conversationState) {
|
|
@@ -965,20 +1454,51 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
965
1454
|
}
|
|
966
1455
|
const conversationId = bound.conversationId;
|
|
967
1456
|
const conversationGroupId = resolveConversationGroupId(sessionKey, conversationId);
|
|
1457
|
+
const resetReason = forcedResetReason
|
|
1458
|
+
?? (recovery?.kind === "rebase" ? "interrupted-run" : (resetState.reason ?? "unknown"));
|
|
968
1459
|
if (bound.reset) {
|
|
969
1460
|
if (sessionKey) {
|
|
1461
|
+
forgetHeldHistoryImages(sessionKey);
|
|
970
1462
|
await clearPersistedConversationState(cacheDir, sessionKey, bound.previousId).catch((error) => {
|
|
971
1463
|
trace(`conversation persistence: reset cleanup failed sessionKey=${sessionKey}: ${String(error)}`);
|
|
972
1464
|
});
|
|
973
1465
|
}
|
|
974
|
-
trace(`conversation reset: reason=${
|
|
1466
|
+
trace(`conversation reset: reason=${resetReason} ` +
|
|
975
1467
|
`sessionKey=${sessionKey ?? "(none)"} ` +
|
|
976
1468
|
`previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
|
|
977
1469
|
}
|
|
978
1470
|
const lastUser = [...prompt].reverse().find((message) => message.role === "user");
|
|
979
|
-
|
|
1471
|
+
const liveTurn = !isCompaction && !ephemeralRun && !resuming
|
|
1472
|
+
&& (!recovery || checkpointUnusable || unfinishedPriorRun)
|
|
1473
|
+
? liveUserTurn(prompt) : undefined;
|
|
1474
|
+
let userText = recovery?.kind === "rebase" && !checkpointUnusable && !unfinishedPriorRun
|
|
980
1475
|
? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
|
|
981
|
-
: (extractUserText(lastUser) || ".");
|
|
1476
|
+
: (liveTurn?.text || extractUserText(lastUser) || ".");
|
|
1477
|
+
if (sessionKey && liveTurn && recovery?.kind !== "rebase") {
|
|
1478
|
+
// A Run without a checkpoint seeds the host history, which already holds
|
|
1479
|
+
// the earlier request; only a checkpoint can be missing it.
|
|
1480
|
+
const rebuildFromHistory = !conversationState?.length;
|
|
1481
|
+
const liveText = userText;
|
|
1482
|
+
userText = carryLostRequest({
|
|
1483
|
+
sessionKey,
|
|
1484
|
+
conversationId,
|
|
1485
|
+
checkpoint: conversationState,
|
|
1486
|
+
text: userText,
|
|
1487
|
+
rebuildFromHistory,
|
|
1488
|
+
});
|
|
1489
|
+
if (!rebuildFromHistory) {
|
|
1490
|
+
rememberLostRequest({
|
|
1491
|
+
sessionKey,
|
|
1492
|
+
conversationId,
|
|
1493
|
+
checkpoint: conversationState,
|
|
1494
|
+
text: userText,
|
|
1495
|
+
latest: liveText,
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
else if (sessionKey && recovery?.kind === "rebase") {
|
|
1500
|
+
dropLostRequest(sessionKey);
|
|
1501
|
+
}
|
|
982
1502
|
// After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
|
|
983
1503
|
// (same <system_reminder> contract the CLI uses after flipping unifiedMode).
|
|
984
1504
|
const startedWithCheckpoint = !!conversationState;
|
|
@@ -1012,9 +1532,28 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1012
1532
|
...(hostPlanFileFor(sessionKey) ? { hostPlanFile: hostPlanFileFor(sessionKey) } : {}),
|
|
1013
1533
|
server: resolveToolServerIdentity("plan_exit", "opencode", knownMcpServers).server,
|
|
1014
1534
|
});
|
|
1535
|
+
const history = extractPromptHistory(prompt, {
|
|
1536
|
+
liveTurnStart: liveTurn?.start,
|
|
1537
|
+
preserveTrailingUser: recovery?.kind === "rebase" && !checkpointUnusable && !unfinishedPriorRun,
|
|
1538
|
+
// A foreign-history rebase replays the other model's tool results too.
|
|
1539
|
+
toolResults: isCompaction || foreignHistory || checkpointUnusable || unfinishedPriorRun
|
|
1540
|
+
|| (recovery?.kind === "rebase" && recovery.reason === "completed-tool-results")
|
|
1541
|
+
? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
|
|
1542
|
+
});
|
|
1543
|
+
const userTurnHostNotes = prepareUserTurnHostNotes({
|
|
1544
|
+
sessionKey,
|
|
1545
|
+
prompt,
|
|
1546
|
+
startedWithCheckpoint,
|
|
1547
|
+
isCompaction,
|
|
1548
|
+
ephemeralRun,
|
|
1549
|
+
resuming,
|
|
1550
|
+
seedHistory: history,
|
|
1551
|
+
liveTurnStart: liveTurn?.start,
|
|
1552
|
+
});
|
|
1015
1553
|
const oneShotReminders = [
|
|
1016
1554
|
modeReminder,
|
|
1017
1555
|
hostPlanAgentNote,
|
|
1556
|
+
...userTurnHostNotes.reminders,
|
|
1018
1557
|
].filter((part) => !!part);
|
|
1019
1558
|
// `systemPrompt` is the host system context composed for a seed Run (kept for
|
|
1020
1559
|
// diagnostics and size estimates). It reaches Cursor only as the frozen
|
|
@@ -1068,12 +1607,6 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1068
1607
|
});
|
|
1069
1608
|
}
|
|
1070
1609
|
}
|
|
1071
|
-
const history = extractPromptHistory(prompt, {
|
|
1072
|
-
preserveTrailingUser: recovery?.kind === "rebase" && !checkpointUnusable,
|
|
1073
|
-
// A foreign-history rebase replays every tool result: the other model's work
|
|
1074
|
-
// exists only in OpenCode history, never in a Cursor checkpoint.
|
|
1075
|
-
toolResults: isCompaction || foreignHistory || checkpointUnusable ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
|
|
1076
|
-
});
|
|
1077
1610
|
await loadAvailableModels();
|
|
1078
1611
|
// Resolve the region-specific Run stream origin once per process (memoized
|
|
1079
1612
|
// in agent-url.ts). Explicit agent host overrides skip GetServerConfig but
|
|
@@ -1095,19 +1628,20 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1095
1628
|
const modelInfo = _availableModels?.find((m) => m.id === cursorModelId);
|
|
1096
1629
|
const supportsImages = resolveCursorModelSupportsImages(cursorModelId, modelInfo?.supportsImages);
|
|
1097
1630
|
if (!resuming) {
|
|
1098
|
-
|
|
1631
|
+
const liveMessages = liveTurn ? prompt.slice(liveTurn.start).filter((message) => message.role === "user") : [lastUser];
|
|
1632
|
+
for (const message of liveMessages) {
|
|
1633
|
+
assertCursorUserImageSupport(message, supportsImages, cursorModelId);
|
|
1634
|
+
}
|
|
1099
1635
|
}
|
|
1636
|
+
// A Run without a checkpoint (new, reset, rebased or compacted conversation) sends every image.
|
|
1637
|
+
const heldImages = sessionKey && !ephemeralRun && conversationState
|
|
1638
|
+
? heldHistoryImageHashes(sessionKey, conversationId, conversationState)
|
|
1639
|
+
: undefined;
|
|
1100
1640
|
const imageExtraction = resuming
|
|
1101
1641
|
? { images: [], hashes: [], candidateCount: 0, duplicateCount: 0, userImageCount: 0, omittedCount: 0 }
|
|
1102
1642
|
: await extractCursorPromptImages(prompt, lastUser, {
|
|
1103
1643
|
supportsImages,
|
|
1104
|
-
|
|
1105
|
-
// history does not re-upload old screenshots. A recovery or
|
|
1106
|
-
// foreign-history rebase opens a new Cursor conversation, so it must
|
|
1107
|
-
// resend the same payload.
|
|
1108
|
-
seenHistoryHashes: recovery?.kind === "rebase" || foreignHistory
|
|
1109
|
-
? undefined
|
|
1110
|
-
: sentHistoryImageHashes(sessionKey),
|
|
1644
|
+
seenHistoryHashes: heldImages,
|
|
1111
1645
|
signal: callOptions.abortSignal,
|
|
1112
1646
|
});
|
|
1113
1647
|
if (imageExtraction.omittedCount) {
|
|
@@ -1128,28 +1662,24 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1128
1662
|
picked,
|
|
1129
1663
|
});
|
|
1130
1664
|
// An explicit pick owns the context tier; otherwise honor the maxMode hint.
|
|
1131
|
-
// OpenCode's variant paramMap does not include maxMode
|
|
1665
|
+
// OpenCode's variant paramMap does not include maxMode, so the cataloged
|
|
1666
|
+
// variant's isMaxMode marker decides it (1m tiers, Grok 4.7's 500k).
|
|
1132
1667
|
const maxMode = resolveVariantMaxMode(parameterValues, {
|
|
1133
1668
|
picked,
|
|
1134
1669
|
maxMode: hintMaxMode,
|
|
1670
|
+
model: modelInfo,
|
|
1135
1671
|
});
|
|
1136
|
-
if (foreignHistory || checkpointUnusable) {
|
|
1672
|
+
if (foreignHistory || checkpointUnusable || unfinishedPriorRun) {
|
|
1137
1673
|
assertForeignHistoryRebaseFits({
|
|
1138
1674
|
modelInfo,
|
|
1139
1675
|
cursorModelId,
|
|
1140
1676
|
maxMode,
|
|
1677
|
+
parameters: parameterValues,
|
|
1141
1678
|
history,
|
|
1142
1679
|
systemPrompt,
|
|
1143
1680
|
userText,
|
|
1144
1681
|
});
|
|
1145
1682
|
}
|
|
1146
|
-
// Do NOT pass callOptions.abortSignal into the h2 Run stream. OpenCode aborts
|
|
1147
|
-
// that signal when a turn ends with tool-calls; the Cursor stream must stay
|
|
1148
|
-
// open until we write the exec results on the next doStream.
|
|
1149
|
-
const stream = await bidiRunStream(token, {
|
|
1150
|
-
baseURL: agentBaseUrl,
|
|
1151
|
-
headers: options.headers,
|
|
1152
|
-
});
|
|
1153
1683
|
// Freeze RequestContext per conversation_id (CLI parity). Rebuilding every
|
|
1154
1684
|
// Run mutates volatile slices (git porcelain, layout) and breaks prompt cache.
|
|
1155
1685
|
const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools, mergedConfig, systemInstructions, sessionID: sessionKey });
|
|
@@ -1254,23 +1784,34 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1254
1784
|
trace(`hash requestContext sha256=${requestContextHash}`);
|
|
1255
1785
|
if (conversationState)
|
|
1256
1786
|
trace(`hash checkpoint sha256=${sha(conversationState)}`);
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1787
|
+
const stream = await openRunWithRequest(token, {
|
|
1788
|
+
baseURL: agentBaseUrl, headers: options.headers,
|
|
1789
|
+
}, reqBytes, "initial Run request", {
|
|
1790
|
+
signal: callOptions.abortSignal,
|
|
1791
|
+
stoppedWithSessionId: lifecycle ? undefined : sessionKey,
|
|
1792
|
+
requestedAt: startOptions?.requestedAt ?? Date.now(),
|
|
1793
|
+
});
|
|
1794
|
+
userTurnHostNotes.sent();
|
|
1795
|
+
const historyImages = sessionKey && !ephemeralRun ? runHistoryImages(heldImages) : undefined;
|
|
1796
|
+
if (historyImages)
|
|
1797
|
+
noteHistoryImagesSent(historyImages, imageExtraction.hashes, lastFrameArrival());
|
|
1265
1798
|
const hostToolDialect = hostToolDialectFromTools(tools, options.defaultDialect);
|
|
1266
1799
|
trace(`host tool dialect: filePathKey=${hostToolDialect.filePathKey} shellTool=${hostToolDialect.shellTool} ` +
|
|
1267
1800
|
`skillArgKey=${hostToolDialect.skillArgKey} tools=[${tools.map((t) => t.name).join(",")}]`);
|
|
1801
|
+
const billingCost = cursorRunCost(cursorModelId, parameterValues);
|
|
1268
1802
|
const session = {
|
|
1803
|
+
billing: {
|
|
1804
|
+
// Title and other ephemeral Runs settle within themselves.
|
|
1805
|
+
key: !ephemeralRun && sessionKey ? sessionKey : `run:${runId}`,
|
|
1806
|
+
...(billingCost ? { cost: billingCost } : {}),
|
|
1807
|
+
prefixTokens: priorTokenDetails?.usedTokens ?? 0,
|
|
1808
|
+
},
|
|
1269
1809
|
sessionId: crypto.randomUUID(),
|
|
1270
1810
|
runId,
|
|
1271
1811
|
conversationId,
|
|
1272
1812
|
cacheDir,
|
|
1273
1813
|
resumeCheckpoint: undefined,
|
|
1814
|
+
...(historyImages ? { historyImages } : {}),
|
|
1274
1815
|
tokenDetails: priorTokenDetails,
|
|
1275
1816
|
tokenDetailsFresh: false,
|
|
1276
1817
|
cacheDiagnostics: {
|
|
@@ -1280,6 +1821,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1280
1821
|
modelId: cursorModelId,
|
|
1281
1822
|
priorTokenDetails,
|
|
1282
1823
|
startedWithCheckpoint: !!conversationState,
|
|
1824
|
+
...(conversationState
|
|
1825
|
+
? {}
|
|
1826
|
+
: { coldReason: ephemeralRun ? "ephemeral" : bound.reset ? resetReason : "no-checkpoint" }),
|
|
1283
1827
|
requestContextReused,
|
|
1284
1828
|
requestContextHash,
|
|
1285
1829
|
systemPromptHash,
|
|
@@ -1294,6 +1838,8 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1294
1838
|
switchModeInTurn: false,
|
|
1295
1839
|
},
|
|
1296
1840
|
openCodeSessionId: ephemeralRun ? undefined : sessionKey,
|
|
1841
|
+
stoppedWithSessionId: lifecycle ? undefined : sessionKey,
|
|
1842
|
+
userTurnHostNotes: userTurnHostNotes.carriedNotes,
|
|
1297
1843
|
checkpointRebaseEligible: !ephemeralRun && !resuming && !recovery && !!conversationState,
|
|
1298
1844
|
hostAgent,
|
|
1299
1845
|
stableSystemPromptHash: frozenSystemPromptHash,
|
|
@@ -1330,6 +1876,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1330
1876
|
deferredTerminalReason: null,
|
|
1331
1877
|
policy: continuationPolicy,
|
|
1332
1878
|
createdAt: Date.now(),
|
|
1879
|
+
requestedAt: startOptions?.requestedAt ?? Date.now(),
|
|
1333
1880
|
lastInboundAt: Date.now(),
|
|
1334
1881
|
lastHeartbeatWriteAt: Date.now(),
|
|
1335
1882
|
semanticDeadlineAt: Date.now() + continuationPolicy.semanticIdleMs,
|
|
@@ -1338,26 +1885,22 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1338
1885
|
};
|
|
1339
1886
|
sessionManager.registerSession(session);
|
|
1340
1887
|
attachSessionHeartbeat(session);
|
|
1341
|
-
const abortIfNeeded = (stream) => {
|
|
1342
|
-
if (!session.closed && !
|
|
1888
|
+
const abortIfNeeded = (signal, stream) => {
|
|
1889
|
+
if (!session.closed && !signal?.aborted && !session.hostInterrupted)
|
|
1343
1890
|
return;
|
|
1344
1891
|
try {
|
|
1345
1892
|
stream?.destroy();
|
|
1346
1893
|
}
|
|
1347
1894
|
catch { /* already closed */ }
|
|
1348
|
-
if (
|
|
1895
|
+
if (signal?.aborted || session.hostInterrupted) {
|
|
1349
1896
|
throw new CursorLocalCancellationError("Cursor progress-only continuation cancelled");
|
|
1350
1897
|
}
|
|
1351
1898
|
throw new CursorProtocolError("Cannot reopen a closed Cursor session");
|
|
1352
1899
|
};
|
|
1353
|
-
session.reopenWithUserMessage = async (text) => {
|
|
1354
|
-
abortIfNeeded();
|
|
1900
|
+
session.reopenWithUserMessage = async (text, signal) => {
|
|
1901
|
+
abortIfNeeded(signal);
|
|
1355
1902
|
const freshToken = await resolveRunBearerToken(options);
|
|
1356
|
-
abortIfNeeded();
|
|
1357
|
-
// Do not pass OpenCode's abortSignal into the h2 stream: a tool-calls abort
|
|
1358
|
-
// must not tear down a Run we still need to pump. Check abort around open.
|
|
1359
|
-
const next = await bidiRunStream(freshToken, { baseURL: agentBaseUrl, headers: options.headers });
|
|
1360
|
-
abortIfNeeded(next);
|
|
1903
|
+
abortIfNeeded(signal);
|
|
1361
1904
|
const conversationState = session.resumeCheckpoint ?? getCheckpoint(session.conversationId);
|
|
1362
1905
|
const nextRunId = crypto.randomUUID();
|
|
1363
1906
|
const reqBytes = buildRunRequest({
|
|
@@ -1374,20 +1917,17 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1374
1917
|
action: "user",
|
|
1375
1918
|
mode: cursorAgentModeWireValue(getActiveCursorMode(session.openCodeSessionId)),
|
|
1376
1919
|
});
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
throw error;
|
|
1386
|
-
}
|
|
1387
|
-
abortIfNeeded(next);
|
|
1920
|
+
const next = await openRunWithRequest(freshToken, {
|
|
1921
|
+
baseURL: agentBaseUrl, headers: options.headers,
|
|
1922
|
+
}, reqBytes, "progress-only continuation Run", {
|
|
1923
|
+
signal,
|
|
1924
|
+
stoppedWithSessionId: session.stoppedWithSessionId ?? session.openCodeSessionId,
|
|
1925
|
+
requestedAt: session.requestedAt ?? session.createdAt,
|
|
1926
|
+
});
|
|
1927
|
+
abortIfNeeded(signal, next);
|
|
1388
1928
|
session.heartbeatCancel?.();
|
|
1389
1929
|
await waitForStreamWrites(session.stream);
|
|
1390
|
-
abortIfNeeded(next);
|
|
1930
|
+
abortIfNeeded(signal, next);
|
|
1391
1931
|
sessionManager.replaceStream(session, next, nextRunId);
|
|
1392
1932
|
attachSessionHeartbeat(session);
|
|
1393
1933
|
};
|
|
@@ -1402,6 +1942,14 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
1402
1942
|
* OpenCode re-sends the full tool-result history on every continuation. Prefer
|
|
1403
1943
|
* the newest result that still has a live pending exec on its tagged session.
|
|
1404
1944
|
*/
|
|
1945
|
+
/** Why the held Run for these results is gone, from the session manager's tombstones. */
|
|
1946
|
+
function describeHeldRunLoss(toolResults) {
|
|
1947
|
+
const reasons = new Set(toolResults.map((r) => {
|
|
1948
|
+
const classification = sessionManager.classify(r.sessionId, r.execId);
|
|
1949
|
+
return classification.kind === "deliverable" ? "open" : classification.reason;
|
|
1950
|
+
}));
|
|
1951
|
+
return [...reasons].join(",");
|
|
1952
|
+
}
|
|
1405
1953
|
export function findContinuationSession(toolResults) {
|
|
1406
1954
|
for (let i = toolResults.length - 1; i >= 0; i--) {
|
|
1407
1955
|
const r = toolResults[i];
|
|
@@ -1457,16 +2005,40 @@ export const FRESH_TURN_DRAIN_TIMEOUT_MS = 8_000;
|
|
|
1457
2005
|
* prefix instead of `superseded-by-new-run` mid-exec.
|
|
1458
2006
|
*/
|
|
1459
2007
|
export const FRESH_TURN_PENDING_CANCEL_REASON = "Host started a new user turn before this tool result was delivered";
|
|
2008
|
+
/** Persist an acknowledged terminal checkpoint without inventing usage counters. */
|
|
2009
|
+
async function persistTerminalCheckpoint(session) {
|
|
2010
|
+
if (!session.openCodeSessionId)
|
|
2011
|
+
return;
|
|
2012
|
+
await persistConversationState(session.cacheDir ?? opencodeGlobalCacheDir(), {
|
|
2013
|
+
sessionKey: session.openCodeSessionId,
|
|
2014
|
+
conversationId: session.conversationId,
|
|
2015
|
+
requestContext: session.requestContext,
|
|
2016
|
+
toolCatalog: session.toolCatalog ?? [],
|
|
2017
|
+
postCompactionRebase: session.postCompactionRebase,
|
|
2018
|
+
hostAgent: session.hostAgent,
|
|
2019
|
+
systemPromptHash: session.stableSystemPromptHash,
|
|
2020
|
+
hostNote: encodePersistedHostNotes(undeliveredHostNotes(session.openCodeSessionId)),
|
|
2021
|
+
}).catch((error) => {
|
|
2022
|
+
trace(`conversation persistence: terminal save failed sessionKey=${session.openCodeSessionId}: ${String(error)}`);
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
1460
2025
|
/**
|
|
1461
|
-
* Before opening a new user-turn Run,
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
2026
|
+
* Before opening a new user-turn Run, settle the held Run's pending results
|
|
2027
|
+
* and drain its terminal checkpoint. The unseen follow-up to results settled
|
|
2028
|
+
* here can finish through Cursor's cancellation contract; an unacknowledged
|
|
2029
|
+
* turn must rebase.
|
|
1465
2030
|
*/
|
|
1466
2031
|
export async function preparePriorSessionForFreshTurn(openCodeSessionId, opts) {
|
|
1467
2032
|
const prior = sessionManager.findOpenByOpenCodeSessionId(openCodeSessionId);
|
|
1468
2033
|
if (!prior)
|
|
1469
2034
|
return "none";
|
|
2035
|
+
const hostCancel = cancelsInFlight.get(prior);
|
|
2036
|
+
if (hostCancel) {
|
|
2037
|
+
// The host stopped that turn; its cancel closes and saves the Run, and this turn resumes from it.
|
|
2038
|
+
await hostCancel;
|
|
2039
|
+
trace(`fresh turn: prior session ${prior.sessionId} was cancelled for the host's stop`);
|
|
2040
|
+
return "none";
|
|
2041
|
+
}
|
|
1470
2042
|
const settledBridged = sessionManager.settleBridgedPending(prior);
|
|
1471
2043
|
if (settledBridged > 0) {
|
|
1472
2044
|
trace(`fresh turn: settled ${settledBridged} bridged pending on prior session ${prior.sessionId} ` +
|
|
@@ -1490,11 +2062,11 @@ export async function preparePriorSessionForFreshTurn(openCodeSessionId, opts) {
|
|
|
1490
2062
|
if (historicalPending.length > 0) {
|
|
1491
2063
|
trace(`fresh turn: delivering ${historicalPending.length} historical tool result(s) ` +
|
|
1492
2064
|
`for pending exec(s) on prior session ${prior.sessionId}`);
|
|
1493
|
-
deliverContinuationResults(prior, historicalPending, { hostAgent: opts?.hostAgent });
|
|
2065
|
+
await deliverContinuationResults(prior, historicalPending, { hostAgent: opts?.hostAgent });
|
|
1494
2066
|
if (prior.closed)
|
|
1495
2067
|
return "settled-only";
|
|
1496
2068
|
}
|
|
1497
|
-
const cancelled = cancelPendingExecsForFreshTurn(prior);
|
|
2069
|
+
const cancelled = await cancelPendingExecsForFreshTurn(prior);
|
|
1498
2070
|
if (cancelled > 0) {
|
|
1499
2071
|
trace(`fresh turn: cancelled ${cancelled} pending exec(s) on prior session ${prior.sessionId} ` +
|
|
1500
2072
|
`remainingPending=${prior.pending.size}`);
|
|
@@ -1510,11 +2082,22 @@ export async function preparePriorSessionForFreshTurn(openCodeSessionId, opts) {
|
|
|
1510
2082
|
`pending after cancel — leaving that Run held; registerSession will not close it`);
|
|
1511
2083
|
return "busy";
|
|
1512
2084
|
}
|
|
1513
|
-
|
|
2085
|
+
let outcome = await drainSessionUntilTurnEnded(prior, {
|
|
1514
2086
|
timeoutMs: opts?.timeoutMs ?? FRESH_TURN_DRAIN_TIMEOUT_MS,
|
|
1515
2087
|
});
|
|
2088
|
+
if (outcome === "busy" && (historicalPending.length > 0 || cancelled > 0)) {
|
|
2089
|
+
// The host ended that turn (rejection, interruption, or an abandoned
|
|
2090
|
+
// call) before its next model call; OpenCode 1.x and a restarted host do
|
|
2091
|
+
// not say which. Cursor started a follow-up nobody will read. Cancel it
|
|
2092
|
+
// through Cursor's protocol action; retain continuity only with its
|
|
2093
|
+
// terminal acknowledgment and checkpoint.
|
|
2094
|
+
outcome = await drainSessionUntilTurnEnded(prior, {
|
|
2095
|
+
timeoutMs: opts?.timeoutMs ?? FRESH_TURN_DRAIN_TIMEOUT_MS,
|
|
2096
|
+
cancelReason: "host_new_user_turn",
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
1516
2099
|
trace(`fresh turn: prior session ${prior.sessionId} drain outcome=${outcome}`);
|
|
1517
|
-
if (outcome === "turn-ended")
|
|
2100
|
+
if (outcome === "turn-ended" || outcome === "cancelled")
|
|
1518
2101
|
return "drained";
|
|
1519
2102
|
if (cancelled > 0 || settledBridged > 0)
|
|
1520
2103
|
return "settled-only";
|
|
@@ -1526,12 +2109,12 @@ export async function preparePriorSessionForFreshTurn(openCodeSessionId, opts) {
|
|
|
1526
2109
|
*
|
|
1527
2110
|
* @returns number of pendings successfully cancelled
|
|
1528
2111
|
*/
|
|
1529
|
-
export function cancelPendingExecsForFreshTurn(session) {
|
|
2112
|
+
export async function cancelPendingExecsForFreshTurn(session) {
|
|
1530
2113
|
if (session.closed || session.pending.size === 0)
|
|
1531
2114
|
return 0;
|
|
1532
2115
|
const synthetic = [];
|
|
1533
2116
|
for (const [execId, pending] of session.pending.entries()) {
|
|
1534
|
-
if (pending.bridged)
|
|
2117
|
+
if (pending.bridged || pending.aborted)
|
|
1535
2118
|
continue;
|
|
1536
2119
|
if (pending.state !== "pending")
|
|
1537
2120
|
continue;
|
|
@@ -1553,7 +2136,7 @@ export function cancelPendingExecsForFreshTurn(session) {
|
|
|
1553
2136
|
if (synthetic.length === 0)
|
|
1554
2137
|
return 0;
|
|
1555
2138
|
const before = session.pending.size;
|
|
1556
|
-
const delivered = deliverContinuationResults(session, synthetic);
|
|
2139
|
+
const delivered = await deliverContinuationResults(session, synthetic);
|
|
1557
2140
|
if (!delivered || delivered.closed) {
|
|
1558
2141
|
// deliverContinuationResults closes on encode/write failure after some
|
|
1559
2142
|
// claims may already have succeeded.
|
|
@@ -1561,6 +2144,347 @@ export function cancelPendingExecsForFreshTurn(session) {
|
|
|
1561
2144
|
}
|
|
1562
2145
|
return Math.max(0, before - session.pending.size);
|
|
1563
2146
|
}
|
|
2147
|
+
/** `ExecServerControlMessage`: Cursor withdrew an exec, e.g. when it stops the Run. */
|
|
2148
|
+
function applyExecControl(session, control, where) {
|
|
2149
|
+
const id = control.abort?.id;
|
|
2150
|
+
if (typeof id !== "number") {
|
|
2151
|
+
trace(`exec control: unknown message ${JSON.stringify(Object.keys(control))} (${where}) sessionId=${session.sessionId}`);
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
const pending = session.pending.get(id);
|
|
2155
|
+
// Interaction answers share the pending map under their own ids, never an exec's.
|
|
2156
|
+
const marked = pending !== undefined && isOwedCursorExec(pending) && sessionManager.markExecAborted(session, id);
|
|
2157
|
+
trace(`exec control: Cursor aborted execId=${id} (${where}) sessionId=${session.sessionId} ` +
|
|
2158
|
+
`${marked ? "— no result will be written for it" : "— not an exec the host still owes"}`);
|
|
2159
|
+
}
|
|
2160
|
+
const ASM_EXEC_SERVER_CONTROL_FIELD = 5;
|
|
2161
|
+
/** A frame the held reader may take: a server heartbeat, a KV request or exec control, and nothing else. */
|
|
2162
|
+
function heldControlFrame(frame) {
|
|
2163
|
+
if (frame.flags & 0x02)
|
|
2164
|
+
return undefined;
|
|
2165
|
+
let payload;
|
|
2166
|
+
try {
|
|
2167
|
+
payload = decodeFramePayload(frame);
|
|
2168
|
+
}
|
|
2169
|
+
catch {
|
|
2170
|
+
return undefined;
|
|
2171
|
+
}
|
|
2172
|
+
const fields = readAllFieldsStrict(payload);
|
|
2173
|
+
if (!fields || fields.length !== 1 || fields[0].wt !== 2)
|
|
2174
|
+
return undefined;
|
|
2175
|
+
const field = fields[0];
|
|
2176
|
+
if (field.fn === ASM_INTERACTION_UPDATE_FIELD) {
|
|
2177
|
+
const update = field.bytes ? readAllFieldsStrict(field.bytes) : undefined;
|
|
2178
|
+
const heartbeat = update?.length === 1
|
|
2179
|
+
&& update[0].fn === INTERACTION_UPDATE_HEARTBEAT_FIELD
|
|
2180
|
+
&& update[0].wt === 2;
|
|
2181
|
+
return heartbeat ? { kind: "heartbeat" } : undefined;
|
|
2182
|
+
}
|
|
2183
|
+
if (field.fn !== ASM_KV_SERVER_MESSAGE_FIELD && field.fn !== ASM_EXEC_SERVER_CONTROL_FIELD)
|
|
2184
|
+
return undefined;
|
|
2185
|
+
let asm;
|
|
2186
|
+
try {
|
|
2187
|
+
asm = decodeMessage("AgentServerMessage", payload);
|
|
2188
|
+
}
|
|
2189
|
+
catch {
|
|
2190
|
+
return undefined;
|
|
2191
|
+
}
|
|
2192
|
+
const kv = asm.kv_server_message;
|
|
2193
|
+
if (kv)
|
|
2194
|
+
return { kind: "kv", kv };
|
|
2195
|
+
const control = asm.exec_server_control_message;
|
|
2196
|
+
return control ? { kind: "exec-control", control } : undefined;
|
|
2197
|
+
}
|
|
2198
|
+
const heldRunReaders = new WeakSet();
|
|
2199
|
+
/** Unread frames then wait for the pump, under HTTP/2 flow control, as before the reader. */
|
|
2200
|
+
const MAX_HELD_FRAMES = 1_000;
|
|
2201
|
+
function isHeldRunIdle(session, frames) {
|
|
2202
|
+
return !session.closed
|
|
2203
|
+
&& !session.hostInterrupted
|
|
2204
|
+
&& !sessionManager.isActivelyPumping(session)
|
|
2205
|
+
&& session.pending.size > 0
|
|
2206
|
+
&& session.frames === frames
|
|
2207
|
+
&& (session.heldFrames?.length ?? 0) < MAX_HELD_FRAMES;
|
|
2208
|
+
}
|
|
2209
|
+
/** An abort the reader may apply now: its exec is registered and still waits on the host. */
|
|
2210
|
+
function abortsPendingExec(session, control) {
|
|
2211
|
+
const id = control.abort?.id;
|
|
2212
|
+
const pending = typeof id === "number" ? session.pending.get(id) : undefined;
|
|
2213
|
+
return !!pending && isOwedCursorExec(pending) && pending.state === "pending" && !pending.aborted;
|
|
2214
|
+
}
|
|
2215
|
+
/**
|
|
2216
|
+
* While the host runs a step's tools nothing pumps the Run, but Cursor keeps
|
|
2217
|
+
* writing: right after a step it sends `tool_requests_listed` or a checkpoint,
|
|
2218
|
+
* then KV blob writes, and it may abort an exec. Cursor CLI's single reader
|
|
2219
|
+
* handles KV and exec control on their own channels at once; an unanswered KV
|
|
2220
|
+
* write left Cursor waiting until the tool finished (seen live). Answer KV,
|
|
2221
|
+
* apply an abort for an exec still waiting on the host, and skip heartbeats;
|
|
2222
|
+
* queue every other frame in `heldFrames`, in arrival order, for the next pump.
|
|
2223
|
+
* An abort whose exec request is still queued stays queued behind it.
|
|
2224
|
+
*
|
|
2225
|
+
* The reader peeks the shared `pendingFrameRead`: a pump that starts meanwhile
|
|
2226
|
+
* awaits the same read, and the reader leaves that frame to it.
|
|
2227
|
+
*/
|
|
2228
|
+
export function readHeldRun(session) {
|
|
2229
|
+
const frames = session.frames;
|
|
2230
|
+
if (!isHeldRunIdle(session, frames))
|
|
2231
|
+
return;
|
|
2232
|
+
// A reader from before the last pump may still be waiting on the stream.
|
|
2233
|
+
applyQueuedAborts(session);
|
|
2234
|
+
if (heldRunReaders.has(session))
|
|
2235
|
+
return;
|
|
2236
|
+
heldRunReaders.add(session);
|
|
2237
|
+
void readHeldFrames(session, frames).finally(() => heldRunReaders.delete(session));
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* An abort queued behind its own exec request waits only until a pump has
|
|
2241
|
+
* registered that exec. If the pump ended its step before reaching the abort,
|
|
2242
|
+
* apply it now, as Cursor CLI does on its control channel; otherwise the exec
|
|
2243
|
+
* runs, keeps its heartbeats and has its late result written.
|
|
2244
|
+
*/
|
|
2245
|
+
function applyQueuedAborts(session) {
|
|
2246
|
+
const queued = session.heldFrames;
|
|
2247
|
+
if (!queued?.length)
|
|
2248
|
+
return;
|
|
2249
|
+
for (let index = 0; index < queued.length;) {
|
|
2250
|
+
const held = heldControlFrame(queued[index]);
|
|
2251
|
+
if (held?.kind === "exec-control" && abortsPendingExec(session, held.control)) {
|
|
2252
|
+
queued.splice(index, 1);
|
|
2253
|
+
applyExecControl(session, held.control, "held queue");
|
|
2254
|
+
}
|
|
2255
|
+
else {
|
|
2256
|
+
index++;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
async function readHeldFrames(session, frames) {
|
|
2261
|
+
while (isHeldRunIdle(session, frames)) {
|
|
2262
|
+
applyQueuedAborts(session);
|
|
2263
|
+
const read = session.pendingFrameRead ??= frames.next();
|
|
2264
|
+
void read.catch(() => { });
|
|
2265
|
+
let result;
|
|
2266
|
+
try {
|
|
2267
|
+
result = await read;
|
|
2268
|
+
}
|
|
2269
|
+
catch {
|
|
2270
|
+
return; // the next pump reads the same rejection, after the queued frames
|
|
2271
|
+
}
|
|
2272
|
+
// A pump started meanwhile reads this frame itself; an ended stream stays for it too.
|
|
2273
|
+
if (!isHeldRunIdle(session, frames) || session.pendingFrameRead !== read || result.done)
|
|
2274
|
+
return;
|
|
2275
|
+
session.pendingFrameRead = undefined;
|
|
2276
|
+
sessionManager.recordSemanticProgress(session);
|
|
2277
|
+
const frame = result.value;
|
|
2278
|
+
const held = heldControlFrame(frame);
|
|
2279
|
+
const kvReply = held?.kind === "kv" ? handleKvServerMessage(held.kv, session) : undefined;
|
|
2280
|
+
if (held?.kind === "heartbeat")
|
|
2281
|
+
continue;
|
|
2282
|
+
if (kvReply) {
|
|
2283
|
+
try {
|
|
2284
|
+
await writeWithBackpressure(session.stream, kvReply.reply, `held KV ${kvReply.kind}_blob reply id=${kvReply.id}`);
|
|
2285
|
+
trace(`held Run: answered KV ${kvReply.kind} id=${kvReply.id} sessionId=${session.sessionId}`);
|
|
2286
|
+
}
|
|
2287
|
+
catch (error) {
|
|
2288
|
+
trace(`held Run: KV reply failed sessionId=${session.sessionId}: ${error.message}`);
|
|
2289
|
+
sessionManager.close(session, "reply-write-failed", toCursorProviderError(error, {
|
|
2290
|
+
replaySafe: false,
|
|
2291
|
+
fallback: "Cursor KV reply failed",
|
|
2292
|
+
}));
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
continue;
|
|
2296
|
+
}
|
|
2297
|
+
if (held?.kind === "exec-control" && abortsPendingExec(session, held.control)) {
|
|
2298
|
+
applyExecControl(session, held.control, "held");
|
|
2299
|
+
if (![...session.pending.values()].some((pending) => isOwedCursorExec(pending) && !pending.aborted)) {
|
|
2300
|
+
trace(`held Run: Cursor aborted every exec it was waiting on sessionId=${session.sessionId}`);
|
|
2301
|
+
}
|
|
2302
|
+
continue;
|
|
2303
|
+
}
|
|
2304
|
+
// Everything else, a KV request this client cannot answer included (the
|
|
2305
|
+
// pump fails the Run on it), waits for the next pump in order.
|
|
2306
|
+
;
|
|
2307
|
+
(session.heldFrames ??= []).push(frame);
|
|
2308
|
+
trace(`held Run: queued a frame for the next pump queued=${session.heldFrames.length} sessionId=${session.sessionId}`);
|
|
2309
|
+
if (frame.flags & 0x02)
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
/** In-progress saves still writing; see `settleRunSavesForTests`. */
|
|
2314
|
+
const runSavesInFlight = new Set();
|
|
2315
|
+
/** Save a Run's conversation before Cursor ends its turn, with the latest checkpoint Cursor sent. */
|
|
2316
|
+
function persistRunInProgress(session, where) {
|
|
2317
|
+
const sessionKey = session.openCodeSessionId;
|
|
2318
|
+
if (!sessionKey)
|
|
2319
|
+
return Promise.resolve();
|
|
2320
|
+
const save = persistConversationState(session.cacheDir ?? opencodeGlobalCacheDir(), {
|
|
2321
|
+
sessionKey,
|
|
2322
|
+
conversationId: session.conversationId,
|
|
2323
|
+
requestContext: session.requestContext,
|
|
2324
|
+
toolCatalog: session.toolCatalog ?? [],
|
|
2325
|
+
postCompactionRebase: session.postCompactionRebase,
|
|
2326
|
+
hostAgent: session.hostAgent,
|
|
2327
|
+
systemPromptHash: session.stableSystemPromptHash,
|
|
2328
|
+
hostNote: encodePersistedHostNotes(undeliveredHostNotes(sessionKey)),
|
|
2329
|
+
runInProgress: true,
|
|
2330
|
+
}).catch((error) => {
|
|
2331
|
+
trace(`conversation persistence: ${where} save failed sessionKey=${sessionKey}: ${String(error)}`);
|
|
2332
|
+
});
|
|
2333
|
+
runSavesInFlight.add(save);
|
|
2334
|
+
void save.finally(() => runSavesInFlight.delete(save));
|
|
2335
|
+
return save;
|
|
2336
|
+
}
|
|
2337
|
+
/** How long a Run must wait on host tools before its state is saved for a restart. */
|
|
2338
|
+
export const HELD_RUN_SAVE_DELAY_MS = 1_000;
|
|
2339
|
+
const heldRunSaveTimers = new WeakMap();
|
|
2340
|
+
// A step that ended in a tool call is recorded only in memory until TurnEnded.
|
|
2341
|
+
// Save it once the host's tool runs a while, so a Stop or a restart during a
|
|
2342
|
+
// shell, question or permission prompt still finds this step on disk.
|
|
2343
|
+
function scheduleHeldRunSave(session) {
|
|
2344
|
+
if (!session.openCodeSessionId)
|
|
2345
|
+
return;
|
|
2346
|
+
clearTimeout(heldRunSaveTimers.get(session));
|
|
2347
|
+
const timer = setTimeout(() => {
|
|
2348
|
+
heldRunSaveTimers.delete(session);
|
|
2349
|
+
if (session.closed || session.hostInterrupted || session.pending.size === 0)
|
|
2350
|
+
return;
|
|
2351
|
+
if (sessionManager.isActivelyPumping(session))
|
|
2352
|
+
return;
|
|
2353
|
+
void persistRunInProgress(session, "held Run");
|
|
2354
|
+
}, HELD_RUN_SAVE_DELAY_MS);
|
|
2355
|
+
timer.unref?.();
|
|
2356
|
+
heldRunSaveTimers.set(session, timer);
|
|
2357
|
+
}
|
|
2358
|
+
/** Cursor CLI's cancel reason for a user Stop. */
|
|
2359
|
+
const HOST_INTERRUPT_CANCEL_REASON = "user_cancelled";
|
|
2360
|
+
/** How long a cancelled Run may take to end by itself before its stream is closed. */
|
|
2361
|
+
export const HOST_INTERRUPT_GRACE_MS = 3_000;
|
|
2362
|
+
/**
|
|
2363
|
+
* The host stopped the turn. OpenCode 2 never aborts `doStream` for that, so
|
|
2364
|
+
* the Run would keep generating, and a held one would wait on results that
|
|
2365
|
+
* never come. Cancel it as Cursor CLI's Stop does: forget what the host owed,
|
|
2366
|
+
* send `cancel_action`, give Cursor a moment to end the Run, then close it.
|
|
2367
|
+
* A Run that ends without `turn_ended` is saved with the last checkpoint
|
|
2368
|
+
* Cursor sent, so the next turn resumes it and recognizes the stopped step.
|
|
2369
|
+
*/
|
|
2370
|
+
export function cancelRunForHostInterrupt(session, reason, opts) {
|
|
2371
|
+
if (session.closed || session.hostInterrupted)
|
|
2372
|
+
return cancelsInFlight.get(session) ?? Promise.resolve();
|
|
2373
|
+
const cancel = cancelRun(session, reason, opts).catch((error) => {
|
|
2374
|
+
trace(`host interrupt: cancel failed sessionId=${session.sessionId} err=${error.message}`);
|
|
2375
|
+
});
|
|
2376
|
+
cancelsInFlight.set(session, cancel);
|
|
2377
|
+
void cancel.finally(() => cancelsInFlight.delete(session));
|
|
2378
|
+
return cancel;
|
|
2379
|
+
}
|
|
2380
|
+
async function cancelRun(session, reason, opts) {
|
|
2381
|
+
session.hostInterrupted = reason;
|
|
2382
|
+
forgetRetryBudget(session.openCodeSessionId);
|
|
2383
|
+
dropLostRequest(session.openCodeSessionId);
|
|
2384
|
+
// Stopped while its turn end was being saved, the host may have kept only part of the step.
|
|
2385
|
+
if (session.openCodeSessionId && sessionManager.isActivelyPumping(session)) {
|
|
2386
|
+
resumeEmittedStep(session.openCodeSessionId, session.conversationId);
|
|
2387
|
+
}
|
|
2388
|
+
const graceMs = opts?.graceMs ?? HOST_INTERRUPT_GRACE_MS;
|
|
2389
|
+
const dropped = sessionManager.abandonPending(session);
|
|
2390
|
+
session.heartbeatCancel?.();
|
|
2391
|
+
trace(`host interrupt: cancelling Run sessionId=${session.sessionId} reason=${reason} ` +
|
|
2392
|
+
`pumping=${sessionManager.isActivelyPumping(session)} droppedPending=${dropped}`);
|
|
2393
|
+
const startedAt = Date.now();
|
|
2394
|
+
const remainingMs = () => Math.max(1, graceMs - (Date.now() - startedAt));
|
|
2395
|
+
let outcome;
|
|
2396
|
+
const write = writeWithBackpressure(session.stream, buildCancelAction(HOST_INTERRUPT_CANCEL_REASON), "cancel action");
|
|
2397
|
+
let timer;
|
|
2398
|
+
const writeTimedOut = new Promise((resolve) => {
|
|
2399
|
+
timer = setTimeout(() => resolve("write-timeout"), remainingMs());
|
|
2400
|
+
timer.unref?.();
|
|
2401
|
+
});
|
|
2402
|
+
try {
|
|
2403
|
+
outcome = await Promise.race([write.then(() => "written"), writeTimedOut]);
|
|
2404
|
+
}
|
|
2405
|
+
catch (error) {
|
|
2406
|
+
trace(`host interrupt: cancel write failed sessionId=${session.sessionId} err=${error.message}`);
|
|
2407
|
+
outcome = "write-failed";
|
|
2408
|
+
}
|
|
2409
|
+
finally {
|
|
2410
|
+
clearTimeout(timer);
|
|
2411
|
+
write.catch(() => { });
|
|
2412
|
+
}
|
|
2413
|
+
if (outcome === "written") {
|
|
2414
|
+
// A pump stops at its next frame and leaves it queued for the drain.
|
|
2415
|
+
if (await waitUntilNotPumping(session, { timeoutMs: remainingMs() })) {
|
|
2416
|
+
// The pump may have taken an exec it was already handling.
|
|
2417
|
+
sessionManager.abandonPending(session);
|
|
2418
|
+
outcome = await drainSessionUntilTurnEnded(session, { timeoutMs: remainingMs(), cancelled: true });
|
|
2419
|
+
}
|
|
2420
|
+
else {
|
|
2421
|
+
outcome = "still-pumping";
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
trace(`host interrupt: Run sessionId=${session.sessionId} outcome=${outcome} afterMs=${Date.now() - startedAt}`);
|
|
2425
|
+
const saved = outcome === "turn-ended" || outcome === "cancelled";
|
|
2426
|
+
if (!saved)
|
|
2427
|
+
keepUndeliveredHostNotes(session);
|
|
2428
|
+
sessionManager.close(session, "host-interrupted");
|
|
2429
|
+
// Closing leaves the conversation's checkpoint and blobs in place for this save.
|
|
2430
|
+
if (!saved)
|
|
2431
|
+
await persistRunInProgress(session, "host interrupt");
|
|
2432
|
+
}
|
|
2433
|
+
const HOST_INTERRUPT_MEMORY_MS = 10 * 60_000;
|
|
2434
|
+
/** The latest stop per OpenCode session, for a Run whose model call started before it but opened after it. */
|
|
2435
|
+
const hostInterrupts = new Map();
|
|
2436
|
+
/** Cancels still draining; a shutdown or the next turn waits for them so their final save is not cut off. */
|
|
2437
|
+
const cancelsInFlight = new Map();
|
|
2438
|
+
/**
|
|
2439
|
+
* Test helper: wait for host-interrupt cancels and in-progress Run saves, which
|
|
2440
|
+
* write after the call that started them returned. Close the Runs first, so a
|
|
2441
|
+
* held Run's save timer finds them closed instead of starting another save.
|
|
2442
|
+
*/
|
|
2443
|
+
export async function settleRunSavesForTests() {
|
|
2444
|
+
while (cancelsInFlight.size > 0 || runSavesInFlight.size > 0) {
|
|
2445
|
+
await Promise.all([...cancelsInFlight.values(), ...runSavesInFlight]);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
function cancelForHostInterrupt(session, reason) {
|
|
2449
|
+
void cancelRunForHostInterrupt(session, reason);
|
|
2450
|
+
}
|
|
2451
|
+
/** Runs of the stopped call were requested before the stop; the next turn's after it. */
|
|
2452
|
+
function requestedBefore(session, at) {
|
|
2453
|
+
return (session.requestedAt ?? session.createdAt) < at;
|
|
2454
|
+
}
|
|
2455
|
+
export function cancelIfHostStoppedSince(session) {
|
|
2456
|
+
const sessionId = stoppedWith(session);
|
|
2457
|
+
const stop = sessionId ? hostInterrupts.get(sessionId) : undefined;
|
|
2458
|
+
if (stop && requestedBefore(session, stop.at))
|
|
2459
|
+
cancelForHostInterrupt(session, stop.reason);
|
|
2460
|
+
}
|
|
2461
|
+
onHostInterrupt(async (openCodeSessionId, reason, at) => {
|
|
2462
|
+
if (reason === "shutdown") {
|
|
2463
|
+
// The next server start resumes the turn; keep what it needs on disk. A Run
|
|
2464
|
+
// still being cancelled is saved now too, in case its drain is cut off.
|
|
2465
|
+
const open = sessionManager.openSessionsStoppedWith(openCodeSessionId)
|
|
2466
|
+
.filter((session) => session.openCodeSessionId);
|
|
2467
|
+
if (open.length > 0)
|
|
2468
|
+
trace(`host interrupt: shutdown — saving ${open.length} open Run(s)`);
|
|
2469
|
+
await Promise.all([
|
|
2470
|
+
...open.map((session) => persistRunInProgress(session, "shutdown")),
|
|
2471
|
+
...cancelsInFlight.values(),
|
|
2472
|
+
]);
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
if (!openCodeSessionId)
|
|
2476
|
+
return;
|
|
2477
|
+
for (const [id, stop] of hostInterrupts) {
|
|
2478
|
+
if (stop.at < at - HOST_INTERRUPT_MEMORY_MS)
|
|
2479
|
+
hostInterrupts.delete(id);
|
|
2480
|
+
}
|
|
2481
|
+
forgetRetryBudget(openCodeSessionId);
|
|
2482
|
+
hostInterrupts.set(openCodeSessionId, { at, reason });
|
|
2483
|
+
for (const session of sessionManager.openSessionsStoppedWith(openCodeSessionId)) {
|
|
2484
|
+
if (requestedBefore(session, at))
|
|
2485
|
+
cancelForHostInterrupt(session, reason);
|
|
2486
|
+
}
|
|
2487
|
+
});
|
|
1564
2488
|
async function waitUntilNotPumping(session, opts) {
|
|
1565
2489
|
if (!sessionManager.isActivelyPumping(session))
|
|
1566
2490
|
return true;
|
|
@@ -1580,7 +2504,11 @@ async function waitUntilNotPumping(session, opts) {
|
|
|
1580
2504
|
/**
|
|
1581
2505
|
* Read remaining frames on an idle held-open Run until `turn_ended`, a
|
|
1582
2506
|
* response-requiring request we cannot answer without the host, or timeout.
|
|
1583
|
-
* Used only
|
|
2507
|
+
* Used only to settle a Run the host left (a fresh turn, a host interrupt) —
|
|
2508
|
+
* not a general pump substitute. `cancelReason` sends `cancel_action` first;
|
|
2509
|
+
* `cancelled`: the caller already sent it. Either way nothing the Run still
|
|
2510
|
+
* sends (display updates, an exec already in flight) needs the host, and
|
|
2511
|
+
* Cursor's closing frames (the final checkpoint) are worth reading.
|
|
1584
2512
|
*/
|
|
1585
2513
|
export async function drainSessionUntilTurnEnded(session, opts) {
|
|
1586
2514
|
if (session.closed)
|
|
@@ -1592,7 +2520,15 @@ export async function drainSessionUntilTurnEnded(session, opts) {
|
|
|
1592
2520
|
sessionManager.beginPump(session, owner);
|
|
1593
2521
|
const deadlineAt = Date.now() + timeoutMs;
|
|
1594
2522
|
let outcome = "timeout";
|
|
2523
|
+
let cancellationCheckpoint = false;
|
|
2524
|
+
const cancelling = opts?.cancelReason !== undefined || opts?.cancelled === true;
|
|
1595
2525
|
try {
|
|
2526
|
+
if (opts?.cancelReason) {
|
|
2527
|
+
session.heartbeatCancel?.();
|
|
2528
|
+
await writeWithBackpressure(session.stream, encodeMessage("AgentClientMessage", {
|
|
2529
|
+
conversation_action: { cancel_action: { reason: opts.cancelReason } },
|
|
2530
|
+
}), "fresh-turn cancellation");
|
|
2531
|
+
}
|
|
1596
2532
|
while (Date.now() < deadlineAt) {
|
|
1597
2533
|
if (session.closed) {
|
|
1598
2534
|
outcome = "interrupted";
|
|
@@ -1618,7 +2554,22 @@ export async function drainSessionUntilTurnEnded(session, opts) {
|
|
|
1618
2554
|
break;
|
|
1619
2555
|
}
|
|
1620
2556
|
const frame = next.value;
|
|
2557
|
+
sessionManager.recordSemanticProgress(session);
|
|
1621
2558
|
if (frame.flags & 0x02) {
|
|
2559
|
+
if (cancelling) {
|
|
2560
|
+
try {
|
|
2561
|
+
const failure = connectFrameError(new TextDecoder().decode(decodeFramePayload(frame)));
|
|
2562
|
+
trace(`fresh turn drain: cancellation terminal code=${failure.code ?? "unknown"} checkpoint=${cancellationCheckpoint}`);
|
|
2563
|
+
if (failure.origin === "server" && failure.code === "canceled" && cancellationCheckpoint) {
|
|
2564
|
+
keepUndeliveredHostNotes(session);
|
|
2565
|
+
await persistTerminalCheckpoint(session);
|
|
2566
|
+
sessionManager.close(session);
|
|
2567
|
+
outcome = "cancelled";
|
|
2568
|
+
break;
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
catch { /* an invalid terminal acknowledgment cannot preserve continuity */ }
|
|
2572
|
+
}
|
|
1622
2573
|
outcome = "interrupted";
|
|
1623
2574
|
break;
|
|
1624
2575
|
}
|
|
@@ -1642,13 +2593,22 @@ export async function drainSessionUntilTurnEnded(session, opts) {
|
|
|
1642
2593
|
const interactionQuery = asm.interaction_query;
|
|
1643
2594
|
const checkpointRaw = asm.conversation_checkpoint_update;
|
|
1644
2595
|
const requiredChannel = responseRequiredChannel(payload);
|
|
2596
|
+
const execControl = asm.exec_server_control_message;
|
|
2597
|
+
if (execControl) {
|
|
2598
|
+
applyExecControl(session, execControl, "drain");
|
|
2599
|
+
continue;
|
|
2600
|
+
}
|
|
1645
2601
|
if (checkpointRaw != null) {
|
|
1646
2602
|
const bytes = normalizeCheckpointBytes(checkpointRaw);
|
|
1647
2603
|
if (bytes && bytes.length > 0) {
|
|
2604
|
+
cancellationCheckpoint = true;
|
|
1648
2605
|
if (session.cacheDiagnostics)
|
|
1649
2606
|
session.cacheDiagnostics.checkpointUpdates++;
|
|
1650
2607
|
setCheckpoint(session.conversationId, bytes);
|
|
1651
|
-
session.
|
|
2608
|
+
noteCheckpoint(session.openCodeSessionId, bytes);
|
|
2609
|
+
acceptResumeCheckpoint(session, bytes, frame);
|
|
2610
|
+
markEarlySteersCheckpointed(session.openCodeSessionId, session.sessionId);
|
|
2611
|
+
noteRunImageCheckpoint(session, bytes, frame);
|
|
1652
2612
|
const tokenDetails = decodeConversationTokenDetails(bytes);
|
|
1653
2613
|
if (tokenDetails) {
|
|
1654
2614
|
if (session.cacheDiagnostics)
|
|
@@ -1658,8 +2618,17 @@ export async function drainSessionUntilTurnEnded(session, opts) {
|
|
|
1658
2618
|
}
|
|
1659
2619
|
}
|
|
1660
2620
|
}
|
|
2621
|
+
if (iu?.context_injection_state)
|
|
2622
|
+
applyContextInjectionState(session, iu.context_injection_state);
|
|
1661
2623
|
if (iu?.turn_ended) {
|
|
2624
|
+
if (opts?.cancelReason && !cancellationCheckpoint) {
|
|
2625
|
+
outcome = "interrupted";
|
|
2626
|
+
break;
|
|
2627
|
+
}
|
|
1662
2628
|
trace(`fresh turn drain: turn_ended raw wire fields: ${debugWalkTurnEnded(payload)}`);
|
|
2629
|
+
keepUndeliveredHostNotes(session);
|
|
2630
|
+
settleEarlySteers(session.openCodeSessionId, session.sessionId);
|
|
2631
|
+
confirmRunImagesAtTurnEnd(session);
|
|
1663
2632
|
if (session.openCodeSessionId) {
|
|
1664
2633
|
await persistConversationState(session.cacheDir ?? opencodeGlobalCacheDir(), {
|
|
1665
2634
|
sessionKey: session.openCodeSessionId,
|
|
@@ -1669,6 +2638,7 @@ export async function drainSessionUntilTurnEnded(session, opts) {
|
|
|
1669
2638
|
postCompactionRebase: session.postCompactionRebase,
|
|
1670
2639
|
hostAgent: session.hostAgent,
|
|
1671
2640
|
systemPromptHash: session.stableSystemPromptHash,
|
|
2641
|
+
hostNote: encodePersistedHostNotes(undeliveredHostNotes(session.openCodeSessionId)),
|
|
1672
2642
|
}).catch((error) => {
|
|
1673
2643
|
trace(`fresh turn drain: TurnEnded save failed ` +
|
|
1674
2644
|
`sessionKey=${session.openCodeSessionId}: ${String(error)}`);
|
|
@@ -1678,6 +2648,7 @@ export async function drainSessionUntilTurnEnded(session, opts) {
|
|
|
1678
2648
|
// even though no OpenCode stream consumer will see this finish.
|
|
1679
2649
|
const turnEnded = iu.turn_ended;
|
|
1680
2650
|
const counters = cursorUsageCountersFromTurnEnded(turnEnded);
|
|
2651
|
+
recordTurnEndedCost(session, turnEnded);
|
|
1681
2652
|
if (session.cacheDiagnostics) {
|
|
1682
2653
|
trace(formatCursorCacheDiagnostics(counters, session.tokenDetails, session.cacheDiagnostics.priorTokenDetails, {
|
|
1683
2654
|
...session.cacheDiagnostics,
|
|
@@ -1709,7 +2680,6 @@ export async function drainSessionUntilTurnEnded(session, opts) {
|
|
|
1709
2680
|
outcome = "interrupted";
|
|
1710
2681
|
break;
|
|
1711
2682
|
}
|
|
1712
|
-
sessionManager.recordSemanticProgress(session);
|
|
1713
2683
|
continue;
|
|
1714
2684
|
}
|
|
1715
2685
|
// The model is answering in a Run the host no longer reads: that output
|
|
@@ -1717,30 +2687,33 @@ export async function drainSessionUntilTurnEnded(session, opts) {
|
|
|
1717
2687
|
// once instead of paying for (and waiting on) an invisible answer; only
|
|
1718
2688
|
// the control tail of a turn that is already ending is worth draining.
|
|
1719
2689
|
if (iu?.text_delta || iu?.thinking_delta || iu?.partial_tool_call) {
|
|
2690
|
+
if (cancelling)
|
|
2691
|
+
continue;
|
|
1720
2692
|
trace(`fresh turn drain: model output resumed sessionId=${session.sessionId}`);
|
|
1721
2693
|
outcome = "busy";
|
|
1722
2694
|
break;
|
|
1723
2695
|
}
|
|
1724
|
-
if (iu?.heartbeat || iu?.step_started || iu?.step_completed)
|
|
1725
|
-
sessionManager.recordSemanticProgress(session);
|
|
2696
|
+
if (iu?.heartbeat || iu?.step_started || iu?.step_completed)
|
|
1726
2697
|
continue;
|
|
1727
|
-
}
|
|
1728
2698
|
if (iu?.tool_call_completed) {
|
|
1729
2699
|
// Display close of a call Cursor already has its answer for (the
|
|
1730
2700
|
// result this drain just delivered, typically). Cursor does not wait
|
|
1731
2701
|
// on it, and its checkpoint and turn_ended follow; stopping here would
|
|
1732
2702
|
// supersede the Run and lose that turn. Its display mirror, if any,
|
|
1733
2703
|
// has no host consumer on this path.
|
|
1734
|
-
sessionManager.recordSemanticProgress(session);
|
|
1735
2704
|
trace(`fresh turn drain: skipped display tool_call_completed sessionId=${session.sessionId}`);
|
|
1736
2705
|
continue;
|
|
1737
2706
|
}
|
|
1738
2707
|
if (iu?.tool_call_started) {
|
|
2708
|
+
if (cancelling)
|
|
2709
|
+
continue;
|
|
1739
2710
|
// A new tool needs host mediation — stop draining.
|
|
1740
2711
|
outcome = "busy";
|
|
1741
2712
|
break;
|
|
1742
2713
|
}
|
|
1743
2714
|
if (esm || interactionQuery || requiredChannel === "exec" || requiredChannel === "interaction") {
|
|
2715
|
+
if (cancelling)
|
|
2716
|
+
continue;
|
|
1744
2717
|
outcome = "busy";
|
|
1745
2718
|
break;
|
|
1746
2719
|
}
|
|
@@ -1860,6 +2833,11 @@ function buildSwitchModeContinuationFrame(pending, result) {
|
|
|
1860
2833
|
* cleared). Returns undefined after closing the session when a write fails, so
|
|
1861
2834
|
* the caller can rebase onto a fresh Run instead of pumping a dead stream.
|
|
1862
2835
|
*/
|
|
2836
|
+
function isShellResultField(resultField) {
|
|
2837
|
+
return resultField === "shell_stream"
|
|
2838
|
+
|| resultField === "shell_result"
|
|
2839
|
+
|| resultField === "background_shell_spawn_result";
|
|
2840
|
+
}
|
|
1863
2841
|
/** Host primary agent of one request, as reported through provider options. */
|
|
1864
2842
|
function hostAgentFromCallOptions(callOptions) {
|
|
1865
2843
|
const providerOptions = callOptions.providerOptions?.cursor;
|
|
@@ -1867,14 +2845,35 @@ function hostAgentFromCallOptions(callOptions) {
|
|
|
1867
2845
|
? String(providerOptions[CURSOR_HOST_AGENT_OPTION]).trim() || undefined
|
|
1868
2846
|
: undefined;
|
|
1869
2847
|
}
|
|
1870
|
-
|
|
2848
|
+
const continuationDeliveryChains = new WeakMap();
|
|
2849
|
+
export async function deliverContinuationResults(session, trailingToolResults, delivery = {}) {
|
|
2850
|
+
// Claim, result writes, state transitions and the step's note injection form
|
|
2851
|
+
// one delivery. Serializing individual frames cannot prevent two callers
|
|
2852
|
+
// from splitting a step's claims and each injecting the same notes.
|
|
2853
|
+
const previous = continuationDeliveryChains.get(session);
|
|
2854
|
+
const current = (previous ? previous.catch(() => undefined) : Promise.resolve())
|
|
2855
|
+
.then(() => deliverContinuationResultsNow(session, trailingToolResults, delivery));
|
|
2856
|
+
continuationDeliveryChains.set(session, current);
|
|
2857
|
+
try {
|
|
2858
|
+
return await current;
|
|
2859
|
+
}
|
|
2860
|
+
finally {
|
|
2861
|
+
if (continuationDeliveryChains.get(session) === current)
|
|
2862
|
+
continuationDeliveryChains.delete(session);
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
async function deliverContinuationResultsNow(session, trailingToolResults,
|
|
1871
2866
|
/** Host primary agent of the request that carries these results. */
|
|
1872
2867
|
delivery = {}) {
|
|
1873
2868
|
const pendingResults = trailingToolResults.filter((r) => r.sessionId === session.sessionId && session.pending.has(r.execId));
|
|
1874
2869
|
trace(`continuation: ${trailingToolResults.length} trailing tool result(s), ` +
|
|
1875
2870
|
`${pendingResults.length} pending for sessionId=${session.sessionId} ` +
|
|
1876
2871
|
`pending={${[...session.pending.keys()].join(",")}}`);
|
|
1877
|
-
|
|
2872
|
+
// Host notes that trailed this step: injected once, after its results.
|
|
2873
|
+
const stepNotes = collectHostNoteBodies(trailingToolResults.flatMap((r) => r.notes ?? []));
|
|
2874
|
+
let deliveredResults = 0;
|
|
2875
|
+
for (const original of pendingResults) {
|
|
2876
|
+
const r = original.error === "" ? { ...original, error: "OpenCode tool execution failed" } : original;
|
|
1878
2877
|
const claim = sessionManager.claim(session.sessionId, r.execId);
|
|
1879
2878
|
if ("kind" in claim) {
|
|
1880
2879
|
if (claim.kind === "deliverable") {
|
|
@@ -1938,11 +2937,14 @@ delivery = {}) {
|
|
|
1938
2937
|
return undefined;
|
|
1939
2938
|
}
|
|
1940
2939
|
}
|
|
2940
|
+
else if (pending.aborted) {
|
|
2941
|
+
if (isShellResultField(pending.resultField))
|
|
2942
|
+
consumeCursorShellResult(r.toolCallId, r.output);
|
|
2943
|
+
trace(`continuation: dropped the result of execId=${r.execId}, which Cursor aborted`);
|
|
2944
|
+
}
|
|
1941
2945
|
else if (!pending.bridged) {
|
|
1942
2946
|
try {
|
|
1943
|
-
const shellResult = pending.resultField
|
|
1944
|
-
|| pending.resultField === "shell_result"
|
|
1945
|
-
|| pending.resultField === "background_shell_spawn_result"
|
|
2947
|
+
const shellResult = isShellResultField(pending.resultField)
|
|
1946
2948
|
? consumeCursorShellResult(r.toolCallId, r.output)
|
|
1947
2949
|
: undefined;
|
|
1948
2950
|
const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
|
|
@@ -2016,14 +3018,19 @@ delivery = {}) {
|
|
|
2016
3018
|
})];
|
|
2017
3019
|
}
|
|
2018
3020
|
if (frames.length === 0) {
|
|
2019
|
-
const
|
|
3021
|
+
const error = r.error ?? shellResult?.error;
|
|
3022
|
+
const images = error ? [] : execResultImages(pending.resultField, r.images);
|
|
3023
|
+
// A failed host tool reports one text as both output and error. Include
|
|
3024
|
+
// failures captured by the shell hook when the SDK keeps only text.
|
|
3025
|
+
// Distinct partial command output still belongs on stdout.
|
|
3026
|
+
const failureText = shellResult !== undefined && error !== undefined && r.output === error;
|
|
2020
3027
|
frames = buildExecClientMessages({
|
|
2021
3028
|
execId: r.execId,
|
|
2022
3029
|
resultField: pending.resultField,
|
|
2023
|
-
output:
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
3030
|
+
output: failureText ? "" : shellResult?.output ?? r.output,
|
|
3031
|
+
error,
|
|
3032
|
+
refusal: r.refusal,
|
|
3033
|
+
interrupted: r.interrupted,
|
|
2027
3034
|
toolName: pending.toolName ?? r.toolName,
|
|
2028
3035
|
resultMetadata: pending.resultMetadata,
|
|
2029
3036
|
shellOutcome: shellResult?.outcome,
|
|
@@ -2042,27 +3049,33 @@ delivery = {}) {
|
|
|
2042
3049
|
return undefined;
|
|
2043
3050
|
}
|
|
2044
3051
|
}
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
3052
|
+
// A provider notice about this one result rides on it as postToolUse hook
|
|
3053
|
+
// context (e.g. images a read could not keep).
|
|
3054
|
+
const hookContexts = hostNoteHookContexts(r.notices ?? []);
|
|
3055
|
+
const notedFrames = hookContexts.length > 0
|
|
3056
|
+
? attachHookAdditionalContexts(frames, hookContexts)
|
|
3057
|
+
: undefined;
|
|
3058
|
+
if (notedFrames)
|
|
3059
|
+
frames = notedFrames;
|
|
3060
|
+
// Ignore checkpoints from before the final result frame's actual write,
|
|
3061
|
+
// including time spent queued behind another write, but admit those that
|
|
3062
|
+
// arrive while that frame drains. Earlier result frames are incomplete.
|
|
3063
|
+
let beforeResultWrite = lastFrameArrival();
|
|
3064
|
+
const outcome = await sessionManager.deliverClaim(claim, frames, () => {
|
|
3065
|
+
beforeResultWrite = lastFrameArrival();
|
|
3066
|
+
});
|
|
2057
3067
|
if (outcome.kind !== "delivered") {
|
|
2058
3068
|
trace(`continuation: delivery stopped execId=${r.execId} reason=${outcome.reason}`);
|
|
2059
3069
|
if (outcome.kind === "duplicate")
|
|
2060
3070
|
continue;
|
|
2061
3071
|
return undefined;
|
|
2062
3072
|
}
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
3073
|
+
deliveredResults++;
|
|
3074
|
+
if (outcome.framesWritten > 0 || (pending.bridged && !pending.aborted)) {
|
|
3075
|
+
invalidateResumeCheckpoint(session, beforeResultWrite, true);
|
|
3076
|
+
}
|
|
3077
|
+
if (session.historyImages)
|
|
3078
|
+
noteHistoryImagesSent(session.historyImages, deliveredImageHashes, lastFrameArrival());
|
|
2066
3079
|
if (deliveredSwitchMode) {
|
|
2067
3080
|
const normalized = deliveredSwitchMode.target.toLowerCase();
|
|
2068
3081
|
setActiveCursorMode(session.openCodeSessionId, deliveredSwitchMode.target, {
|
|
@@ -2102,22 +3115,428 @@ delivery = {}) {
|
|
|
2102
3115
|
trace(`continuation: mirrored todoread snapshot items=${snapshot.length}`);
|
|
2103
3116
|
}
|
|
2104
3117
|
}
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
3118
|
+
if (pending.aborted)
|
|
3119
|
+
continue;
|
|
3120
|
+
session.usageEstimate.inputTokens += estimateTokens(r.output.length);
|
|
3121
|
+
if (pending.bridged) {
|
|
3122
|
+
trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
|
|
3123
|
+
continue;
|
|
3124
|
+
}
|
|
3125
|
+
const resultKind = pending.resultField === ASK_QUESTION_RESULT_FIELD
|
|
3126
|
+
? "ask_question answer"
|
|
3127
|
+
: pending.resultField === SWITCH_MODE_RESULT_FIELD
|
|
3128
|
+
? "switch_mode answer"
|
|
3129
|
+
: pending.resultField === CREATE_PLAN_RESULT_FIELD
|
|
3130
|
+
? "create_plan answer"
|
|
3131
|
+
: "exec result";
|
|
3132
|
+
trace(`continuation: wrote ${resultKind} execId=${r.execId} field=${pending.resultField} ` +
|
|
3133
|
+
`frames=${outcome.framesWritten} outLen=${r.output.length}`);
|
|
3134
|
+
}
|
|
3135
|
+
if (stepNotes.length > 0) {
|
|
3136
|
+
if (deliveredResults === 0) {
|
|
3137
|
+
// Every result of this step was already delivered, and its notes with it.
|
|
3138
|
+
trace(`continuation: host notes belong to an already delivered step; not injected again count=${stepNotes.length}`);
|
|
3139
|
+
}
|
|
3140
|
+
else if (!(await injectHostNotes(session, stepNotes))) {
|
|
3141
|
+
return undefined;
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
return session;
|
|
3145
|
+
}
|
|
3146
|
+
/**
|
|
3147
|
+
* Add host notes to the running turn as Cursor SDK `Run.steer` does:
|
|
3148
|
+
* `inject_context_action.user_context` with plain text, bound to this Run.
|
|
3149
|
+
* Written after the step's results, so Cursor sees them before its next step.
|
|
3150
|
+
*/
|
|
3151
|
+
async function injectHostNotes(session, notes, order = nextHostNoteOrder++) {
|
|
3152
|
+
const text = hostNoteText(notes);
|
|
3153
|
+
if (!text)
|
|
3154
|
+
return true;
|
|
3155
|
+
const injectionId = crypto.randomUUID();
|
|
3156
|
+
const sessionKey = session.openCodeSessionId;
|
|
3157
|
+
if (sessionKey)
|
|
3158
|
+
trackHostNoteInjection(sessionKey, injectionId, [...notes], session, order);
|
|
3159
|
+
try {
|
|
3160
|
+
await writeWithBackpressure(session.stream, encodeMessage("AgentClientMessage", {
|
|
3161
|
+
conversation_action: {
|
|
3162
|
+
inject_context_action: {
|
|
3163
|
+
injection_id: injectionId,
|
|
3164
|
+
...(session.runId ? { expected_run_id: session.runId } : {}),
|
|
3165
|
+
user_context: { user_message: { text, message_id: crypto.randomUUID() } },
|
|
3166
|
+
},
|
|
3167
|
+
},
|
|
3168
|
+
}), "host note injection");
|
|
3169
|
+
trace(`continuation: host notes injected id=${injectionId} count=${notes.length} chars=${text.length}`);
|
|
3170
|
+
return true;
|
|
3171
|
+
}
|
|
3172
|
+
catch (error) {
|
|
3173
|
+
// The notes stay tracked against this closed Run and reach the next one.
|
|
3174
|
+
trace(`continuation: host note injection write failed id=${injectionId}: ${error.message}`);
|
|
3175
|
+
sessionManager.close(session, "result-write-failed", toCursorProviderError(error, {
|
|
3176
|
+
replaySafe: false, fallback: "Cursor host note injection write failed",
|
|
3177
|
+
}));
|
|
3178
|
+
return false;
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
function trackHostNoteInjection(sessionKey, injectionId, notes, session, order) {
|
|
3182
|
+
const entries = hostNoteInjectionsBySession.get(sessionKey) ?? new Map();
|
|
3183
|
+
entries.set(injectionId, { notes, session, order });
|
|
3184
|
+
hostNoteInjectionsBySession.delete(sessionKey);
|
|
3185
|
+
hostNoteInjectionsBySession.set(sessionKey, entries);
|
|
3186
|
+
while (hostNoteInjectionsBySession.size > MAX_TURN_STATE_SESSIONS) {
|
|
3187
|
+
const oldest = hostNoteInjectionsBySession.keys().next().value;
|
|
3188
|
+
if (!oldest)
|
|
3189
|
+
break;
|
|
3190
|
+
hostNoteInjectionsBySession.delete(oldest);
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
/**
|
|
3194
|
+
* `InteractionUpdate.context_injection_state` (#23) for an injection this
|
|
3195
|
+
* provider sent. Same outcomes as Cursor SDK `Run.steer`: `delivered` hands
|
|
3196
|
+
* the notes to the turn; `queued` waits; `queued_for_next_turn`, `cancelled`
|
|
3197
|
+
* and `rejected` keep them for the session's next user turn.
|
|
3198
|
+
* Returns true when Cursor delivered an early steer: the model answers it next.
|
|
3199
|
+
*/
|
|
3200
|
+
function applyContextInjectionState(session, value) {
|
|
3201
|
+
const update = value;
|
|
3202
|
+
const [kind, detail] = Object.entries(update.state ?? {}).find(([, v]) => v != null) ?? ["unknown", undefined];
|
|
3203
|
+
const reason = detail?.reason;
|
|
3204
|
+
trace(`context_injection_state: id=${update.injection_id ?? ""} state=${kind}` +
|
|
3205
|
+
(reason ? ` reason=${JSON.stringify(reason)}` : ""));
|
|
3206
|
+
const sessionKey = session.openCodeSessionId;
|
|
3207
|
+
if (kind === "delivered" && markEarlySteerDelivered(sessionKey, update.injection_id))
|
|
3208
|
+
return true;
|
|
3209
|
+
const entries = sessionKey ? hostNoteInjectionsBySession.get(sessionKey) : undefined;
|
|
3210
|
+
const entry = update.injection_id ? entries?.get(update.injection_id) : undefined;
|
|
3211
|
+
if (!sessionKey || !entries || !entry || kind === "queued" || kind === "unknown")
|
|
3212
|
+
return false;
|
|
3213
|
+
entries.delete(update.injection_id);
|
|
3214
|
+
if (entries.size === 0)
|
|
3215
|
+
hostNoteInjectionsBySession.delete(sessionKey);
|
|
3216
|
+
if (kind === "delivered")
|
|
3217
|
+
return false;
|
|
3218
|
+
appendUndeliveredHostNotes(sessionKey, [{ notes: entry.notes, order: entry.order }]);
|
|
3219
|
+
trace(`continuation: host note injection ${kind}; kept for the next user turn sessionKey=${sessionKey}`);
|
|
3220
|
+
return false;
|
|
3221
|
+
}
|
|
3222
|
+
/**
|
|
3223
|
+
* Return unacknowledged injections to the next-user-turn store: those of
|
|
3224
|
+
* `owner` when its turn ended, else those whose Run has closed.
|
|
3225
|
+
*/
|
|
3226
|
+
function releaseHostNoteInjections(sessionKey, reason, owner) {
|
|
3227
|
+
const entries = hostNoteInjectionsBySession.get(sessionKey);
|
|
3228
|
+
if (!entries)
|
|
3229
|
+
return;
|
|
3230
|
+
for (const [injectionId, entry] of entries) {
|
|
3231
|
+
if (owner ? entry.session !== owner : !entry.session.closed)
|
|
3232
|
+
continue;
|
|
3233
|
+
entries.delete(injectionId);
|
|
3234
|
+
appendUndeliveredHostNotes(sessionKey, [{ notes: entry.notes, order: entry.order }]);
|
|
3235
|
+
trace(`continuation: host note injection unacknowledged (${reason}); kept for the next user turn id=${injectionId}`);
|
|
3236
|
+
}
|
|
3237
|
+
if (entries.size === 0)
|
|
3238
|
+
hostNoteInjectionsBySession.delete(sessionKey);
|
|
3239
|
+
}
|
|
3240
|
+
/** Remove and return `owner`'s unacknowledged injections, oldest first. */
|
|
3241
|
+
function takeHostNoteInjections(owner) {
|
|
3242
|
+
const sessionKey = owner.openCodeSessionId;
|
|
3243
|
+
const entries = sessionKey ? hostNoteInjectionsBySession.get(sessionKey) : undefined;
|
|
3244
|
+
if (!sessionKey || !entries)
|
|
3245
|
+
return [];
|
|
3246
|
+
const notes = [];
|
|
3247
|
+
for (const [injectionId, entry] of entries) {
|
|
3248
|
+
if (entry.session !== owner)
|
|
3249
|
+
continue;
|
|
3250
|
+
entries.delete(injectionId);
|
|
3251
|
+
notes.push({ notes: entry.notes, order: entry.order });
|
|
3252
|
+
}
|
|
3253
|
+
if (entries.size === 0)
|
|
3254
|
+
hostNoteInjectionsBySession.delete(sessionKey);
|
|
3255
|
+
return notes.sort((a, b) => a.order - b.order);
|
|
3256
|
+
}
|
|
3257
|
+
/** Notes the restart snapshot kept for this session's next user turn. */
|
|
3258
|
+
export function restorePersistedHostNotes(sessionKey, persisted) {
|
|
3259
|
+
const notes = decodePersistedHostNotes(persisted);
|
|
3260
|
+
if (notes.length > 0)
|
|
3261
|
+
rememberUndeliveredHostNotes(sessionKey, [{ notes, order: nextHostNoteOrder++ }]);
|
|
3262
|
+
}
|
|
3263
|
+
/**
|
|
3264
|
+
* Host notes a new Run's user turn carries, as `<system_reminder>` blocks:
|
|
3265
|
+
* notes no earlier Run delivered, then notes OpenCode placed before this user
|
|
3266
|
+
* message (they exist only in host history, which a checkpointed Run does not
|
|
3267
|
+
* resend). Call `sent()` once the Run request is written.
|
|
3268
|
+
*/
|
|
3269
|
+
export function prepareUserTurnHostNotes(input) {
|
|
3270
|
+
const { sessionKey, startedWithCheckpoint, isCompaction, ephemeralRun, resuming } = input;
|
|
3271
|
+
// Notes injected into a Run that closed without acknowledging them can no
|
|
3272
|
+
// longer be delivered there; a still-open Run keeps its own.
|
|
3273
|
+
if (sessionKey && !isCompaction && !ephemeralRun)
|
|
3274
|
+
releaseHostNoteInjections(sessionKey, "run-closed");
|
|
3275
|
+
// An interrupted-turn resume carries no user message; notes wait for the next one.
|
|
3276
|
+
const undelivered = isCompaction || ephemeralRun || resuming || !sessionKey
|
|
3277
|
+
? undefined
|
|
3278
|
+
: undeliveredHostNotesBySession.get(sessionKey);
|
|
3279
|
+
// Compaction/history rewrites can remove notes, and ordinary seeds omit tool
|
|
3280
|
+
// results (including read instructions). Only omit a deferred note when its
|
|
3281
|
+
// text really reaches Cursor in this Run's seed. The seed merges adjacent
|
|
3282
|
+
// user-role entries and carries tool observations as user text, so a note
|
|
3283
|
+
// can sit inside a larger entry: match its text, not whole entries.
|
|
3284
|
+
const seededText = startedWithCheckpoint ? "" : (input.seedHistory ?? extractPromptHistory(input.prompt))
|
|
3285
|
+
.filter((message) => message.role === "user")
|
|
3286
|
+
.map((message) => message.content)
|
|
3287
|
+
.join("\n\n");
|
|
3288
|
+
const turnBoundary = !isCompaction && !ephemeralRun && !resuming
|
|
3289
|
+
? input.liveTurnStart !== undefined
|
|
3290
|
+
? collectHostNoteBodies(input.prompt.slice(input.liveTurnStart).map((message) => hostTailNote(message)?.text))
|
|
3291
|
+
: startedWithCheckpoint ? hostNotesBeforeUserTurn(input.prompt) : []
|
|
3292
|
+
: [];
|
|
3293
|
+
const deferred = undelivered?.flatMap((batch) => batch.notes)
|
|
3294
|
+
.filter((note) => !seededText.includes(note.trim()) && !turnBoundary.some((text) => text.includes(note.trim()))) ?? [];
|
|
3295
|
+
if (turnBoundary.length > 0) {
|
|
3296
|
+
trace(`fresh turn: ${turnBoundary.length} host note(s) before the user message ride with it`);
|
|
3297
|
+
}
|
|
3298
|
+
return {
|
|
3299
|
+
carriedNotes: [
|
|
3300
|
+
...(undelivered ?? []),
|
|
3301
|
+
...(turnBoundary.length ? [{ notes: turnBoundary, order: nextHostNoteOrder++ }] : []),
|
|
3302
|
+
],
|
|
3303
|
+
reminders: [
|
|
3304
|
+
...wrapHostNotesForCursor(deferred),
|
|
3305
|
+
...wrapHostNotesForCursor(turnBoundary),
|
|
3306
|
+
],
|
|
3307
|
+
sent: () => {
|
|
3308
|
+
if (sessionKey && undelivered?.length) {
|
|
3309
|
+
forgetUndeliveredHostNotes(sessionKey, undelivered, startedWithCheckpoint ? "user-turn" : "seed-history");
|
|
3310
|
+
}
|
|
3311
|
+
},
|
|
3312
|
+
};
|
|
3313
|
+
}
|
|
3314
|
+
function undeliveredHostNotes(sessionKey) {
|
|
3315
|
+
return (undeliveredHostNotesBySession.get(sessionKey) ?? []).flatMap((batch) => batch.notes);
|
|
3316
|
+
}
|
|
3317
|
+
function appendUndeliveredHostNotes(sessionKey, notes) {
|
|
3318
|
+
if (notes.length === 0)
|
|
3319
|
+
return;
|
|
3320
|
+
rememberUndeliveredHostNotes(sessionKey, [
|
|
3321
|
+
...(undeliveredHostNotesBySession.get(sessionKey) ?? []),
|
|
3322
|
+
...notes,
|
|
3323
|
+
]);
|
|
3324
|
+
}
|
|
3325
|
+
function collectHostNoteBodies(notes) {
|
|
3326
|
+
return notes.filter((note) => typeof note === "string" && note.length > 0);
|
|
3327
|
+
}
|
|
3328
|
+
/** At turn_ended, keep this Run's unacknowledged host notes for the next user turn. */
|
|
3329
|
+
function keepUndeliveredHostNotes(session) {
|
|
3330
|
+
if (session.openCodeSessionId)
|
|
3331
|
+
releaseHostNoteInjections(session.openCodeSessionId, "turn-ended", session);
|
|
3332
|
+
}
|
|
3333
|
+
function rememberUndeliveredHostNotes(sessionKey, notes) {
|
|
3334
|
+
undeliveredHostNotesBySession.delete(sessionKey);
|
|
3335
|
+
undeliveredHostNotesBySession.set(sessionKey, [...new Set(notes)].sort((a, b) => a.order - b.order));
|
|
3336
|
+
while (undeliveredHostNotesBySession.size > MAX_TURN_STATE_SESSIONS) {
|
|
3337
|
+
const oldest = undeliveredHostNotesBySession.keys().next().value;
|
|
3338
|
+
if (!oldest)
|
|
3339
|
+
break;
|
|
3340
|
+
undeliveredHostNotesBySession.delete(oldest);
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
function forgetUndeliveredHostNotes(sessionKey, notes, carrier) {
|
|
3344
|
+
const current = undeliveredHostNotesBySession.get(sessionKey);
|
|
3345
|
+
if (!current)
|
|
3346
|
+
return;
|
|
3347
|
+
const remaining = current.filter((batch) => !notes.includes(batch));
|
|
3348
|
+
if (remaining.length)
|
|
3349
|
+
undeliveredHostNotesBySession.set(sessionKey, remaining);
|
|
3350
|
+
else
|
|
3351
|
+
undeliveredHostNotesBySession.delete(sessionKey);
|
|
3352
|
+
trace(`continuation: undelivered host notes sent in the ${carrier} sessionKey=${sessionKey} count=${notes.length}`);
|
|
3353
|
+
}
|
|
3354
|
+
/** Runs whose `turn_ended` is being handled; a message injected now would never be delivered. */
|
|
3355
|
+
const endingTurns = new WeakSet();
|
|
3356
|
+
/** Inject a message OpenCode holds until this step ends, while Cursor still works on the step. */
|
|
3357
|
+
function injectHostSteer(session, steer) {
|
|
3358
|
+
const { runId, stream } = session;
|
|
3359
|
+
const takesSteers = () => !session.closed && !session.hostInterrupted && !!session.runId && !endingTurns.has(session)
|
|
3360
|
+
&& sessionManager.isActivelyPumping(session) && session.stream === stream && session.runId === runId;
|
|
3361
|
+
if (!takesSteers())
|
|
3362
|
+
return false;
|
|
3363
|
+
const injectionId = crypto.randomUUID();
|
|
3364
|
+
if (!recordEarlySteer({
|
|
3365
|
+
...steer, injectionId, run: session.sessionId, delivered: false, checkpointed: false, answered: false,
|
|
3366
|
+
})) {
|
|
3367
|
+
trace(`steer: too many early steers tracked for sessionID=${steer.sessionID}; left for the next step`);
|
|
3368
|
+
return false;
|
|
3369
|
+
}
|
|
3370
|
+
// While it waits behind another write, the host may withdraw it, or its Run may end or be replaced.
|
|
3371
|
+
writeWithBackpressure(stream, encodeMessage("AgentClientMessage", {
|
|
3372
|
+
conversation_action: {
|
|
3373
|
+
inject_context_action: {
|
|
3374
|
+
injection_id: injectionId,
|
|
3375
|
+
expected_run_id: runId,
|
|
3376
|
+
user_context: { user_message: { text: steer.text, message_id: crypto.randomUUID() } },
|
|
3377
|
+
},
|
|
3378
|
+
},
|
|
3379
|
+
}), "steer injection", () => isEarlySteerTracked(steer.sessionID, injectionId) && takesSteers()).then((written) => {
|
|
3380
|
+
if (written)
|
|
3381
|
+
trace(`steer: injected a mid-step message runId=${runId} id=${injectionId} inboxID=${steer.inboxID}`);
|
|
3382
|
+
else
|
|
3383
|
+
forgetUndeliveredEarlySteer(steer.sessionID, injectionId);
|
|
3384
|
+
}, (error) => {
|
|
3385
|
+
// Bytes buffered before a failed drain may still reach Cursor; only a rejected write is certainly lost.
|
|
3386
|
+
if (!(error instanceof StreamWriteDrainError))
|
|
3387
|
+
forgetUndeliveredEarlySteer(steer.sessionID, injectionId);
|
|
3388
|
+
trace(`steer: injection write failed id=${injectionId}: ${error.message}`);
|
|
3389
|
+
});
|
|
3390
|
+
return true;
|
|
3391
|
+
}
|
|
3392
|
+
/** Match only wholly textual messages; keep attachment-bearing neighbors opaque and intact. */
|
|
3393
|
+
function trailingUserTexts(prompt) {
|
|
3394
|
+
let start = prompt.length;
|
|
3395
|
+
while (start > 0 && (prompt[start - 1].role === "user" || prompt[start - 1].role === "system"))
|
|
3396
|
+
start--;
|
|
3397
|
+
if (start === prompt.length)
|
|
3398
|
+
return undefined;
|
|
3399
|
+
const texts = [];
|
|
3400
|
+
const nonText = [];
|
|
3401
|
+
const notes = [];
|
|
3402
|
+
for (let index = start; index < prompt.length; index++) {
|
|
3403
|
+
const message = prompt[index];
|
|
3404
|
+
if (hostTailNote(message)) {
|
|
3405
|
+
notes.push(message);
|
|
3406
|
+
continue;
|
|
3407
|
+
}
|
|
3408
|
+
if (message.role !== "user" || message.content.some((part) => part.type !== "text")) {
|
|
3409
|
+
nonText.push(index);
|
|
2108
3410
|
continue;
|
|
2109
3411
|
}
|
|
2110
|
-
|
|
2111
|
-
? "ask_question answer"
|
|
2112
|
-
: pending.resultField === SWITCH_MODE_RESULT_FIELD
|
|
2113
|
-
? "switch_mode answer"
|
|
2114
|
-
: pending.resultField === CREATE_PLAN_RESULT_FIELD
|
|
2115
|
-
? "create_plan answer"
|
|
2116
|
-
: "exec result";
|
|
2117
|
-
trace(`continuation: wrote ${resultKind} execId=${r.execId} field=${pending.resultField} ` +
|
|
2118
|
-
`frames=${outcome.framesWritten} outLen=${r.output.length}`);
|
|
3412
|
+
texts.push({ index, text: message.content.map((part) => part.text).join("\n") });
|
|
2119
3413
|
}
|
|
2120
|
-
return
|
|
3414
|
+
return { start, texts, nonText, notes };
|
|
3415
|
+
}
|
|
3416
|
+
/** The same host history boundary must scope both answered copies and already-promoted receipts. */
|
|
3417
|
+
function steerHostTurn(prompt, start) {
|
|
3418
|
+
return createHash("sha256").update(JSON.stringify(prompt[start - 1])).digest("hex");
|
|
3419
|
+
}
|
|
3420
|
+
/** What the Run that answered a session's early steers needs to save the session's snapshot. */
|
|
3421
|
+
const steerAnsweringRuns = new Map();
|
|
3422
|
+
function rememberSteerAnsweringRun(sessionKey, session) {
|
|
3423
|
+
const { openCodeSessionId, cacheDir, conversationId, requestContext, toolCatalog, postCompactionRebase, hostAgent, stableSystemPromptHash } = session;
|
|
3424
|
+
steerAnsweringRuns.delete(sessionKey);
|
|
3425
|
+
steerAnsweringRuns.set(sessionKey, {
|
|
3426
|
+
openCodeSessionId, cacheDir, conversationId, requestContext, toolCatalog, postCompactionRebase, hostAgent, stableSystemPromptHash,
|
|
3427
|
+
});
|
|
3428
|
+
while (steerAnsweringRuns.size > MAX_TURN_STATE_SESSIONS) {
|
|
3429
|
+
const oldest = steerAnsweringRuns.keys().next().value;
|
|
3430
|
+
if (!oldest)
|
|
3431
|
+
break;
|
|
3432
|
+
steerAnsweringRuns.delete(oldest);
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
/**
|
|
3436
|
+
* OpenCode adds its own copy of an early steer once the model has answered it. A step made only of
|
|
3437
|
+
* such copies needs no Run (`"all"`); otherwise the copies are left out of the prompt.
|
|
3438
|
+
*/
|
|
3439
|
+
async function leaveOutAnsweredEarlySteers(prompt, sessionKey) {
|
|
3440
|
+
if (!sessionKey || sessionManager.findOpenByOpenCodeSessionId(sessionKey)?.pending.size)
|
|
3441
|
+
return undefined;
|
|
3442
|
+
const tail = trailingUserTexts(prompt);
|
|
3443
|
+
if (!tail || !["assistant", "tool"].includes(prompt[tail.start - 1]?.role))
|
|
3444
|
+
return undefined;
|
|
3445
|
+
if (prompt[tail.start - 1]?.role === "assistant" && detectForeignHistory({
|
|
3446
|
+
sessionKey, conversationId: peekConversationId(sessionKey), prompt,
|
|
3447
|
+
}))
|
|
3448
|
+
return undefined;
|
|
3449
|
+
const hostTurn = steerHostTurn(prompt, tail.start);
|
|
3450
|
+
const notes = tail.notes.map(hostTailNote);
|
|
3451
|
+
const answered = new Map();
|
|
3452
|
+
for (const { index, text } of tail.texts) {
|
|
3453
|
+
const taken = [...answered.values()];
|
|
3454
|
+
const [record] = matchEarlySteers(sessionKey, [text], (candidate) => candidate.answered && (!candidate.hostTurn || candidate.hostTurn === hostTurn) && !taken.includes(candidate));
|
|
3455
|
+
if (record)
|
|
3456
|
+
answered.set(index, record);
|
|
3457
|
+
}
|
|
3458
|
+
if (answered.size === 0)
|
|
3459
|
+
return undefined;
|
|
3460
|
+
const firstCopy = [...answered.values()].some((record) => !record.hostTurn);
|
|
3461
|
+
for (const record of answered.values())
|
|
3462
|
+
record.hostTurn = hostTurn;
|
|
3463
|
+
trace(`fresh turn: the model already answered ${answered.size} early steer(s) ` +
|
|
3464
|
+
`inboxIDs=${[...answered.values()].map((record) => record.inboxID).join(",")} — left out of this step`);
|
|
3465
|
+
// Remove only matching copies. Preserve all other messages, their parts and their ordering.
|
|
3466
|
+
if (answered.size < tail.texts.length || tail.nonText.length || notes.some((note) => note?.media?.length)) {
|
|
3467
|
+
return prompt.filter((_message, index) => !answered.has(index));
|
|
3468
|
+
}
|
|
3469
|
+
const run = steerAnsweringRuns.get(sessionKey);
|
|
3470
|
+
// Host notes in this step reach Cursor with the next user turn instead.
|
|
3471
|
+
const noteTexts = collectHostNoteBodies(notes.map((note) => note?.text));
|
|
3472
|
+
if (firstCopy && noteTexts.length > 0) {
|
|
3473
|
+
appendUndeliveredHostNotes(sessionKey, [{ notes: noteTexts, order: nextHostNoteOrder++ }]);
|
|
3474
|
+
}
|
|
3475
|
+
if (run)
|
|
3476
|
+
await persistTerminalCheckpoint(run);
|
|
3477
|
+
return "all";
|
|
3478
|
+
}
|
|
3479
|
+
function answeredSteerStream() {
|
|
3480
|
+
return new ReadableStream({
|
|
3481
|
+
start(controller) {
|
|
3482
|
+
controller.enqueue({ type: "stream-start", warnings: [] });
|
|
3483
|
+
controller.enqueue({
|
|
3484
|
+
type: "finish",
|
|
3485
|
+
finishReason: { unified: "stop", raw: undefined },
|
|
3486
|
+
usage: emptyLanguageModelV3Usage(),
|
|
3487
|
+
providerMetadata: { copilot: { totalNanoAiu: 0 }, cursor: { usageVersion: 3, occupancyOnly: true } },
|
|
3488
|
+
});
|
|
3489
|
+
controller.close();
|
|
3490
|
+
},
|
|
3491
|
+
});
|
|
3492
|
+
}
|
|
3493
|
+
/**
|
|
3494
|
+
* OpenCode adds its own copy of an early steer after the step's tool results. The held Run already
|
|
3495
|
+
* has it, so the step continues there without it instead of starting a new user turn.
|
|
3496
|
+
*/
|
|
3497
|
+
function continuationAfterEarlySteers(prompt, sessionKey) {
|
|
3498
|
+
const held = sessionKey ? sessionManager.findOpenByOpenCodeSessionId(sessionKey) : undefined;
|
|
3499
|
+
if (!held || held.pending.size === 0)
|
|
3500
|
+
return undefined;
|
|
3501
|
+
const tail = trailingUserTexts(prompt);
|
|
3502
|
+
if (!tail || tail.nonText.length || prompt[tail.start - 1]?.role !== "tool")
|
|
3503
|
+
return undefined;
|
|
3504
|
+
const hostTurn = steerHostTurn(prompt, tail.start);
|
|
3505
|
+
const steers = [];
|
|
3506
|
+
const newTexts = [];
|
|
3507
|
+
for (const { text } of tail.texts) {
|
|
3508
|
+
const [record] = matchEarlySteers(sessionKey, [text], (candidate) => candidate.run === held.sessionId && !candidate.answered && !steers.includes(candidate));
|
|
3509
|
+
if (record)
|
|
3510
|
+
steers.push(record);
|
|
3511
|
+
else
|
|
3512
|
+
newTexts.push(text);
|
|
3513
|
+
}
|
|
3514
|
+
if (steers.length === 0)
|
|
3515
|
+
return undefined;
|
|
3516
|
+
const results = extractTrailingToolResults([...prompt.slice(0, tail.start), ...tail.notes]);
|
|
3517
|
+
if (results.length === 0 || findContinuationSession(results) !== held)
|
|
3518
|
+
return undefined;
|
|
3519
|
+
// The host has already promoted these messages. Keep them for a follow-up after the held
|
|
3520
|
+
// turn, rather than superseding its tool results or dropping all but the last user message.
|
|
3521
|
+
const added = [];
|
|
3522
|
+
for (const text of newTexts) {
|
|
3523
|
+
const record = {
|
|
3524
|
+
sessionID: sessionKey, inboxID: crypto.randomUUID(), injectionId: crypto.randomUUID(),
|
|
3525
|
+
run: held.sessionId, text, delivered: false, checkpointed: false, answered: false, promoted: true, hostTurn,
|
|
3526
|
+
};
|
|
3527
|
+
if (!recordEarlySteer(record)) {
|
|
3528
|
+
forgetEarlySteerRecords(sessionKey, added);
|
|
3529
|
+
return undefined;
|
|
3530
|
+
}
|
|
3531
|
+
added.push(record);
|
|
3532
|
+
}
|
|
3533
|
+
for (const steer of steers) {
|
|
3534
|
+
steer.promoted = true;
|
|
3535
|
+
steer.hostTurn = hostTurn;
|
|
3536
|
+
}
|
|
3537
|
+
trace(`continuation: ${results.length} trailing tool result(s) before ${steers.length} early steer(s) ` +
|
|
3538
|
+
`the held Run already has inboxIDs=${steers.map((steer) => steer.inboxID).join(",")}`);
|
|
3539
|
+
return { results, session: held };
|
|
2121
3540
|
}
|
|
2122
3541
|
async function loadAvailableModels() {
|
|
2123
3542
|
const cacheDir = opencodeGlobalCacheDir();
|
|
@@ -2147,6 +3566,7 @@ function resolveTelemetryEnabled(options) {
|
|
|
2147
3566
|
return options.telemetryEnabled ?? isTruthyEnv(process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY);
|
|
2148
3567
|
}
|
|
2149
3568
|
function resolveExplicitAgentBaseURL(options) {
|
|
3569
|
+
// Legacy `baseURL` aliases `agentBaseURL`; read it without the deprecated accessor.
|
|
2150
3570
|
const raw = options.agentBaseURL ?? options.baseURL;
|
|
2151
3571
|
if (!raw)
|
|
2152
3572
|
return undefined;
|
|
@@ -2159,75 +3579,16 @@ function resolveExplicitAgentBaseURL(options) {
|
|
|
2159
3579
|
function isTruthyEnv(value) {
|
|
2160
3580
|
return value === "1" || value === "true";
|
|
2161
3581
|
}
|
|
2162
|
-
const streamWriteChains = new WeakMap();
|
|
2163
|
-
async function waitForStreamWrites(stream) {
|
|
2164
|
-
const pending = streamWriteChains.get(stream);
|
|
2165
|
-
if (pending)
|
|
2166
|
-
await pending.catch(() => undefined);
|
|
2167
|
-
}
|
|
2168
|
-
/**
|
|
2169
|
-
* Keep awaited protocol writes ordered per Run stream. In particular, a large
|
|
2170
|
-
* KV get_blob reply must drain before another KV reply or heartbeat is queued;
|
|
2171
|
-
* otherwise one heartbeat observes the backlog created by dozens of ignored
|
|
2172
|
-
* false write() results and reports the wrong operation as the failure.
|
|
2173
|
-
*/
|
|
2174
|
-
async function writeWithBackpressure(stream, message, operation) {
|
|
2175
|
-
const previous = streamWriteChains.get(stream);
|
|
2176
|
-
const current = (previous ? previous.catch(() => undefined) : Promise.resolve())
|
|
2177
|
-
.then(() => writeWithBackpressureNow(stream, message, operation));
|
|
2178
|
-
streamWriteChains.set(stream, current);
|
|
2179
|
-
try {
|
|
2180
|
-
await current;
|
|
2181
|
-
}
|
|
2182
|
-
finally {
|
|
2183
|
-
if (streamWriteChains.get(stream) === current)
|
|
2184
|
-
streamWriteChains.delete(stream);
|
|
2185
|
-
}
|
|
2186
|
-
}
|
|
2187
|
-
async function writeWithBackpressureNow(stream, message, operation) {
|
|
2188
|
-
let accepted;
|
|
2189
|
-
try {
|
|
2190
|
-
accepted = stream.write(message);
|
|
2191
|
-
}
|
|
2192
|
-
catch (cause) {
|
|
2193
|
-
throw toCursorProviderError(cause, {
|
|
2194
|
-
replaySafe: false,
|
|
2195
|
-
fallback: `Cursor ${operation} write failed`,
|
|
2196
|
-
});
|
|
2197
|
-
}
|
|
2198
|
-
if (accepted !== false)
|
|
2199
|
-
return;
|
|
2200
|
-
trace(`stream write backpressured: operation=${operation} bytes=${message.length}`);
|
|
2201
|
-
if (!stream.waitForDrain) {
|
|
2202
|
-
throw new CursorTransportError(`Cursor ${operation} write was backpressured`, {
|
|
2203
|
-
transient: false,
|
|
2204
|
-
replaySafe: false,
|
|
2205
|
-
code: "CURSOR_WRITE_BACKPRESSURE",
|
|
2206
|
-
});
|
|
2207
|
-
}
|
|
2208
|
-
try {
|
|
2209
|
-
await stream.waitForDrain(5_000);
|
|
2210
|
-
trace(`stream write drained: operation=${operation} bytes=${message.length}`);
|
|
2211
|
-
}
|
|
2212
|
-
catch (cause) {
|
|
2213
|
-
throw toCursorProviderError(cause, {
|
|
2214
|
-
replaySafe: false,
|
|
2215
|
-
fallback: `Cursor ${operation} backpressure drain failed`,
|
|
2216
|
-
});
|
|
2217
|
-
}
|
|
2218
|
-
}
|
|
2219
3582
|
async function nextFrameWithSemanticDeadline(session) {
|
|
3583
|
+
const idleTimeout = () => new CursorTransportError(`Cursor Run sent no frames for ${session.policy.semanticIdleMs}ms`, { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" });
|
|
2220
3584
|
const remainingMs = session.semanticDeadlineAt - Date.now();
|
|
2221
|
-
if (remainingMs <= 0)
|
|
2222
|
-
throw
|
|
2223
|
-
}
|
|
3585
|
+
if (remainingMs <= 0)
|
|
3586
|
+
throw idleTimeout();
|
|
2224
3587
|
let timer;
|
|
2225
3588
|
let rejectCancelled;
|
|
2226
3589
|
const deadline = new Promise((_, reject) => {
|
|
2227
3590
|
rejectCancelled = reject;
|
|
2228
|
-
timer = setTimeout(() =>
|
|
2229
|
-
reject(new CursorTransportError(`Cursor semantic-progress timeout after ${session.policy.semanticIdleMs}ms`, { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" }));
|
|
2230
|
-
}, remainingMs);
|
|
3591
|
+
timer = setTimeout(() => reject(idleTimeout()), remainingMs);
|
|
2231
3592
|
timer.unref?.();
|
|
2232
3593
|
});
|
|
2233
3594
|
session.semanticDeadlineCancel = () => {
|
|
@@ -2245,6 +3606,48 @@ async function nextFrameWithSemanticDeadline(session) {
|
|
|
2245
3606
|
session.semanticDeadlineCancel = null;
|
|
2246
3607
|
}
|
|
2247
3608
|
}
|
|
3609
|
+
/**
|
|
3610
|
+
* Hand a background or soft-background shell to a supervised terminal record.
|
|
3611
|
+
* Where that is unavailable (native Windows, runtime storage this conversation
|
|
3612
|
+
* never advertised or cannot create), the request keeps the unmanaged
|
|
3613
|
+
* background wrappers it already carries.
|
|
3614
|
+
*/
|
|
3615
|
+
function manageBackgroundShell(parsed, policy, context) {
|
|
3616
|
+
const workspaceRoot = workspaceRootFromRequestContext(context);
|
|
3617
|
+
// The host runs the command in the directory its wrapper records, so
|
|
3618
|
+
// OpenCode 2's shell hook can correlate the two by command and directory.
|
|
3619
|
+
// Resolve against the workspace here: the hook sees the host's directory,
|
|
3620
|
+
// and a relative path resolved later would use the daemon's own cwd.
|
|
3621
|
+
policy.workingDirectory = path.resolve(workspaceRoot, policy.workingDirectory || ".");
|
|
3622
|
+
parsed.args.workdir = policy.workingDirectory;
|
|
3623
|
+
parsed.resultMetadata = { ...parsed.resultMetadata, working_directory: policy.workingDirectory };
|
|
3624
|
+
const root = runtimeRootFromContext(context);
|
|
3625
|
+
const env = context?.env;
|
|
3626
|
+
if (process.platform === "win32" || !root || env?.terminals_folder !== path.join(root, "terminals"))
|
|
3627
|
+
return;
|
|
3628
|
+
let managed;
|
|
3629
|
+
try {
|
|
3630
|
+
policy.shellPath = resolveCursorShellPath(undefined, workspaceRoot);
|
|
3631
|
+
managed = createManagedTerminal(policy, root);
|
|
3632
|
+
}
|
|
3633
|
+
catch (error) {
|
|
3634
|
+
trace(`background shell: supervised terminal unavailable, keeping the unmanaged wrapper: ${error.message}`);
|
|
3635
|
+
return;
|
|
3636
|
+
}
|
|
3637
|
+
// A spawn's host command is the launcher, which embeds the command verbatim
|
|
3638
|
+
// for approval. A soft-background command keeps its own text: shell hooks
|
|
3639
|
+
// substitute the launcher, and a host without them runs it in the foreground.
|
|
3640
|
+
// The launcher reports within its own startup deadline, so the host's
|
|
3641
|
+
// default timeout stays in place for a host that runs the command as is.
|
|
3642
|
+
if (policy.backgroundSpawn)
|
|
3643
|
+
parsed.args.command = managed.command;
|
|
3644
|
+
parsed.resultMetadata = {
|
|
3645
|
+
...parsed.resultMetadata,
|
|
3646
|
+
managed_command: managed.command,
|
|
3647
|
+
managed_job_directory: managed.directory,
|
|
3648
|
+
managed_shell_path: policy.shellPath,
|
|
3649
|
+
};
|
|
3650
|
+
}
|
|
2248
3651
|
/**
|
|
2249
3652
|
* Read the held-open stream, emitting stream parts, until the turn boundary:
|
|
2250
3653
|
* - tool call(s) for one Cursor generation → emit each tool-call as it
|
|
@@ -2296,6 +3699,10 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2296
3699
|
const { textId, reasoningId } = ids;
|
|
2297
3700
|
const advertisedToolNames = advertisedToolNamesFromDescriptors(session.toolDescriptors);
|
|
2298
3701
|
const advertisedToolNameSet = new Set(advertisedToolNames.map((name) => resolveCustomWebToolAlias(name, session.toolAliases)));
|
|
3702
|
+
// The epoch catalog still lists `edit`/`write` after a switch to a GPT model; this turn's host list picks the file tool.
|
|
3703
|
+
const fileToolNames = () => session.permittedToolNames && session.permittedToolNames.size > 0
|
|
3704
|
+
? session.permittedToolNames
|
|
3705
|
+
: advertisedToolNameSet;
|
|
2299
3706
|
let textStarted = false;
|
|
2300
3707
|
let reasoningStarted = false;
|
|
2301
3708
|
let assistantText = "";
|
|
@@ -2307,8 +3714,9 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2307
3714
|
let lifecycleRefusedExec = false;
|
|
2308
3715
|
let progressContinuationAttempts = 0;
|
|
2309
3716
|
let emittedHostTools = 0;
|
|
3717
|
+
const earlyRows = new EarlyToolRows();
|
|
2310
3718
|
let planHandoffCancellationRequested = false;
|
|
2311
|
-
const replaySafety = new AttemptReplaySafety(session.sessionId);
|
|
3719
|
+
const replaySafety = new AttemptReplaySafety(session.sessionId, session.replaySafety ??= {});
|
|
2312
3720
|
const failRunProtocol = (message, code) => {
|
|
2313
3721
|
replaySafety.markBarrier("unknown-or-malformed-frame");
|
|
2314
3722
|
const error = new CursorProtocolError(message, { code });
|
|
@@ -2476,13 +3884,13 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2476
3884
|
* continue. External and otherwise ineligible reads retain OpenCode's normal
|
|
2477
3885
|
* permission-aware path.
|
|
2478
3886
|
*/
|
|
2479
|
-
const recoverCorrelatedEditRead = (parsed, displayCallId) => {
|
|
3887
|
+
const recoverCorrelatedEditRead = async (parsed, displayCallId) => {
|
|
2480
3888
|
if (!displayCallId ||
|
|
2481
3889
|
parsed.resultField !== "read_result" ||
|
|
2482
3890
|
parsed.toolName !== "read" ||
|
|
2483
|
-
//
|
|
2484
|
-
//
|
|
2485
|
-
!(
|
|
3891
|
+
// The follow-up write_args can be a targeted edit even when write is
|
|
3892
|
+
// withheld. Every permitted mutation route needs the complete read.
|
|
3893
|
+
!(fileToolNames().has("edit") || fileToolNames().has("write") || hostPatchTool(fileToolNames())))
|
|
2486
3894
|
return false;
|
|
2487
3895
|
const stored = session.displayToolCalls.get(displayCallId);
|
|
2488
3896
|
const display = parseDisplayToolCall(displayCallId, stored);
|
|
@@ -2499,37 +3907,24 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2499
3907
|
const absolutePath = resolvePath(requestedPath);
|
|
2500
3908
|
if (absolutePath !== resolvePath(editPath))
|
|
2501
3909
|
return false;
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
: undefined;
|
|
2510
|
-
if (code !== "ENOENT")
|
|
2511
|
-
return false;
|
|
2512
|
-
exists = false;
|
|
3910
|
+
const answer = cursorEditReadAnswer(absolutePath, workspaceRoot);
|
|
3911
|
+
if (answer === "external") {
|
|
3912
|
+
parsed.resultMetadata = {
|
|
3913
|
+
...parsed.resultMetadata,
|
|
3914
|
+
correlatedEditCallId: displayCallId,
|
|
3915
|
+
};
|
|
3916
|
+
return false;
|
|
2513
3917
|
}
|
|
2514
|
-
if (
|
|
2515
|
-
|
|
2516
|
-
|
|
3918
|
+
if (answer === "host")
|
|
3919
|
+
return false;
|
|
3920
|
+
if (answer === "complete") {
|
|
2517
3921
|
try {
|
|
2518
|
-
const realRoot = fs.realpathSync(workspaceRoot);
|
|
2519
|
-
const realTarget = fs.realpathSync(absolutePath);
|
|
2520
|
-
const relative = path.relative(realRoot, realTarget);
|
|
2521
|
-
if (relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
2522
|
-
parsed.resultMetadata = {
|
|
2523
|
-
...parsed.resultMetadata,
|
|
2524
|
-
correlatedEditCallId: displayCallId,
|
|
2525
|
-
};
|
|
2526
|
-
return false;
|
|
2527
|
-
}
|
|
2528
3922
|
const frames = buildCompleteEditReadMessages(parsed.id, absolutePath, requestedPath);
|
|
2529
3923
|
if (!frames)
|
|
2530
3924
|
return false;
|
|
2531
|
-
for (const frame of frames)
|
|
2532
|
-
session.stream
|
|
3925
|
+
for (const frame of frames) {
|
|
3926
|
+
await writeWithBackpressure(session.stream, frame, "complete edit read reply");
|
|
3927
|
+
}
|
|
2533
3928
|
const editCall = session.editToolCalls?.get(displayCallId);
|
|
2534
3929
|
if (editCall)
|
|
2535
3930
|
editCall.completeRead = true;
|
|
@@ -2537,7 +3932,10 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2537
3932
|
`path=${JSON.stringify(requestedPath)}; awaiting write_args`);
|
|
2538
3933
|
return true;
|
|
2539
3934
|
}
|
|
2540
|
-
catch {
|
|
3935
|
+
catch (error) {
|
|
3936
|
+
// Once reply bytes are buffered, falling back to a host read would
|
|
3937
|
+
// answer the same exec again. Transport failures terminate the attempt.
|
|
3938
|
+
rethrowTransportWriteFailure(error);
|
|
2541
3939
|
parsed.resultMetadata = {
|
|
2542
3940
|
...parsed.resultMetadata,
|
|
2543
3941
|
correlatedEditCallId: displayCallId,
|
|
@@ -2545,6 +3943,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2545
3943
|
return false;
|
|
2546
3944
|
}
|
|
2547
3945
|
}
|
|
3946
|
+
// "empty": a missing target.
|
|
2548
3947
|
try {
|
|
2549
3948
|
for (const frame of buildExecClientMessages({
|
|
2550
3949
|
execId: parsed.id,
|
|
@@ -2554,13 +3953,14 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2554
3953
|
resultMetadata: { path: requestedPath },
|
|
2555
3954
|
workspaceRoot,
|
|
2556
3955
|
})) {
|
|
2557
|
-
session.stream
|
|
3956
|
+
await writeWithBackpressure(session.stream, frame, "missing edit target reply");
|
|
2558
3957
|
}
|
|
2559
3958
|
trace(`exec: missing edit target treated as empty file id=${parsed.id} ` +
|
|
2560
3959
|
`path=${JSON.stringify(requestedPath)}; awaiting write_args`);
|
|
2561
3960
|
return true;
|
|
2562
3961
|
}
|
|
2563
3962
|
catch (e) {
|
|
3963
|
+
rethrowTransportWriteFailure(e);
|
|
2564
3964
|
const error = new Error(`Failed to recover Cursor edit read: ${e.message}`);
|
|
2565
3965
|
trace(`exec: edit read recovery FAILED ${error.message}`);
|
|
2566
3966
|
safeError(error);
|
|
@@ -2568,6 +3968,31 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2568
3968
|
return true;
|
|
2569
3969
|
}
|
|
2570
3970
|
};
|
|
3971
|
+
/**
|
|
3972
|
+
* Cursor reads each Task attachment, tagged with the Task's call id, before
|
|
3973
|
+
* its subagent request. A host read would put the file in this session's
|
|
3974
|
+
* context instead of the child's, cost a step, and claim the display call
|
|
3975
|
+
* that carries the Task's title. Answer it here and keep the display call.
|
|
3976
|
+
* Returns undefined when this is not an attachment read, false when the
|
|
3977
|
+
* reply failed and the attempt was closed.
|
|
3978
|
+
*/
|
|
3979
|
+
const answerTaskAttachmentRead = async (parsed, displayCallId) => {
|
|
3980
|
+
if (!displayCallId || parsed.resultField !== "read_result" || parsed.toolName !== "read")
|
|
3981
|
+
return undefined;
|
|
3982
|
+
const display = parseDisplayToolCall(displayCallId, session.displayToolCalls.get(displayCallId));
|
|
3983
|
+
if (display?.variant !== "task_tool_call")
|
|
3984
|
+
return undefined;
|
|
3985
|
+
const requested = opencodePathArg(parsed.args) ?? "";
|
|
3986
|
+
const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
|
|
3987
|
+
const target = resolveReadTargetPath(requested, workspaceRoot);
|
|
3988
|
+
if (!taskToolCallAttachments(session.displayToolCalls.get(displayCallId)).some((attachment) => resolveReadTargetPath(attachment, workspaceRoot) === target))
|
|
3989
|
+
return undefined;
|
|
3990
|
+
const ok = await writeExecFrames(buildTaskAttachmentReadMessages(parsed.id, target, requested), `reply to task attachment read id=${parsed.id}`);
|
|
3991
|
+
if (!ok)
|
|
3992
|
+
return false;
|
|
3993
|
+
trace(`exec: task attachment read answered id=${parsed.id} callId=${displayCallId} path=${JSON.stringify(requested)}`);
|
|
3994
|
+
return true;
|
|
3995
|
+
};
|
|
2571
3996
|
/**
|
|
2572
3997
|
* A read of a path that does not exist would otherwise reach OpenCode as a
|
|
2573
3998
|
* real tool call and raise a permission prompt for a file the user never had.
|
|
@@ -2583,8 +4008,12 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2583
4008
|
* handshake read into an empty-file success. EACCES/EPERM and other stat
|
|
2584
4009
|
* errors fall through to OpenCode so a genuine permission decision stands.
|
|
2585
4010
|
*/
|
|
2586
|
-
const rejectMissingReadTarget = (parsed) => {
|
|
2587
|
-
|
|
4011
|
+
const rejectMissingReadTarget = async (parsed) => {
|
|
4012
|
+
// This validation belongs to Cursor's native LocalReadExecutor contract.
|
|
4013
|
+
// MCP reads belong to the advertised OpenCode executor: both OpenCode
|
|
4014
|
+
// generations support directories and permission-gate missing paths.
|
|
4015
|
+
// Sending a typed ReadResult to an MCP call leaves it without mcp_result.
|
|
4016
|
+
if (parsed.toolName !== "read" || parsed.resultField !== "read_result")
|
|
2588
4017
|
return false;
|
|
2589
4018
|
const requested = opencodePathArg(parsed.args) ?? "";
|
|
2590
4019
|
if (!requested)
|
|
@@ -2603,7 +4032,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2603
4032
|
return false;
|
|
2604
4033
|
try {
|
|
2605
4034
|
for (const frame of buildReadRejectionMessages(parsed.id, readResult)) {
|
|
2606
|
-
session.stream
|
|
4035
|
+
await writeWithBackpressure(session.stream, frame, "missing read target reply");
|
|
2607
4036
|
}
|
|
2608
4037
|
const kind = Object.keys(readResult)[0];
|
|
2609
4038
|
trace(`exec: rejected missing read target id=${parsed.id} kind=${kind} ` +
|
|
@@ -2611,6 +4040,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2611
4040
|
return true;
|
|
2612
4041
|
}
|
|
2613
4042
|
catch (e) {
|
|
4043
|
+
rethrowTransportWriteFailure(e);
|
|
2614
4044
|
const error = new Error(`Failed to reject Cursor read of a missing path: ${e.message}`);
|
|
2615
4045
|
trace(`exec: missing read rejection FAILED ${error.message}`);
|
|
2616
4046
|
safeError(error);
|
|
@@ -2687,17 +4117,50 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2687
4117
|
session.usageEstimate.outputTokens += estimateTokens(text.length);
|
|
2688
4118
|
safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text });
|
|
2689
4119
|
};
|
|
4120
|
+
const owedPendingCount = () => {
|
|
4121
|
+
let owed = 0;
|
|
4122
|
+
for (const pending of session.pending.values())
|
|
4123
|
+
if (isOwedCursorExec(pending))
|
|
4124
|
+
owed++;
|
|
4125
|
+
return owed;
|
|
4126
|
+
};
|
|
4127
|
+
/** `continuedAs`: the host tool this Cursor call runs as instead; otherwise it never ran. */
|
|
4128
|
+
const closeEarlyRow = (row, continuedAs) => {
|
|
4129
|
+
replaySafety.markBarrier("visible-tool-row");
|
|
4130
|
+
// Text that streamed while the row was open ends before its tool-call.
|
|
4131
|
+
closeOpenSpans();
|
|
4132
|
+
safeEnqueue({ type: "tool-input-end", id: row.toolCallId });
|
|
4133
|
+
safeEnqueue({
|
|
4134
|
+
type: "tool-call",
|
|
4135
|
+
toolCallId: row.toolCallId,
|
|
4136
|
+
toolName: row.toolName,
|
|
4137
|
+
input: "{}",
|
|
4138
|
+
providerExecuted: true,
|
|
4139
|
+
});
|
|
4140
|
+
safeEnqueue({
|
|
4141
|
+
type: "tool-result",
|
|
4142
|
+
toolCallId: row.toolCallId,
|
|
4143
|
+
toolName: row.toolName,
|
|
4144
|
+
result: continuedAs ? earlyRowHandoff(continuedAs) : EARLY_ROW_CLOSED,
|
|
4145
|
+
isError: !continuedAs,
|
|
4146
|
+
});
|
|
4147
|
+
};
|
|
4148
|
+
const settleOpenEarlyRows = () => {
|
|
4149
|
+
for (const row of earlyRows.drain())
|
|
4150
|
+
closeEarlyRow(row);
|
|
4151
|
+
};
|
|
2690
4152
|
const emitFinish = (te, reason, settledUsage, settledSource) => {
|
|
4153
|
+
settleOpenEarlyRows();
|
|
2691
4154
|
flushToollessText();
|
|
2692
4155
|
closeOpenSpans();
|
|
2693
4156
|
const est = session.usageEstimate;
|
|
2694
4157
|
// OpenCode TUI/GUI replace each assistant message's tokens (they do not
|
|
2695
4158
|
// sum occupancy) and the TUI footer requires tokens.output > 0. Cost is
|
|
2696
4159
|
// added per step-finish. Emit checkpoint occupancy snapshots at tool-call
|
|
2697
|
-
// boundaries
|
|
2698
|
-
//
|
|
2699
|
-
//
|
|
2700
|
-
//
|
|
4160
|
+
// boundaries and once more at TurnEnded/stop, each billed through
|
|
4161
|
+
// `src/billing.ts`. Held-Run TurnEnded counters are cumulative across
|
|
4162
|
+
// every tool step, but this finish only spans the last generation slice —
|
|
4163
|
+
// putting output_tokens/reasoning there makes host tok/s
|
|
2701
4164
|
// (generated/stepElapsed) absurd. Keep exact request counters under
|
|
2702
4165
|
// providerMetadata.cursor.*Raw. Char/4 usageEstimate stays traces-only.
|
|
2703
4166
|
const tokenDetails = session.tokenDetails;
|
|
@@ -2707,23 +4170,48 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2707
4170
|
? "checkpoint-current-run"
|
|
2708
4171
|
: "checkpoint-previous-turn"
|
|
2709
4172
|
: undefined;
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
4173
|
+
// Bill the step (see `src/billing.ts`): a tool step is an estimate shaped
|
|
4174
|
+
// like one model call; the turn's last step reconciles the catalog-priced
|
|
4175
|
+
// aggregate counters. Either way its token total stays the occupancy snapshot.
|
|
4176
|
+
const billing = session.billing;
|
|
4177
|
+
const shaped = occupancyDetails && !settledUsage
|
|
4178
|
+
? shapeStepUsage({
|
|
4179
|
+
usedTokens: occupancyDetails.usedTokens,
|
|
4180
|
+
prefixTokens: billing.prefixTokens,
|
|
4181
|
+
prices: hostStepPrices(billing.cost, occupancyDetails.usedTokens - 1),
|
|
4182
|
+
...(te && billing.cost ? { targetUsd: billingLedger.outstanding(billing.key) } : {}),
|
|
4183
|
+
})
|
|
4184
|
+
: undefined;
|
|
4185
|
+
const stepCostUsd = shaped?.costUsd ?? 0;
|
|
4186
|
+
const usage = settledUsage ?? shaped?.usage ?? emptyLanguageModelV3Usage();
|
|
2713
4187
|
const counters = te ? cursorUsageCountersFromTurnEnded(te) : undefined;
|
|
2714
|
-
|
|
2715
|
-
//
|
|
2716
|
-
//
|
|
4188
|
+
const ledger = billingLedger.totals(billing.key);
|
|
4189
|
+
// Metadata describes the total *if delivered*. Commit it only after the
|
|
4190
|
+
// consumer accepts the finish, otherwise later turns must still settle it.
|
|
4191
|
+
ledger.billedUsd += stepCostUsd;
|
|
4192
|
+
const estimatedUsd = billingLedger.estimated(billing.key) + (te ? 0 : stepCostUsd);
|
|
4193
|
+
const billingMetadata = {
|
|
4194
|
+
stepUsd: stepCostUsd,
|
|
4195
|
+
estimate: !te,
|
|
4196
|
+
sessionRealUsd: ledger.realUsd,
|
|
4197
|
+
sessionBilledUsd: ledger.billedUsd,
|
|
4198
|
+
...(estimatedUsd > 0 ? { sessionEstimatedUsd: estimatedUsd } : {}),
|
|
4199
|
+
};
|
|
4200
|
+
// OpenCode 1.x bills `totalNanoAiu`; OpenCode 2 bills the same amount from
|
|
4201
|
+
// the shaped tokens. TurnEnded stays a real (non-occupancyOnly) finish so
|
|
4202
|
+
// hosts that collapse tool-boundary occupancy keep one context snapshot.
|
|
4203
|
+
const copilot = { totalNanoAiu: nanoAiuForUsd(stepCostUsd) };
|
|
2717
4204
|
const providerMetadata = te
|
|
2718
|
-
? {
|
|
2719
|
-
|
|
2720
|
-
...
|
|
2721
|
-
}
|
|
4205
|
+
? (() => {
|
|
4206
|
+
const metadata = cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource);
|
|
4207
|
+
return { copilot, ...metadata, cursor: { ...metadata.cursor, billing: billingMetadata } };
|
|
4208
|
+
})()
|
|
2722
4209
|
: {
|
|
2723
|
-
|
|
4210
|
+
copilot,
|
|
2724
4211
|
cursor: {
|
|
2725
4212
|
usageVersion: 3,
|
|
2726
4213
|
occupancyOnly: true,
|
|
4214
|
+
billing: billingMetadata,
|
|
2727
4215
|
...(occupancyDetails && contextSource
|
|
2728
4216
|
? { context: cursorContextUsageMetadata(occupancyDetails, contextSource) }
|
|
2729
4217
|
: {}),
|
|
@@ -2732,47 +4220,29 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2732
4220
|
const reasonLabel = typeof reason === "object" && reason && "unified" in reason
|
|
2733
4221
|
? String(reason.unified ?? "unknown")
|
|
2734
4222
|
: String(reason);
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
:
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
:
|
|
2747
|
-
const rawIn = te
|
|
2748
|
-
? turnEndedCounter(te, "input_tokens")
|
|
2749
|
-
: occupancyDetails
|
|
2750
|
-
? occupancyDetails.usedTokens
|
|
2751
|
-
: est.inputTokens;
|
|
2752
|
-
const rawOut = te
|
|
2753
|
-
? turnEndedCounter(te, "output_tokens")
|
|
2754
|
-
: occupancyDetails
|
|
2755
|
-
? 1
|
|
2756
|
-
: est.outputTokens;
|
|
2757
|
-
const rawCacheRead = te
|
|
2758
|
-
? turnEndedCounter(te, "cache_read")
|
|
2759
|
-
: occupancyPrefixCache ?? est.cacheRead;
|
|
2760
|
-
const rawCacheWrite = te ? turnEndedCounter(te, "cache_write") : est.cacheWrite;
|
|
2761
|
-
trace(`finish: reason=${reasonLabel} ` +
|
|
2762
|
-
`v3In=${inTotal} v3Out=${outTotal} ` +
|
|
2763
|
-
`v3CacheRead=${usage.inputTokens?.cacheRead ?? 0} v3CacheWrite=${usage.inputTokens?.cacheWrite ?? 0} ` +
|
|
2764
|
-
`v3Reasoning=${usage.outputTokens?.reasoning ?? 0} ` +
|
|
2765
|
-
`rawIn=${rawIn} rawOut=${rawOut} rawCacheRead=${rawCacheRead} rawCacheWrite=${rawCacheWrite} ` +
|
|
2766
|
-
`${occupancyPrefixCache !== undefined ? `occupancyPrefixCache=${occupancyPrefixCache} ` : ""}` +
|
|
2767
|
-
`source=${te
|
|
4223
|
+
trace(formatFinishTrace({
|
|
4224
|
+
reason: reasonLabel,
|
|
4225
|
+
usage,
|
|
4226
|
+
turnEnded: counters,
|
|
4227
|
+
occupancy: occupancyDetails
|
|
4228
|
+
? {
|
|
4229
|
+
usedTokens: occupancyDetails.usedTokens,
|
|
4230
|
+
priorUsedTokens: session.cacheDiagnostics?.priorTokenDetails?.usedTokens ?? 0,
|
|
4231
|
+
}
|
|
4232
|
+
: undefined,
|
|
4233
|
+
estimate: est,
|
|
4234
|
+
source: te
|
|
2768
4235
|
? settledSource ?? (contextSource ?? "unavailable")
|
|
2769
|
-
:
|
|
4236
|
+
: occupancyDetails ? `occupancy-${contextSource ?? "unavailable"}` : "intermediate-zero",
|
|
4237
|
+
}));
|
|
2770
4238
|
// Validate the usage we actually send. Occupancy finishes (tool-call and
|
|
2771
4239
|
// TurnEnded/stop) use prior-prefix cacheRead — never compare that against
|
|
2772
4240
|
// aggregate TurnEnded request cache ratios (false mismatch). Raw request
|
|
2773
4241
|
// counters stay on `finish:` and cache diagnosis only.
|
|
2774
4242
|
if (occupancyDetails) {
|
|
2775
|
-
|
|
4243
|
+
// The shaped split is the intended partition; validate totals against it.
|
|
4244
|
+
const cached = (usage.inputTokens.cacheRead ?? 0) + (usage.inputTokens.cacheWrite ?? 0);
|
|
4245
|
+
trace(formatTurnUsageValidation(occupancyValidationCounters(occupancyDetails, { usedTokens: cached, maxTokens: occupancyDetails.maxTokens }), usage, occupancyDetails, contextSource, "occupancy"));
|
|
2776
4246
|
}
|
|
2777
4247
|
else if (counters) {
|
|
2778
4248
|
trace(formatTurnUsageValidation(counters, usage, tokenDetails, contextSource));
|
|
@@ -2780,29 +4250,42 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2780
4250
|
if (counters) {
|
|
2781
4251
|
trace(formatCursorCacheDiagnostics(counters, tokenDetails, cacheDiagnostics.priorTokenDetails, cacheDiagnostics));
|
|
2782
4252
|
}
|
|
2783
|
-
safeEnqueue({
|
|
4253
|
+
const delivered = safeEnqueue({
|
|
2784
4254
|
type: "finish",
|
|
2785
4255
|
usage,
|
|
2786
4256
|
finishReason: reason,
|
|
2787
4257
|
...(providerMetadata ? { providerMetadata } : {}),
|
|
2788
4258
|
});
|
|
4259
|
+
if (delivered && session.openCodeSessionId) {
|
|
4260
|
+
finishEmittedStep(session.openCodeSessionId, session.conversationId);
|
|
4261
|
+
if (reason.unified === "tool-calls")
|
|
4262
|
+
scheduleHeldRunSave(session);
|
|
4263
|
+
}
|
|
4264
|
+
if (delivered && shaped && occupancyDetails) {
|
|
4265
|
+
billingLedger.recordBilled(billing.key, shaped.costUsd, te ? undefined : billing);
|
|
4266
|
+
billing.prefixTokens = occupancyDetails.usedTokens;
|
|
4267
|
+
}
|
|
4268
|
+
const booked = billingLedger.totals(billing.key);
|
|
4269
|
+
trace(`billing: step=${te ? "turn-end" : "estimate"} pricing=${billing.cost ? "priced" : "unpriced"} ` +
|
|
4270
|
+
`delivered=${delivered} stepUsd=${(delivered ? stepCostUsd : 0).toFixed(6)} ` +
|
|
4271
|
+
`sessionRealUsd=${booked.realUsd.toFixed(6)} sessionBilledUsd=${booked.billedUsd.toFixed(6)} ` +
|
|
4272
|
+
`sessionEstimatedUsd=${billingLedger.estimated(billing.key).toFixed(6)} key=${billing.key}`);
|
|
2789
4273
|
};
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
}).catch((error) => {
|
|
2802
|
-
trace(`conversation persistence: terminal save failed sessionKey=${session.openCodeSessionId}: ${String(error)}`);
|
|
2803
|
-
});
|
|
4274
|
+
// The cancel path drains and closes the Run; a frame read here goes back to it.
|
|
4275
|
+
const stopForHostInterrupt = (read) => {
|
|
4276
|
+
if (read?.done)
|
|
4277
|
+
session.pendingFrameRead = Promise.resolve(read);
|
|
4278
|
+
else if (read)
|
|
4279
|
+
session.pushbackFrame = read.value;
|
|
4280
|
+
settleOpenEarlyRows();
|
|
4281
|
+
closeOpenSpans();
|
|
4282
|
+
clearParallelStep();
|
|
4283
|
+
trace(`pump: host interrupted the turn sessionId=${session.sessionId} — leaving the cancelled Run to its drain`);
|
|
4284
|
+
throw new CursorLocalCancellationError("Cursor Run cancelled: the host stopped the turn");
|
|
2804
4285
|
};
|
|
2805
4286
|
while (true) {
|
|
4287
|
+
if (session.hostInterrupted)
|
|
4288
|
+
stopForHostInterrupt();
|
|
2806
4289
|
// Consumer cancelled / closed the ReadableStream. Stop reading Cursor
|
|
2807
4290
|
// frames so a continuation doStream can resume the same iterator —
|
|
2808
4291
|
// keeping the loop alive would discard frames the next pump needs.
|
|
@@ -2851,11 +4334,17 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2851
4334
|
const failure = error instanceof CursorProviderError
|
|
2852
4335
|
? error
|
|
2853
4336
|
: new CursorRunInterruptedError(`Cursor Run frame stream interrupted: ${error.message}`, { cause: error });
|
|
4337
|
+
settleOpenEarlyRows();
|
|
4338
|
+
if (session.hostInterrupted)
|
|
4339
|
+
stopForHostInterrupt();
|
|
2854
4340
|
if (finishInterruptedTools("remote-error", failure))
|
|
2855
4341
|
return;
|
|
2856
4342
|
throw finalizeFailure(failure);
|
|
2857
4343
|
}
|
|
4344
|
+
if (session.hostInterrupted)
|
|
4345
|
+
stopForHostInterrupt(next);
|
|
2858
4346
|
if (next.done) {
|
|
4347
|
+
settleOpenEarlyRows();
|
|
2859
4348
|
closeOpenSpans();
|
|
2860
4349
|
trace("pump: frames iterator ended before turn_ended");
|
|
2861
4350
|
if (finishInterruptedTools("remote-clean-close"))
|
|
@@ -2864,6 +4353,10 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2864
4353
|
throw finalizeFailure(failure);
|
|
2865
4354
|
}
|
|
2866
4355
|
const frame = next.value;
|
|
4356
|
+
// As in Cursor CLI, any inbound frame (heartbeats included) proves the Run is
|
|
4357
|
+
// alive: a model composing a large tool input sends only heartbeats or token
|
|
4358
|
+
// counts until the call arrives.
|
|
4359
|
+
sessionManager.recordSemanticProgress(session);
|
|
2867
4360
|
if (frame.flags & 0x02) {
|
|
2868
4361
|
// A successful agent turn has an explicit turn_ended update before the
|
|
2869
4362
|
// Connect envelope closes. Reaching end-stream here means the Run was
|
|
@@ -2877,6 +4370,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2877
4370
|
replaySafety.markBarrier("unknown-or-malformed-frame");
|
|
2878
4371
|
}
|
|
2879
4372
|
}
|
|
4373
|
+
settleOpenEarlyRows();
|
|
2880
4374
|
closeOpenSpans();
|
|
2881
4375
|
const failure = payload
|
|
2882
4376
|
? connectFrameError(payload)
|
|
@@ -2887,7 +4381,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2887
4381
|
&& session.pending.size === 0 && isHostPlanEntryPending(session.openCodeSessionId)) {
|
|
2888
4382
|
// Cancellation is an explicit terminal acknowledgment, not TurnEnded.
|
|
2889
4383
|
// Preserve its final checkpoint without inventing aggregate billing.
|
|
2890
|
-
await persistTerminalCheckpoint();
|
|
4384
|
+
await persistTerminalCheckpoint(session);
|
|
2891
4385
|
trace("host-agent-mode: Run cancellation acknowledged for plan handoff");
|
|
2892
4386
|
emitFinish(undefined, { unified: "stop", raw: undefined });
|
|
2893
4387
|
sessionManager.close(session);
|
|
@@ -2931,7 +4425,6 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2931
4425
|
const interactionQuery = asm.interaction_query;
|
|
2932
4426
|
const checkpointRaw = asm.conversation_checkpoint_update;
|
|
2933
4427
|
const topField = payload.length > 0 ? payload[0] >> 3 : 0;
|
|
2934
|
-
const checkpointProgress = normalizeCheckpointBytes(checkpointRaw);
|
|
2935
4428
|
const requiredChannel = responseRequiredChannel(payload);
|
|
2936
4429
|
if (requiredChannel === "multiple" ||
|
|
2937
4430
|
(requiredChannel === "exec" && !esm) ||
|
|
@@ -2945,11 +4438,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2945
4438
|
kv,
|
|
2946
4439
|
execControl,
|
|
2947
4440
|
interactionQuery,
|
|
2948
|
-
checkpointBytes: checkpointProgress,
|
|
2949
4441
|
});
|
|
2950
|
-
if (replayFrame.semanticProgress) {
|
|
2951
|
-
sessionManager.recordSemanticProgress(session);
|
|
2952
|
-
}
|
|
2953
4442
|
if (replayFrame.barrier === "unknown-or-malformed-frame" && isDebugEnabled()) {
|
|
2954
4443
|
const layout = describeFrameLayout(payload);
|
|
2955
4444
|
if (!unknownFrameLayouts.has(layout)) {
|
|
@@ -2959,6 +4448,8 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2959
4448
|
}
|
|
2960
4449
|
if (replayFrame.barrier)
|
|
2961
4450
|
replaySafety.markBarrier(replayFrame.barrier);
|
|
4451
|
+
if (replayFrame.barrier === "non-control-exec")
|
|
4452
|
+
replaySafety.recordExec(esm?.id);
|
|
2962
4453
|
// Reseeding is allowed only while every frame so far was positively a
|
|
2963
4454
|
// control frame. Anything else, including unknown top-level fields, may have
|
|
2964
4455
|
// carried output or stateful activity.
|
|
@@ -2997,7 +4488,10 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
2997
4488
|
if (bytes && bytes.length > 0) {
|
|
2998
4489
|
cacheDiagnostics.checkpointUpdates++;
|
|
2999
4490
|
setCheckpoint(session.conversationId, bytes);
|
|
3000
|
-
session.
|
|
4491
|
+
noteCheckpoint(session.openCodeSessionId, bytes);
|
|
4492
|
+
acceptResumeCheckpoint(session, bytes, frame);
|
|
4493
|
+
markEarlySteersCheckpointed(session.openCodeSessionId, session.sessionId);
|
|
4494
|
+
noteRunImageCheckpoint(session, bytes, frame);
|
|
3001
4495
|
const tokenDetails = decodeConversationTokenDetails(bytes);
|
|
3002
4496
|
if (tokenDetails && !(planHandoffCancellationRequested && tokenDetails.usedTokens === 0
|
|
3003
4497
|
&& (session.tokenDetails?.usedTokens ?? 0) > 0)) {
|
|
@@ -3010,6 +4504,12 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3010
4504
|
`categories=${formatCursorTokenCategories(tokenDetails)}`
|
|
3011
4505
|
: " context=unavailable";
|
|
3012
4506
|
trace(`checkpoint: stored ${bytes.length}B for conversationId=${session.conversationId}${context}`);
|
|
4507
|
+
if (tokenDetails && !rulesCategoryChecked.has(session) && currentCursorTokenBreakdown(tokenDetails)) {
|
|
4508
|
+
rulesCategoryChecked.add(session);
|
|
4509
|
+
const warning = missingRulesWarning(tokenDetails, systemInstructionsRuleText(session.requestContext)?.length ?? 0, session.conversationId);
|
|
4510
|
+
if (warning)
|
|
4511
|
+
trace(warning);
|
|
4512
|
+
}
|
|
3013
4513
|
}
|
|
3014
4514
|
}
|
|
3015
4515
|
if (iu?.text_delta) {
|
|
@@ -3029,11 +4529,43 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3029
4529
|
return;
|
|
3030
4530
|
}
|
|
3031
4531
|
trace(`turn_ended raw wire fields: ${debugWalkTurnEnded(payload)}`);
|
|
4532
|
+
endingTurns.add(session);
|
|
4533
|
+
keepUndeliveredHostNotes(session);
|
|
4534
|
+
const steers = settleEarlySteers(session.openCodeSessionId, session.sessionId);
|
|
4535
|
+
if (steers.answered && session.openCodeSessionId) {
|
|
4536
|
+
trace(`steer: the model answered ${steers.answered} early steer(s) in this turn`);
|
|
4537
|
+
rememberSteerAnsweringRun(session.openCodeSessionId, session);
|
|
4538
|
+
}
|
|
3032
4539
|
const turnEnded = iu.turn_ended;
|
|
3033
|
-
|
|
4540
|
+
recordTurnEndedCost(session, turnEnded);
|
|
4541
|
+
if (session.openCodeSessionId)
|
|
4542
|
+
finishEmittedStep(session.openCodeSessionId, session.conversationId);
|
|
4543
|
+
confirmRunImagesAtTurnEnd(session);
|
|
4544
|
+
await persistTerminalCheckpoint(session);
|
|
3034
4545
|
const checkpoint = session.resumeCheckpoint ?? getCheckpoint(session.conversationId);
|
|
4546
|
+
if (steers.undelivered.length > 0 && typeof session.reopenWithUserMessage === "function") {
|
|
4547
|
+
// OpenCode will not send these again: they were left out of this turn's continuation.
|
|
4548
|
+
trace(`steer: Cursor did not take ${steers.undelivered.length} early steer(s); sending them as a follow-up Run`);
|
|
4549
|
+
if (session.openCodeSessionId)
|
|
4550
|
+
resumeEmittedStep(session.openCodeSessionId, session.conversationId);
|
|
4551
|
+
try {
|
|
4552
|
+
const beforeFollowup = lastFrameArrival();
|
|
4553
|
+
await session.reopenWithUserMessage(steers.undelivered.map((steer) => steer.text).join("\n\n"), abortSignal);
|
|
4554
|
+
// Revoke the old state, admitting checkpoints received while reopening.
|
|
4555
|
+
invalidateResumeCheckpoint(session, beforeFollowup);
|
|
4556
|
+
endingTurns.delete(session);
|
|
4557
|
+
textBreakPending = true;
|
|
4558
|
+
continue;
|
|
4559
|
+
}
|
|
4560
|
+
catch (error) {
|
|
4561
|
+
// Not transient: recovery would resume a checkpoint without the message.
|
|
4562
|
+
throw new CursorTransportError(`Cursor did not take a message sent during this turn, and sending it again failed: ${error.message}`, { transient: false, replaySafe: false, cause: error });
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
3035
4565
|
if (typeof session.reopenWithUserMessage === "function"
|
|
3036
4566
|
&& !isHostPlanEntryPending(session.openCodeSessionId)
|
|
4567
|
+
// A steer Cursor did not take runs as OpenCode's next step; a nudge would delay it.
|
|
4568
|
+
&& steers.leftToHost === 0
|
|
3037
4569
|
&& checkpoint
|
|
3038
4570
|
&& checkpoint.length > 0
|
|
3039
4571
|
&& shouldContinueProgressOnlyTurn({
|
|
@@ -3046,8 +4578,13 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3046
4578
|
})) {
|
|
3047
4579
|
progressContinuationAttempts += 1;
|
|
3048
4580
|
trace("progress-only: continuing attempt=1");
|
|
4581
|
+
if (session.openCodeSessionId)
|
|
4582
|
+
resumeEmittedStep(session.openCodeSessionId, session.conversationId);
|
|
3049
4583
|
try {
|
|
3050
|
-
|
|
4584
|
+
const beforeFollowup = lastFrameArrival();
|
|
4585
|
+
await session.reopenWithUserMessage(progressOnlyContinuationPrompt(workspaceRootFromRequestContext(session.requestContext)), abortSignal);
|
|
4586
|
+
invalidateResumeCheckpoint(session, beforeFollowup);
|
|
4587
|
+
endingTurns.delete(session);
|
|
3051
4588
|
assistantText = "";
|
|
3052
4589
|
continue;
|
|
3053
4590
|
}
|
|
@@ -3227,6 +4764,10 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3227
4764
|
else if (iu?.step_completed) {
|
|
3228
4765
|
cacheDiagnostics.stepCompletes++;
|
|
3229
4766
|
}
|
|
4767
|
+
else if (iu?.context_injection_state) {
|
|
4768
|
+
if (applyContextInjectionState(session, iu.context_injection_state))
|
|
4769
|
+
textBreakPending = true;
|
|
4770
|
+
}
|
|
3230
4771
|
else if (esm) {
|
|
3231
4772
|
cacheDiagnostics.execRequests++;
|
|
3232
4773
|
const esmId = esm.id ?? 0;
|
|
@@ -3319,8 +4860,58 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3319
4860
|
else {
|
|
3320
4861
|
replaySafety.markBarrier("non-control-exec");
|
|
3321
4862
|
const displayCallId = extractExecDisplayCallId(esm);
|
|
4863
|
+
// Task prerequisites take precedence over the runtime-file reader:
|
|
4864
|
+
// even a terminal/catalog attachment must keep its Task's display owner.
|
|
4865
|
+
if (session.allowTools && esm.read_args) {
|
|
4866
|
+
const parsedRead = parseExecServerMessage(esm, session.hostToolDialect);
|
|
4867
|
+
if (parsedRead) {
|
|
4868
|
+
const attachmentRead = await answerTaskAttachmentRead(parsedRead, displayCallId);
|
|
4869
|
+
if (attachmentRead === false)
|
|
4870
|
+
return;
|
|
4871
|
+
if (attachmentRead)
|
|
4872
|
+
continue;
|
|
4873
|
+
}
|
|
4874
|
+
}
|
|
4875
|
+
const runtimeMessages = await runtimeExecMessages(esm, session.requestContext, session.allowTools);
|
|
4876
|
+
if (runtimeMessages) {
|
|
4877
|
+
for (const message of runtimeMessages) {
|
|
4878
|
+
await writeWithBackpressure(session.stream, message, `runtime metadata reply id=${esmId}`);
|
|
4879
|
+
}
|
|
4880
|
+
if (displayCallId)
|
|
4881
|
+
session.displayToolCalls.delete(displayCallId);
|
|
4882
|
+
disposeParallelCall(displayCallId);
|
|
4883
|
+
if (closeIfParallelStepComplete())
|
|
4884
|
+
return;
|
|
4885
|
+
continue;
|
|
4886
|
+
}
|
|
3322
4887
|
const parsed = parseExecServerMessage(esm, session.hostToolDialect);
|
|
3323
4888
|
if (parsed) {
|
|
4889
|
+
// The model can reach the same internal file through an advertised
|
|
4890
|
+
// canonical read over MCP. It still must not ask the host to approve
|
|
4891
|
+
// access to provider-owned cache metadata.
|
|
4892
|
+
if (parsed.toolName === "read" && parsed.resultField === "mcp_result") {
|
|
4893
|
+
const internalRead = await runtimeExecMessages({
|
|
4894
|
+
id: parsed.id,
|
|
4895
|
+
read_args: { ...parsed.args, path: opencodePathArg(parsed.args) },
|
|
4896
|
+
}, session.requestContext, session.allowTools);
|
|
4897
|
+
if (internalRead) {
|
|
4898
|
+
const reply = decodeMessage("AgentClientMessage", internalRead[0]);
|
|
4899
|
+
const read = reply.exec_client_message.read_result;
|
|
4900
|
+
const error = read.error?.error ?? (read.file_not_found ? "Runtime metadata file was not found" : undefined);
|
|
4901
|
+
const frames = buildExecClientMessages({
|
|
4902
|
+
execId: parsed.id, resultField: "mcp_result", toolName: "read",
|
|
4903
|
+
output: read.success?.content ?? "", error,
|
|
4904
|
+
});
|
|
4905
|
+
for (const message of frames)
|
|
4906
|
+
await writeWithBackpressure(session.stream, message, `runtime MCP read reply id=${esmId}`);
|
|
4907
|
+
if (displayCallId)
|
|
4908
|
+
session.displayToolCalls.delete(displayCallId);
|
|
4909
|
+
disposeParallelCall(displayCallId);
|
|
4910
|
+
if (closeIfParallelStepComplete())
|
|
4911
|
+
return;
|
|
4912
|
+
continue;
|
|
4913
|
+
}
|
|
4914
|
+
}
|
|
3324
4915
|
const executableToolName = resolveCustomWebToolAlias(parsed.toolName, session.toolAliases);
|
|
3325
4916
|
if (executableToolName !== parsed.toolName) {
|
|
3326
4917
|
trace(`web tool alias resolved: ${parsed.toolName} -> ${executableToolName}`);
|
|
@@ -3335,6 +4926,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3335
4926
|
if (display?.variant === "task_tool_call") {
|
|
3336
4927
|
const description = typeof display.args.description === "string" ? display.args.description : undefined;
|
|
3337
4928
|
preferCorrelatedTaskDescription(parsed, description);
|
|
4929
|
+
appendTaskAttachments(parsed, taskToolCallAttachments(stored), workspaceRootFromRequestContext(session.requestContext));
|
|
3338
4930
|
if (description?.trim()) {
|
|
3339
4931
|
trace(`exec: preferred TaskToolCall description callId=${displayCallId} ` +
|
|
3340
4932
|
`description=${JSON.stringify(description.trim())}`);
|
|
@@ -3350,7 +4942,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3350
4942
|
if (!editCall?.completeRead)
|
|
3351
4943
|
rejectPartialReadMutation(parsed);
|
|
3352
4944
|
if (editCall) {
|
|
3353
|
-
const remapped = remapCorrelatedEditWriteForCatalog(parsed,
|
|
4945
|
+
const remapped = remapCorrelatedEditWriteForCatalog(parsed, fileToolNames(), editCall.path, workspaceRootFromRequestContext(session.requestContext));
|
|
3354
4946
|
if (parsed.resultField === "write_result" && displayCallId) {
|
|
3355
4947
|
session.editToolCalls?.delete(displayCallId);
|
|
3356
4948
|
}
|
|
@@ -3359,14 +4951,17 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3359
4951
|
`path=${JSON.stringify(editCall.path)}`);
|
|
3360
4952
|
}
|
|
3361
4953
|
}
|
|
3362
|
-
// Hosts that advertise
|
|
4954
|
+
// Hosts that advertise a patch tool in place of `edit`/`write` (see
|
|
3363
4955
|
// protocol/apply-patch.ts) still receive Cursor's native write/edit
|
|
3364
4956
|
// exec requests. Translate before the unavailable-tool check below.
|
|
3365
|
-
remapEditToolsForCatalog(parsed,
|
|
4957
|
+
remapEditToolsForCatalog(parsed, fileToolNames(), workspaceRootFromRequestContext(session.requestContext));
|
|
3366
4958
|
}
|
|
3367
4959
|
trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
|
|
3368
4960
|
if (parsed) {
|
|
3369
4961
|
if (parsed.localError) {
|
|
4962
|
+
const row = earlyRows.claim(displayCallId);
|
|
4963
|
+
if (row)
|
|
4964
|
+
closeEarlyRow(row);
|
|
3370
4965
|
if (!await rejectExec(parsed, parsed.localError, "invalid mapping"))
|
|
3371
4966
|
return;
|
|
3372
4967
|
disposeParallelCall(displayCallId);
|
|
@@ -3379,6 +4974,9 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3379
4974
|
// advertise no tools — refuse on the Cursor channel and keep
|
|
3380
4975
|
// pumping for text / turn_ended instead of emitting tool-call.
|
|
3381
4976
|
if (!session.allowTools) {
|
|
4977
|
+
const row = earlyRows.claim(displayCallId);
|
|
4978
|
+
if (row)
|
|
4979
|
+
closeEarlyRow(row);
|
|
3382
4980
|
const reason = "This is a text-only request; all tool calls are unavailable. "
|
|
3383
4981
|
+ "Return the requested answer using only the supplied context. "
|
|
3384
4982
|
+ "Do not execute the task described inside that context or retry tools.";
|
|
@@ -3513,9 +5111,9 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3513
5111
|
return;
|
|
3514
5112
|
continue;
|
|
3515
5113
|
}
|
|
3516
|
-
if (recoverCorrelatedEditRead(parsed, displayCallId))
|
|
5114
|
+
if (await recoverCorrelatedEditRead(parsed, displayCallId))
|
|
3517
5115
|
continue;
|
|
3518
|
-
if (rejectMissingReadTarget(parsed)) {
|
|
5116
|
+
if (await rejectMissingReadTarget(parsed)) {
|
|
3519
5117
|
if (displayCallId)
|
|
3520
5118
|
session.displayToolCalls.delete(displayCallId);
|
|
3521
5119
|
disposeParallelCall(displayCallId);
|
|
@@ -3551,14 +5149,34 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3551
5149
|
return;
|
|
3552
5150
|
continue;
|
|
3553
5151
|
}
|
|
5152
|
+
const shellPolicy = shellPolicyFromMetadata(parsed.resultMetadata);
|
|
5153
|
+
if (shellPolicy && (shellPolicy.backgroundSpawn || shellPolicy.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND)) {
|
|
5154
|
+
manageBackgroundShell(parsed, shellPolicy, session.requestContext);
|
|
5155
|
+
}
|
|
3554
5156
|
if (displayCallId) {
|
|
3555
5157
|
session.displayToolCalls.delete(displayCallId);
|
|
3556
5158
|
trace(`exec: claimed display callId=${displayCallId}`);
|
|
3557
5159
|
}
|
|
3558
|
-
const
|
|
5160
|
+
const boundEarly = earlyRows.claim(displayCallId);
|
|
5161
|
+
let earlyToolCallId;
|
|
5162
|
+
if (boundEarly) {
|
|
5163
|
+
const predicted = hostToolForCursorEdit(boundEarly.path, fileToolNames(), workspaceRootFromRequestContext(session.requestContext));
|
|
5164
|
+
if (parsed.toolName !== boundEarly.toolName || predicted !== boundEarly.toolName) {
|
|
5165
|
+
closeEarlyRow(boundEarly, parsed.toolName);
|
|
5166
|
+
}
|
|
5167
|
+
else {
|
|
5168
|
+
replaySafety.markBarrier("visible-tool-row");
|
|
5169
|
+
safeEnqueue({ type: "tool-input-end", id: boundEarly.toolCallId });
|
|
5170
|
+
sessionManager.bindToolCall(boundEarly.toolCallId, session.sessionId, parsed.id);
|
|
5171
|
+
earlyToolCallId = boundEarly.toolCallId;
|
|
5172
|
+
}
|
|
5173
|
+
}
|
|
5174
|
+
const built = buildToolCallPart(parsed, session.sessionId);
|
|
5175
|
+
const tc = earlyToolCallId ? { ...built, toolCallId: earlyToolCallId } : built;
|
|
3559
5176
|
if (parsed.resultField === "shell_stream"
|
|
3560
5177
|
|| parsed.resultField === "shell_result"
|
|
3561
|
-
|| parsed.resultField === "background_shell_spawn_result"
|
|
5178
|
+
|| parsed.resultField === "background_shell_spawn_result"
|
|
5179
|
+
|| shellPolicy !== undefined) {
|
|
3562
5180
|
registerCursorShellCall(tc.toolCallId, parsed.resultMetadata);
|
|
3563
5181
|
}
|
|
3564
5182
|
// Keep the stream open; the result arrives on the next doStream call.
|
|
@@ -3575,6 +5193,8 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3575
5193
|
}
|
|
3576
5194
|
// tc.input is already a JSON string (LanguageModelV3ToolCall.input).
|
|
3577
5195
|
trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
|
|
5196
|
+
if (!(session.parallelStep && shouldHoldParallelStep(session.parallelStep)))
|
|
5197
|
+
settleOpenEarlyRows();
|
|
3578
5198
|
emittedHostTools++;
|
|
3579
5199
|
// Close open text/reasoning spans before tool-call (required by AI SDK V3).
|
|
3580
5200
|
closeOpenSpans();
|
|
@@ -3855,6 +5475,9 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3855
5475
|
return;
|
|
3856
5476
|
}
|
|
3857
5477
|
}
|
|
5478
|
+
else if (execControl) {
|
|
5479
|
+
applyExecControl(session, execControl, "pump");
|
|
5480
|
+
}
|
|
3858
5481
|
else if (kv) {
|
|
3859
5482
|
// KV blob channel: ack set_blob / answer get_blob, then keep pumping.
|
|
3860
5483
|
// Not replying hangs the turn — see protocol/kv.ts.
|
|
@@ -3888,6 +5511,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3888
5511
|
}
|
|
3889
5512
|
catch (e) {
|
|
3890
5513
|
if (e instanceof CursorProviderError) {
|
|
5514
|
+
settleOpenEarlyRows();
|
|
3891
5515
|
if (finishInterruptedTools("remote-error", e))
|
|
3892
5516
|
return;
|
|
3893
5517
|
throw e;
|
|
@@ -3899,9 +5523,44 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
3899
5523
|
// exec/args decode) must not abort the whole turn — log and skip.
|
|
3900
5524
|
trace(`frame dispatch FAILED (skipping): topField=${topField}`);
|
|
3901
5525
|
}
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
5526
|
+
const announced = iu?.partial_tool_call ? cursorEditAnnouncement(iu.partial_tool_call) : undefined;
|
|
5527
|
+
if (announced
|
|
5528
|
+
&& !earlyRows.seen(announced.callId)
|
|
5529
|
+
&& shouldOpenEarlyRow({
|
|
5530
|
+
allowTools: session.allowTools,
|
|
5531
|
+
hostInterrupted: !!session.hostInterrupted,
|
|
5532
|
+
holdingParallelStep: !!session.parallelStep && shouldHoldParallelStep(session.parallelStep),
|
|
5533
|
+
emittedHostTools,
|
|
5534
|
+
owedPending: owedPendingCount(),
|
|
5535
|
+
})) {
|
|
5536
|
+
const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
|
|
5537
|
+
// An edit whose private read goes to the host gets that read as its first
|
|
5538
|
+
// exec, which would close the row as a failed edit.
|
|
5539
|
+
const toolName = cursorEditReadReachesHost(path.resolve(workspaceRoot, announced.path), workspaceRoot)
|
|
5540
|
+
? undefined
|
|
5541
|
+
: hostToolForCursorEdit(announced.path, fileToolNames(), workspaceRoot);
|
|
5542
|
+
if (!toolName)
|
|
5543
|
+
earlyRows.skip(announced.callId);
|
|
5544
|
+
else {
|
|
5545
|
+
const row = {
|
|
5546
|
+
cursorCallId: announced.callId,
|
|
5547
|
+
toolCallId: earlyRowToolCallId(session.sessionId, announced.callId),
|
|
5548
|
+
toolName,
|
|
5549
|
+
path: announced.path,
|
|
5550
|
+
};
|
|
5551
|
+
earlyRows.openRow(row);
|
|
5552
|
+
replaySafety.markBarrier("visible-tool-row");
|
|
5553
|
+
safeEnqueue({
|
|
5554
|
+
type: "tool-input-start",
|
|
5555
|
+
id: row.toolCallId,
|
|
5556
|
+
toolName: row.toolName,
|
|
5557
|
+
});
|
|
5558
|
+
}
|
|
5559
|
+
}
|
|
5560
|
+
// heartbeat / step / tool-input deltas stay display-only. An edit
|
|
5561
|
+
// partial with a path may have opened a row above; the exec channel
|
|
5562
|
+
// still decides the call. Checkpoints and interaction queries are
|
|
5563
|
+
// handled above.
|
|
3905
5564
|
}
|
|
3906
5565
|
}
|
|
3907
5566
|
/** Normalize protobufjs bytes / Buffer / number[] into a Uint8Array. */
|
|
@@ -3922,7 +5581,9 @@ function normalizeCheckpointBytes(raw) {
|
|
|
3922
5581
|
}
|
|
3923
5582
|
function correlateToolResults(results) {
|
|
3924
5583
|
return results.flatMap(result => {
|
|
3925
|
-
const parsed = parseExecIdFromToolCallId(result.toolCallId)
|
|
5584
|
+
const parsed = parseExecIdFromToolCallId(result.toolCallId)
|
|
5585
|
+
?? sessionManager.toolCallExec(result.toolCallId)
|
|
5586
|
+
?? parseEarlyRowToolCallId(result.toolCallId);
|
|
3926
5587
|
return parsed ? [{ ...result, ...parsed }] : [];
|
|
3927
5588
|
});
|
|
3928
5589
|
}
|
|
@@ -3936,14 +5597,22 @@ function extractToolResultContent(prompt) {
|
|
|
3936
5597
|
if (p.type !== "tool-result")
|
|
3937
5598
|
continue;
|
|
3938
5599
|
const toolCallId = p.toolCallId ?? "";
|
|
3939
|
-
const
|
|
3940
|
-
const
|
|
5600
|
+
const failure = toolResultFailure(p.providerOptions, toolCallId);
|
|
5601
|
+
const normalized = toolResultOutputToText(p.output);
|
|
5602
|
+
const text = failure?.text ?? normalized.text;
|
|
5603
|
+
const isError = failure !== undefined || normalized.isError;
|
|
5604
|
+
const media = isError ? [] : toolResultOutputMedia(p.output);
|
|
5605
|
+
const toolName = p.toolName ?? "mcp";
|
|
5606
|
+
const instruction = toolName === "read" && !isError ? extractOpenCodeReadInstruction(text) : undefined;
|
|
3941
5607
|
out.push({
|
|
3942
5608
|
toolCallId,
|
|
3943
|
-
toolName
|
|
5609
|
+
toolName,
|
|
3944
5610
|
output: text,
|
|
3945
|
-
error: isError ? text : undefined,
|
|
5611
|
+
error: isError ? text || "OpenCode tool execution failed" : undefined,
|
|
5612
|
+
...(failure?.refusal ? { refusal: failure.refusal } : {}),
|
|
5613
|
+
...(failure?.type === "aborted" ? { interrupted: true } : {}),
|
|
3946
5614
|
...(media.length > 0 ? { media } : {}),
|
|
5615
|
+
...(instruction ? { notes: [instruction] } : {}),
|
|
3947
5616
|
});
|
|
3948
5617
|
}
|
|
3949
5618
|
}
|
|
@@ -3959,6 +5628,14 @@ const SYSTEM_UPDATE_CLOSE = "</system-update>";
|
|
|
3959
5628
|
const SYSTEM_REMINDER_OPEN = "<system-reminder>";
|
|
3960
5629
|
const SYSTEM_REMINDER_CLOSE = "</system-reminder>";
|
|
3961
5630
|
const TOOL_MEDIA_CAPTION = "Attached media from tool result:";
|
|
5631
|
+
// OpenCode 2 loads a nested AGENTS.md during a step as a synthetic user message
|
|
5632
|
+
// of `Instructions from: <absolute path>\n<content>` blocks (the same text
|
|
5633
|
+
// OpenCode 1 puts in a read's <system-reminder>). No marker reaches the provider.
|
|
5634
|
+
const OPENCODE_INSTRUCTIONS = /^Instructions from: (?:\/|[A-Za-z]:[\\/])/;
|
|
5635
|
+
// OpenCode 2 ShellResult.notification, packages/core/src/shell/result.ts.
|
|
5636
|
+
// Its raw command may contain quotes or newlines. Keep the complete notice as
|
|
5637
|
+
// context; do not derive execution status by parsing its command or output.
|
|
5638
|
+
const OPENCODE_SHELL_NOTIFICATION = /^<shell id="[^"\r\n]+" state="(?:completed|cancelled|error)" command="[\s\S]*">\r?\n[\s\S]*\r?\n<\/shell>$/;
|
|
3962
5639
|
function wrappedHostNote(text, open, close) {
|
|
3963
5640
|
return text.startsWith(open) && text.endsWith(close);
|
|
3964
5641
|
}
|
|
@@ -3977,7 +5654,9 @@ function hostTailNote(message) {
|
|
|
3977
5654
|
if (part.type !== "text")
|
|
3978
5655
|
return undefined;
|
|
3979
5656
|
const text = part.text.trim();
|
|
3980
|
-
if (wrappedHostNote(text, SYSTEM_UPDATE_OPEN, SYSTEM_UPDATE_CLOSE)
|
|
5657
|
+
if (wrappedHostNote(text, SYSTEM_UPDATE_OPEN, SYSTEM_UPDATE_CLOSE)
|
|
5658
|
+
|| OPENCODE_INSTRUCTIONS.test(text)
|
|
5659
|
+
|| OPENCODE_SHELL_NOTIFICATION.test(text)) {
|
|
3981
5660
|
texts.push(text);
|
|
3982
5661
|
append = true;
|
|
3983
5662
|
continue;
|
|
@@ -3994,6 +5673,27 @@ function hostTailNote(message) {
|
|
|
3994
5673
|
return reminderOnly ? {} : undefined;
|
|
3995
5674
|
return { text: texts.join("\n") };
|
|
3996
5675
|
}
|
|
5676
|
+
/**
|
|
5677
|
+
* Host notes between the previous reply and the closing user message, e.g. an
|
|
5678
|
+
* OpenCode 2 `<system-update>` after MCP servers reconnect. The host's leading
|
|
5679
|
+
* system prompt is never one of them. Empty unless the prompt ends with a
|
|
5680
|
+
* user message that is not itself a note.
|
|
5681
|
+
*/
|
|
5682
|
+
export function hostNotesBeforeUserTurn(prompt) {
|
|
5683
|
+
const last = prompt.length - 1;
|
|
5684
|
+
if (last < 0 || prompt[last].role !== "user" || hostTailNote(prompt[last]))
|
|
5685
|
+
return [];
|
|
5686
|
+
const firstNonSystem = prompt.findIndex((message) => message.role !== "system");
|
|
5687
|
+
const notes = [];
|
|
5688
|
+
for (let index = last - 1; index > firstNonSystem; index--) {
|
|
5689
|
+
const note = hostTailNote(prompt[index]);
|
|
5690
|
+
if (!note)
|
|
5691
|
+
break;
|
|
5692
|
+
if (note.text)
|
|
5693
|
+
notes.unshift(note.text);
|
|
5694
|
+
}
|
|
5695
|
+
return notes;
|
|
5696
|
+
}
|
|
3997
5697
|
/**
|
|
3998
5698
|
* Split off host notes that trail the live tool results. They are not a new
|
|
3999
5699
|
* user turn: the held Run must still receive its tool results.
|
|
@@ -4029,13 +5729,11 @@ export function extractTrailingToolResults(prompt) {
|
|
|
4029
5729
|
if (media.length > 0)
|
|
4030
5730
|
attributeTrailingMedia(contents, media);
|
|
4031
5731
|
const results = correlateToolResults(contents);
|
|
4032
|
-
//
|
|
4033
|
-
//
|
|
5732
|
+
// Read instructions occurred with their results, before the step's trailing
|
|
5733
|
+
// updates. Keep that order when the delivery injects their combined text.
|
|
4034
5734
|
const last = results.at(-1);
|
|
4035
5735
|
if (last && notes.length > 0) {
|
|
4036
|
-
results[results.length - 1] = {
|
|
4037
|
-
...last, output: [last.output, ...notes].filter(Boolean).join("\n\n"), notes: notes.join("\n\n"),
|
|
4038
|
-
};
|
|
5736
|
+
results[results.length - 1] = { ...last, notes: [...(last.notes ?? []), ...notes] };
|
|
4039
5737
|
}
|
|
4040
5738
|
return results;
|
|
4041
5739
|
}
|
|
@@ -4052,12 +5750,16 @@ function attributeTrailingMedia(results, media) {
|
|
|
4052
5750
|
if (results.length === 0)
|
|
4053
5751
|
return;
|
|
4054
5752
|
if (results.length === 1) {
|
|
5753
|
+
if (results[0].error !== undefined)
|
|
5754
|
+
return;
|
|
4055
5755
|
if (results[0].toolName === "read" && !MEDIA_READ_OUTPUTS.has(results[0].output))
|
|
4056
5756
|
return;
|
|
4057
5757
|
results[0].media = [...(results[0].media ?? []), ...media];
|
|
4058
5758
|
return;
|
|
4059
5759
|
}
|
|
4060
5760
|
const bounds = results.map(result => {
|
|
5761
|
+
if (result.error !== undefined)
|
|
5762
|
+
return { min: 0, max: 0 };
|
|
4061
5763
|
if (result.toolName === "read") {
|
|
4062
5764
|
const count = MEDIA_READ_OUTPUTS.has(result.output) ? 1 : 0;
|
|
4063
5765
|
return { min: count, max: count };
|
|
@@ -4066,7 +5768,7 @@ function attributeTrailingMedia(results, media) {
|
|
|
4066
5768
|
return { min: 1, max: media.length };
|
|
4067
5769
|
// These canonical tools have text output only. Opaque MCP tools remain
|
|
4068
5770
|
// unconstrained, even if another tool also returned descriptive text.
|
|
4069
|
-
if (["glob", "grep", "ls", "edit", "write", "
|
|
5771
|
+
if (["glob", "grep", "ls", "edit", "write", "todowrite", "todoread"].includes(result.toolName) || isHostPatchTool(result.toolName)) {
|
|
4070
5772
|
return { min: 0, max: 0 };
|
|
4071
5773
|
}
|
|
4072
5774
|
return { min: 0, max: media.length };
|
|
@@ -4117,17 +5819,45 @@ export async function decodeTrailingToolImages(session, results, signal, maxByte
|
|
|
4117
5819
|
const notice = omittedCount > 0
|
|
4118
5820
|
? `[${omittedCount} tool-result image(s) omitted: decoding failed or the attachment budget was exceeded.]`
|
|
4119
5821
|
: undefined;
|
|
5822
|
+
// A read that kept an image is encoded from its bytes, so output text would
|
|
5823
|
+
// be dropped; its notice rides on that result as hook context instead.
|
|
5824
|
+
const noticeAsContext = notice !== undefined && pending.resultField === "read_result" && images.length > 0;
|
|
4120
5825
|
decoded.push({
|
|
4121
5826
|
...result, images, imageHashes: hashes,
|
|
4122
|
-
...(notice ? {
|
|
5827
|
+
...(notice && !noticeAsContext ? {
|
|
4123
5828
|
output: `${result.output}\n\n${notice}`,
|
|
4124
|
-
|
|
4125
|
-
...(pending.resultField === "read_result" && images.length === 0 ? { error: notice } : {}),
|
|
5829
|
+
...(pending.resultField === "read_result" ? { error: notice } : {}),
|
|
4126
5830
|
} : {}),
|
|
5831
|
+
...(noticeAsContext ? { notices: [...(result.notices ?? []), notice] } : {}),
|
|
4127
5832
|
});
|
|
4128
5833
|
}
|
|
4129
5834
|
return decoded;
|
|
4130
5835
|
}
|
|
5836
|
+
/** Debug summary of the last prompt messages: roles, part kinds, short text previews. */
|
|
5837
|
+
export function describePromptTail(prompt, count = 4) {
|
|
5838
|
+
const preview = (text) => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}…` : text);
|
|
5839
|
+
return prompt.slice(Math.max(0, prompt.length - count)).map((m) => {
|
|
5840
|
+
if (!Array.isArray(m.content))
|
|
5841
|
+
return `${m.role}:scalar${typeof m.content === "string" ? `(${preview(m.content)})` : ""}`;
|
|
5842
|
+
const kinds = m.content.map((p) => {
|
|
5843
|
+
const part = p;
|
|
5844
|
+
if (part.type === "tool-result") {
|
|
5845
|
+
const out = part.output;
|
|
5846
|
+
const value = out?.value;
|
|
5847
|
+
const nonText = Array.isArray(value)
|
|
5848
|
+
? value.filter((item) => item && typeof item === "object" && item.type !== "text").length
|
|
5849
|
+
: 0;
|
|
5850
|
+
return `tool-result(${part.toolName},${typeof out?.type === "string" ? out.type : "?"},mediaish=${nonText})`;
|
|
5851
|
+
}
|
|
5852
|
+
if (part.type === "text" && typeof part.text === "string")
|
|
5853
|
+
return `text(${preview(part.text)})`;
|
|
5854
|
+
return String(part.type ?? "?");
|
|
5855
|
+
});
|
|
5856
|
+
const options = m.providerOptions;
|
|
5857
|
+
const marks = options ? `{${Object.entries(options).map(([k, v]) => `${k}:${JSON.stringify(v).slice(0, 80)}`).join(";")}}` : "";
|
|
5858
|
+
return `${m.role}${marks}:[${kinds.join(",")}]`;
|
|
5859
|
+
}).join(" | ");
|
|
5860
|
+
}
|
|
4131
5861
|
/** Detect a host-owned canonical plan review, excluding Cursor exec replies. */
|
|
4132
5862
|
export function hasApprovedUncorrelatedPlanStageResult(prompt) {
|
|
4133
5863
|
const { end } = liveTail(prompt);
|
|
@@ -4143,7 +5873,7 @@ export function hasApprovedUncorrelatedPlanStageResult(prompt) {
|
|
|
4143
5873
|
if (parseExecIdFromToolCallId(part.toolCallId))
|
|
4144
5874
|
continue;
|
|
4145
5875
|
const result = toolResultOutputToText(part.output);
|
|
4146
|
-
return !result.isError;
|
|
5876
|
+
return !result.isError && !toolResultFailure(part.providerOptions, part.toolCallId);
|
|
4147
5877
|
}
|
|
4148
5878
|
}
|
|
4149
5879
|
return false;
|
|
@@ -4157,10 +5887,10 @@ function toolResultOutputToText(output) {
|
|
|
4157
5887
|
// LanguageModelV3 tool-result output: { type: "text"|"json"|"error-text"|..., value }
|
|
4158
5888
|
const isError = o.type === "execution-denied" || (typeof o.type === "string" && o.type.startsWith("error"));
|
|
4159
5889
|
if (o.type === "execution-denied") {
|
|
4160
|
-
return { text: String(o.reason
|
|
5890
|
+
return { text: String(o.reason || "Tool execution denied"), isError: true };
|
|
4161
5891
|
}
|
|
4162
5892
|
if (o.type === "text" || o.type === "error-text") {
|
|
4163
|
-
return { text: String(o.value ?? ""), isError };
|
|
5893
|
+
return { text: String(o.value ?? "") || (isError ? "OpenCode tool execution failed" : ""), isError };
|
|
4164
5894
|
}
|
|
4165
5895
|
if (o.type === "json" || o.type === "error-json") {
|
|
4166
5896
|
return { text: JSON.stringify(o.value ?? null), isError };
|
|
@@ -4225,6 +5955,7 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
4225
5955
|
const names = new Set(tools.map((tool) => tool.name));
|
|
4226
5956
|
if (names.size === 0)
|
|
4227
5957
|
return undefined;
|
|
5958
|
+
const patchTool = hostPatchTool(names);
|
|
4228
5959
|
const instructions = [];
|
|
4229
5960
|
const subagents = extractHostSubagentCatalog(tools);
|
|
4230
5961
|
const planEntryHandoff = !names.has("plan_enter") && hostAgentModeSwitchKind() === "resumes";
|
|
@@ -4332,16 +6063,16 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
4332
6063
|
? "- For file changes, use OpenCode `edit` for targeted changes to existing files and `write` to create files or intentionally replace complete contents; do not use shell, Python, or heredocs to change file content while these tools are available."
|
|
4333
6064
|
: "- Use OpenCode `write` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available.");
|
|
4334
6065
|
}
|
|
4335
|
-
else if (
|
|
4336
|
-
// This host withheld `edit`/`write` and offers
|
|
6066
|
+
else if (patchTool) {
|
|
6067
|
+
// This host withheld `edit`/`write` and offers a patch tool instead. Native
|
|
4337
6068
|
// Cursor write/edit requests are translated into it, so say so rather than
|
|
4338
6069
|
// leaving the turn with no file-editing guidance at all.
|
|
4339
|
-
instructions.push(
|
|
6070
|
+
instructions.push(`- Use OpenCode \`${patchTool}\` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available. Cursor-native write and edit requests are accepted and converted to \`${patchTool}\` automatically.`);
|
|
4340
6071
|
}
|
|
4341
6072
|
if (hostToolDialectFromTools(tools).filePathKey === "path") {
|
|
4342
6073
|
instructions.push("- OpenCode file tools take `path` as an absolute path under the workspace root above. Do not pass a project-relative path, and do not invent a different absolute prefix.");
|
|
4343
6074
|
}
|
|
4344
|
-
if (names.has("edit") || names.has("write") ||
|
|
6075
|
+
if (names.has("edit") || names.has("write") || patchTool) {
|
|
4345
6076
|
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.");
|
|
4346
6077
|
}
|
|
4347
6078
|
return [
|
|
@@ -4370,6 +6101,18 @@ export function estimateTokens(chars) {
|
|
|
4370
6101
|
return 0;
|
|
4371
6102
|
return Math.ceil(chars / 4);
|
|
4372
6103
|
}
|
|
6104
|
+
/**
|
|
6105
|
+
* Add the catalog-priced cost for a finished Run to its session's ledger. Called
|
|
6106
|
+
* once per `TurnEnded`, including Runs no OpenCode step reports (a drained
|
|
6107
|
+
* prior Run, a progress-only continuation); a later priced turn-end attempts to settle it.
|
|
6108
|
+
*/
|
|
6109
|
+
function recordTurnEndedCost(session, turnEnded) {
|
|
6110
|
+
const realUsd = turnEndedCostUsd(session.billing.cost, cursorUsageCountersFromTurnEnded(turnEnded), session.tokenDetails?.usedTokens ?? 0);
|
|
6111
|
+
if (realUsd === undefined)
|
|
6112
|
+
return;
|
|
6113
|
+
billingLedger.recordReal(session.billing.key, realUsd, session.billing);
|
|
6114
|
+
trace(`billing: turn real=${realUsd.toFixed(6)} key=${session.billing.key}`);
|
|
6115
|
+
}
|
|
4373
6116
|
/** Preserve exact request-local Cursor counters as diagnostics. */
|
|
4374
6117
|
export function cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource) {
|
|
4375
6118
|
return {
|
|
@@ -4386,6 +6129,51 @@ export function cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource)
|
|
|
4386
6129
|
},
|
|
4387
6130
|
};
|
|
4388
6131
|
}
|
|
6132
|
+
/** Partial assistant output may grow on a host retry; it is not a new user turn. */
|
|
6133
|
+
export function retryTurnKey(prompt, withoutLastInterruptionNotice = false) {
|
|
6134
|
+
// OpenCode 2 runner/llm.ts emits this synthetic after an incomplete stream;
|
|
6135
|
+
// runner/to-llm-message.ts lowers it to an ordinary user text part. Limit
|
|
6136
|
+
// recognition to exact, standalone text after assistant output. A plugin's
|
|
6137
|
+
// trailing host-note parts do not identify a user turn: they may appear only
|
|
6138
|
+
// on this request and disappear from the next request's history. Normalize
|
|
6139
|
+
// them for hashing only. The budget admits the continuation only after a
|
|
6140
|
+
// requested host retry and remembers its full key across tool steps.
|
|
6141
|
+
const interrupted = "The previous response was interrupted. Continue from where you left off without repeating completed content.";
|
|
6142
|
+
const messages = prompt.filter(message => message.role !== "system" && !hostTailNote(message)).map(message => {
|
|
6143
|
+
if (message.role !== "user")
|
|
6144
|
+
return message;
|
|
6145
|
+
let end = message.content.length;
|
|
6146
|
+
while (end > 1 && hostTailNote({ role: "user", content: [message.content[end - 1]] }) !== undefined)
|
|
6147
|
+
end--;
|
|
6148
|
+
return end === message.content.length ? message : { ...message, content: message.content.slice(0, end) };
|
|
6149
|
+
});
|
|
6150
|
+
let end = messages.length;
|
|
6151
|
+
while (end > 0 && messages[end - 1].role !== "user")
|
|
6152
|
+
end--;
|
|
6153
|
+
const last = messages[end - 1];
|
|
6154
|
+
if (withoutLastInterruptionNotice && last?.role === "user" && last.content.length === 1
|
|
6155
|
+
&& last.content[0]?.type === "text" && last.content[0].text === interrupted
|
|
6156
|
+
&& messages[end - 2]?.role === "assistant") {
|
|
6157
|
+
// Remove only this continuation, retaining earlier literal user requests.
|
|
6158
|
+
end--;
|
|
6159
|
+
while (end > 0 && messages[end - 1].role !== "user")
|
|
6160
|
+
end--;
|
|
6161
|
+
}
|
|
6162
|
+
return createHash("sha256").update(JSON.stringify(messages.slice(0, end))).digest("hex");
|
|
6163
|
+
}
|
|
6164
|
+
/** All user messages after the last assistant/tool boundary form one live user turn. */
|
|
6165
|
+
export function liveUserTurn(prompt) {
|
|
6166
|
+
let start = prompt.length;
|
|
6167
|
+
while (start > 0 && (prompt[start - 1].role === "user" || prompt[start - 1].role === "system"))
|
|
6168
|
+
start--;
|
|
6169
|
+
if (start === 0)
|
|
6170
|
+
while (start < prompt.length && prompt[start].role === "system")
|
|
6171
|
+
start++;
|
|
6172
|
+
if (start === prompt.length)
|
|
6173
|
+
return undefined;
|
|
6174
|
+
const texts = prompt.slice(start).flatMap((message) => message.role === "user" && !hostTailNote(message) ? [extractUserText(message)] : []);
|
|
6175
|
+
return { start, text: texts.join("\n\n") || "." };
|
|
6176
|
+
}
|
|
4389
6177
|
/**
|
|
4390
6178
|
* Prior prompt turns for a seed ConversationStateStructure. Tool results must
|
|
4391
6179
|
* never be replayed as assistant-authored prose: that teaches the model to
|
|
@@ -4399,11 +6187,20 @@ export function extractPromptHistory(prompt, options) {
|
|
|
4399
6187
|
const toolResults = options?.toolResults ?? "omit";
|
|
4400
6188
|
let trailingToolStart = prompt.length;
|
|
4401
6189
|
if (toolResults === "trailing") {
|
|
6190
|
+
// A rebased continuation may end with host notes after the step's results.
|
|
6191
|
+
let notesStart = prompt.length;
|
|
6192
|
+
while (notesStart > 0 && hostTailNote(prompt[notesStart - 1]))
|
|
6193
|
+
notesStart--;
|
|
6194
|
+
trailingToolStart = notesStart;
|
|
4402
6195
|
while (trailingToolStart > 0 && prompt[trailingToolStart - 1]?.role === "tool") {
|
|
4403
6196
|
trailingToolStart--;
|
|
4404
6197
|
}
|
|
6198
|
+
if (trailingToolStart === notesStart)
|
|
6199
|
+
trailingToolStart = prompt.length;
|
|
4405
6200
|
}
|
|
4406
6201
|
for (let messageIndex = 0; messageIndex < prompt.length; messageIndex++) {
|
|
6202
|
+
if (!options?.preserveTrailingUser && options?.liveTurnStart !== undefined && messageIndex >= options.liveTurnStart)
|
|
6203
|
+
break;
|
|
4407
6204
|
const m = prompt[messageIndex];
|
|
4408
6205
|
if (m.role === "system") {
|
|
4409
6206
|
if (typeof m.content === "string" && m.content.length > 0) {
|
|
@@ -4434,7 +6231,8 @@ export function extractPromptHistory(prompt, options) {
|
|
|
4434
6231
|
continue;
|
|
4435
6232
|
const toolName = typeof p.toolName === "string" && p.toolName ? p.toolName : "tool";
|
|
4436
6233
|
const toolCallId = typeof p.toolCallId === "string" ? p.toolCallId : "";
|
|
4437
|
-
const
|
|
6234
|
+
const failure = toolResultFailure(p.providerOptions, toolCallId);
|
|
6235
|
+
const result = failure ? { text: failure.text, isError: true } : toolResultOutputToText(p.output);
|
|
4438
6236
|
results.push(formatSeedToolObservation({
|
|
4439
6237
|
toolName,
|
|
4440
6238
|
toolCallId,
|
|
@@ -4447,7 +6245,7 @@ export function extractPromptHistory(prompt, options) {
|
|
|
4447
6245
|
}
|
|
4448
6246
|
}
|
|
4449
6247
|
// Live user message is the Run action, not seed history.
|
|
4450
|
-
if (!options?.preserveTrailingUser && out.length > 0 && out[out.length - 1].role === "user") {
|
|
6248
|
+
if (options?.liveTurnStart === undefined && !options?.preserveTrailingUser && out.length > 0 && out[out.length - 1].role === "user") {
|
|
4451
6249
|
out.pop();
|
|
4452
6250
|
}
|
|
4453
6251
|
return out;
|
|
@@ -4495,9 +6293,12 @@ export const FOREIGN_HISTORY_REBASE_CONTEXT_SHARE = 0.8;
|
|
|
4495
6293
|
*/
|
|
4496
6294
|
export function assertForeignHistoryRebaseFits(input) {
|
|
4497
6295
|
const documented = getDocumentedCursorModelContext(input.cursorModelId);
|
|
4498
|
-
|
|
6296
|
+
// Max mode does not imply the model's largest tier: Opus 300k Fast is a
|
|
6297
|
+
// max-mode variant with a 300k window.
|
|
6298
|
+
const variantContext = parseCursorContextLimit(input.parameters?.find((parameter) => parameter.id === "context")?.value);
|
|
6299
|
+
const limit = variantContext ?? (input.maxMode
|
|
4499
6300
|
? (input.modelInfo?.maxContextForMaxMode ?? documented?.maxContextForMaxMode ?? 1_000_000)
|
|
4500
|
-
: (input.modelInfo?.maxContext ?? documented?.maxContext ?? 200_000);
|
|
6301
|
+
: (input.modelInfo?.maxContext ?? documented?.maxContext ?? 200_000));
|
|
4501
6302
|
const chars = input.history.reduce((sum, message) => sum + message.content.length, 0)
|
|
4502
6303
|
+ (input.systemPrompt?.length ?? 0)
|
|
4503
6304
|
+ input.userText.length;
|
|
@@ -4552,9 +6353,7 @@ export async function refreshHeldSessionToolCatalog(session, callOptions) {
|
|
|
4552
6353
|
const sessionKey = session.openCodeSessionId;
|
|
4553
6354
|
const previousAdvertised = session.toolCatalog?.length ?? 0;
|
|
4554
6355
|
const incomingTools = extractTools(callOptions);
|
|
4555
|
-
const
|
|
4556
|
-
const compactionOption = providerOptions?.[CURSOR_COMPACTION_OPTION];
|
|
4557
|
-
const isCompaction = compactionOption === true || (compactionOption === undefined && !!sessionKey && isCompactionSession(sessionKey));
|
|
6356
|
+
const isCompaction = isCompactionCall(callOptions, sessionKey);
|
|
4558
6357
|
const toolState = await resolveTurnToolState({
|
|
4559
6358
|
sessionKey,
|
|
4560
6359
|
incomingTools,
|
|
@@ -4662,6 +6461,19 @@ function toolsInFixedOrder(tools) {
|
|
|
4662
6461
|
});
|
|
4663
6462
|
}
|
|
4664
6463
|
/** Exported for tests — false for compaction/summary (no tools) and toolChoice none. */
|
|
6464
|
+
/**
|
|
6465
|
+
* The classic plugin marks OpenCode's agent="compaction" through chat.params.
|
|
6466
|
+
* OpenCode 2.0 removed that hook, so its plugin writes the same request-local
|
|
6467
|
+
* option from session hooks. The session marker is a fallback only when a
|
|
6468
|
+
* host does not preserve mutable options; explicit false prevents concurrent
|
|
6469
|
+
* title/generate calls from inheriting a compaction marker.
|
|
6470
|
+
* Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
|
|
6471
|
+
*/
|
|
6472
|
+
function isCompactionCall(callOptions, sessionKey) {
|
|
6473
|
+
const providerOptions = callOptions.providerOptions?.cursor;
|
|
6474
|
+
const compactionOption = providerOptions?.[CURSOR_COMPACTION_OPTION];
|
|
6475
|
+
return compactionOption === true || (compactionOption === undefined && !!sessionKey && isCompactionSession(sessionKey));
|
|
6476
|
+
}
|
|
4665
6477
|
export function computeAllowTools(toolCount, toolChoice) {
|
|
4666
6478
|
return toolCount > 0 && toolChoice?.type !== "none";
|
|
4667
6479
|
}
|
|
@@ -4763,11 +6575,12 @@ export function resolveTurnConversationReset(input) {
|
|
|
4763
6575
|
return { reset: true, reason: "history-rewrite" };
|
|
4764
6576
|
}
|
|
4765
6577
|
// Diagnostics only: accept promptIdentity for remember without reminting.
|
|
4766
|
-
|
|
6578
|
+
const legacyPromptIdentity = input.promptIdentity;
|
|
6579
|
+
if (sessionKey && legacyPromptIdentity) {
|
|
4767
6580
|
const previous = promptIdentityBySession.get(sessionKey);
|
|
4768
6581
|
rememberPromptIdentity(sessionKey, {
|
|
4769
6582
|
...previous,
|
|
4770
|
-
...normalizePromptIdentity(
|
|
6583
|
+
...normalizePromptIdentity(legacyPromptIdentity),
|
|
4771
6584
|
});
|
|
4772
6585
|
}
|
|
4773
6586
|
if (sessionKey && postCompactionRebaseBySession.delete(sessionKey)) {
|
|
@@ -4776,11 +6589,17 @@ export function resolveTurnConversationReset(input) {
|
|
|
4776
6589
|
return { reset: false };
|
|
4777
6590
|
}
|
|
4778
6591
|
export function resetTurnStateForTests() {
|
|
6592
|
+
resetRetryBudgetsForTests();
|
|
4779
6593
|
toolCatalogBySession.clear();
|
|
4780
6594
|
postCompactionRebaseBySession.clear();
|
|
4781
6595
|
promptIdentityBySession.clear();
|
|
4782
6596
|
mirroredTodosBySession.clear();
|
|
4783
|
-
|
|
6597
|
+
resetHeldHistoryImagesForTests();
|
|
6598
|
+
undeliveredHostNotesBySession.clear();
|
|
6599
|
+
steerAnsweringRuns.clear();
|
|
6600
|
+
resetHostSteersForTests();
|
|
6601
|
+
hostNoteInjectionsBySession.clear();
|
|
6602
|
+
hostInterrupts.clear();
|
|
4784
6603
|
resetContextEpochsForTests();
|
|
4785
6604
|
resetFrozenRequestContextsForTests();
|
|
4786
6605
|
}
|
|
@@ -4818,6 +6637,8 @@ function foldStreamParts(parts) {
|
|
|
4818
6637
|
else if (part.type === "reasoning-delta")
|
|
4819
6638
|
reasoning += part.delta;
|
|
4820
6639
|
else if (part.type === "tool-call") {
|
|
6640
|
+
if (part.providerExecuted)
|
|
6641
|
+
continue;
|
|
4821
6642
|
content.push({
|
|
4822
6643
|
type: "tool-call",
|
|
4823
6644
|
toolCallId: part.toolCallId,
|