@playwo/opencode-cursor-oauth 0.0.0-dev.65683458d3f1 → 0.0.0-dev.9b39a4eb497b
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/dist/index.js +8 -6
- package/dist/proxy.d.ts +0 -3
- package/dist/proxy.js +369 -94
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { generateCursorAuthParams, getTokenExpiry, pollCursorAuth, refreshCursorToken, } from "./auth";
|
|
2
2
|
import { configurePluginLogger, errorDetails, logPluginError, logPluginWarn } from "./logger";
|
|
3
3
|
import { getCursorModels } from "./models";
|
|
4
|
-
import {
|
|
4
|
+
import { startProxy, stopProxy, } from "./proxy";
|
|
5
5
|
const CURSOR_PROVIDER_ID = "cursor";
|
|
6
6
|
let lastModelDiscoveryError = null;
|
|
7
7
|
/**
|
|
@@ -128,12 +128,14 @@ export const CursorAuthPlugin = async (input) => {
|
|
|
128
128
|
},
|
|
129
129
|
],
|
|
130
130
|
},
|
|
131
|
-
async "chat.headers"(
|
|
132
|
-
if (
|
|
131
|
+
async "chat.headers"(incoming, output) {
|
|
132
|
+
if (incoming.model.providerID !== CURSOR_PROVIDER_ID)
|
|
133
133
|
return;
|
|
134
|
-
output.headers[
|
|
135
|
-
output.headers[
|
|
136
|
-
|
|
134
|
+
output.headers["x-opencode-session-id"] = incoming.sessionID;
|
|
135
|
+
output.headers["x-session-id"] = incoming.sessionID;
|
|
136
|
+
if (incoming.agent) {
|
|
137
|
+
output.headers["x-opencode-agent"] = incoming.agent;
|
|
138
|
+
}
|
|
137
139
|
},
|
|
138
140
|
};
|
|
139
141
|
};
|
package/dist/proxy.d.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
export declare const OPENCODE_SESSION_ID_HEADER = "x-opencode-session-id";
|
|
2
|
-
export declare const OPENCODE_AGENT_HEADER = "x-opencode-agent";
|
|
3
|
-
export declare const OPENCODE_MESSAGE_ID_HEADER = "x-opencode-message-id";
|
|
4
1
|
interface CursorUnaryRpcOptions {
|
|
5
2
|
accessToken: string;
|
|
6
3
|
rpcPath: string;
|
package/dist/proxy.js
CHANGED
|
@@ -22,15 +22,11 @@ const CURSOR_API_URL = process.env.CURSOR_API_URL ?? "https://api2.cursor.sh";
|
|
|
22
22
|
const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
|
|
23
23
|
const CURSOR_CONNECT_PROTOCOL_VERSION = "1";
|
|
24
24
|
const CONNECT_END_STREAM_FLAG = 0b00000010;
|
|
25
|
-
export const OPENCODE_SESSION_ID_HEADER = "x-opencode-session-id";
|
|
26
|
-
export const OPENCODE_AGENT_HEADER = "x-opencode-agent";
|
|
27
|
-
export const OPENCODE_MESSAGE_ID_HEADER = "x-opencode-message-id";
|
|
28
25
|
const SSE_HEADERS = {
|
|
29
26
|
"Content-Type": "text/event-stream",
|
|
30
27
|
"Cache-Control": "no-cache",
|
|
31
28
|
Connection: "keep-alive",
|
|
32
29
|
};
|
|
33
|
-
const EPHEMERAL_CURSOR_AGENTS = new Set(["title", "summary"]);
|
|
34
30
|
// Active bridges keyed by a session token (derived from conversation state).
|
|
35
31
|
// When tool_calls are returned, the bridge stays alive. The next request
|
|
36
32
|
// with tool results looks up the bridge and sends mcpResult messages.
|
|
@@ -45,6 +41,31 @@ function evictStaleConversations() {
|
|
|
45
41
|
}
|
|
46
42
|
}
|
|
47
43
|
}
|
|
44
|
+
function normalizeAgentKey(agentKey) {
|
|
45
|
+
const trimmed = agentKey?.trim();
|
|
46
|
+
return trimmed ? trimmed : "default";
|
|
47
|
+
}
|
|
48
|
+
function hashString(value) {
|
|
49
|
+
return createHash("sha256").update(value).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
function createStoredConversation() {
|
|
52
|
+
return {
|
|
53
|
+
conversationId: crypto.randomUUID(),
|
|
54
|
+
checkpoint: null,
|
|
55
|
+
blobStore: new Map(),
|
|
56
|
+
lastAccessMs: Date.now(),
|
|
57
|
+
systemPromptHash: "",
|
|
58
|
+
completedTurnsFingerprint: "",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function resetStoredConversation(stored) {
|
|
62
|
+
stored.conversationId = crypto.randomUUID();
|
|
63
|
+
stored.checkpoint = null;
|
|
64
|
+
stored.blobStore = new Map();
|
|
65
|
+
stored.lastAccessMs = Date.now();
|
|
66
|
+
stored.systemPromptHash = "";
|
|
67
|
+
stored.completedTurnsFingerprint = "";
|
|
68
|
+
}
|
|
48
69
|
/** Connect protocol frame: [1-byte flags][4-byte BE length][payload] */
|
|
49
70
|
function frameConnectMessage(data, flags = 0) {
|
|
50
71
|
const frame = Buffer.alloc(5 + data.length);
|
|
@@ -475,11 +496,11 @@ export async function startProxy(getAccessToken, models = []) {
|
|
|
475
496
|
throw new Error("Cursor proxy access token provider not configured");
|
|
476
497
|
}
|
|
477
498
|
const accessToken = await proxyAccessTokenProvider();
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
});
|
|
499
|
+
const sessionId = req.headers.get("x-opencode-session-id")
|
|
500
|
+
?? req.headers.get("x-session-id")
|
|
501
|
+
?? undefined;
|
|
502
|
+
const agentKey = req.headers.get("x-opencode-agent") ?? undefined;
|
|
503
|
+
return handleChatCompletion(body, accessToken, { sessionId, agentKey });
|
|
483
504
|
}
|
|
484
505
|
catch (err) {
|
|
485
506
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -517,10 +538,15 @@ export function stopProxy() {
|
|
|
517
538
|
activeBridges.clear();
|
|
518
539
|
conversationStates.clear();
|
|
519
540
|
}
|
|
520
|
-
function handleChatCompletion(body, accessToken,
|
|
521
|
-
const
|
|
541
|
+
function handleChatCompletion(body, accessToken, context = {}) {
|
|
542
|
+
const parsed = parseMessages(body.messages);
|
|
543
|
+
const { systemPrompt, userText, turns, toolResults, pendingAssistantSummary, completedTurnsFingerprint, } = parsed;
|
|
522
544
|
const modelId = body.model;
|
|
523
|
-
const
|
|
545
|
+
const normalizedAgentKey = normalizeAgentKey(context.agentKey);
|
|
546
|
+
const isTitleAgent = normalizedAgentKey === "title";
|
|
547
|
+
const tools = isTitleAgent
|
|
548
|
+
? []
|
|
549
|
+
: selectToolsForChoice(body.tools ?? [], body.tool_choice);
|
|
524
550
|
if (!userText && toolResults.length === 0) {
|
|
525
551
|
return new Response(JSON.stringify({
|
|
526
552
|
error: {
|
|
@@ -529,16 +555,24 @@ function handleChatCompletion(body, accessToken, requestScope = {}) {
|
|
|
529
555
|
},
|
|
530
556
|
}), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
531
557
|
}
|
|
532
|
-
// bridgeKey:
|
|
558
|
+
// bridgeKey: session/agent-scoped, for active tool-call bridges
|
|
533
559
|
// convKey: model-independent, for conversation state that survives model switches
|
|
534
|
-
const bridgeKey = deriveBridgeKey(modelId, body.messages,
|
|
535
|
-
const convKey = deriveConversationKey(body.messages,
|
|
560
|
+
const bridgeKey = deriveBridgeKey(modelId, body.messages, context.sessionId, context.agentKey);
|
|
561
|
+
const convKey = deriveConversationKey(body.messages, context.sessionId, context.agentKey);
|
|
536
562
|
const activeBridge = activeBridges.get(bridgeKey);
|
|
537
563
|
if (activeBridge && toolResults.length > 0) {
|
|
538
564
|
activeBridges.delete(bridgeKey);
|
|
539
565
|
if (activeBridge.bridge.alive) {
|
|
566
|
+
if (activeBridge.modelId !== modelId) {
|
|
567
|
+
logPluginWarn("Resuming pending Cursor tool call on original model after model switch", {
|
|
568
|
+
requestedModelId: modelId,
|
|
569
|
+
resumedModelId: activeBridge.modelId,
|
|
570
|
+
convKey,
|
|
571
|
+
bridgeKey,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
540
574
|
// Resume the live bridge with tool results
|
|
541
|
-
return handleToolResultResume(activeBridge, toolResults,
|
|
575
|
+
return handleToolResultResume(activeBridge, toolResults, bridgeKey, convKey);
|
|
542
576
|
}
|
|
543
577
|
// Bridge died (timeout, server disconnect, etc.).
|
|
544
578
|
// Clean up and fall through to start a fresh bridge.
|
|
@@ -553,28 +587,52 @@ function handleChatCompletion(body, accessToken, requestScope = {}) {
|
|
|
553
587
|
}
|
|
554
588
|
let stored = conversationStates.get(convKey);
|
|
555
589
|
if (!stored) {
|
|
556
|
-
stored =
|
|
557
|
-
conversationId: crypto.randomUUID(),
|
|
558
|
-
checkpoint: null,
|
|
559
|
-
blobStore: new Map(),
|
|
560
|
-
lastAccessMs: Date.now(),
|
|
561
|
-
};
|
|
590
|
+
stored = createStoredConversation();
|
|
562
591
|
conversationStates.set(convKey, stored);
|
|
563
592
|
}
|
|
593
|
+
const systemPromptHash = hashString(systemPrompt);
|
|
594
|
+
if (stored.checkpoint
|
|
595
|
+
&& (stored.systemPromptHash !== systemPromptHash
|
|
596
|
+
|| (turns.length > 0 && stored.completedTurnsFingerprint !== completedTurnsFingerprint))) {
|
|
597
|
+
resetStoredConversation(stored);
|
|
598
|
+
}
|
|
599
|
+
stored.systemPromptHash = systemPromptHash;
|
|
600
|
+
stored.completedTurnsFingerprint = completedTurnsFingerprint;
|
|
564
601
|
stored.lastAccessMs = Date.now();
|
|
565
602
|
evictStaleConversations();
|
|
566
603
|
// Build the request. When tool results are present but the bridge died,
|
|
567
604
|
// we must still include the last user text so Cursor has context.
|
|
568
605
|
const mcpTools = buildMcpToolDefinitions(tools);
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
606
|
+
const needsInitialHandoff = !stored.checkpoint && (turns.length > 0 || pendingAssistantSummary || toolResults.length > 0);
|
|
607
|
+
const replayTurns = needsInitialHandoff ? [] : turns;
|
|
608
|
+
let effectiveUserText = needsInitialHandoff
|
|
609
|
+
? buildInitialHandoffPrompt(userText, turns, pendingAssistantSummary, toolResults)
|
|
610
|
+
: toolResults.length > 0
|
|
611
|
+
? buildToolResumePrompt(userText, pendingAssistantSummary, toolResults)
|
|
612
|
+
: userText;
|
|
613
|
+
if (isTitleAgent) {
|
|
614
|
+
effectiveUserText = buildTitleUserPrompt(systemPrompt, effectiveUserText);
|
|
615
|
+
}
|
|
616
|
+
const payload = buildCursorRequest(modelId, systemPrompt, effectiveUserText, replayTurns, stored.conversationId, stored.checkpoint, stored.blobStore);
|
|
573
617
|
payload.mcpTools = mcpTools;
|
|
574
618
|
if (body.stream === false) {
|
|
575
|
-
return handleNonStreamingResponse(payload, accessToken, modelId, convKey
|
|
619
|
+
return handleNonStreamingResponse(payload, accessToken, modelId, convKey, {
|
|
620
|
+
systemPrompt,
|
|
621
|
+
systemPromptHash,
|
|
622
|
+
completedTurnsFingerprint,
|
|
623
|
+
turns,
|
|
624
|
+
userText,
|
|
625
|
+
agentKey: normalizedAgentKey,
|
|
626
|
+
});
|
|
576
627
|
}
|
|
577
|
-
return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey
|
|
628
|
+
return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, {
|
|
629
|
+
systemPrompt,
|
|
630
|
+
systemPromptHash,
|
|
631
|
+
completedTurnsFingerprint,
|
|
632
|
+
turns,
|
|
633
|
+
userText,
|
|
634
|
+
agentKey: normalizedAgentKey,
|
|
635
|
+
});
|
|
578
636
|
}
|
|
579
637
|
/** Normalize OpenAI message content to a plain string. */
|
|
580
638
|
function textContent(content) {
|
|
@@ -589,8 +647,6 @@ function textContent(content) {
|
|
|
589
647
|
}
|
|
590
648
|
function parseMessages(messages) {
|
|
591
649
|
let systemPrompt = "You are a helpful assistant.";
|
|
592
|
-
const pairs = [];
|
|
593
|
-
const toolResults = [];
|
|
594
650
|
// Collect system messages
|
|
595
651
|
const systemParts = messages
|
|
596
652
|
.filter((m) => m.role === "system")
|
|
@@ -598,40 +654,184 @@ function parseMessages(messages) {
|
|
|
598
654
|
if (systemParts.length > 0) {
|
|
599
655
|
systemPrompt = systemParts.join("\n");
|
|
600
656
|
}
|
|
601
|
-
// Separate tool results from conversation turns
|
|
602
657
|
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
603
|
-
|
|
658
|
+
const parsedTurns = [];
|
|
659
|
+
let currentTurn;
|
|
604
660
|
for (const msg of nonSystem) {
|
|
605
|
-
if (msg.role === "
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
661
|
+
if (msg.role === "user") {
|
|
662
|
+
if (currentTurn)
|
|
663
|
+
parsedTurns.push(currentTurn);
|
|
664
|
+
currentTurn = {
|
|
665
|
+
userText: textContent(msg.content),
|
|
666
|
+
segments: [],
|
|
667
|
+
};
|
|
668
|
+
continue;
|
|
610
669
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
pairs.push({ userText: pendingUser, assistantText: "" });
|
|
614
|
-
}
|
|
615
|
-
pendingUser = textContent(msg.content);
|
|
670
|
+
if (!currentTurn) {
|
|
671
|
+
currentTurn = { userText: "", segments: [] };
|
|
616
672
|
}
|
|
617
|
-
|
|
618
|
-
// Skip assistant messages that are just tool_calls with no text
|
|
673
|
+
if (msg.role === "assistant") {
|
|
619
674
|
const text = textContent(msg.content);
|
|
620
|
-
if (
|
|
621
|
-
|
|
622
|
-
pendingUser = "";
|
|
675
|
+
if (text) {
|
|
676
|
+
currentTurn.segments.push({ kind: "assistantText", text });
|
|
623
677
|
}
|
|
678
|
+
if (msg.tool_calls?.length) {
|
|
679
|
+
currentTurn.segments.push({
|
|
680
|
+
kind: "assistantToolCalls",
|
|
681
|
+
toolCalls: msg.tool_calls,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (msg.role === "tool") {
|
|
687
|
+
currentTurn.segments.push({
|
|
688
|
+
kind: "toolResult",
|
|
689
|
+
result: {
|
|
690
|
+
toolCallId: msg.tool_call_id ?? "",
|
|
691
|
+
content: textContent(msg.content),
|
|
692
|
+
},
|
|
693
|
+
});
|
|
624
694
|
}
|
|
625
695
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
696
|
+
if (currentTurn)
|
|
697
|
+
parsedTurns.push(currentTurn);
|
|
698
|
+
let userText = "";
|
|
699
|
+
let toolResults = [];
|
|
700
|
+
let pendingAssistantSummary = "";
|
|
701
|
+
let completedTurnStates = parsedTurns;
|
|
702
|
+
const lastTurn = parsedTurns.at(-1);
|
|
703
|
+
if (lastTurn) {
|
|
704
|
+
const trailingSegments = splitTrailingToolResults(lastTurn.segments);
|
|
705
|
+
const hasAssistantSummary = trailingSegments.base.length > 0;
|
|
706
|
+
if (trailingSegments.trailing.length > 0 && hasAssistantSummary) {
|
|
707
|
+
completedTurnStates = parsedTurns.slice(0, -1);
|
|
708
|
+
userText = lastTurn.userText;
|
|
709
|
+
toolResults = trailingSegments.trailing.map((segment) => segment.result);
|
|
710
|
+
pendingAssistantSummary = summarizeTurnSegments(trailingSegments.base);
|
|
711
|
+
}
|
|
712
|
+
else if (lastTurn.userText && lastTurn.segments.length === 0) {
|
|
713
|
+
completedTurnStates = parsedTurns.slice(0, -1);
|
|
714
|
+
userText = lastTurn.userText;
|
|
715
|
+
}
|
|
629
716
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
717
|
+
const turns = completedTurnStates
|
|
718
|
+
.map((turn) => ({
|
|
719
|
+
userText: turn.userText,
|
|
720
|
+
assistantText: summarizeTurnSegments(turn.segments),
|
|
721
|
+
}))
|
|
722
|
+
.filter((turn) => turn.userText || turn.assistantText);
|
|
723
|
+
return {
|
|
724
|
+
systemPrompt,
|
|
725
|
+
userText,
|
|
726
|
+
turns,
|
|
727
|
+
toolResults,
|
|
728
|
+
pendingAssistantSummary,
|
|
729
|
+
completedTurnsFingerprint: buildCompletedTurnsFingerprint(systemPrompt, turns),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
function splitTrailingToolResults(segments) {
|
|
733
|
+
let index = segments.length;
|
|
734
|
+
while (index > 0 && segments[index - 1]?.kind === "toolResult") {
|
|
735
|
+
index -= 1;
|
|
633
736
|
}
|
|
634
|
-
return {
|
|
737
|
+
return {
|
|
738
|
+
base: segments.slice(0, index),
|
|
739
|
+
trailing: segments.slice(index).filter((segment) => segment.kind === "toolResult"),
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
function summarizeTurnSegments(segments) {
|
|
743
|
+
const parts = [];
|
|
744
|
+
for (const segment of segments) {
|
|
745
|
+
if (segment.kind === "assistantText") {
|
|
746
|
+
const trimmed = segment.text.trim();
|
|
747
|
+
if (trimmed)
|
|
748
|
+
parts.push(trimmed);
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (segment.kind === "assistantToolCalls") {
|
|
752
|
+
const summary = segment.toolCalls.map(formatToolCallSummary).join("\n\n");
|
|
753
|
+
if (summary)
|
|
754
|
+
parts.push(summary);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
parts.push(formatToolResultSummary(segment.result));
|
|
758
|
+
}
|
|
759
|
+
return parts.join("\n\n").trim();
|
|
760
|
+
}
|
|
761
|
+
function formatToolCallSummary(call) {
|
|
762
|
+
const args = call.function.arguments?.trim();
|
|
763
|
+
return args
|
|
764
|
+
? `[assistant requested tool ${call.function.name} id=${call.id}]\n${args}`
|
|
765
|
+
: `[assistant requested tool ${call.function.name} id=${call.id}]`;
|
|
766
|
+
}
|
|
767
|
+
function formatToolResultSummary(result) {
|
|
768
|
+
const label = result.toolCallId
|
|
769
|
+
? `[tool result id=${result.toolCallId}]`
|
|
770
|
+
: "[tool result]";
|
|
771
|
+
const content = result.content.trim();
|
|
772
|
+
return content ? `${label}\n${content}` : label;
|
|
773
|
+
}
|
|
774
|
+
function buildCompletedTurnsFingerprint(systemPrompt, turns) {
|
|
775
|
+
return hashString(JSON.stringify({ systemPrompt, turns }));
|
|
776
|
+
}
|
|
777
|
+
function buildToolResumePrompt(userText, pendingAssistantSummary, toolResults) {
|
|
778
|
+
const parts = [userText.trim()];
|
|
779
|
+
if (pendingAssistantSummary.trim()) {
|
|
780
|
+
parts.push(`[previous assistant tool activity]\n${pendingAssistantSummary.trim()}`);
|
|
781
|
+
}
|
|
782
|
+
if (toolResults.length > 0) {
|
|
783
|
+
parts.push(toolResults.map(formatToolResultSummary).join("\n\n"));
|
|
784
|
+
}
|
|
785
|
+
return parts.filter(Boolean).join("\n\n");
|
|
786
|
+
}
|
|
787
|
+
function buildInitialHandoffPrompt(userText, turns, pendingAssistantSummary, toolResults) {
|
|
788
|
+
const transcript = turns.map((turn, index) => {
|
|
789
|
+
const sections = [`Turn ${index + 1}`];
|
|
790
|
+
if (turn.userText.trim())
|
|
791
|
+
sections.push(`User: ${turn.userText.trim()}`);
|
|
792
|
+
if (turn.assistantText.trim())
|
|
793
|
+
sections.push(`Assistant: ${turn.assistantText.trim()}`);
|
|
794
|
+
return sections.join("\n");
|
|
795
|
+
});
|
|
796
|
+
const inProgress = buildToolResumePrompt("", pendingAssistantSummary, toolResults).trim();
|
|
797
|
+
const history = [
|
|
798
|
+
...transcript,
|
|
799
|
+
...(inProgress ? [`In-progress turn\n${inProgress}`] : []),
|
|
800
|
+
].join("\n\n").trim();
|
|
801
|
+
if (!history)
|
|
802
|
+
return userText;
|
|
803
|
+
return [
|
|
804
|
+
"[OpenCode session handoff]",
|
|
805
|
+
"You are continuing an existing session that previously ran on another provider/model.",
|
|
806
|
+
"Treat the transcript below as prior conversation history before answering the latest user message.",
|
|
807
|
+
"",
|
|
808
|
+
"<previous-session-transcript>",
|
|
809
|
+
history,
|
|
810
|
+
"</previous-session-transcript>",
|
|
811
|
+
"",
|
|
812
|
+
"Latest user message:",
|
|
813
|
+
userText.trim(),
|
|
814
|
+
].filter(Boolean).join("\n");
|
|
815
|
+
}
|
|
816
|
+
function buildTitleUserPrompt(systemPrompt, content) {
|
|
817
|
+
return [systemPrompt.trim(), content.trim()].filter(Boolean).join("\n\n");
|
|
818
|
+
}
|
|
819
|
+
function selectToolsForChoice(tools, toolChoice) {
|
|
820
|
+
if (!tools.length)
|
|
821
|
+
return [];
|
|
822
|
+
if (toolChoice === undefined || toolChoice === null || toolChoice === "auto" || toolChoice === "required") {
|
|
823
|
+
return tools;
|
|
824
|
+
}
|
|
825
|
+
if (toolChoice === "none") {
|
|
826
|
+
return [];
|
|
827
|
+
}
|
|
828
|
+
if (typeof toolChoice === "object") {
|
|
829
|
+
const choice = toolChoice;
|
|
830
|
+
if (choice.type === "function" && typeof choice.function?.name === "string") {
|
|
831
|
+
return tools.filter((tool) => tool.function.name === choice.function.name);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return tools;
|
|
635
835
|
}
|
|
636
836
|
/** Convert OpenAI tool definitions to Cursor's MCP tool protobuf format. */
|
|
637
837
|
function buildMcpToolDefinitions(tools) {
|
|
@@ -774,6 +974,12 @@ function makeHeartbeatBytes() {
|
|
|
774
974
|
});
|
|
775
975
|
return toBinary(AgentClientMessageSchema, heartbeat);
|
|
776
976
|
}
|
|
977
|
+
function scheduleBridgeEnd(bridge) {
|
|
978
|
+
queueMicrotask(() => {
|
|
979
|
+
if (bridge.alive)
|
|
980
|
+
bridge.end();
|
|
981
|
+
});
|
|
982
|
+
}
|
|
777
983
|
/**
|
|
778
984
|
* Create a stateful parser for Connect protocol frames.
|
|
779
985
|
* Handles buffering partial data across chunks.
|
|
@@ -1086,40 +1292,56 @@ function sendExecResult(execMsg, messageCase, value, sendFrame) {
|
|
|
1086
1292
|
});
|
|
1087
1293
|
sendFrame(toBinary(AgentClientMessageSchema, clientMessage));
|
|
1088
1294
|
}
|
|
1089
|
-
/** Derive a key for active bridge lookup (tool-call continuations).
|
|
1090
|
-
function deriveBridgeKey(modelId, messages,
|
|
1295
|
+
/** Derive a key for active bridge lookup (tool-call continuations). */
|
|
1296
|
+
function deriveBridgeKey(modelId, messages, sessionId, agentKey) {
|
|
1297
|
+
if (sessionId) {
|
|
1298
|
+
const normalizedAgent = normalizeAgentKey(agentKey);
|
|
1299
|
+
return createHash("sha256")
|
|
1300
|
+
.update(`bridge:${sessionId}:${normalizedAgent}`)
|
|
1301
|
+
.digest("hex")
|
|
1302
|
+
.slice(0, 16);
|
|
1303
|
+
}
|
|
1091
1304
|
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
1092
1305
|
const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
|
|
1306
|
+
const normalizedAgent = normalizeAgentKey(agentKey);
|
|
1093
1307
|
return createHash("sha256")
|
|
1094
|
-
.update(`bridge:${
|
|
1308
|
+
.update(`bridge:${normalizedAgent}:${modelId}:${firstUserText.slice(0, 200)}`)
|
|
1095
1309
|
.digest("hex")
|
|
1096
1310
|
.slice(0, 16);
|
|
1097
1311
|
}
|
|
1098
1312
|
/** Derive a key for conversation state. Model-independent so context survives model switches. */
|
|
1099
|
-
function deriveConversationKey(messages,
|
|
1100
|
-
if (
|
|
1101
|
-
const
|
|
1102
|
-
? `${requestScope.sessionID}:${requestScope.agent ?? ""}:${requestScope.messageID ?? crypto.randomUUID()}`
|
|
1103
|
-
: `${requestScope.sessionID}:${requestScope.agent ?? "default"}`;
|
|
1313
|
+
function deriveConversationKey(messages, sessionId, agentKey) {
|
|
1314
|
+
if (sessionId) {
|
|
1315
|
+
const normalizedAgent = normalizeAgentKey(agentKey);
|
|
1104
1316
|
return createHash("sha256")
|
|
1105
|
-
.update(`
|
|
1317
|
+
.update(`session:${sessionId}:${normalizedAgent}`)
|
|
1106
1318
|
.digest("hex")
|
|
1107
1319
|
.slice(0, 16);
|
|
1108
1320
|
}
|
|
1109
|
-
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
1110
|
-
const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
|
|
1111
1321
|
return createHash("sha256")
|
|
1112
|
-
.update(
|
|
1322
|
+
.update(`${normalizeAgentKey(agentKey)}:${buildConversationFingerprint(messages)}`)
|
|
1113
1323
|
.digest("hex")
|
|
1114
1324
|
.slice(0, 16);
|
|
1115
1325
|
}
|
|
1116
|
-
function
|
|
1117
|
-
return
|
|
1118
|
-
|
|
1119
|
-
|
|
1326
|
+
function buildConversationFingerprint(messages) {
|
|
1327
|
+
return messages.map((message) => {
|
|
1328
|
+
const toolCallIDs = (message.tool_calls ?? []).map((call) => call.id).join(",");
|
|
1329
|
+
return `${message.role}:${textContent(message.content)}:${message.tool_call_id ?? ""}:${toolCallIDs}`;
|
|
1330
|
+
}).join("\n---\n");
|
|
1331
|
+
}
|
|
1332
|
+
function updateStoredConversationAfterCompletion(convKey, metadata, assistantText) {
|
|
1333
|
+
const stored = conversationStates.get(convKey);
|
|
1334
|
+
if (!stored)
|
|
1335
|
+
return;
|
|
1336
|
+
const nextTurns = metadata.userText
|
|
1337
|
+
? [...metadata.turns, { userText: metadata.userText, assistantText: assistantText.trim() }]
|
|
1338
|
+
: metadata.turns;
|
|
1339
|
+
stored.systemPromptHash = metadata.systemPromptHash;
|
|
1340
|
+
stored.completedTurnsFingerprint = buildCompletedTurnsFingerprint(metadata.systemPrompt, nextTurns);
|
|
1341
|
+
stored.lastAccessMs = Date.now();
|
|
1120
1342
|
}
|
|
1121
1343
|
/** Create an SSE streaming Response that reads from a live bridge. */
|
|
1122
|
-
function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
|
|
1344
|
+
function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, metadata) {
|
|
1123
1345
|
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1124
1346
|
const created = Math.floor(Date.now() / 1000);
|
|
1125
1347
|
const stream = new ReadableStream({
|
|
@@ -1167,6 +1389,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1167
1389
|
totalTokens: 0,
|
|
1168
1390
|
};
|
|
1169
1391
|
const tagFilter = createThinkingTagFilter();
|
|
1392
|
+
let assistantText = metadata.assistantSeedText ?? "";
|
|
1170
1393
|
let mcpExecReceived = false;
|
|
1171
1394
|
let endStreamError = null;
|
|
1172
1395
|
const processChunk = createConnectFrameParser((messageBytes) => {
|
|
@@ -1180,8 +1403,10 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1180
1403
|
const { content, reasoning } = tagFilter.process(text);
|
|
1181
1404
|
if (reasoning)
|
|
1182
1405
|
sendSSE(makeChunk({ reasoning_content: reasoning }));
|
|
1183
|
-
if (content)
|
|
1406
|
+
if (content) {
|
|
1407
|
+
assistantText += content;
|
|
1184
1408
|
sendSSE(makeChunk({ content }));
|
|
1409
|
+
}
|
|
1185
1410
|
}
|
|
1186
1411
|
},
|
|
1187
1412
|
// onMcpExec — the model wants to execute a tool.
|
|
@@ -1191,8 +1416,21 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1191
1416
|
const flushed = tagFilter.flush();
|
|
1192
1417
|
if (flushed.reasoning)
|
|
1193
1418
|
sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
|
|
1194
|
-
if (flushed.content)
|
|
1419
|
+
if (flushed.content) {
|
|
1420
|
+
assistantText += flushed.content;
|
|
1195
1421
|
sendSSE(makeChunk({ content: flushed.content }));
|
|
1422
|
+
}
|
|
1423
|
+
const assistantSeedText = [
|
|
1424
|
+
assistantText.trim(),
|
|
1425
|
+
formatToolCallSummary({
|
|
1426
|
+
id: exec.toolCallId,
|
|
1427
|
+
type: "function",
|
|
1428
|
+
function: {
|
|
1429
|
+
name: exec.toolName,
|
|
1430
|
+
arguments: exec.decodedArgs,
|
|
1431
|
+
},
|
|
1432
|
+
}),
|
|
1433
|
+
].filter(Boolean).join("\n\n");
|
|
1196
1434
|
const toolCallIndex = state.toolCallIndex++;
|
|
1197
1435
|
sendSSE(makeChunk({
|
|
1198
1436
|
tool_calls: [{
|
|
@@ -1212,6 +1450,11 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1212
1450
|
blobStore,
|
|
1213
1451
|
mcpTools,
|
|
1214
1452
|
pendingExecs: state.pendingExecs,
|
|
1453
|
+
modelId,
|
|
1454
|
+
metadata: {
|
|
1455
|
+
...metadata,
|
|
1456
|
+
assistantSeedText,
|
|
1457
|
+
},
|
|
1215
1458
|
});
|
|
1216
1459
|
sendSSE(makeChunk({}, "tool_calls"));
|
|
1217
1460
|
sendDone();
|
|
@@ -1237,6 +1480,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1237
1480
|
...errorDetails(endStreamError),
|
|
1238
1481
|
});
|
|
1239
1482
|
}
|
|
1483
|
+
scheduleBridgeEnd(bridge);
|
|
1240
1484
|
});
|
|
1241
1485
|
bridge.onData(processChunk);
|
|
1242
1486
|
bridge.onClose((code) => {
|
|
@@ -1259,23 +1503,27 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1259
1503
|
const flushed = tagFilter.flush();
|
|
1260
1504
|
if (flushed.reasoning)
|
|
1261
1505
|
sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
|
|
1262
|
-
if (flushed.content)
|
|
1506
|
+
if (flushed.content) {
|
|
1507
|
+
assistantText += flushed.content;
|
|
1263
1508
|
sendSSE(makeChunk({ content: flushed.content }));
|
|
1509
|
+
}
|
|
1510
|
+
updateStoredConversationAfterCompletion(convKey, metadata, assistantText);
|
|
1264
1511
|
sendSSE(makeChunk({}, "stop"));
|
|
1265
1512
|
sendSSE(makeUsageChunk());
|
|
1266
1513
|
sendDone();
|
|
1267
1514
|
closeController();
|
|
1268
1515
|
}
|
|
1269
|
-
else
|
|
1270
|
-
// Bridge died while tool calls are pending (timeout, crash, etc.).
|
|
1271
|
-
// Close the SSE stream so the client doesn't hang forever.
|
|
1272
|
-
sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
|
|
1273
|
-
sendSSE(makeChunk({}, "stop"));
|
|
1274
|
-
sendSSE(makeUsageChunk());
|
|
1275
|
-
sendDone();
|
|
1276
|
-
closeController();
|
|
1277
|
-
// Remove stale entry so the next request doesn't try to resume it.
|
|
1516
|
+
else {
|
|
1278
1517
|
activeBridges.delete(bridgeKey);
|
|
1518
|
+
if (code !== 0 && !closed) {
|
|
1519
|
+
// Bridge died while tool calls are pending (timeout, crash, etc.).
|
|
1520
|
+
// Close the SSE stream so the client doesn't hang forever.
|
|
1521
|
+
sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
|
|
1522
|
+
sendSSE(makeChunk({}, "stop"));
|
|
1523
|
+
sendSSE(makeUsageChunk());
|
|
1524
|
+
sendDone();
|
|
1525
|
+
closeController();
|
|
1526
|
+
}
|
|
1279
1527
|
}
|
|
1280
1528
|
});
|
|
1281
1529
|
},
|
|
@@ -1293,13 +1541,20 @@ async function startBridge(accessToken, requestBytes) {
|
|
|
1293
1541
|
const heartbeatTimer = setInterval(() => bridge.write(makeHeartbeatBytes()), 5_000);
|
|
1294
1542
|
return { bridge, heartbeatTimer };
|
|
1295
1543
|
}
|
|
1296
|
-
async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey) {
|
|
1544
|
+
async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, metadata) {
|
|
1297
1545
|
const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
|
|
1298
|
-
return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey);
|
|
1546
|
+
return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey, metadata);
|
|
1299
1547
|
}
|
|
1300
1548
|
/** Resume a paused bridge by sending MCP results and continuing to stream. */
|
|
1301
|
-
function handleToolResultResume(active, toolResults,
|
|
1302
|
-
const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs } = active;
|
|
1549
|
+
function handleToolResultResume(active, toolResults, bridgeKey, convKey) {
|
|
1550
|
+
const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs, modelId, metadata } = active;
|
|
1551
|
+
const resumeMetadata = {
|
|
1552
|
+
...metadata,
|
|
1553
|
+
assistantSeedText: [
|
|
1554
|
+
metadata.assistantSeedText?.trim() ?? "",
|
|
1555
|
+
toolResults.map(formatToolResultSummary).join("\n\n"),
|
|
1556
|
+
].filter(Boolean).join("\n\n"),
|
|
1557
|
+
};
|
|
1303
1558
|
// Send mcpResult for each pending exec that has a matching tool result
|
|
1304
1559
|
for (const exec of pendingExecs) {
|
|
1305
1560
|
const result = toolResults.find((r) => r.toolCallId === exec.toolCallId);
|
|
@@ -1339,12 +1594,15 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
|
|
|
1339
1594
|
});
|
|
1340
1595
|
bridge.write(toBinary(AgentClientMessageSchema, clientMessage));
|
|
1341
1596
|
}
|
|
1342
|
-
return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey);
|
|
1597
|
+
return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, resumeMetadata);
|
|
1343
1598
|
}
|
|
1344
|
-
async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
|
|
1599
|
+
async function handleNonStreamingResponse(payload, accessToken, modelId, convKey, metadata) {
|
|
1345
1600
|
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1346
1601
|
const created = Math.floor(Date.now() / 1000);
|
|
1347
|
-
const { text, usage } = await collectFullResponse(payload, accessToken, modelId, convKey);
|
|
1602
|
+
const { text, usage, finishReason, toolCalls } = await collectFullResponse(payload, accessToken, modelId, convKey, metadata);
|
|
1603
|
+
const message = finishReason === "tool_calls"
|
|
1604
|
+
? { role: "assistant", content: null, tool_calls: toolCalls }
|
|
1605
|
+
: { role: "assistant", content: text };
|
|
1348
1606
|
return new Response(JSON.stringify({
|
|
1349
1607
|
id: completionId,
|
|
1350
1608
|
object: "chat.completion",
|
|
@@ -1353,17 +1611,18 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
|
|
|
1353
1611
|
choices: [
|
|
1354
1612
|
{
|
|
1355
1613
|
index: 0,
|
|
1356
|
-
message
|
|
1357
|
-
finish_reason:
|
|
1614
|
+
message,
|
|
1615
|
+
finish_reason: finishReason,
|
|
1358
1616
|
},
|
|
1359
1617
|
],
|
|
1360
1618
|
usage,
|
|
1361
1619
|
}), { headers: { "Content-Type": "application/json" } });
|
|
1362
1620
|
}
|
|
1363
|
-
async function collectFullResponse(payload, accessToken, modelId, convKey) {
|
|
1621
|
+
async function collectFullResponse(payload, accessToken, modelId, convKey, metadata) {
|
|
1364
1622
|
const { promise, resolve, reject } = Promise.withResolvers();
|
|
1365
1623
|
let fullText = "";
|
|
1366
1624
|
let endStreamError = null;
|
|
1625
|
+
const pendingToolCalls = [];
|
|
1367
1626
|
const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
|
|
1368
1627
|
const state = {
|
|
1369
1628
|
toolCallIndex: 0,
|
|
@@ -1380,7 +1639,17 @@ async function collectFullResponse(payload, accessToken, modelId, convKey) {
|
|
|
1380
1639
|
return;
|
|
1381
1640
|
const { content } = tagFilter.process(text);
|
|
1382
1641
|
fullText += content;
|
|
1383
|
-
}, () => {
|
|
1642
|
+
}, (exec) => {
|
|
1643
|
+
pendingToolCalls.push({
|
|
1644
|
+
id: exec.toolCallId,
|
|
1645
|
+
type: "function",
|
|
1646
|
+
function: {
|
|
1647
|
+
name: exec.toolName,
|
|
1648
|
+
arguments: exec.decodedArgs,
|
|
1649
|
+
},
|
|
1650
|
+
});
|
|
1651
|
+
scheduleBridgeEnd(bridge);
|
|
1652
|
+
}, (checkpointBytes) => {
|
|
1384
1653
|
const stored = conversationStates.get(convKey);
|
|
1385
1654
|
if (stored) {
|
|
1386
1655
|
stored.checkpoint = checkpointBytes;
|
|
@@ -1400,6 +1669,7 @@ async function collectFullResponse(payload, accessToken, modelId, convKey) {
|
|
|
1400
1669
|
...errorDetails(endStreamError),
|
|
1401
1670
|
});
|
|
1402
1671
|
}
|
|
1672
|
+
scheduleBridgeEnd(bridge);
|
|
1403
1673
|
}));
|
|
1404
1674
|
bridge.onClose(() => {
|
|
1405
1675
|
clearInterval(heartbeatTimer);
|
|
@@ -1415,10 +1685,15 @@ async function collectFullResponse(payload, accessToken, modelId, convKey) {
|
|
|
1415
1685
|
reject(endStreamError);
|
|
1416
1686
|
return;
|
|
1417
1687
|
}
|
|
1688
|
+
if (pendingToolCalls.length === 0) {
|
|
1689
|
+
updateStoredConversationAfterCompletion(convKey, metadata, fullText);
|
|
1690
|
+
}
|
|
1418
1691
|
const usage = computeUsage(state);
|
|
1419
1692
|
resolve({
|
|
1420
1693
|
text: fullText,
|
|
1421
1694
|
usage,
|
|
1695
|
+
finishReason: pendingToolCalls.length > 0 ? "tool_calls" : "stop",
|
|
1696
|
+
toolCalls: pendingToolCalls,
|
|
1422
1697
|
});
|
|
1423
1698
|
});
|
|
1424
1699
|
return promise;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playwo/opencode-cursor-oauth",
|
|
3
|
-
"version": "0.0.0-dev.
|
|
3
|
+
"version": "0.0.0-dev.9b39a4eb497b",
|
|
4
4
|
"description": "OpenCode plugin that connects Cursor's API to OpenCode via OAuth, model discovery, and a local OpenAI-compatible proxy.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|