@bitkyc08/opencodex 2.7.36 → 2.7.37
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.ja.md +8 -1
- package/README.ko.md +7 -1
- package/README.md +7 -1
- package/README.ru.md +7 -1
- package/README.zh-CN.md +7 -1
- package/gui/dist/assets/index-BhUTxmCy.js +52 -0
- package/gui/dist/assets/index-oOZcqVmj.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +7 -0
- package/src/adapters/cursor/message-mapper.ts +3 -0
- package/src/adapters/cursor/protobuf-request.ts +223 -27
- package/src/adapters/cursor/request-builder.ts +41 -15
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +3 -1
- package/src/adapters/cursor.ts +44 -9
- package/src/adapters/google.ts +115 -62
- package/src/adapters/kiro.ts +3 -17
- package/src/adapters/openai-chat.ts +16 -5
- package/src/adapters/openai-responses.ts +56 -1
- package/src/adapters/run-turn-queue.ts +11 -1
- package/src/bridge.ts +139 -69
- package/src/chat/outbound.ts +135 -73
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/doctor.ts +197 -2
- package/src/cli/index.ts +17 -3
- package/src/cli/status.ts +80 -0
- package/src/cli/v2.ts +14 -2
- package/src/codex/auth-context.ts +18 -2
- package/src/codex/catalog/bundled.ts +83 -27
- package/src/codex/catalog/effort.ts +95 -3
- package/src/codex/catalog/parsing.ts +17 -0
- package/src/codex/catalog/provider-fetch.ts +31 -8
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/model-cache.ts +44 -0
- package/src/codex/runtime.ts +529 -0
- package/src/codex/shim.ts +608 -10
- package/src/combos/resolve.ts +7 -2
- package/src/config.ts +32 -1
- package/src/lib/bun-stream-caps.ts +88 -0
- package/src/lib/crash-guard.ts +3 -1
- package/src/lib/sse-decoder.ts +25 -6
- package/src/responses/parser.ts +2 -1
- package/src/responses/state.ts +10 -2
- package/src/server/auth-cors.ts +4 -1
- package/src/server/index.ts +191 -1
- package/src/server/live.ts +491 -0
- package/src/server/management/config-routes.ts +79 -3
- package/src/server/management/provider-routes.ts +2 -0
- package/src/server/management/shared.ts +6 -6
- package/src/server/management/system-routes.ts +65 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/memory-watchdog.ts +112 -0
- package/src/server/relay-eager.ts +199 -0
- package/src/server/relay.ts +131 -81
- package/src/server/responses/collaboration.ts +20 -3
- package/src/server/responses/core.ts +236 -21
- package/src/server/responses/encrypted-payload.ts +118 -41
- package/src/server/ws-bridge.ts +7 -0
- package/src/types.ts +25 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +19 -0
- package/src/usage/summary.ts +11 -8
- package/gui/dist/assets/index-BpX-hoSd.css +0 -1
- package/gui/dist/assets/index-ZmFopEYw.js +0 -52
|
@@ -46,9 +46,17 @@ import {
|
|
|
46
46
|
} from "./tool-definitions";
|
|
47
47
|
|
|
48
48
|
const encoder = new TextEncoder();
|
|
49
|
+
const decoder = new TextDecoder();
|
|
49
50
|
|
|
50
51
|
/** Parameter id advertised by Cursor's `default` model for its Cost/Balance/Intelligence control. */
|
|
51
52
|
export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization";
|
|
53
|
+
// Cursor external workers reject oversized root replay sets with a late invalid_argument after
|
|
54
|
+
// hydrating every blob (observed at 208 roots with usedTokens=0). Keep headroom below that boundary,
|
|
55
|
+
// retaining all system prompts and the newest model-visible history. Cursor IDE similarly bounds /
|
|
56
|
+
// compacts long conversations rather than replaying an unbounded message list.
|
|
57
|
+
export const CURSOR_EXTERNAL_ROOT_BLOB_LIMIT = 192;
|
|
58
|
+
/** Approximate prompt-size guard; tool schemas and protocol framing consume context separately. */
|
|
59
|
+
export const CURSOR_EXTERNAL_ROOT_BYTE_LIMIT = 512 * 1024;
|
|
52
60
|
|
|
53
61
|
/** Runtime timezone for protobuf RequestContextEnv (dynamic, never hardcoded). */
|
|
54
62
|
function runtimeTimeZone(): string {
|
|
@@ -72,7 +80,59 @@ function jsonBlob(value: unknown): Uint8Array {
|
|
|
72
80
|
return encoder.encode(JSON.stringify(value));
|
|
73
81
|
}
|
|
74
82
|
|
|
75
|
-
|
|
83
|
+
type StoredRootBlob = {
|
|
84
|
+
id: Uint8Array;
|
|
85
|
+
byteLength: number;
|
|
86
|
+
role: "system" | "user" | "assistant" | "toolResult";
|
|
87
|
+
messageIndex?: number;
|
|
88
|
+
/** Original JSON text payload used when an active tool result must be truncated to fit. */
|
|
89
|
+
text?: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function storedRootBlob(
|
|
93
|
+
value: unknown,
|
|
94
|
+
role: StoredRootBlob["role"],
|
|
95
|
+
opts?: { messageIndex?: number; text?: string },
|
|
96
|
+
): StoredRootBlob {
|
|
97
|
+
const data = jsonBlob(value);
|
|
98
|
+
return {
|
|
99
|
+
id: storeCursorBlob(data),
|
|
100
|
+
byteLength: data.byteLength,
|
|
101
|
+
role,
|
|
102
|
+
...(opts?.messageIndex !== undefined ? { messageIndex: opts.messageIndex } : {}),
|
|
103
|
+
...(opts?.text !== undefined ? { text: opts.text } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function truncateToolResultBlob(entry: StoredRootBlob, maxBytes: number): StoredRootBlob | null {
|
|
108
|
+
if (entry.byteLength <= maxBytes) return entry;
|
|
109
|
+
if (entry.role !== "toolResult" || entry.text === undefined) return null;
|
|
110
|
+
const marker = "\n…[truncated for Cursor external replay budget]";
|
|
111
|
+
const encoded = encoder.encode(entry.text);
|
|
112
|
+
// Leave headroom for JSON envelope (`role`/`content` wrapper) around the truncated text.
|
|
113
|
+
let keepBytes = Math.min(encoded.byteLength, Math.max(0, maxBytes - encoder.encode(marker).byteLength - 96));
|
|
114
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
115
|
+
let end = keepBytes;
|
|
116
|
+
while (end > 0 && end < encoded.byteLength && (encoded[end]! & 0xc0) === 0x80) end -= 1;
|
|
117
|
+
const truncated = `${decoder.decode(encoded.subarray(0, end))}${marker}`;
|
|
118
|
+
const result = storedRootBlob(
|
|
119
|
+
{ role: "user", content: [{ type: "text", text: truncated }] },
|
|
120
|
+
"toolResult",
|
|
121
|
+
{ messageIndex: entry.messageIndex, text: truncated },
|
|
122
|
+
);
|
|
123
|
+
if (result.byteLength <= maxBytes) return result;
|
|
124
|
+
if (end === 0) break;
|
|
125
|
+
keepBytes = Math.max(0, end - (result.byteLength - maxBytes) - 16);
|
|
126
|
+
}
|
|
127
|
+
const markerOnly = storedRootBlob(
|
|
128
|
+
{ role: "user", content: [{ type: "text", text: marker.trimStart() }] },
|
|
129
|
+
"toolResult",
|
|
130
|
+
{ messageIndex: entry.messageIndex, text: marker.trimStart() },
|
|
131
|
+
);
|
|
132
|
+
return markerOnly.byteLength <= maxBytes ? markerOnly : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function systemPromptBlobs(request: CursorRunRequest): StoredRootBlob[] {
|
|
76
136
|
const prompts = request.system.length > 0 ? [...request.system] : ["You are a helpful assistant."];
|
|
77
137
|
if (cursorRequestHasShellAlias(request.tools)) prompts.push(CURSOR_SHELL_ALIAS_SYSTEM_NOTE);
|
|
78
138
|
const cursorToolGuidance = buildCursorToolGuidanceSystemNote(
|
|
@@ -80,13 +140,16 @@ function systemPromptBlobs(request: CursorRunRequest): Uint8Array[] {
|
|
|
80
140
|
request.toolChoice,
|
|
81
141
|
);
|
|
82
142
|
if (cursorToolGuidance) prompts.push(cursorToolGuidance);
|
|
83
|
-
return prompts.map(content =>
|
|
143
|
+
return prompts.map(content => storedRootBlob({ role: "system", content }, "system"));
|
|
84
144
|
}
|
|
85
145
|
|
|
86
|
-
function assistantRootText(
|
|
146
|
+
function assistantRootText(
|
|
147
|
+
message: Extract<OcxMessage, { role: "assistant" }>,
|
|
148
|
+
includeThinking: boolean,
|
|
149
|
+
): string {
|
|
87
150
|
if (typeof message.content === "string") return message.content;
|
|
88
151
|
return message.content
|
|
89
|
-
.map(part => (part.type === "text" ? part.text : part.type === "thinking" ? part.thinking : undefined))
|
|
152
|
+
.map(part => (part.type === "text" ? part.text : includeThinking && part.type === "thinking" ? part.thinking : undefined))
|
|
90
153
|
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
|
91
154
|
.join("\n");
|
|
92
155
|
}
|
|
@@ -97,11 +160,23 @@ function assistantRootText(message: Extract<OcxMessage, { role: "assistant" }>):
|
|
|
97
160
|
// because it travels in the action. Tool results are rendered as user-role text with a marker, and
|
|
98
161
|
// each entry is a SHA-256 blob ID (Cursor fetches the bytes back via getBlobArgs). Mirrors the
|
|
99
162
|
// danger-pi reference buildRootPromptMessagesJson.
|
|
100
|
-
function rootPromptMessages(request: CursorRunRequest):
|
|
163
|
+
function rootPromptMessages(request: CursorRunRequest): {
|
|
164
|
+
ids: Uint8Array[];
|
|
165
|
+
byteLength: number;
|
|
166
|
+
historyMessageStart: number;
|
|
167
|
+
} {
|
|
101
168
|
const entries = systemPromptBlobs(request);
|
|
169
|
+
const systemEntryCount = entries.length;
|
|
102
170
|
const messages = request.rawMessages;
|
|
103
|
-
if (!messages?.length)
|
|
171
|
+
if (!messages?.length) {
|
|
172
|
+
return {
|
|
173
|
+
ids: entries.map(entry => entry.id),
|
|
174
|
+
byteLength: entries.reduce((sum, entry) => sum + entry.byteLength, 0),
|
|
175
|
+
historyMessageStart: 0,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
104
178
|
|
|
179
|
+
const externalModel = isCursorExternalWireModel(request.modelId);
|
|
105
180
|
const lastRawIsToolResult = messages.at(-1)?.role === "toolResult";
|
|
106
181
|
const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages);
|
|
107
182
|
|
|
@@ -111,24 +186,110 @@ function rootPromptMessages(request: CursorRunRequest): Uint8Array[] {
|
|
|
111
186
|
if (!message) continue;
|
|
112
187
|
if (message.role === "user" || message.role === "developer") {
|
|
113
188
|
const text = contentText(message).trim();
|
|
114
|
-
|
|
189
|
+
// Cursor root replay expects OpenAI-style content parts for historical user messages.
|
|
190
|
+
// A bare string survives blob hydration but external workers reject the completed replay
|
|
191
|
+
// before tokenization (`usedTokens: 0`, then invalid_argument).
|
|
192
|
+
if (text.length > 0) {
|
|
193
|
+
entries.push(storedRootBlob({
|
|
194
|
+
role: "user",
|
|
195
|
+
content: [{ type: "text", text }],
|
|
196
|
+
}, "user", { messageIndex: i }));
|
|
197
|
+
}
|
|
115
198
|
} else if (message.role === "assistant") {
|
|
116
|
-
|
|
117
|
-
|
|
199
|
+
// External Cursor clients do not replay hidden reasoning as assistant-visible prompt text.
|
|
200
|
+
// Native Composer state can preserve it through ThinkingMessage/history structures.
|
|
201
|
+
const text = assistantRootText(message, !externalModel).trim();
|
|
202
|
+
if (text.length > 0) {
|
|
203
|
+
entries.push(storedRootBlob(
|
|
204
|
+
{ role: "assistant", content: [{ type: "text", text }] },
|
|
205
|
+
"assistant",
|
|
206
|
+
{ messageIndex: i },
|
|
207
|
+
));
|
|
208
|
+
}
|
|
118
209
|
// Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here.
|
|
119
|
-
// rootPromptMessagesJson is the model-visible prompt, so a synthetic "[Tool Call]" marker in an
|
|
120
|
-
// assistant turn gets few-shot-mimicked: the model then emits later (esp. parallel/mixed) tool
|
|
121
|
-
// calls as inert text instead of real tool frames, halting multi-tool continuations. The paired
|
|
122
|
-
// tool result below ([Tool Result]/[Tool Error]) carries the call id/name/output Cursor needs to
|
|
123
|
-
// continue, and conversationTurns replays the native mcpToolCall step. Mirrors request-builder.ts
|
|
124
|
-
// contentPartToText() which returns undefined for toolCall for the same reason.
|
|
125
210
|
} else if (message.role === "toolResult") {
|
|
126
211
|
const prefix = message.isError ? "[Tool Error]" : "[Tool Result]";
|
|
127
212
|
const text = `${prefix}\n${toolResultToText(message)}`;
|
|
128
|
-
entries.push(
|
|
213
|
+
entries.push(storedRootBlob(
|
|
214
|
+
{ role: "user", content: [{ type: "text", text }] },
|
|
215
|
+
"toolResult",
|
|
216
|
+
{ messageIndex: i, text },
|
|
217
|
+
));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let selected = entries;
|
|
222
|
+
let historyMessageStart = 0;
|
|
223
|
+
if (externalModel) {
|
|
224
|
+
const systemEntries = entries.slice(0, systemEntryCount);
|
|
225
|
+
const history = entries.slice(systemEntryCount);
|
|
226
|
+
const systemBytes = systemEntries.reduce((sum, entry) => sum + entry.byteLength, 0);
|
|
227
|
+
const historyLimit = Math.max(0, CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - systemEntryCount);
|
|
228
|
+
const historyBudget = Math.max(0, CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - systemBytes);
|
|
229
|
+
|
|
230
|
+
// Retain the active trailing tool-result block when it fits (may truncate text).
|
|
231
|
+
// If even a truncation marker cannot fit the remaining budget, omit it rather than
|
|
232
|
+
// emitting an oversized root blob.
|
|
233
|
+
let activeStart = history.length;
|
|
234
|
+
while (activeStart > 0 && history[activeStart - 1]?.role === "toolResult") activeStart -= 1;
|
|
235
|
+
const active = history
|
|
236
|
+
.slice(activeStart)
|
|
237
|
+
.map(entry => truncateToolResultBlob(entry, historyBudget))
|
|
238
|
+
.filter((entry): entry is StoredRootBlob => entry !== null);
|
|
239
|
+
let activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0);
|
|
240
|
+
while (active.length > 1 && activeBytes > historyBudget) {
|
|
241
|
+
const dropped = active.shift();
|
|
242
|
+
activeBytes -= dropped?.byteLength ?? 0;
|
|
243
|
+
}
|
|
244
|
+
if (active.length === 1 && active[0] && activeBytes > historyBudget) {
|
|
245
|
+
const truncated = truncateToolResultBlob(active[0], historyBudget);
|
|
246
|
+
if (truncated) {
|
|
247
|
+
active[0] = truncated;
|
|
248
|
+
activeBytes = truncated.byteLength;
|
|
249
|
+
} else {
|
|
250
|
+
active.length = 0;
|
|
251
|
+
activeBytes = 0;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const prior = history.slice(0, activeStart);
|
|
256
|
+
const keptPrior: StoredRootBlob[] = [];
|
|
257
|
+
let priorBytes = 0;
|
|
258
|
+
// Take complete turns from the end: a turn starts at a user/developer root entry.
|
|
259
|
+
let i = prior.length - 1;
|
|
260
|
+
while (i >= 0 && keptPrior.length + active.length < historyLimit) {
|
|
261
|
+
let turnStart = i;
|
|
262
|
+
while (turnStart > 0 && prior[turnStart]?.role !== "user") turnStart -= 1;
|
|
263
|
+
const turn = prior.slice(turnStart, i + 1);
|
|
264
|
+
const turnBytes = turn.reduce((sum, entry) => sum + entry.byteLength, 0);
|
|
265
|
+
if (
|
|
266
|
+
keptPrior.length + active.length + turn.length > historyLimit
|
|
267
|
+
|| priorBytes + activeBytes + turnBytes > historyBudget
|
|
268
|
+
) {
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
keptPrior.unshift(...turn);
|
|
272
|
+
priorBytes += turnBytes;
|
|
273
|
+
i = turnStart - 1;
|
|
129
274
|
}
|
|
275
|
+
|
|
276
|
+
const historyEntries = [...keptPrior, ...active];
|
|
277
|
+
// Guard against orphan assistant / toolResult at the start of the retained suffix.
|
|
278
|
+
while (historyEntries[0]?.role === "assistant" || historyEntries[0]?.role === "toolResult") {
|
|
279
|
+
// Never drop the sole active tool-result block.
|
|
280
|
+
if (historyEntries.length <= active.length) break;
|
|
281
|
+
historyEntries.shift();
|
|
282
|
+
}
|
|
283
|
+
selected = [...systemEntries, ...historyEntries];
|
|
284
|
+
const firstKept = historyEntries.find(entry => entry.messageIndex !== undefined);
|
|
285
|
+
historyMessageStart = firstKept?.messageIndex ?? (messages.length);
|
|
130
286
|
}
|
|
131
|
-
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
ids: selected.map(entry => entry.id),
|
|
290
|
+
byteLength: selected.reduce((sum, entry) => sum + entry.byteLength, 0),
|
|
291
|
+
historyMessageStart,
|
|
292
|
+
};
|
|
132
293
|
}
|
|
133
294
|
|
|
134
295
|
function contentText(message: OcxMessage): string {
|
|
@@ -240,11 +401,13 @@ function lastActionIndex(messages: readonly OcxMessage[] | undefined): number {
|
|
|
240
401
|
return -1;
|
|
241
402
|
}
|
|
242
403
|
|
|
243
|
-
function conversationTurns(request: CursorRunRequest): Uint8Array[] {
|
|
404
|
+
function conversationTurns(request: CursorRunRequest, historyMessageStart = 0): Uint8Array[] {
|
|
244
405
|
const messages = request.rawMessages;
|
|
245
406
|
if (!messages?.length) return [];
|
|
246
407
|
const end = lastActionIndex(messages);
|
|
408
|
+
const externalModel = isCursorExternalWireModel(request.modelId);
|
|
247
409
|
const historyEnd = messages.at(-1)?.role === "toolResult" ? messages.length : Math.max(0, end);
|
|
410
|
+
const start = externalModel ? Math.max(0, historyMessageStart) : 0;
|
|
248
411
|
const turns: Uint8Array[] = [];
|
|
249
412
|
let current: { userMessage: Uint8Array; steps: Uint8Array[] } | undefined;
|
|
250
413
|
const pendingToolCalls = new Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>();
|
|
@@ -261,10 +424,24 @@ function conversationTurns(request: CursorRunRequest): Uint8Array[] {
|
|
|
261
424
|
pendingToolCalls.clear();
|
|
262
425
|
};
|
|
263
426
|
|
|
264
|
-
for (const message of messages.slice(
|
|
427
|
+
for (const message of messages.slice(start, historyEnd)) {
|
|
265
428
|
if (message.role === "assistant") {
|
|
266
429
|
if (!current) continue;
|
|
267
430
|
for (const part of message.content) {
|
|
431
|
+
if (externalModel) {
|
|
432
|
+
// Working external-model clients replay only assistant text. Native mcpToolCall and
|
|
433
|
+
// ThinkingMessage structures are Composer state and cause external workers to hydrate
|
|
434
|
+
// the blobs, reach stepCompleted, then reject the turn with invalid_argument.
|
|
435
|
+
if (part.type === "text" && part.text.length > 0) {
|
|
436
|
+
current.steps.push(storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, {
|
|
437
|
+
message: {
|
|
438
|
+
case: "assistantMessage",
|
|
439
|
+
value: create(AssistantMessageSchema, { text: part.text }),
|
|
440
|
+
},
|
|
441
|
+
}))));
|
|
442
|
+
}
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
268
445
|
if (part.type === "toolCall") {
|
|
269
446
|
pendingToolCalls.set(part.id, part);
|
|
270
447
|
continue;
|
|
@@ -276,6 +453,16 @@ function conversationTurns(request: CursorRunRequest): Uint8Array[] {
|
|
|
276
453
|
}
|
|
277
454
|
if (message.role === "toolResult") {
|
|
278
455
|
if (!current) continue;
|
|
456
|
+
if (externalModel) {
|
|
457
|
+
const prefix = message.isError ? "[Tool Error]" : "[Tool Result]";
|
|
458
|
+
current.steps.push(storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, {
|
|
459
|
+
message: {
|
|
460
|
+
case: "assistantMessage",
|
|
461
|
+
value: create(AssistantMessageSchema, { text: `${prefix}\n${contentToText(message.content)}` }),
|
|
462
|
+
},
|
|
463
|
+
}))));
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
279
466
|
const priorCall = pendingToolCalls.get(message.toolCallId);
|
|
280
467
|
if (priorCall) {
|
|
281
468
|
current.steps.push(toolCallStep(priorCall, message));
|
|
@@ -322,13 +509,11 @@ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
|
|
|
322
509
|
const text = lastRole === "user" || lastRole === "developer"
|
|
323
510
|
? appendCursorShellAliasHint(request.tools, appendCursorGenericToolUseHint(request.tools, rawText))
|
|
324
511
|
: rawText;
|
|
325
|
-
//
|
|
326
|
-
// conversation with the tool result carried as structured conversation history (mcpToolCall.result
|
|
327
|
-
// in conversationTurns). It must NOT inject the tool result text as a new UserMessageAction — that
|
|
328
|
-
// would pollute the model input and double-deliver the result. Use ResumeAction so Cursor picks up
|
|
329
|
-
// from the history we provided.
|
|
512
|
+
// Tool-result-only turns resume the remembered Cursor conversation with results in history.
|
|
330
513
|
const lastRawIsToolResult = request.rawMessages?.at(-1)?.role === "toolResult";
|
|
331
|
-
const actionCase = !lastRawIsToolResult && text.trim().length > 0
|
|
514
|
+
const actionCase = !lastRawIsToolResult && text.trim().length > 0
|
|
515
|
+
? "userMessageAction"
|
|
516
|
+
: "resumeAction";
|
|
332
517
|
const action = create(ConversationActionSchema, {
|
|
333
518
|
action: actionCase === "userMessageAction"
|
|
334
519
|
? {
|
|
@@ -348,19 +533,27 @@ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
|
|
|
348
533
|
}),
|
|
349
534
|
},
|
|
350
535
|
});
|
|
536
|
+
const rootPromptMessagesState = rootPromptMessages(request);
|
|
537
|
+
const rootPromptMessageIds = rootPromptMessagesState.ids;
|
|
538
|
+
const turnIds = conversationTurns(request, rootPromptMessagesState.historyMessageStart);
|
|
351
539
|
debugProviderDiagnostic("cursor", "run-request", {
|
|
352
540
|
wireModel: request.modelId,
|
|
353
541
|
action: actionCase,
|
|
354
542
|
conversationId: request.conversationId,
|
|
355
543
|
turnType: lastRawIsToolResult ? "tool-continuation" : "initial",
|
|
356
544
|
externalModel: isCursorExternalWireModel(request.modelId),
|
|
545
|
+
rawMessages: request.rawMessages?.length ?? 0,
|
|
546
|
+
rootBlobs: rootPromptMessageIds.length,
|
|
547
|
+
rootBytes: rootPromptMessagesState.byteLength,
|
|
548
|
+
turnBlobs: turnIds.length,
|
|
549
|
+
tools: request.tools?.length ?? 0,
|
|
357
550
|
});
|
|
358
551
|
|
|
359
552
|
const runRequest = create(AgentRunRequestSchema, {
|
|
360
553
|
conversationId: request.conversationId,
|
|
361
554
|
conversationState: create(ConversationStateStructureSchema, {
|
|
362
|
-
rootPromptMessagesJson:
|
|
363
|
-
turns:
|
|
555
|
+
rootPromptMessagesJson: rootPromptMessageIds,
|
|
556
|
+
turns: turnIds,
|
|
364
557
|
todos: [],
|
|
365
558
|
pendingToolCalls: [],
|
|
366
559
|
previousWorkspaceUris: [],
|
|
@@ -379,6 +572,9 @@ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
|
|
|
379
572
|
displayNameShort: request.modelId,
|
|
380
573
|
aliases: [],
|
|
381
574
|
}),
|
|
575
|
+
// requested_model is currently a Cursor Router-only surface. External model clients still
|
|
576
|
+
// send model_details alone; sending both makes external workers reach stepCompleted and then
|
|
577
|
+
// reject the turn with invalid_argument.
|
|
382
578
|
...(request.routingLevel ? {
|
|
383
579
|
requestedModel: create(RequestedModelSchema, {
|
|
384
580
|
modelId: request.modelId,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type {
|
|
2
3
|
OcxAssistantContentPart,
|
|
3
4
|
OcxContentPart,
|
|
@@ -8,7 +9,7 @@ import type {
|
|
|
8
9
|
} from "../../types";
|
|
9
10
|
import { isAllowedToolChoice, namespacedToolName, toolChoiceAliases, type OcxTool, type OcxToolChoice } from "../../types";
|
|
10
11
|
import type { CursorRequestMessage, CursorRunRequest } from "./types";
|
|
11
|
-
import { cursorWireModelSelection,
|
|
12
|
+
import { cursorWireModelSelection, type CursorRoutingLevel } from "./discovery";
|
|
12
13
|
import { cursorEffortSuffix } from "./effort-map";
|
|
13
14
|
import {
|
|
14
15
|
cursorMcpToolEncodedSize,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
cursorToolWireName,
|
|
18
19
|
cursorToolsForActivePrompt,
|
|
19
20
|
} from "./tool-definitions";
|
|
21
|
+
import { lookupCursorThreadConversation } from "./thread-continuity";
|
|
20
22
|
|
|
21
23
|
/** Probe-verified Cursor Connect boundaries, with byte headroom for the enclosing field. */
|
|
22
24
|
export const CURSOR_TOOL_COUNT_LIMIT = 330;
|
|
@@ -159,6 +161,43 @@ export function generatedCursorConversationId(): string {
|
|
|
159
161
|
return `cursor_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
/** Derive an opaque provider-scoped Cursor id from the upstream client's conversation identity. */
|
|
165
|
+
export function cursorConversationIdFromClientThread(threadId: string, identityScope?: string): string {
|
|
166
|
+
const digest = createHash("sha256")
|
|
167
|
+
.update("ocx:cursor:thread:")
|
|
168
|
+
.update(identityScope?.trim() || "local")
|
|
169
|
+
.update("\0")
|
|
170
|
+
.update(threadId)
|
|
171
|
+
.digest("hex")
|
|
172
|
+
.slice(0, 32);
|
|
173
|
+
return `cursor_${digest}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolve the Cursor conversation id for this turn.
|
|
178
|
+
* Priority: force-fresh → isolate helper → remembered → thread override → client thread → random.
|
|
179
|
+
* Never use OpenAI Responses `previous_response_id` (resp_*) or shared `prompt_cache_key`
|
|
180
|
+
* (cache-cohort fingerprint, not conversation ownership).
|
|
181
|
+
*/
|
|
182
|
+
export function resolveCursorConversationId(
|
|
183
|
+
parsed: OcxParsedRequest,
|
|
184
|
+
_wireModelId: string,
|
|
185
|
+
options: CreateCursorRequestOptions = {},
|
|
186
|
+
): string {
|
|
187
|
+
if (options.forceFreshConversation === true) return generatedCursorConversationId();
|
|
188
|
+
// Helper/shadow/compaction turns must not append into the parent's Cursor conversation,
|
|
189
|
+
// even when previous_response_id restored the parent's remembered id.
|
|
190
|
+
if (parsed._cursorIsolateConversation === true) return generatedCursorConversationId();
|
|
191
|
+
if (parsed._cursorConversationId) return parsed._cursorConversationId;
|
|
192
|
+
const threadId = parsed._clientThreadId?.trim();
|
|
193
|
+
if (threadId) {
|
|
194
|
+
const recovered = lookupCursorThreadConversation(threadId, parsed._cursorIdentityScope);
|
|
195
|
+
if (recovered) return recovered;
|
|
196
|
+
return cursorConversationIdFromClientThread(threadId, parsed._cursorIdentityScope);
|
|
197
|
+
}
|
|
198
|
+
return generatedCursorConversationId();
|
|
199
|
+
}
|
|
200
|
+
|
|
162
201
|
export interface CreateCursorRequestOptions {
|
|
163
202
|
/** Force a brand-new Cursor conversation id even when remembered state exists. */
|
|
164
203
|
forceFreshConversation?: boolean;
|
|
@@ -176,23 +215,10 @@ export function createCursorRequest(
|
|
|
176
215
|
const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice);
|
|
177
216
|
const limitNote = catalogLimitNote(budget.tools, budget.omitted);
|
|
178
217
|
const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning);
|
|
179
|
-
const lastRaw = parsed.context.messages.at(-1);
|
|
180
|
-
// External Cursor models (e.g. gpt-5.6-sol) can corrupt server-side conversation state across
|
|
181
|
-
// tool-result continuations when ResumeAction reuses the same conversationId. Force a fresh id
|
|
182
|
-
// so the full history is replayed without depending on that state.
|
|
183
|
-
const forceFreshConversation =
|
|
184
|
-
options.forceFreshConversation === true
|
|
185
|
-
|| (lastRaw?.role === "toolResult" && isCursorExternalWireModel(model.modelId));
|
|
186
218
|
return {
|
|
187
219
|
modelId: model.modelId,
|
|
188
220
|
...(model.routingLevel ? { routingLevel: model.routingLevel } : {}),
|
|
189
|
-
|
|
190
|
-
// back to the OpenAI Responses previous_response_id (resp_*): that is a Responses-chain id in a
|
|
191
|
-
// different namespace and would start an unrelated Cursor conversation, breaking tool-result
|
|
192
|
-
// continuation. If we have no remembered Cursor conversation, start a fresh one.
|
|
193
|
-
conversationId: forceFreshConversation
|
|
194
|
-
? generatedCursorConversationId()
|
|
195
|
-
: (parsed._cursorConversationId ?? generatedCursorConversationId()),
|
|
221
|
+
conversationId: resolveCursorConversationId(parsed, model.modelId, options),
|
|
196
222
|
system: [...(parsed.context.systemPrompt ?? []), ...(limitNote ? [limitNote] : [])],
|
|
197
223
|
messages,
|
|
198
224
|
rawMessages: parsed.context.messages,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded in-memory overrides for Cursor conversation continuity.
|
|
3
|
+
*
|
|
4
|
+
* When an invalid_argument recovery mints a fresh conversation id for a store:false
|
|
5
|
+
* thread-identified client, later turns without previous_response_id must reuse that
|
|
6
|
+
* recovered id instead of recomputing the stale deterministic thread hash.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const OVERRIDE_TTL_MS = 60 * 60 * 1000;
|
|
10
|
+
const OVERRIDE_MAX_ENTRIES = 2048;
|
|
11
|
+
|
|
12
|
+
const overrides = new Map<string, { conversationId: string; updatedAt: number }>();
|
|
13
|
+
|
|
14
|
+
function now(): number {
|
|
15
|
+
return Date.now();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function prune(at: number): void {
|
|
19
|
+
for (const [key, entry] of overrides) {
|
|
20
|
+
if (at - entry.updatedAt > OVERRIDE_TTL_MS) overrides.delete(key);
|
|
21
|
+
else break; // Map iterates insertion order; refreshed entries are moved to the end
|
|
22
|
+
}
|
|
23
|
+
while (overrides.size > OVERRIDE_MAX_ENTRIES) {
|
|
24
|
+
const oldest = overrides.keys().next().value;
|
|
25
|
+
if (oldest === undefined) break;
|
|
26
|
+
overrides.delete(oldest);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Scope key for a client thread, optionally namespaced by authenticated tenant/operator identity. */
|
|
31
|
+
export function cursorThreadScopeKey(threadId: string, identityScope?: string): string {
|
|
32
|
+
const scope = identityScope?.trim() || "local";
|
|
33
|
+
return `${scope}\0${threadId}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function rememberCursorThreadConversation(
|
|
37
|
+
threadId: string,
|
|
38
|
+
conversationId: string,
|
|
39
|
+
identityScope?: string,
|
|
40
|
+
): void {
|
|
41
|
+
const key = cursorThreadScopeKey(threadId, identityScope);
|
|
42
|
+
const at = now();
|
|
43
|
+
overrides.delete(key);
|
|
44
|
+
overrides.set(key, { conversationId, updatedAt: at });
|
|
45
|
+
prune(at);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function lookupCursorThreadConversation(
|
|
49
|
+
threadId: string,
|
|
50
|
+
identityScope?: string,
|
|
51
|
+
): string | undefined {
|
|
52
|
+
const key = cursorThreadScopeKey(threadId, identityScope);
|
|
53
|
+
const entry = overrides.get(key);
|
|
54
|
+
if (!entry) return undefined;
|
|
55
|
+
const at = now();
|
|
56
|
+
if (at - entry.updatedAt > OVERRIDE_TTL_MS) {
|
|
57
|
+
overrides.delete(key);
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
overrides.delete(key);
|
|
61
|
+
overrides.set(key, { conversationId: entry.conversationId, updatedAt: at });
|
|
62
|
+
return entry.conversationId;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function clearCursorThreadContinuityForTests(): void {
|
|
66
|
+
overrides.clear();
|
|
67
|
+
}
|
|
@@ -42,7 +42,9 @@ export type CursorServerMessage =
|
|
|
42
42
|
| { type: "heartbeat" }
|
|
43
43
|
| { type: "kv_get"; key: string }
|
|
44
44
|
| { type: "kv_set"; key: string; value: Uint8Array }
|
|
45
|
-
| { type: "exec"; execCase: string; requestId: string }
|
|
45
|
+
| { type: "exec"; execCase: string; requestId: string }
|
|
46
|
+
/** A native exec/MCP action ran locally; retrying this turn could duplicate its side effects. */
|
|
47
|
+
| { type: "local_side_effect" };
|
|
46
48
|
|
|
47
49
|
export type CursorClientMessage =
|
|
48
50
|
| { type: "kv_value"; key: string; value?: Uint8Array }
|
package/src/adapters/cursor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { AdapterEvent, OcxProviderConfig } from "../types";
|
|
2
3
|
import type { ProviderAdapter } from "./base";
|
|
3
4
|
import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy";
|
|
@@ -5,12 +6,14 @@ import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErro
|
|
|
5
6
|
import { isCursorExternalWireModel } from "./cursor/discovery";
|
|
6
7
|
import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store";
|
|
7
8
|
import { mapCursorServerMessage } from "./cursor/message-mapper";
|
|
8
|
-
import { createCursorRequest
|
|
9
|
+
import { createCursorRequest } from "./cursor/request-builder";
|
|
9
10
|
import {
|
|
10
11
|
createLiveCursorTransport,
|
|
11
12
|
CursorMissingCredentialError,
|
|
12
13
|
rekeyCursorContextUsage,
|
|
14
|
+
resolveCursorToken,
|
|
13
15
|
} from "./cursor/live-transport";
|
|
16
|
+
import { rememberCursorThreadConversation } from "./cursor/thread-continuity";
|
|
14
17
|
import { runCursorTurnWithRetry } from "./cursor/transport-retry";
|
|
15
18
|
import {
|
|
16
19
|
createDisabledCursorTransport,
|
|
@@ -77,16 +80,36 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
|
|
|
77
80
|
const makeTransport = deps.createTransport ?? createLiveCursorTransport;
|
|
78
81
|
const kv = deps.kv ?? createCursorKvStore();
|
|
79
82
|
const rekeyContextUsage = deps.rekeyContextUsage ?? rekeyCursorContextUsage;
|
|
80
|
-
|
|
83
|
+
// Namespace thread→conversation derivation by the authenticated Cursor credential so
|
|
84
|
+
// shared-proxy tenants with different Cursor accounts cannot collide on a parent thread id.
|
|
85
|
+
// Prefer an already-set auth scope (e.g. Codex pool account) when present.
|
|
86
|
+
if (!_parsed._cursorIdentityScope) {
|
|
87
|
+
try {
|
|
88
|
+
const token = resolveCursorToken(provider, incoming.headers);
|
|
89
|
+
_parsed._cursorIdentityScope = createHash("sha256")
|
|
90
|
+
.update("ocx:cursor:acct:")
|
|
91
|
+
.update(token)
|
|
92
|
+
.digest("hex")
|
|
93
|
+
.slice(0, 16);
|
|
94
|
+
} catch {
|
|
95
|
+
/* Missing credential is handled by the live transport path below. */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
81
98
|
const previousConversationId = _parsed._cursorConversationId;
|
|
82
99
|
let request = createCursorRequest(_parsed);
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
100
|
+
// The builder may derive a stable provider id from the client thread when Responses state
|
|
101
|
+
// is unavailable. Rekey only existing state; there is nothing to migrate on a fresh turn,
|
|
102
|
+
// and isolated helper/compaction turns must never inherit or donate the parent's usage state.
|
|
103
|
+
if (
|
|
104
|
+
previousConversationId
|
|
105
|
+
&& request.conversationId !== previousConversationId
|
|
106
|
+
&& _parsed._cursorIsolateConversation !== true
|
|
107
|
+
) {
|
|
86
108
|
rekeyContextUsage(previousConversationId, request.conversationId);
|
|
87
109
|
}
|
|
88
110
|
_parsed._cursorConversationId = request.conversationId;
|
|
89
111
|
let emittedOutput = false;
|
|
112
|
+
let replayUnsafe = false;
|
|
90
113
|
const lastRawIsToolResult = _parsed.context.messages.at(-1)?.role === "toolResult";
|
|
91
114
|
|
|
92
115
|
const runOnce = async (activeRequest: ReturnType<typeof createCursorRequest>) => {
|
|
@@ -104,6 +127,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
|
|
|
104
127
|
emit({ type: "error", message: "Cursor turn was aborted." });
|
|
105
128
|
return;
|
|
106
129
|
}
|
|
130
|
+
if (message.type === "local_side_effect") replayUnsafe = true;
|
|
107
131
|
const events = mapCursorServerMessage(message, {
|
|
108
132
|
kv,
|
|
109
133
|
writeClient: clientMessage => {
|
|
@@ -121,14 +145,15 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
|
|
|
121
145
|
try {
|
|
122
146
|
await runOnce(request);
|
|
123
147
|
} catch (err) {
|
|
124
|
-
// One-shot fallback
|
|
125
|
-
//
|
|
126
|
-
//
|
|
148
|
+
// One-shot fallback for external-model Connect invalid_argument before any
|
|
149
|
+
// non-heartbeat output. Retries apply only to safe plain-user turns; tool-result
|
|
150
|
+
// resumes, local exec/MCP side effects, and already-emitted output fail closed.
|
|
127
151
|
if (
|
|
128
152
|
!isCursorInvalidArgumentError(err)
|
|
129
153
|
|| !isCursorExternalWireModel(request.modelId)
|
|
130
|
-
||
|
|
154
|
+
|| lastRawIsToolResult
|
|
131
155
|
|| emittedOutput
|
|
156
|
+
|| replayUnsafe
|
|
132
157
|
|| incoming.abortSignal?.aborted
|
|
133
158
|
) {
|
|
134
159
|
throw err;
|
|
@@ -138,6 +163,16 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
|
|
|
138
163
|
request = createCursorRequest(_parsed, { forceFreshConversation: true });
|
|
139
164
|
rekeyContextUsage(failedConversationId, request.conversationId);
|
|
140
165
|
_parsed._cursorConversationId = request.conversationId;
|
|
166
|
+
// Persist recovery for store:false clients that only send a parent thread id, so the
|
|
167
|
+
// next turn does not recompute the stale deterministic thread hash. Isolated helper /
|
|
168
|
+
// compaction turns must not park their throwaway id under the parent thread key.
|
|
169
|
+
if (_parsed._clientThreadId && _parsed._cursorIsolateConversation !== true) {
|
|
170
|
+
rememberCursorThreadConversation(
|
|
171
|
+
_parsed._clientThreadId,
|
|
172
|
+
request.conversationId,
|
|
173
|
+
_parsed._cursorIdentityScope,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
141
176
|
await runOnce(request);
|
|
142
177
|
}
|
|
143
178
|
} catch (err) {
|