cursor-opencode-provider 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -17
- package/SECURITY.md +42 -0
- package/dist/activity.d.ts +15 -0
- package/dist/activity.js +76 -0
- package/dist/auth.d.ts +2 -1
- package/dist/auth.js +19 -13
- package/dist/context/build.js +0 -7
- package/dist/context/rules.d.ts +4 -0
- package/dist/context/rules.js +53 -17
- package/dist/debug.d.ts +9 -0
- package/dist/debug.js +44 -4
- package/dist/errors.d.ts +59 -0
- package/dist/errors.js +199 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +4 -0
- package/dist/language-model.d.ts +54 -7
- package/dist/language-model.js +730 -197
- package/dist/models.d.ts +7 -0
- package/dist/models.js +259 -79
- package/dist/plugin.js +38 -11
- package/dist/protocol/exec-variants.d.ts +24 -0
- package/dist/protocol/exec-variants.js +55 -0
- package/dist/protocol/interactions.d.ts +8 -1
- package/dist/protocol/interactions.js +15 -2
- package/dist/protocol/messages.js +109 -26
- package/dist/protocol/request.d.ts +2 -4
- package/dist/protocol/request.js +0 -8
- package/dist/protocol/struct.js +1 -1
- package/dist/protocol/tool-call-bridge.js +13 -1
- package/dist/protocol/tools.d.ts +8 -1
- package/dist/protocol/tools.js +384 -39
- package/dist/session.d.ts +115 -8
- package/dist/session.js +362 -31
- package/dist/shell-timeout.d.ts +57 -0
- package/dist/shell-timeout.js +186 -0
- package/dist/transport/connect.d.ts +37 -1
- package/dist/transport/connect.js +679 -115
- package/package.json +8 -2
package/dist/language-model.js
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { bidiRunStream, normalizeAgentRunOrigin } from "./transport/connect.js";
|
|
4
|
+
import { bidiRunStream, CursorRunInterruptedError, normalizeAgentRunOrigin, } from "./transport/connect.js";
|
|
4
5
|
import { trace } from "./debug.js";
|
|
5
6
|
import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
|
|
6
7
|
import { decodeFramePayload } from "./protocol/framing.js";
|
|
7
8
|
import { decodeMessage } from "./protocol/messages.js";
|
|
8
9
|
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, } from "./protocol/tools.js";
|
|
10
|
+
import { describeCursorExecVariant } from "./protocol/exec-variants.js";
|
|
9
11
|
import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
|
|
10
12
|
import { handleKvServerMessage } from "./protocol/kv.js";
|
|
11
13
|
import { handleInteractionQuery, inspectInteractionQueryWire } from "./protocol/interactions.js";
|
|
12
14
|
import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
|
|
13
15
|
import { conversationBlobCount } from "./protocol/blob-store.js";
|
|
14
16
|
import { bindConversationId, } from "./protocol/conversation-bind.js";
|
|
15
|
-
import { sessionManager } from "./session.js";
|
|
17
|
+
import { resolveContinuationPolicy, sessionManager, } from "./session.js";
|
|
18
|
+
import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
|
|
16
19
|
import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
|
|
17
20
|
import { buildRequestContext } from "./context/build.js";
|
|
18
21
|
import { opencodeGlobalCacheDir } from "./context/paths.js";
|
|
19
22
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
20
23
|
import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
|
|
24
|
+
import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
|
|
21
25
|
let _availableModels;
|
|
22
26
|
// mtime of the cache file the last time we loaded it. Compared on each call
|
|
23
27
|
// so discoverModels' background refresh is picked up without a process restart.
|
|
@@ -32,6 +36,173 @@ const toolCatalogBySession = new Map();
|
|
|
32
36
|
// tool use instead of emitting exec requests. Rebase once on the next turn.
|
|
33
37
|
const postCompactionRebaseBySession = new Set();
|
|
34
38
|
export const MAX_TURN_STATE_SESSIONS = 256;
|
|
39
|
+
const DEFAULT_RETRY_POLICY = {
|
|
40
|
+
maxAttempts: 3,
|
|
41
|
+
baseDelayMs: 500,
|
|
42
|
+
maxDelayMs: 8_000,
|
|
43
|
+
};
|
|
44
|
+
const MAX_RETRY_ATTEMPTS = 10;
|
|
45
|
+
const MAX_RETRY_DELAY_MS = 30_000;
|
|
46
|
+
function retryInteger(name, value, fallback) {
|
|
47
|
+
const resolved = value === undefined ? fallback : value;
|
|
48
|
+
if (typeof resolved !== "number" || !Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
49
|
+
throw new CursorProtocolError(`Cursor retry ${name} must be a positive integer`);
|
|
50
|
+
}
|
|
51
|
+
return resolved;
|
|
52
|
+
}
|
|
53
|
+
export function resolveRetryPolicy(options) {
|
|
54
|
+
if (options !== undefined && (options === null || typeof options !== "object" || Array.isArray(options))) {
|
|
55
|
+
throw new CursorProtocolError("Cursor retry options must be an object");
|
|
56
|
+
}
|
|
57
|
+
for (const key of Object.keys(options ?? {})) {
|
|
58
|
+
if (!["maxAttempts", "baseDelayMs", "maxDelayMs"].includes(key)) {
|
|
59
|
+
throw new CursorProtocolError(`Unknown Cursor retry option: ${key}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const maxAttempts = retryInteger("maxAttempts", options?.maxAttempts, DEFAULT_RETRY_POLICY.maxAttempts);
|
|
63
|
+
const baseDelayMs = retryInteger("baseDelayMs", options?.baseDelayMs, DEFAULT_RETRY_POLICY.baseDelayMs);
|
|
64
|
+
const maxDelayMs = retryInteger("maxDelayMs", options?.maxDelayMs, DEFAULT_RETRY_POLICY.maxDelayMs);
|
|
65
|
+
if (maxAttempts > MAX_RETRY_ATTEMPTS) {
|
|
66
|
+
throw new CursorProtocolError(`Cursor retry maxAttempts must be no greater than ${MAX_RETRY_ATTEMPTS}`);
|
|
67
|
+
}
|
|
68
|
+
if (baseDelayMs > MAX_RETRY_DELAY_MS || maxDelayMs > MAX_RETRY_DELAY_MS) {
|
|
69
|
+
throw new CursorProtocolError(`Cursor retry delays must be no greater than ${MAX_RETRY_DELAY_MS}ms`);
|
|
70
|
+
}
|
|
71
|
+
if (baseDelayMs > maxDelayMs) {
|
|
72
|
+
throw new CursorProtocolError("Cursor retry baseDelayMs must be no greater than maxDelayMs");
|
|
73
|
+
}
|
|
74
|
+
return { maxAttempts, baseDelayMs, maxDelayMs };
|
|
75
|
+
}
|
|
76
|
+
function retryDelayMs(error, attempt, policy) {
|
|
77
|
+
if (error.retryAfterMs !== undefined)
|
|
78
|
+
return Math.min(MAX_RETRY_DELAY_MS, error.retryAfterMs);
|
|
79
|
+
const ceiling = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** Math.max(0, attempt - 1));
|
|
80
|
+
return Math.floor(Math.random() * ceiling);
|
|
81
|
+
}
|
|
82
|
+
function sleepForRetry(delayMs, signal) {
|
|
83
|
+
if (signal?.aborted)
|
|
84
|
+
return Promise.reject(new CursorLocalCancellationError("Cursor retry cancelled"));
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const timer = setTimeout(finish, delayMs);
|
|
87
|
+
const onAbort = () => finish(new CursorLocalCancellationError("Cursor retry cancelled"));
|
|
88
|
+
function finish(error) {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
signal?.removeEventListener("abort", onAbort);
|
|
91
|
+
if (error)
|
|
92
|
+
reject(error);
|
|
93
|
+
else
|
|
94
|
+
resolve();
|
|
95
|
+
}
|
|
96
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function retryDelayFromValue(value) {
|
|
100
|
+
if (typeof value === "string") {
|
|
101
|
+
const seconds = /^(\d+(?:\.\d+)?)s$/.exec(value.trim());
|
|
102
|
+
if (seconds)
|
|
103
|
+
return Math.ceil(Number(seconds[1]) * 1_000);
|
|
104
|
+
const protobufDelay = retryInfoProtobufDelayMs(value.trim());
|
|
105
|
+
if (protobufDelay !== undefined)
|
|
106
|
+
return protobufDelay;
|
|
107
|
+
}
|
|
108
|
+
if (!value || typeof value !== "object")
|
|
109
|
+
return undefined;
|
|
110
|
+
const duration = value;
|
|
111
|
+
const seconds = Number(duration.seconds ?? 0);
|
|
112
|
+
const nanos = Number(duration.nanos ?? 0);
|
|
113
|
+
if (!Number.isFinite(seconds) || !Number.isFinite(nanos) || seconds < 0 || nanos < 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
return Math.ceil(seconds * 1_000 + nanos / 1_000_000);
|
|
116
|
+
}
|
|
117
|
+
/** Decode google.rpc.RetryInfo.value without adding another protobuf schema. */
|
|
118
|
+
function retryInfoProtobufDelayMs(encoded) {
|
|
119
|
+
if (!encoded || encoded.length > 512 || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(encoded)) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
let bytes;
|
|
123
|
+
try {
|
|
124
|
+
bytes = Buffer.from(encoded.replaceAll("-", "+").replaceAll("_", "/"), "base64");
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const readVarint = (input, start) => {
|
|
130
|
+
let value = 0n;
|
|
131
|
+
let shift = 0n;
|
|
132
|
+
for (let offset = start; offset < input.length && offset < start + 10; offset++) {
|
|
133
|
+
const byte = input[offset];
|
|
134
|
+
value |= BigInt(byte & 0x7f) << shift;
|
|
135
|
+
if ((byte & 0x80) === 0)
|
|
136
|
+
return [value, offset + 1];
|
|
137
|
+
shift += 7n;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
};
|
|
141
|
+
const outerKey = readVarint(bytes, 0);
|
|
142
|
+
if (!outerKey || outerKey[0] !== 0x0an)
|
|
143
|
+
return undefined;
|
|
144
|
+
const outerLength = readVarint(bytes, outerKey[1]);
|
|
145
|
+
if (!outerLength || outerLength[0] > BigInt(bytes.length - outerLength[1]))
|
|
146
|
+
return undefined;
|
|
147
|
+
const duration = bytes.subarray(outerLength[1], outerLength[1] + Number(outerLength[0]));
|
|
148
|
+
let offset = 0;
|
|
149
|
+
let seconds = 0n;
|
|
150
|
+
let nanos = 0n;
|
|
151
|
+
while (offset < duration.length) {
|
|
152
|
+
const key = readVarint(duration, offset);
|
|
153
|
+
if (!key)
|
|
154
|
+
return undefined;
|
|
155
|
+
offset = key[1];
|
|
156
|
+
const field = Number(key[0] >> 3n);
|
|
157
|
+
if (Number(key[0] & 7n) !== 0)
|
|
158
|
+
return undefined;
|
|
159
|
+
const item = readVarint(duration, offset);
|
|
160
|
+
if (!item)
|
|
161
|
+
return undefined;
|
|
162
|
+
offset = item[1];
|
|
163
|
+
if (field === 1)
|
|
164
|
+
seconds = item[0];
|
|
165
|
+
else if (field === 2)
|
|
166
|
+
nanos = item[0];
|
|
167
|
+
}
|
|
168
|
+
if (seconds > BigInt(Number.MAX_SAFE_INTEGER) || nanos > 999999999n)
|
|
169
|
+
return undefined;
|
|
170
|
+
return Math.ceil(Number(seconds) * 1_000 + Number(nanos) / 1_000_000);
|
|
171
|
+
}
|
|
172
|
+
export function connectFrameError(payload) {
|
|
173
|
+
try {
|
|
174
|
+
const envelope = JSON.parse(payload);
|
|
175
|
+
const code = typeof envelope.error?.code === "string" ? envelope.error.code : "unknown";
|
|
176
|
+
if (code === "unauthenticated" || code === "permission_denied") {
|
|
177
|
+
return new CursorAuthError(`Cursor authentication failed (${code}); reauthenticate with Cursor`, { code });
|
|
178
|
+
}
|
|
179
|
+
let retryAfterMs = retryDelayFromValue(envelope.error?.retryAfter ?? envelope.error?.retry_after);
|
|
180
|
+
let hasRetryInfo = false;
|
|
181
|
+
if (Array.isArray(envelope.error?.details)) {
|
|
182
|
+
for (const detail of envelope.error.details) {
|
|
183
|
+
if (!detail || typeof detail !== "object")
|
|
184
|
+
continue;
|
|
185
|
+
const record = detail;
|
|
186
|
+
const type = record.type;
|
|
187
|
+
if (type === "google.rpc.RetryInfo" || (typeof type === "string" && type.endsWith("/google.rpc.RetryInfo"))) {
|
|
188
|
+
hasRetryInfo = true;
|
|
189
|
+
retryAfterMs ??= retryDelayFromValue(record.retryDelay ?? record.retry_delay ?? record.value);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return new CursorServerError(`Cursor API error (code=${code})`, {
|
|
194
|
+
transient: isTransientGrpcStatus(code) || hasRetryInfo,
|
|
195
|
+
replaySafe: true,
|
|
196
|
+
code,
|
|
197
|
+
retryAfterMs: retryAfterMs === undefined
|
|
198
|
+
? undefined
|
|
199
|
+
: Math.min(MAX_RETRY_DELAY_MS, retryAfterMs),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return new CursorProtocolError("Cursor returned a malformed Connect error envelope");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
35
206
|
function rememberToolCatalog(sessionKey, tools) {
|
|
36
207
|
toolCatalogBySession.delete(sessionKey);
|
|
37
208
|
toolCatalogBySession.set(sessionKey, tools);
|
|
@@ -88,6 +259,12 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
88
259
|
baseUrl: resolveApiBaseURL(options),
|
|
89
260
|
});
|
|
90
261
|
const prompt = callOptions.prompt;
|
|
262
|
+
const promptTokens = estimatePromptTokens(prompt);
|
|
263
|
+
const retryPolicy = resolveRetryPolicy(options.retry);
|
|
264
|
+
// pumpWithRecovery owns the complete per-turn attempt budget. Opening a
|
|
265
|
+
// replacement session here must be a single attempt; otherwise setup retry
|
|
266
|
+
// loops nest inside recovery and `maxAttempts` no longer caps total Runs.
|
|
267
|
+
const openSession = (startOptions) => startSession(modelId, token, callOptions, options, startOptions);
|
|
91
268
|
// ── Continuation vs fresh turn ──
|
|
92
269
|
// OpenCode embeds *all* historical tool results in every prompt. Only the
|
|
93
270
|
// trailing tool-message suffix (after the last assistant/user message) is a
|
|
@@ -97,65 +274,33 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
97
274
|
const trailingToolResults = extractTrailingToolResults(prompt);
|
|
98
275
|
let session = findContinuationSession(trailingToolResults);
|
|
99
276
|
if (session) {
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
`pending={${[...session.pending.keys()].join(",")}}`);
|
|
105
|
-
for (const r of pendingResults) {
|
|
106
|
-
const pending = session.pending.get(r.execId);
|
|
107
|
-
if (!pending)
|
|
108
|
-
continue;
|
|
109
|
-
if (pending.bridged) {
|
|
110
|
-
// Display-only Cursor tool — OpenCode already ran it; nothing to write
|
|
111
|
-
// back on the Run stream.
|
|
112
|
-
session.usageEstimate.inputTokens += estimateTokens(r.output.length);
|
|
113
|
-
trace(`continuation: bridged tool result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
|
|
114
|
-
sessionManager.resolve(session.sessionId, r.execId);
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
try {
|
|
118
|
-
for (const frame of buildExecClientMessages({
|
|
119
|
-
execId: r.execId,
|
|
120
|
-
resultField: pending.resultField,
|
|
121
|
-
output: r.output,
|
|
122
|
-
error: r.error,
|
|
123
|
-
toolName: pending.toolName ?? r.toolName,
|
|
124
|
-
})) {
|
|
125
|
-
session.stream.write(frame);
|
|
126
|
-
}
|
|
127
|
-
// Tool results enlarge Cursor's context for the rest of this Run.
|
|
128
|
-
session.usageEstimate.inputTokens += estimateTokens(r.output.length);
|
|
129
|
-
trace(`continuation: wrote exec result execId=${r.execId} field=${pending.resultField} outLen=${r.output.length}`);
|
|
130
|
-
// Only resolve after the write succeeded — if it threw, the server never
|
|
131
|
-
// got the result and would block on heartbeats forever otherwise.
|
|
132
|
-
sessionManager.resolve(session.sessionId, r.execId);
|
|
133
|
-
}
|
|
134
|
-
catch (e) {
|
|
135
|
-
trace(`continuation: write FAILED execId=${r.execId} err=${e.message} — leaving pending`);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
sessionManager.touch(session);
|
|
277
|
+
// Write pending results onto the held-open Run. A dead stream closes the
|
|
278
|
+
// session and returns undefined so we fall through to history rebase
|
|
279
|
+
// instead of pumping a connection that can no longer accept writes.
|
|
280
|
+
session = deliverContinuationResults(session, trailingToolResults);
|
|
139
281
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
282
|
+
if (!session) {
|
|
283
|
+
if (trailingToolResults.length > 0) {
|
|
284
|
+
// True continuation (prompt ends with tool results) but the held-open Run
|
|
285
|
+
// is gone (or its write path just failed). Rebase the complete OpenCode
|
|
286
|
+
// prompt onto a fresh conversation: its seed history includes the
|
|
287
|
+
// completed tool result, so no result or advertised tool is lost and
|
|
288
|
+
// Cursor can continue instead of deadlocking.
|
|
289
|
+
const ids = trailingToolResults.map((r) => `${r.sessionId}:${r.execId}`).join(",");
|
|
290
|
+
trace(`continuation: ${trailingToolResults.length} interrupted trailing tool result(s) [${ids}] — rebasing fresh Run`);
|
|
291
|
+
session = await openSession({ recovery: true });
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
// Fresh turn (prompt ends with user/assistant text). Historical tool
|
|
295
|
+
// results may exist mid-prompt; they are not live exec replies.
|
|
296
|
+
const historical = extractToolResults(prompt).length;
|
|
297
|
+
if (historical > 0) {
|
|
298
|
+
trace(`fresh turn: ignoring ${historical} historical tool result(s) (not trailing)`);
|
|
299
|
+
}
|
|
300
|
+
session = await openSession();
|
|
153
301
|
}
|
|
154
|
-
session = await startSession(modelId, token, callOptions, options);
|
|
155
302
|
}
|
|
156
|
-
|
|
157
|
-
const textId = crypto.randomUUID();
|
|
158
|
-
const reasoningId = crypto.randomUUID();
|
|
303
|
+
let activeSession = session;
|
|
159
304
|
return {
|
|
160
305
|
stream: new ReadableStream({
|
|
161
306
|
async pull(controller) {
|
|
@@ -172,13 +317,15 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
172
317
|
trace(`pull: stream-start enqueue failed (cancelled) err=${e.message}`);
|
|
173
318
|
return;
|
|
174
319
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
320
|
+
activeSession = await pumpWithRecovery({
|
|
321
|
+
initialSession: activeSession,
|
|
322
|
+
controller,
|
|
323
|
+
abortSignal: callOptions.abortSignal,
|
|
324
|
+
promptTokens,
|
|
325
|
+
retryPolicy,
|
|
326
|
+
recover: () => openSession({ recovery: true }),
|
|
327
|
+
onSession: (next) => { activeSession = next; },
|
|
328
|
+
});
|
|
182
329
|
try {
|
|
183
330
|
controller.close();
|
|
184
331
|
}
|
|
@@ -187,9 +334,9 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
187
334
|
}
|
|
188
335
|
}
|
|
189
336
|
catch (e) {
|
|
190
|
-
|
|
337
|
+
activeSession.pumpActive = false;
|
|
191
338
|
trace(`pull: pump threw (cleaning up): ${e.message}`);
|
|
192
|
-
sessionManager.
|
|
339
|
+
sessionManager.close(activeSession);
|
|
193
340
|
try {
|
|
194
341
|
controller.error(e instanceof Error ? e : new Error(String(e)));
|
|
195
342
|
}
|
|
@@ -202,12 +349,60 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
202
349
|
// OpenCode cancels the ReadableStream after "tool-calls"; keep the
|
|
203
350
|
// Cursor Run stream alive so the next doStream can write results.
|
|
204
351
|
trace("ReadableStream cancel() → closeUnlessPending");
|
|
205
|
-
sessionManager.closeUnlessPending(
|
|
352
|
+
sessionManager.closeUnlessPending(activeSession);
|
|
206
353
|
},
|
|
207
354
|
}),
|
|
208
355
|
};
|
|
209
356
|
}
|
|
210
|
-
async function
|
|
357
|
+
export async function pumpWithRecovery(input) {
|
|
358
|
+
let session = input.initialSession;
|
|
359
|
+
const retryPolicy = input.retryPolicy ?? {
|
|
360
|
+
...DEFAULT_RETRY_POLICY,
|
|
361
|
+
maxAttempts: (input.maxRecoveries ?? 1) + 1,
|
|
362
|
+
};
|
|
363
|
+
const maxRecoveries = retryPolicy.maxAttempts - 1;
|
|
364
|
+
input.onSession?.(session);
|
|
365
|
+
for (let attempt = 0;; attempt++) {
|
|
366
|
+
const pumpedSession = session;
|
|
367
|
+
const pumpOwner = Symbol("cursor-pump");
|
|
368
|
+
sessionManager.beginPump(pumpedSession, pumpOwner);
|
|
369
|
+
try {
|
|
370
|
+
await pump(pumpedSession, input.controller, {
|
|
371
|
+
textId: crypto.randomUUID(),
|
|
372
|
+
reasoningId: crypto.randomUUID(),
|
|
373
|
+
promptTokens: input.promptTokens ?? 0,
|
|
374
|
+
}, input.abortSignal);
|
|
375
|
+
return session;
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
const failure = toCursorProviderError(error, {
|
|
379
|
+
replaySafe: error instanceof CursorProviderError ? error.replaySafe : false,
|
|
380
|
+
fallback: "Cursor Run interrupted",
|
|
381
|
+
});
|
|
382
|
+
if (!failure.transient)
|
|
383
|
+
throw failure;
|
|
384
|
+
if (!failure.replaySafe) {
|
|
385
|
+
throw retrySuppressedError(failure, "after visible output or stateful server activity", attempt + 1, maxRecoveries + 1);
|
|
386
|
+
}
|
|
387
|
+
if (attempt >= maxRecoveries) {
|
|
388
|
+
throw new CursorRetryExhaustedError(attempt + 1, failure);
|
|
389
|
+
}
|
|
390
|
+
trace(`Run interrupted: sessionId=${pumpedSession.sessionId} attempt=${attempt + 1}/${maxRecoveries} ` +
|
|
391
|
+
`err=${failure.message} — rebasing fresh Run`);
|
|
392
|
+
sessionManager.close(pumpedSession, "remote-error", failure);
|
|
393
|
+
const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy);
|
|
394
|
+
trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`);
|
|
395
|
+
await sleepForRetry(delayMs, input.abortSignal);
|
|
396
|
+
session = await input.recover();
|
|
397
|
+
input.onSession?.(session);
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
sessionManager.endPump(pumpedSession, pumpOwner);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async function startSession(modelId, token, callOptions, options, startOptions) {
|
|
405
|
+
const continuationPolicy = resolveContinuationPolicy(options.continuation);
|
|
211
406
|
const prompt = callOptions.prompt;
|
|
212
407
|
const incomingTools = extractTools(callOptions);
|
|
213
408
|
const sessionKey = opencodeSessionKey(callOptions);
|
|
@@ -224,23 +419,30 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
224
419
|
const tools = toolState.advertisedTools;
|
|
225
420
|
const allowTools = toolState.allowTools;
|
|
226
421
|
const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
|
|
422
|
+
const recovery = startOptions?.recovery === true;
|
|
227
423
|
// Compaction must not reuse the prior conversation; its first normal turn
|
|
228
424
|
// must also rebase so the summary-agent checkpoint cannot replace the normal
|
|
229
425
|
// system prompt and OpenCode's newly compacted history.
|
|
230
|
-
const bound = bindConversationId(sessionKey, { reset: resetState.reset });
|
|
426
|
+
const bound = bindConversationId(sessionKey, { reset: resetState.reset || recovery });
|
|
231
427
|
const conversationId = bound.conversationId;
|
|
232
428
|
if (bound.reset) {
|
|
233
|
-
trace(`conversation reset: reason=${resetState.reason ?? "unknown"} ` +
|
|
429
|
+
trace(`conversation reset: reason=${recovery ? "interrupted-run" : (resetState.reason ?? "unknown")} ` +
|
|
234
430
|
`sessionKey=${sessionKey ?? "(none)"} ` +
|
|
235
431
|
`previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
|
|
236
432
|
}
|
|
237
|
-
const userText =
|
|
433
|
+
const userText = recovery
|
|
434
|
+
? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
|
|
435
|
+
: (extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".");
|
|
436
|
+
const workspaceRoot = path.resolve(options.workspaceRoot || process.cwd());
|
|
238
437
|
const baseSystemPrompt = extractSystemPrompt(prompt);
|
|
239
|
-
const interactionGuidance = buildOpenCodeInteractionGuidance(tools, isCompaction);
|
|
438
|
+
const interactionGuidance = buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot);
|
|
240
439
|
const systemPrompt = interactionGuidance
|
|
241
440
|
? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n")
|
|
242
441
|
: baseSystemPrompt;
|
|
243
|
-
const history = extractPromptHistory(prompt
|
|
442
|
+
const history = extractPromptHistory(prompt, {
|
|
443
|
+
preserveTrailingUser: recovery,
|
|
444
|
+
toolResults: isCompaction ? "all" : (recovery ? "trailing" : "omit"),
|
|
445
|
+
});
|
|
244
446
|
await loadAvailableModels();
|
|
245
447
|
// Resolve the region-specific Run stream origin once per process (memoized
|
|
246
448
|
// in agent-url.ts). Explicit agent host overrides skip GetServerConfig but
|
|
@@ -275,7 +477,6 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
275
477
|
baseURL: agentBaseUrl,
|
|
276
478
|
headers: options.headers,
|
|
277
479
|
});
|
|
278
|
-
const workspaceRoot = options.workspaceRoot || process.cwd();
|
|
279
480
|
const requestContext = await buildRequestContext({ workspaceRoot, tools });
|
|
280
481
|
// Resolve descriptors once from the merged OpenCode config so MCP identity is
|
|
281
482
|
// consistent across AgentRunRequest and both request_context reply paths.
|
|
@@ -294,7 +495,6 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
294
495
|
conversationState,
|
|
295
496
|
parameterValues,
|
|
296
497
|
maxMode,
|
|
297
|
-
availableModels: _availableModels,
|
|
298
498
|
tools,
|
|
299
499
|
toolDescriptors,
|
|
300
500
|
requestContext,
|
|
@@ -331,10 +531,17 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
331
531
|
trace(`hash systemPrompt sha256=${sha(systemPrompt)}`);
|
|
332
532
|
if (conversationState)
|
|
333
533
|
trace(`hash checkpoint sha256=${sha(conversationState)}`);
|
|
334
|
-
|
|
534
|
+
try {
|
|
535
|
+
await writeWithBackpressure(stream, reqBytes, "initial Run request");
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
stream.destroy();
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
335
541
|
const session = {
|
|
336
542
|
sessionId: crypto.randomUUID(),
|
|
337
543
|
conversationId,
|
|
544
|
+
openCodeSessionId: sessionKey,
|
|
338
545
|
stream,
|
|
339
546
|
frames: stream.frames()[Symbol.asyncIterator](),
|
|
340
547
|
pending: new Map(),
|
|
@@ -346,15 +553,50 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
346
553
|
allowTools,
|
|
347
554
|
usageEstimate,
|
|
348
555
|
pumpActive: false,
|
|
556
|
+
pumpOwner: null,
|
|
349
557
|
heartbeat: null,
|
|
350
|
-
|
|
558
|
+
heartbeatCancel: null,
|
|
559
|
+
hardDeadlineTimer: null,
|
|
560
|
+
semanticDeadlineCancel: null,
|
|
561
|
+
terminalUnsubscribe: null,
|
|
562
|
+
deferredTerminalReason: null,
|
|
563
|
+
policy: continuationPolicy,
|
|
564
|
+
createdAt: Date.now(),
|
|
565
|
+
lastInboundAt: Date.now(),
|
|
566
|
+
lastHeartbeatWriteAt: Date.now(),
|
|
567
|
+
semanticDeadlineAt: Date.now() + continuationPolicy.semanticIdleMs,
|
|
568
|
+
closeError: null,
|
|
569
|
+
closed: false,
|
|
351
570
|
};
|
|
571
|
+
sessionManager.registerSession(session);
|
|
572
|
+
let heartbeatWritePending = false;
|
|
352
573
|
session.heartbeat = setInterval(() => {
|
|
353
|
-
|
|
354
|
-
|
|
574
|
+
if (session.closed)
|
|
575
|
+
return;
|
|
576
|
+
if (heartbeatWritePending) {
|
|
577
|
+
sessionManager.close(session, "heartbeat-write-failed", new CursorTransportError("Cursor heartbeat write remained backpressured", {
|
|
578
|
+
transient: false,
|
|
579
|
+
replaySafe: false,
|
|
580
|
+
code: "CURSOR_HEARTBEAT_BACKPRESSURE",
|
|
581
|
+
}));
|
|
582
|
+
return;
|
|
355
583
|
}
|
|
356
|
-
|
|
357
|
-
|
|
584
|
+
heartbeatWritePending = true;
|
|
585
|
+
void writeWithBackpressure(stream, buildHeartbeat(), "heartbeat")
|
|
586
|
+
.then(() => sessionManager.recordHeartbeatWrite(session))
|
|
587
|
+
.catch((cause) => {
|
|
588
|
+
sessionManager.close(session, "heartbeat-write-failed", toCursorProviderError(cause, {
|
|
589
|
+
replaySafe: false,
|
|
590
|
+
fallback: "Cursor heartbeat write failed",
|
|
591
|
+
}));
|
|
592
|
+
})
|
|
593
|
+
.finally(() => { heartbeatWritePending = false; });
|
|
594
|
+
}, continuationPolicy.heartbeatMs);
|
|
595
|
+
session.heartbeat.unref?.();
|
|
596
|
+
session.heartbeatCancel = () => {
|
|
597
|
+
if (session.heartbeat)
|
|
598
|
+
clearInterval(session.heartbeat);
|
|
599
|
+
};
|
|
358
600
|
callOptions.abortSignal?.addEventListener("abort", () => {
|
|
359
601
|
// Abort after tool-calls is normal — preserve pending sessions.
|
|
360
602
|
trace("abortSignal aborted → closeUnlessPending");
|
|
@@ -362,37 +604,6 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
362
604
|
}, { once: true });
|
|
363
605
|
return session;
|
|
364
606
|
}
|
|
365
|
-
/** Emit a soft-stop stream when trailing tool results have no live Cursor Run. */
|
|
366
|
-
function orphanedToolResultsStream(ids) {
|
|
367
|
-
const textId = crypto.randomUUID();
|
|
368
|
-
const message = `The Cursor agent stream ended before tool results could be delivered ` +
|
|
369
|
-
`(${ids.split(",").length} result(s)). Please retry your request.`;
|
|
370
|
-
trace(`orphanedToolResultsStream: soft-stop for [${ids}]`);
|
|
371
|
-
return {
|
|
372
|
-
stream: new ReadableStream({
|
|
373
|
-
pull(controller) {
|
|
374
|
-
try {
|
|
375
|
-
controller.enqueue({ type: "stream-start", warnings: [] });
|
|
376
|
-
controller.enqueue({ type: "text-start", id: textId });
|
|
377
|
-
controller.enqueue({ type: "text-delta", id: textId, delta: message });
|
|
378
|
-
controller.enqueue({ type: "text-end", id: textId });
|
|
379
|
-
controller.enqueue({
|
|
380
|
-
type: "finish",
|
|
381
|
-
finishReason: { unified: "stop", raw: undefined },
|
|
382
|
-
usage: {
|
|
383
|
-
inputTokens: { total: 0, noCache: undefined, cacheRead: 0, cacheWrite: 0 },
|
|
384
|
-
outputTokens: { total: 0, text: undefined, reasoning: undefined },
|
|
385
|
-
},
|
|
386
|
-
});
|
|
387
|
-
controller.close();
|
|
388
|
-
}
|
|
389
|
-
catch (e) {
|
|
390
|
-
trace(`orphanedToolResultsStream: enqueue failed err=${e.message}`);
|
|
391
|
-
}
|
|
392
|
-
},
|
|
393
|
-
}),
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
607
|
/**
|
|
397
608
|
* OpenCode re-sends the full tool-result history on every continuation. Prefer
|
|
398
609
|
* the newest result that still has a live pending exec on its tagged session.
|
|
@@ -406,6 +617,70 @@ export function findContinuationSession(toolResults) {
|
|
|
406
617
|
}
|
|
407
618
|
return undefined;
|
|
408
619
|
}
|
|
620
|
+
/**
|
|
621
|
+
* Deliver trailing tool results onto a live continuation session.
|
|
622
|
+
* Returns the same session when writes succeed (or only bridged results were
|
|
623
|
+
* cleared). Returns undefined after closing the session when a write fails, so
|
|
624
|
+
* the caller can rebase onto a fresh Run instead of pumping a dead stream.
|
|
625
|
+
*/
|
|
626
|
+
export function deliverContinuationResults(session, trailingToolResults) {
|
|
627
|
+
const pendingResults = trailingToolResults.filter((r) => r.sessionId === session.sessionId && session.pending.has(r.execId));
|
|
628
|
+
trace(`continuation: ${trailingToolResults.length} trailing tool result(s), ` +
|
|
629
|
+
`${pendingResults.length} pending for sessionId=${session.sessionId} ` +
|
|
630
|
+
`pending={${[...session.pending.keys()].join(",")}}`);
|
|
631
|
+
for (const r of pendingResults) {
|
|
632
|
+
const claim = sessionManager.claim(session.sessionId, r.execId);
|
|
633
|
+
if ("kind" in claim) {
|
|
634
|
+
if (claim.kind === "deliverable") {
|
|
635
|
+
throw new CursorProtocolError("Cursor continuation claim remained unclaimed");
|
|
636
|
+
}
|
|
637
|
+
if (claim.kind === "duplicate") {
|
|
638
|
+
trace(`continuation: skipped duplicate execId=${r.execId} reason=${claim.reason}`);
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
trace(`continuation: unavailable execId=${r.execId} reason=${claim.reason}`);
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
const pending = claim.pending;
|
|
645
|
+
let frames = [];
|
|
646
|
+
if (!pending.bridged) {
|
|
647
|
+
try {
|
|
648
|
+
const shellResult = pending.resultField === "shell_stream"
|
|
649
|
+
? consumeCursorShellResult(r.toolCallId, r.output)
|
|
650
|
+
: undefined;
|
|
651
|
+
frames = buildExecClientMessages({
|
|
652
|
+
execId: r.execId,
|
|
653
|
+
resultField: pending.resultField,
|
|
654
|
+
output: shellResult?.output ?? r.output,
|
|
655
|
+
error: r.error,
|
|
656
|
+
toolName: pending.toolName ?? r.toolName,
|
|
657
|
+
resultMetadata: pending.resultMetadata,
|
|
658
|
+
shellOutcome: shellResult?.outcome,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
trace(`continuation: result encode FAILED execId=${r.execId} err=${error.message}`);
|
|
663
|
+
sessionManager.close(session, "result-write-failed");
|
|
664
|
+
return undefined;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const outcome = sessionManager.deliverClaim(claim, frames);
|
|
668
|
+
if (outcome.kind !== "delivered") {
|
|
669
|
+
trace(`continuation: delivery stopped execId=${r.execId} reason=${outcome.reason}`);
|
|
670
|
+
if (outcome.kind === "duplicate")
|
|
671
|
+
continue;
|
|
672
|
+
return undefined;
|
|
673
|
+
}
|
|
674
|
+
session.usageEstimate.inputTokens += estimateTokens(r.output.length);
|
|
675
|
+
if (pending.bridged) {
|
|
676
|
+
trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
trace(`continuation: wrote exec result execId=${r.execId} field=${pending.resultField} ` +
|
|
680
|
+
`frames=${outcome.framesWritten} outLen=${r.output.length}`);
|
|
681
|
+
}
|
|
682
|
+
return session;
|
|
683
|
+
}
|
|
409
684
|
async function loadAvailableModels() {
|
|
410
685
|
const cacheDir = opencodeGlobalCacheDir();
|
|
411
686
|
try {
|
|
@@ -439,23 +714,89 @@ function resolveExplicitAgentBaseURL(options) {
|
|
|
439
714
|
return undefined;
|
|
440
715
|
const normalized = normalizeAgentRunOrigin(raw);
|
|
441
716
|
if (!normalized) {
|
|
442
|
-
throw new
|
|
717
|
+
throw new CursorProtocolError("Invalid Cursor agent base URL override: expected https://*.cursor.sh");
|
|
443
718
|
}
|
|
444
719
|
return normalized;
|
|
445
720
|
}
|
|
446
721
|
function isTruthyEnv(value) {
|
|
447
722
|
return value === "1" || value === "true";
|
|
448
723
|
}
|
|
724
|
+
async function writeWithBackpressure(stream, message, operation) {
|
|
725
|
+
let accepted;
|
|
726
|
+
try {
|
|
727
|
+
accepted = stream.write(message);
|
|
728
|
+
}
|
|
729
|
+
catch (cause) {
|
|
730
|
+
throw toCursorProviderError(cause, {
|
|
731
|
+
replaySafe: false,
|
|
732
|
+
fallback: `Cursor ${operation} write failed`,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
if (accepted !== false)
|
|
736
|
+
return;
|
|
737
|
+
if (!stream.waitForDrain) {
|
|
738
|
+
throw new CursorTransportError(`Cursor ${operation} write was backpressured`, {
|
|
739
|
+
transient: false,
|
|
740
|
+
replaySafe: false,
|
|
741
|
+
code: "CURSOR_WRITE_BACKPRESSURE",
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
await stream.waitForDrain(5_000);
|
|
746
|
+
}
|
|
747
|
+
catch (cause) {
|
|
748
|
+
throw toCursorProviderError(cause, {
|
|
749
|
+
replaySafe: false,
|
|
750
|
+
fallback: `Cursor ${operation} backpressure drain failed`,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
async function nextFrameWithSemanticDeadline(session) {
|
|
755
|
+
const remainingMs = session.semanticDeadlineAt - Date.now();
|
|
756
|
+
if (remainingMs <= 0) {
|
|
757
|
+
throw new CursorTransportError(`Cursor semantic-progress timeout after ${session.policy.semanticIdleMs}ms`, { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" });
|
|
758
|
+
}
|
|
759
|
+
let timer;
|
|
760
|
+
let rejectCancelled;
|
|
761
|
+
const deadline = new Promise((_, reject) => {
|
|
762
|
+
rejectCancelled = reject;
|
|
763
|
+
timer = setTimeout(() => {
|
|
764
|
+
reject(new CursorTransportError(`Cursor semantic-progress timeout after ${session.policy.semanticIdleMs}ms`, { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" }));
|
|
765
|
+
}, remainingMs);
|
|
766
|
+
timer.unref?.();
|
|
767
|
+
});
|
|
768
|
+
session.semanticDeadlineCancel = () => {
|
|
769
|
+
rejectCancelled?.(session.closeError ?? new CursorTransportError("Cursor semantic wait cancelled locally", {
|
|
770
|
+
transient: false,
|
|
771
|
+
replaySafe: false,
|
|
772
|
+
}));
|
|
773
|
+
};
|
|
774
|
+
try {
|
|
775
|
+
return await Promise.race([session.frames.next(), deadline]);
|
|
776
|
+
}
|
|
777
|
+
finally {
|
|
778
|
+
if (timer)
|
|
779
|
+
clearTimeout(timer);
|
|
780
|
+
session.semanticDeadlineCancel = null;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
449
783
|
/**
|
|
450
784
|
* Read the held-open stream, emitting stream parts, until the turn boundary:
|
|
451
785
|
* - a tool call (exec_server_message) → emit tool-call, finish "tool-calls",
|
|
452
786
|
* and KEEP the session open for the result on the next doStream call;
|
|
453
|
-
* - turn_ended
|
|
787
|
+
* - turn_ended → finish "stop" and close the session;
|
|
788
|
+
* - transport EOF before turn_ended → throw for one fresh-Run recovery.
|
|
454
789
|
*/
|
|
455
790
|
export async function pump(session, controller, ids, abortSignal) {
|
|
791
|
+
sessionManager.registerSession(session);
|
|
456
792
|
const { textId, reasoningId } = ids;
|
|
793
|
+
const promptTokens = ids.promptTokens ?? 0;
|
|
794
|
+
const advertisedToolNames = advertisedToolNamesFromDescriptors(session.toolDescriptors);
|
|
795
|
+
const advertisedToolNameSet = new Set(advertisedToolNames);
|
|
457
796
|
let textStarted = false;
|
|
458
797
|
let reasoningStarted = false;
|
|
798
|
+
let emittedOutputChars = 0;
|
|
799
|
+
let replaySafe = true;
|
|
459
800
|
// OpenCode cancels the ReadableStream between turns (see the cancel handler
|
|
460
801
|
// in doStreamImpl). The frames iterator can still yield a final `done` after
|
|
461
802
|
// the cancel lands — controller.enqueue on a cancelled controller throws.
|
|
@@ -485,6 +826,80 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
485
826
|
}
|
|
486
827
|
streamClosed = true;
|
|
487
828
|
};
|
|
829
|
+
/** Reply on Cursor's correlated exec channel without exposing a host tool call. */
|
|
830
|
+
const rejectExec = (parsed, reason, label) => {
|
|
831
|
+
try {
|
|
832
|
+
for (const frame of buildExecClientMessages({
|
|
833
|
+
execId: parsed.id,
|
|
834
|
+
resultField: parsed.resultField,
|
|
835
|
+
output: "",
|
|
836
|
+
error: reason,
|
|
837
|
+
toolName: parsed.toolName,
|
|
838
|
+
resultMetadata: parsed.resultMetadata,
|
|
839
|
+
})) {
|
|
840
|
+
session.stream.write(frame);
|
|
841
|
+
}
|
|
842
|
+
trace(`exec: REFUSED ${label} toolName=${parsed.toolName} id=${parsed.id}`);
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
catch (e) {
|
|
846
|
+
const error = new Error(`Failed to reject Cursor tool request (${label}): ${e.message}`);
|
|
847
|
+
trace(`exec: REFUSED reply FAILED ${error.message}`);
|
|
848
|
+
safeError(error);
|
|
849
|
+
sessionManager.close(session);
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
/**
|
|
854
|
+
* Cursor's streamed edit handshake reads the target before it sends the
|
|
855
|
+
* replacement through write_args. For a new file that read naturally fails,
|
|
856
|
+
* which makes the model abandon the edit and fall back to a shell heredoc.
|
|
857
|
+
* Treat only a missing target correlated to an edit_tool_call as an empty
|
|
858
|
+
* file, allowing Cursor to continue to the ordinary OpenCode write call.
|
|
859
|
+
*/
|
|
860
|
+
const recoverMissingEditRead = (parsed, displayCallId) => {
|
|
861
|
+
if (!displayCallId ||
|
|
862
|
+
parsed.resultField !== "read_result" ||
|
|
863
|
+
parsed.toolName !== "read" ||
|
|
864
|
+
!advertisedToolNameSet.has("write"))
|
|
865
|
+
return false;
|
|
866
|
+
const stored = session.displayToolCalls.get(displayCallId);
|
|
867
|
+
const display = parseDisplayToolCall(displayCallId, stored);
|
|
868
|
+
if (display?.variant !== "edit_tool_call" || display.bridgeable === false)
|
|
869
|
+
return false;
|
|
870
|
+
const requestedPath = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
|
|
871
|
+
const editPath = typeof display.args.path === "string" ? display.args.path : "";
|
|
872
|
+
if (!requestedPath || !editPath)
|
|
873
|
+
return false;
|
|
874
|
+
const workspaceRoot = typeof session.requestContext.workspace_project_dir === "string"
|
|
875
|
+
? session.requestContext.workspace_project_dir
|
|
876
|
+
: process.cwd();
|
|
877
|
+
const resolvePath = (value) => path.resolve(workspaceRoot, value);
|
|
878
|
+
const absolutePath = resolvePath(requestedPath);
|
|
879
|
+
if (absolutePath !== resolvePath(editPath) || fs.existsSync(absolutePath))
|
|
880
|
+
return false;
|
|
881
|
+
try {
|
|
882
|
+
for (const frame of buildExecClientMessages({
|
|
883
|
+
execId: parsed.id,
|
|
884
|
+
resultField: parsed.resultField,
|
|
885
|
+
output: "",
|
|
886
|
+
toolName: parsed.toolName,
|
|
887
|
+
resultMetadata: { path: requestedPath },
|
|
888
|
+
})) {
|
|
889
|
+
session.stream.write(frame);
|
|
890
|
+
}
|
|
891
|
+
trace(`exec: missing edit target treated as empty file id=${parsed.id} ` +
|
|
892
|
+
`path=${JSON.stringify(requestedPath)}; awaiting write_args`);
|
|
893
|
+
return true;
|
|
894
|
+
}
|
|
895
|
+
catch (e) {
|
|
896
|
+
const error = new Error(`Failed to recover Cursor edit read for a new file: ${e.message}`);
|
|
897
|
+
trace(`exec: edit read recovery FAILED ${error.message}`);
|
|
898
|
+
safeError(error);
|
|
899
|
+
sessionManager.close(session);
|
|
900
|
+
return true;
|
|
901
|
+
}
|
|
902
|
+
};
|
|
488
903
|
/** AI SDK V3 requires text-end / reasoning-end before finish or tool-call. */
|
|
489
904
|
const closeOpenSpans = () => {
|
|
490
905
|
for (const part of spanEndParts({ textStarted, reasoningStarted, textId, reasoningId })) {
|
|
@@ -496,6 +911,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
496
911
|
const emitText = (text) => {
|
|
497
912
|
if (!text)
|
|
498
913
|
return;
|
|
914
|
+
replaySafe = false;
|
|
499
915
|
// Close reasoning before text (hosts expect reasoning-end before text-start).
|
|
500
916
|
if (reasoningStarted && !textStarted) {
|
|
501
917
|
safeEnqueue({ type: "reasoning-end", id: reasoningId });
|
|
@@ -506,17 +922,22 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
506
922
|
textStarted = true;
|
|
507
923
|
}
|
|
508
924
|
session.usageEstimate.outputTokens += estimateTokens(text.length);
|
|
509
|
-
safeEnqueue({ type: "text-delta", id: textId, delta: text })
|
|
925
|
+
if (safeEnqueue({ type: "text-delta", id: textId, delta: text })) {
|
|
926
|
+
emittedOutputChars += text.length;
|
|
927
|
+
}
|
|
510
928
|
};
|
|
511
929
|
const emitReasoning = (text) => {
|
|
512
930
|
if (!text)
|
|
513
931
|
return;
|
|
932
|
+
replaySafe = false;
|
|
514
933
|
if (!reasoningStarted) {
|
|
515
934
|
safeEnqueue({ type: "reasoning-start", id: reasoningId });
|
|
516
935
|
reasoningStarted = true;
|
|
517
936
|
}
|
|
518
937
|
session.usageEstimate.outputTokens += estimateTokens(text.length);
|
|
519
|
-
safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text })
|
|
938
|
+
if (safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text })) {
|
|
939
|
+
emittedOutputChars += text.length;
|
|
940
|
+
}
|
|
520
941
|
};
|
|
521
942
|
const emitFinish = (te, reason) => {
|
|
522
943
|
closeOpenSpans();
|
|
@@ -532,25 +953,32 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
532
953
|
const est = session.usageEstimate;
|
|
533
954
|
const usage = {
|
|
534
955
|
inputTokens: {
|
|
535
|
-
total:
|
|
956
|
+
total: promptTokens,
|
|
536
957
|
noCache: undefined,
|
|
537
|
-
cacheRead:
|
|
538
|
-
cacheWrite:
|
|
958
|
+
cacheRead: 0,
|
|
959
|
+
cacheWrite: 0,
|
|
539
960
|
},
|
|
540
961
|
outputTokens: {
|
|
541
|
-
total:
|
|
962
|
+
total: estimateTokens(emittedOutputChars),
|
|
542
963
|
text: undefined,
|
|
543
964
|
reasoning: undefined,
|
|
544
965
|
},
|
|
545
966
|
};
|
|
967
|
+
const providerMetadata = te ? cursorTurnEndedProviderMetadata(te) : undefined;
|
|
546
968
|
const reasonLabel = typeof reason === "object" && reason && "unified" in reason
|
|
547
969
|
? String(reason.unified ?? "unknown")
|
|
548
970
|
: String(reason);
|
|
549
971
|
trace(`finish: reason=${reasonLabel} ` +
|
|
550
|
-
`
|
|
551
|
-
`
|
|
972
|
+
`requestIn=${usage.inputTokens.total} requestOut=${usage.outputTokens.total} ` +
|
|
973
|
+
`rawIn=${est.inputTokens} rawOut=${est.outputTokens} ` +
|
|
974
|
+
`rawCacheRead=${est.cacheRead} rawCacheWrite=${est.cacheWrite} ` +
|
|
552
975
|
`source=${te ? "turn_ended" : "estimate"}`);
|
|
553
|
-
safeEnqueue({
|
|
976
|
+
safeEnqueue({
|
|
977
|
+
type: "finish",
|
|
978
|
+
usage,
|
|
979
|
+
finishReason: reason,
|
|
980
|
+
...(providerMetadata ? { providerMetadata } : {}),
|
|
981
|
+
});
|
|
554
982
|
};
|
|
555
983
|
while (true) {
|
|
556
984
|
// Consumer cancelled / closed the ReadableStream. Stop reading Cursor
|
|
@@ -568,31 +996,45 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
568
996
|
sessionManager.closeUnlessPending(session);
|
|
569
997
|
return;
|
|
570
998
|
}
|
|
571
|
-
|
|
999
|
+
let next;
|
|
1000
|
+
try {
|
|
1001
|
+
next = session.pending.size === 0
|
|
1002
|
+
? await nextFrameWithSemanticDeadline(session)
|
|
1003
|
+
: await session.frames.next();
|
|
1004
|
+
}
|
|
1005
|
+
catch (error) {
|
|
1006
|
+
closeOpenSpans();
|
|
1007
|
+
const failure = error instanceof CursorProviderError
|
|
1008
|
+
? error
|
|
1009
|
+
: new CursorRunInterruptedError(`Cursor Run frame stream interrupted: ${error.message}`, { cause: error });
|
|
1010
|
+
failure.replaySafe = replaySafe && failure.replaySafe;
|
|
1011
|
+
throw failure;
|
|
1012
|
+
}
|
|
572
1013
|
if (next.done) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
1014
|
+
closeOpenSpans();
|
|
1015
|
+
trace("pump: frames iterator ended before turn_ended");
|
|
1016
|
+
const failure = new CursorRunInterruptedError();
|
|
1017
|
+
failure.replaySafe = replaySafe;
|
|
1018
|
+
throw failure;
|
|
577
1019
|
}
|
|
578
1020
|
const frame = next.value;
|
|
579
1021
|
if (frame.flags & 0x02) {
|
|
580
|
-
//
|
|
581
|
-
//
|
|
1022
|
+
// A successful agent turn has an explicit turn_ended update before the
|
|
1023
|
+
// Connect envelope closes. Reaching end-stream here means the Run was
|
|
1024
|
+
// interrupted, even if the HTTP status itself was 200.
|
|
1025
|
+
let payload = "";
|
|
582
1026
|
if (frame.payload.length > 0) {
|
|
583
1027
|
try {
|
|
584
|
-
|
|
585
|
-
if (text.includes('"error"')) {
|
|
586
|
-
safeError(new Error(`Cursor API error: ${text.slice(0, 500)}`));
|
|
587
|
-
sessionManager.close(session);
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
1028
|
+
payload = new TextDecoder().decode(decodeFramePayload(frame));
|
|
590
1029
|
}
|
|
591
1030
|
catch { /* not decodable */ }
|
|
592
1031
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1032
|
+
closeOpenSpans();
|
|
1033
|
+
const failure = payload
|
|
1034
|
+
? connectFrameError(payload)
|
|
1035
|
+
: new CursorRunInterruptedError();
|
|
1036
|
+
failure.replaySafe = replaySafe && failure.replaySafe;
|
|
1037
|
+
throw failure;
|
|
596
1038
|
}
|
|
597
1039
|
// decodeFramePayload can throw on a corrupt gzip payload (gunzipSync).
|
|
598
1040
|
// Skip the frame rather than abort the whole turn.
|
|
@@ -612,6 +1054,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
612
1054
|
// A single malformed/truncated frame must not abort the whole turn
|
|
613
1055
|
// (protobufjs throws "index out of range: …" on length overruns). Log it
|
|
614
1056
|
// and keep pumping.
|
|
1057
|
+
replaySafe = false;
|
|
615
1058
|
const preview = Array.from(payload.subarray(0, 32))
|
|
616
1059
|
.map((x) => x.toString(16).padStart(2, "0"))
|
|
617
1060
|
.join("");
|
|
@@ -625,6 +1068,21 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
625
1068
|
const interactionQuery = asm.interaction_query;
|
|
626
1069
|
const checkpointRaw = asm.conversation_checkpoint_update;
|
|
627
1070
|
const topField = payload.length > 0 ? payload[0] >> 3 : 0;
|
|
1071
|
+
const textProgress = iu?.text_delta?.text;
|
|
1072
|
+
const thinkingProgress = iu?.thinking_delta?.text;
|
|
1073
|
+
const checkpointProgress = normalizeCheckpointBytes(checkpointRaw);
|
|
1074
|
+
if ((typeof textProgress === "string" && textProgress.length > 0) ||
|
|
1075
|
+
(typeof thinkingProgress === "string" && thinkingProgress.length > 0) ||
|
|
1076
|
+
!!iu?.turn_ended ||
|
|
1077
|
+
!!iu?.tool_call_started ||
|
|
1078
|
+
!!iu?.tool_call_completed ||
|
|
1079
|
+
!!esm ||
|
|
1080
|
+
!!kv ||
|
|
1081
|
+
!!interactionQuery ||
|
|
1082
|
+
!!checkpointProgress?.length) {
|
|
1083
|
+
replaySafe = false;
|
|
1084
|
+
sessionManager.recordSemanticProgress(session);
|
|
1085
|
+
}
|
|
628
1086
|
{
|
|
629
1087
|
const iuKind = iu ? Object.keys(iu).find((k) => iu[k]) : undefined;
|
|
630
1088
|
trace(`pump frame: topField=${topField} interaction_update=${iuKind ?? "-"} ` +
|
|
@@ -748,7 +1206,11 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
748
1206
|
trace(`exec request_context: replied`);
|
|
749
1207
|
}
|
|
750
1208
|
catch (e) {
|
|
751
|
-
|
|
1209
|
+
const error = new Error(`Failed to answer Cursor request_context probe: ${e.message}`);
|
|
1210
|
+
trace(`exec request_context: write FAILED ${error.message}`);
|
|
1211
|
+
safeError(error);
|
|
1212
|
+
sessionManager.close(session);
|
|
1213
|
+
return;
|
|
752
1214
|
}
|
|
753
1215
|
}
|
|
754
1216
|
else if (esm.mcp_state_exec_args) {
|
|
@@ -774,32 +1236,11 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
774
1236
|
else {
|
|
775
1237
|
const parsed = parseExecServerMessage(esm);
|
|
776
1238
|
const displayCallId = extractExecDisplayCallId(esm);
|
|
777
|
-
if (displayCallId) {
|
|
778
|
-
session.displayToolCalls.delete(displayCallId);
|
|
779
|
-
trace(`exec: claimed display callId=${displayCallId}`);
|
|
780
|
-
}
|
|
781
1239
|
trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
|
|
782
1240
|
if (parsed) {
|
|
783
1241
|
if (parsed.localError) {
|
|
784
|
-
|
|
785
|
-
try {
|
|
786
|
-
for (const frame of buildExecClientMessages({
|
|
787
|
-
execId: parsed.id,
|
|
788
|
-
resultField: parsed.resultField,
|
|
789
|
-
output: "",
|
|
790
|
-
error: parsed.localError,
|
|
791
|
-
toolName: parsed.toolName,
|
|
792
|
-
})) {
|
|
793
|
-
session.stream.write(frame);
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
catch (e) {
|
|
797
|
-
const error = new Error(`Failed to reject unsupported Cursor tool request: ${e.message}`);
|
|
798
|
-
trace(`exec: invalid-mapping reply FAILED ${error.message}`);
|
|
799
|
-
safeError(error);
|
|
800
|
-
sessionManager.close(session);
|
|
1242
|
+
if (!rejectExec(parsed, parsed.localError, "invalid mapping"))
|
|
801
1243
|
return;
|
|
802
|
-
}
|
|
803
1244
|
continue;
|
|
804
1245
|
}
|
|
805
1246
|
// OpenCode throws "Tool call not allowed while generating summary"
|
|
@@ -808,26 +1249,41 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
808
1249
|
// pumping for text / turn_ended instead of emitting tool-call.
|
|
809
1250
|
if (!session.allowTools) {
|
|
810
1251
|
const reason = "Tool calls are not available during this turn (summary/compaction).";
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
}
|
|
1252
|
+
if (!rejectExec(parsed, reason, "allowTools=false"))
|
|
1253
|
+
return;
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
// Cursor has native capabilities (Task, filesystem, shell, etc.) in
|
|
1257
|
+
// addition to the MCP descriptors sent by this provider. The model
|
|
1258
|
+
// can request one even when the current OpenCode agent omitted its
|
|
1259
|
+
// corresponding host tool. Emitting that request makes OpenCode
|
|
1260
|
+
// manufacture an `invalid` tool result. Refuse it on the held-open
|
|
1261
|
+
// Cursor exec channel instead, using the request's exact typed result.
|
|
1262
|
+
if (!advertisedToolNameSet.has(parsed.toolName)) {
|
|
1263
|
+
const available = advertisedToolNames.length > 0
|
|
1264
|
+
? advertisedToolNames.join(", ")
|
|
1265
|
+
: "none";
|
|
1266
|
+
const reason = `OpenCode tool '${parsed.toolName}' is unavailable for the current agent. ` +
|
|
1267
|
+
`Available tools: ${available}. Continue using only available tools; do not retry ` +
|
|
1268
|
+
`'${parsed.toolName}'.`;
|
|
1269
|
+
trace(`exec: unavailable catalog target toolName=${parsed.toolName} ` +
|
|
1270
|
+
`advertised=[${advertisedToolNames.join(",")}]`);
|
|
1271
|
+
if (!rejectExec(parsed, reason, "unavailable tool"))
|
|
1272
|
+
return;
|
|
826
1273
|
continue;
|
|
827
1274
|
}
|
|
1275
|
+
if (recoverMissingEditRead(parsed, displayCallId))
|
|
1276
|
+
continue;
|
|
1277
|
+
if (displayCallId) {
|
|
1278
|
+
session.displayToolCalls.delete(displayCallId);
|
|
1279
|
+
trace(`exec: claimed display callId=${displayCallId}`);
|
|
1280
|
+
}
|
|
828
1281
|
const tc = buildToolCallPart(parsed, session.sessionId);
|
|
1282
|
+
if (parsed.resultField === "shell_stream") {
|
|
1283
|
+
registerCursorShellCall(tc.toolCallId, parsed.resultMetadata);
|
|
1284
|
+
}
|
|
829
1285
|
// Keep the stream open; the result arrives on the next doStream call.
|
|
830
|
-
sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName);
|
|
1286
|
+
sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName, false, parsed.resultMetadata);
|
|
831
1287
|
// tc.input is already a JSON string (LanguageModelV3ToolCall.input).
|
|
832
1288
|
trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
|
|
833
1289
|
// Close open text/reasoning spans before tool-call (required by AI SDK V3).
|
|
@@ -846,11 +1302,12 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
846
1302
|
// wrong reply recreates the heartbeat-only deadlock. Fail promptly so
|
|
847
1303
|
// schema drift is actionable.
|
|
848
1304
|
const variantField = detectExecVariantField(payload);
|
|
1305
|
+
const variantDescription = describeCursorExecVariant(variantField);
|
|
849
1306
|
const hex = Array.from(payload.subarray(0, 48))
|
|
850
1307
|
.map((x) => x.toString(16).padStart(2, "0"))
|
|
851
1308
|
.join("");
|
|
852
|
-
trace(`exec UNMAPPED: id=${esmId}
|
|
853
|
-
const err = new Error(`Unsupported Cursor exec variant
|
|
1309
|
+
trace(`exec UNMAPPED: id=${esmId} variant=${variantDescription} keys=[${Object.keys(esm).join(",")}] hex=${hex}`);
|
|
1310
|
+
const err = new Error(`Unsupported Cursor exec variant ${variantDescription} (id=${esmId})`);
|
|
854
1311
|
safeError(err);
|
|
855
1312
|
sessionManager.close(session);
|
|
856
1313
|
return;
|
|
@@ -859,7 +1316,8 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
859
1316
|
else if (interactionQuery) {
|
|
860
1317
|
// InteractionQuery is a must-reply channel, just like exec and KV. AI
|
|
861
1318
|
// SDK has no Cursor-specific UI callback, so answer immediately with the
|
|
862
|
-
// conservative headless policy from protocol/interactions.ts
|
|
1319
|
+
// conservative headless policy from protocol/interactions.ts (including
|
|
1320
|
+
// F14 create_plan auto-ack / empty plan_uri for CLI headless parity).
|
|
863
1321
|
try {
|
|
864
1322
|
const handled = handleInteractionQuery(interactionQuery, payload);
|
|
865
1323
|
session.stream.write(handled.reply);
|
|
@@ -892,7 +1350,13 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
892
1350
|
`found=${handled.found} echoed=${!!handled.echoed} ` +
|
|
893
1351
|
`sessionBlobs=${session.blobs.size} convBlobs=${conversationBlobCount(session.conversationId)}`);
|
|
894
1352
|
}
|
|
895
|
-
catch {
|
|
1353
|
+
catch (e) {
|
|
1354
|
+
const error = new Error(`Failed to answer Cursor KV blob request: ${e.message}`);
|
|
1355
|
+
trace(`kv: write FAILED ${error.message}`);
|
|
1356
|
+
safeError(error);
|
|
1357
|
+
sessionManager.close(session);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
896
1360
|
}
|
|
897
1361
|
}
|
|
898
1362
|
}
|
|
@@ -931,11 +1395,13 @@ function extractToolResults(prompt) {
|
|
|
931
1395
|
const p = part;
|
|
932
1396
|
if (p.type !== "tool-result")
|
|
933
1397
|
continue;
|
|
934
|
-
const
|
|
1398
|
+
const toolCallId = p.toolCallId ?? "";
|
|
1399
|
+
const parsed = parseExecIdFromToolCallId(toolCallId);
|
|
935
1400
|
if (!parsed)
|
|
936
1401
|
continue;
|
|
937
1402
|
const { text, isError } = toolResultOutputToText(p.output);
|
|
938
1403
|
out.push({
|
|
1404
|
+
toolCallId,
|
|
939
1405
|
sessionId: parsed.sessionId,
|
|
940
1406
|
execId: parsed.execId,
|
|
941
1407
|
toolName: p.toolName ?? "mcp",
|
|
@@ -1001,10 +1467,12 @@ function extractSystemPrompt(prompt) {
|
|
|
1001
1467
|
* Redirect only to OpenCode tools that are genuinely advertised this turn;
|
|
1002
1468
|
* compaction keeps its dedicated summary prompt unchanged.
|
|
1003
1469
|
*/
|
|
1004
|
-
export function buildOpenCodeInteractionGuidance(tools, isCompaction) {
|
|
1470
|
+
export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot) {
|
|
1005
1471
|
if (isCompaction)
|
|
1006
1472
|
return undefined;
|
|
1007
1473
|
const names = new Set(tools.map((tool) => tool.name));
|
|
1474
|
+
if (names.size === 0)
|
|
1475
|
+
return undefined;
|
|
1008
1476
|
const instructions = [];
|
|
1009
1477
|
if (names.has("question")) {
|
|
1010
1478
|
instructions.push("- When user input is required, call the OpenCode `question` tool; do not use Cursor's native AskQuestion interaction.");
|
|
@@ -1021,10 +1489,18 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction) {
|
|
|
1021
1489
|
if (names.has("webfetch")) {
|
|
1022
1490
|
instructions.push("- To fetch a known URL, call the OpenCode `webfetch` tool; do not use Cursor's native WebFetch interaction.");
|
|
1023
1491
|
}
|
|
1024
|
-
if (
|
|
1025
|
-
|
|
1492
|
+
if (names.has("write")) {
|
|
1493
|
+
instructions.push(names.has("edit")
|
|
1494
|
+
? "- 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."
|
|
1495
|
+
: "- Use OpenCode `write` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available.");
|
|
1496
|
+
}
|
|
1026
1497
|
return [
|
|
1027
|
-
|
|
1498
|
+
`OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
|
|
1499
|
+
`Workspace root: ${JSON.stringify(workspaceRoot)}. Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.`,
|
|
1500
|
+
"Call only tools in that exact list. Cursor-native tools that are not listed—including Task/subagents—are unavailable; do not invoke them. If a capability is absent, complete the work directly with the listed tools or explain the limitation.",
|
|
1501
|
+
...(instructions.length > 0
|
|
1502
|
+
? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
|
|
1503
|
+
: []),
|
|
1028
1504
|
...instructions,
|
|
1029
1505
|
"Emit the actual tool call and wait for its result; never merely claim or summarize that a tool was used.",
|
|
1030
1506
|
].join("\n");
|
|
@@ -1035,14 +1511,53 @@ export function estimateTokens(chars) {
|
|
|
1035
1511
|
return 0;
|
|
1036
1512
|
return Math.ceil(chars / 4);
|
|
1037
1513
|
}
|
|
1514
|
+
/** Estimate current-request prompt tokens from the complete serialized V3 prompt. */
|
|
1515
|
+
export function estimatePromptTokens(prompt) {
|
|
1516
|
+
const serializedContent = prompt
|
|
1517
|
+
.map((message) => typeof message.content === "string"
|
|
1518
|
+
? message.content
|
|
1519
|
+
: (JSON.stringify(message.content) ?? ""))
|
|
1520
|
+
.join("\n");
|
|
1521
|
+
return estimateTokens(serializedContent.length);
|
|
1522
|
+
}
|
|
1523
|
+
/** Preserve cumulative Cursor counters as diagnostics, never as AI SDK request usage. */
|
|
1524
|
+
export function cursorTurnEndedProviderMetadata(te) {
|
|
1525
|
+
const counter = (key) => {
|
|
1526
|
+
const value = te[key];
|
|
1527
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
1528
|
+
? Math.trunc(value)
|
|
1529
|
+
: 0;
|
|
1530
|
+
};
|
|
1531
|
+
return {
|
|
1532
|
+
cursor: {
|
|
1533
|
+
usageVersion: 2,
|
|
1534
|
+
inputTokensRaw: counter("input_tokens"),
|
|
1535
|
+
outputTokensRaw: counter("output_tokens"),
|
|
1536
|
+
cacheReadRaw: counter("cache_read"),
|
|
1537
|
+
cacheWriteRaw: counter("cache_write"),
|
|
1538
|
+
reasoningTokensRaw: counter("reasoning_tokens"),
|
|
1539
|
+
},
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1038
1542
|
/**
|
|
1039
|
-
* Prior prompt turns for a seed ConversationStateStructure
|
|
1040
|
-
*
|
|
1041
|
-
* result
|
|
1543
|
+
* Prior prompt turns for a seed ConversationStateStructure. Tool results must
|
|
1544
|
+
* never be replayed as assistant-authored prose: that teaches the model to
|
|
1545
|
+
* counterfeit `Tool result (...)` text instead of emitting a real tool call.
|
|
1546
|
+
* Normal rebases omit old results; compaction can retain all results and
|
|
1547
|
+
* interrupted continuations retain only the trailing live result suffix as
|
|
1548
|
+
* explicit OpenCode-host observations.
|
|
1042
1549
|
*/
|
|
1043
|
-
export function extractPromptHistory(prompt) {
|
|
1550
|
+
export function extractPromptHistory(prompt, options) {
|
|
1044
1551
|
const out = [];
|
|
1045
|
-
|
|
1552
|
+
const toolResults = options?.toolResults ?? "omit";
|
|
1553
|
+
let trailingToolStart = prompt.length;
|
|
1554
|
+
if (toolResults === "trailing") {
|
|
1555
|
+
while (trailingToolStart > 0 && prompt[trailingToolStart - 1]?.role === "tool") {
|
|
1556
|
+
trailingToolStart--;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
for (let messageIndex = 0; messageIndex < prompt.length; messageIndex++) {
|
|
1560
|
+
const m = prompt[messageIndex];
|
|
1046
1561
|
if (m.role === "system") {
|
|
1047
1562
|
if (typeof m.content === "string" && m.content.length > 0) {
|
|
1048
1563
|
out.push({ role: "system", content: m.content });
|
|
@@ -1062,25 +1577,43 @@ export function extractPromptHistory(prompt) {
|
|
|
1062
1577
|
continue;
|
|
1063
1578
|
}
|
|
1064
1579
|
if (m.role === "tool" && Array.isArray(m.content)) {
|
|
1580
|
+
if (toolResults === "omit" ||
|
|
1581
|
+
(toolResults === "trailing" && messageIndex < trailingToolStart))
|
|
1582
|
+
continue;
|
|
1065
1583
|
const results = [];
|
|
1066
1584
|
for (const part of m.content) {
|
|
1067
1585
|
const p = part;
|
|
1068
1586
|
if (p.type !== "tool-result")
|
|
1069
1587
|
continue;
|
|
1070
1588
|
const toolName = typeof p.toolName === "string" && p.toolName ? p.toolName : "tool";
|
|
1589
|
+
const toolCallId = typeof p.toolCallId === "string" ? p.toolCallId : "";
|
|
1071
1590
|
const result = toolResultOutputToText(p.output);
|
|
1072
|
-
|
|
1073
|
-
|
|
1591
|
+
results.push(formatSeedToolObservation({
|
|
1592
|
+
toolName,
|
|
1593
|
+
toolCallId,
|
|
1594
|
+
output: result.text,
|
|
1595
|
+
isError: result.isError,
|
|
1596
|
+
}));
|
|
1074
1597
|
}
|
|
1075
1598
|
if (results.length > 0)
|
|
1076
|
-
appendSeedHistory(out, "
|
|
1599
|
+
appendSeedHistory(out, "user", results.join("\n\n"));
|
|
1077
1600
|
}
|
|
1078
1601
|
}
|
|
1079
1602
|
// Live user message is the Run action, not seed history.
|
|
1080
|
-
if (out.length > 0 && out[out.length - 1].role === "user")
|
|
1603
|
+
if (!options?.preserveTrailingUser && out.length > 0 && out[out.length - 1].role === "user") {
|
|
1081
1604
|
out.pop();
|
|
1605
|
+
}
|
|
1082
1606
|
return out;
|
|
1083
1607
|
}
|
|
1608
|
+
function formatSeedToolObservation(input) {
|
|
1609
|
+
const metadata = JSON.stringify({
|
|
1610
|
+
source: "opencode-tool",
|
|
1611
|
+
tool: input.toolName,
|
|
1612
|
+
callId: input.toolCallId,
|
|
1613
|
+
status: input.isError ? "error" : "completed",
|
|
1614
|
+
});
|
|
1615
|
+
return `OpenCode host observation ${metadata}:\n${input.output}`;
|
|
1616
|
+
}
|
|
1084
1617
|
function extractAssistantHistoryText(msg) {
|
|
1085
1618
|
const content = msg.content;
|
|
1086
1619
|
if (typeof content === "string")
|