@playwo/opencode-cursor-oauth 0.0.0-dev.1b946f85e9b0 → 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/README.md +19 -91
- package/dist/index.js +10 -1
- package/dist/proxy.js +412 -97
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,103 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-cursor-oauth
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
models inside OpenCode with full tool-calling support.
|
|
3
|
+
Use Cursor models (Claude, GPT, Gemini, etc.) inside [OpenCode](https://opencode.ai).
|
|
5
4
|
|
|
6
|
-
##
|
|
5
|
+
## What it does
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
- **OAuth login** to Cursor via browser
|
|
8
|
+
- **Model discovery** — automatically fetches your available Cursor models
|
|
9
|
+
- **Local proxy** — runs an OpenAI-compatible endpoint that translates to Cursor's gRPC protocol
|
|
10
|
+
- **Auto-refresh** — handles token expiration automatically
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
{
|
|
12
|
-
"$schema": "https://opencode.ai/config.json",
|
|
13
|
-
"plugin": [
|
|
14
|
-
"@playwo/opencode-cursor-oauth"
|
|
15
|
-
],
|
|
16
|
-
"provider": {
|
|
17
|
-
"cursor": {
|
|
18
|
-
"name": "Cursor"
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
The `cursor` provider stub is required because OpenCode drops providers that do
|
|
25
|
-
not already exist in its bundled provider catalog.
|
|
26
|
-
|
|
27
|
-
OpenCode installs npm plugins automatically at startup, so users do not need to
|
|
28
|
-
clone this repository.
|
|
29
|
-
|
|
30
|
-
## Authenticate
|
|
31
|
-
|
|
32
|
-
```sh
|
|
33
|
-
opencode auth login --provider cursor
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
This opens Cursor OAuth in the browser. Tokens are stored in
|
|
37
|
-
`~/.local/share/opencode/auth.json` and refreshed automatically.
|
|
38
|
-
|
|
39
|
-
## Use
|
|
40
|
-
|
|
41
|
-
Start OpenCode and select any Cursor model. The plugin starts a local
|
|
42
|
-
OpenAI-compatible proxy on demand and routes requests through Cursor's gRPC API.
|
|
43
|
-
|
|
44
|
-
## How it works
|
|
45
|
-
|
|
46
|
-
1. OAuth — browser-based login to Cursor via PKCE.
|
|
47
|
-
2. Model discovery — queries Cursor's gRPC API for all available models; if discovery fails, the plugin disables the Cursor provider for that load and shows a visible error toast instead of crashing OpenCode.
|
|
48
|
-
3. Local proxy — translates `POST /v1/chat/completions` into Cursor's
|
|
49
|
-
protobuf/Connect protocol.
|
|
50
|
-
4. Native tool routing — rejects Cursor's built-in filesystem/shell tools and
|
|
51
|
-
exposes OpenCode's tool surface via Cursor MCP instead.
|
|
52
|
-
|
|
53
|
-
Cursor agent streaming uses Cursor's `RunSSE` + `BidiAppend` transport, so the
|
|
54
|
-
plugin runs entirely inside OpenCode without a Node sidecar.
|
|
12
|
+
## Install
|
|
55
13
|
|
|
56
|
-
|
|
14
|
+
Add to your `opencode.json`:
|
|
57
15
|
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"plugin": ["@playwo/opencode-cursor-oauth"]
|
|
19
|
+
}
|
|
58
20
|
```
|
|
59
|
-
OpenCode --> /v1/chat/completions --> Bun.serve (proxy)
|
|
60
|
-
|
|
|
61
|
-
RunSSE stream + BidiAppend writes
|
|
62
|
-
|
|
|
63
|
-
Cursor Connect/SSE transport
|
|
64
|
-
|
|
|
65
|
-
api2.cursor.sh gRPC
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
### Tool call flow
|
|
69
|
-
|
|
70
|
-
```
|
|
71
|
-
1. Cursor model receives OpenAI tools via RequestContext (as MCP tool defs)
|
|
72
|
-
2. Model tries native tools (readArgs, shellArgs, etc.)
|
|
73
|
-
3. Proxy rejects each with typed error (ReadRejected, ShellRejected, etc.)
|
|
74
|
-
4. Model falls back to MCP tool -> mcpArgs exec message
|
|
75
|
-
5. Proxy emits OpenAI tool_calls SSE chunk, pauses the Cursor stream
|
|
76
|
-
6. OpenCode executes tool, sends result in follow-up request
|
|
77
|
-
7. Proxy resumes the Cursor stream with mcpResult and continues streaming
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## Develop locally
|
|
81
|
-
|
|
82
|
-
```sh
|
|
83
|
-
bun install
|
|
84
|
-
bun run build
|
|
85
|
-
bun test/smoke.ts
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
## Publish
|
|
89
21
|
|
|
90
|
-
|
|
22
|
+
Then authenticate via the OpenCode UI (Settings → Providers → Cursor → Login).
|
|
91
23
|
|
|
92
|
-
|
|
93
|
-
- versioned releases publish `latest` using the `package.json` version and upload the packed `.tgz` to the GitHub release
|
|
94
|
-
|
|
95
|
-
Repository secrets required:
|
|
24
|
+
## Requirements
|
|
96
25
|
|
|
97
|
-
-
|
|
26
|
+
- Cursor account with API access
|
|
27
|
+
- OpenCode 1.2+
|
|
98
28
|
|
|
99
|
-
##
|
|
29
|
+
## License
|
|
100
30
|
|
|
101
|
-
|
|
102
|
-
- [Bun](https://bun.sh)
|
|
103
|
-
- Active [Cursor](https://cursor.com) subscription
|
|
31
|
+
MIT
|
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 { startProxy, stopProxy } from "./proxy";
|
|
4
|
+
import { startProxy, stopProxy, } from "./proxy";
|
|
5
5
|
const CURSOR_PROVIDER_ID = "cursor";
|
|
6
6
|
let lastModelDiscoveryError = null;
|
|
7
7
|
/**
|
|
@@ -128,6 +128,15 @@ export const CursorAuthPlugin = async (input) => {
|
|
|
128
128
|
},
|
|
129
129
|
],
|
|
130
130
|
},
|
|
131
|
+
async "chat.headers"(incoming, output) {
|
|
132
|
+
if (incoming.model.providerID !== CURSOR_PROVIDER_ID)
|
|
133
|
+
return;
|
|
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
|
+
}
|
|
139
|
+
},
|
|
131
140
|
};
|
|
132
141
|
};
|
|
133
142
|
function buildCursorProviderModels(models, port) {
|
package/dist/proxy.js
CHANGED
|
@@ -41,6 +41,31 @@ function evictStaleConversations() {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
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
|
+
}
|
|
44
69
|
/** Connect protocol frame: [1-byte flags][4-byte BE length][payload] */
|
|
45
70
|
function frameConnectMessage(data, flags = 0) {
|
|
46
71
|
const frame = Buffer.alloc(5 + data.length);
|
|
@@ -471,7 +496,11 @@ export async function startProxy(getAccessToken, models = []) {
|
|
|
471
496
|
throw new Error("Cursor proxy access token provider not configured");
|
|
472
497
|
}
|
|
473
498
|
const accessToken = await proxyAccessTokenProvider();
|
|
474
|
-
|
|
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 });
|
|
475
504
|
}
|
|
476
505
|
catch (err) {
|
|
477
506
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -509,10 +538,15 @@ export function stopProxy() {
|
|
|
509
538
|
activeBridges.clear();
|
|
510
539
|
conversationStates.clear();
|
|
511
540
|
}
|
|
512
|
-
function handleChatCompletion(body, accessToken) {
|
|
513
|
-
const
|
|
541
|
+
function handleChatCompletion(body, accessToken, context = {}) {
|
|
542
|
+
const parsed = parseMessages(body.messages);
|
|
543
|
+
const { systemPrompt, userText, turns, toolResults, pendingAssistantSummary, completedTurnsFingerprint, } = parsed;
|
|
514
544
|
const modelId = body.model;
|
|
515
|
-
const
|
|
545
|
+
const normalizedAgentKey = normalizeAgentKey(context.agentKey);
|
|
546
|
+
const isTitleAgent = normalizedAgentKey === "title";
|
|
547
|
+
const tools = isTitleAgent
|
|
548
|
+
? []
|
|
549
|
+
: selectToolsForChoice(body.tools ?? [], body.tool_choice);
|
|
516
550
|
if (!userText && toolResults.length === 0) {
|
|
517
551
|
return new Response(JSON.stringify({
|
|
518
552
|
error: {
|
|
@@ -521,16 +555,24 @@ function handleChatCompletion(body, accessToken) {
|
|
|
521
555
|
},
|
|
522
556
|
}), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
523
557
|
}
|
|
524
|
-
// bridgeKey:
|
|
558
|
+
// bridgeKey: session/agent-scoped, for active tool-call bridges
|
|
525
559
|
// convKey: model-independent, for conversation state that survives model switches
|
|
526
|
-
const bridgeKey = deriveBridgeKey(modelId, body.messages);
|
|
527
|
-
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);
|
|
528
562
|
const activeBridge = activeBridges.get(bridgeKey);
|
|
529
563
|
if (activeBridge && toolResults.length > 0) {
|
|
530
564
|
activeBridges.delete(bridgeKey);
|
|
531
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
|
+
}
|
|
532
574
|
// Resume the live bridge with tool results
|
|
533
|
-
return handleToolResultResume(activeBridge, toolResults,
|
|
575
|
+
return handleToolResultResume(activeBridge, toolResults, bridgeKey, convKey);
|
|
534
576
|
}
|
|
535
577
|
// Bridge died (timeout, server disconnect, etc.).
|
|
536
578
|
// Clean up and fall through to start a fresh bridge.
|
|
@@ -545,28 +587,52 @@ function handleChatCompletion(body, accessToken) {
|
|
|
545
587
|
}
|
|
546
588
|
let stored = conversationStates.get(convKey);
|
|
547
589
|
if (!stored) {
|
|
548
|
-
stored =
|
|
549
|
-
conversationId: deterministicConversationId(convKey),
|
|
550
|
-
checkpoint: null,
|
|
551
|
-
blobStore: new Map(),
|
|
552
|
-
lastAccessMs: Date.now(),
|
|
553
|
-
};
|
|
590
|
+
stored = createStoredConversation();
|
|
554
591
|
conversationStates.set(convKey, stored);
|
|
555
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;
|
|
556
601
|
stored.lastAccessMs = Date.now();
|
|
557
602
|
evictStaleConversations();
|
|
558
603
|
// Build the request. When tool results are present but the bridge died,
|
|
559
604
|
// we must still include the last user text so Cursor has context.
|
|
560
605
|
const mcpTools = buildMcpToolDefinitions(tools);
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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);
|
|
565
617
|
payload.mcpTools = mcpTools;
|
|
566
618
|
if (body.stream === false) {
|
|
567
|
-
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
|
+
});
|
|
568
627
|
}
|
|
569
|
-
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
|
+
});
|
|
570
636
|
}
|
|
571
637
|
/** Normalize OpenAI message content to a plain string. */
|
|
572
638
|
function textContent(content) {
|
|
@@ -581,8 +647,6 @@ function textContent(content) {
|
|
|
581
647
|
}
|
|
582
648
|
function parseMessages(messages) {
|
|
583
649
|
let systemPrompt = "You are a helpful assistant.";
|
|
584
|
-
const pairs = [];
|
|
585
|
-
const toolResults = [];
|
|
586
650
|
// Collect system messages
|
|
587
651
|
const systemParts = messages
|
|
588
652
|
.filter((m) => m.role === "system")
|
|
@@ -590,40 +654,184 @@ function parseMessages(messages) {
|
|
|
590
654
|
if (systemParts.length > 0) {
|
|
591
655
|
systemPrompt = systemParts.join("\n");
|
|
592
656
|
}
|
|
593
|
-
// Separate tool results from conversation turns
|
|
594
657
|
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
595
|
-
|
|
658
|
+
const parsedTurns = [];
|
|
659
|
+
let currentTurn;
|
|
596
660
|
for (const msg of nonSystem) {
|
|
597
|
-
if (msg.role === "
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
661
|
+
if (msg.role === "user") {
|
|
662
|
+
if (currentTurn)
|
|
663
|
+
parsedTurns.push(currentTurn);
|
|
664
|
+
currentTurn = {
|
|
665
|
+
userText: textContent(msg.content),
|
|
666
|
+
segments: [],
|
|
667
|
+
};
|
|
668
|
+
continue;
|
|
602
669
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
pairs.push({ userText: pendingUser, assistantText: "" });
|
|
606
|
-
}
|
|
607
|
-
pendingUser = textContent(msg.content);
|
|
670
|
+
if (!currentTurn) {
|
|
671
|
+
currentTurn = { userText: "", segments: [] };
|
|
608
672
|
}
|
|
609
|
-
|
|
610
|
-
// Skip assistant messages that are just tool_calls with no text
|
|
673
|
+
if (msg.role === "assistant") {
|
|
611
674
|
const text = textContent(msg.content);
|
|
612
|
-
if (
|
|
613
|
-
|
|
614
|
-
pendingUser = "";
|
|
675
|
+
if (text) {
|
|
676
|
+
currentTurn.segments.push({ kind: "assistantText", text });
|
|
615
677
|
}
|
|
678
|
+
if (msg.tool_calls?.length) {
|
|
679
|
+
currentTurn.segments.push({
|
|
680
|
+
kind: "assistantToolCalls",
|
|
681
|
+
toolCalls: msg.tool_calls,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
continue;
|
|
616
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
|
+
});
|
|
694
|
+
}
|
|
695
|
+
}
|
|
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
|
+
}
|
|
716
|
+
}
|
|
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;
|
|
736
|
+
}
|
|
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));
|
|
617
758
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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()}`);
|
|
621
781
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
lastUserText = last.userText;
|
|
782
|
+
if (toolResults.length > 0) {
|
|
783
|
+
parts.push(toolResults.map(formatToolResultSummary).join("\n\n"));
|
|
625
784
|
}
|
|
626
|
-
return
|
|
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;
|
|
627
835
|
}
|
|
628
836
|
/** Convert OpenAI tool definitions to Cursor's MCP tool protobuf format. */
|
|
629
837
|
function buildMcpToolDefinitions(tools) {
|
|
@@ -766,6 +974,12 @@ function makeHeartbeatBytes() {
|
|
|
766
974
|
});
|
|
767
975
|
return toBinary(AgentClientMessageSchema, heartbeat);
|
|
768
976
|
}
|
|
977
|
+
function scheduleBridgeEnd(bridge) {
|
|
978
|
+
queueMicrotask(() => {
|
|
979
|
+
if (bridge.alive)
|
|
980
|
+
bridge.end();
|
|
981
|
+
});
|
|
982
|
+
}
|
|
769
983
|
/**
|
|
770
984
|
* Create a stateful parser for Connect protocol frames.
|
|
771
985
|
* Handles buffering partial data across chunks.
|
|
@@ -908,6 +1122,12 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
|
|
|
908
1122
|
const blobId = kvMsg.message.value.blobId;
|
|
909
1123
|
const blobIdKey = Buffer.from(blobId).toString("hex");
|
|
910
1124
|
const blobData = blobStore.get(blobIdKey);
|
|
1125
|
+
if (!blobData) {
|
|
1126
|
+
logPluginWarn("Cursor requested missing blob", {
|
|
1127
|
+
blobId: blobIdKey,
|
|
1128
|
+
knownBlobCount: blobStore.size,
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
911
1131
|
sendKvResponse(kvMsg, "getBlobResult", create(GetBlobResultSchema, blobData ? { blobData } : {}), sendFrame);
|
|
912
1132
|
}
|
|
913
1133
|
else if (kvCase === "setBlobArgs") {
|
|
@@ -1072,42 +1292,56 @@ function sendExecResult(execMsg, messageCase, value, sendFrame) {
|
|
|
1072
1292
|
});
|
|
1073
1293
|
sendFrame(toBinary(AgentClientMessageSchema, clientMessage));
|
|
1074
1294
|
}
|
|
1075
|
-
/** Derive a key for active bridge lookup (tool-call continuations).
|
|
1076
|
-
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
|
+
}
|
|
1077
1304
|
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
1078
1305
|
const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
|
|
1306
|
+
const normalizedAgent = normalizeAgentKey(agentKey);
|
|
1079
1307
|
return createHash("sha256")
|
|
1080
|
-
.update(`bridge:${modelId}:${firstUserText.slice(0, 200)}`)
|
|
1308
|
+
.update(`bridge:${normalizedAgent}:${modelId}:${firstUserText.slice(0, 200)}`)
|
|
1081
1309
|
.digest("hex")
|
|
1082
1310
|
.slice(0, 16);
|
|
1083
1311
|
}
|
|
1084
1312
|
/** Derive a key for conversation state. Model-independent so context survives model switches. */
|
|
1085
|
-
function deriveConversationKey(messages) {
|
|
1086
|
-
|
|
1087
|
-
|
|
1313
|
+
function deriveConversationKey(messages, sessionId, agentKey) {
|
|
1314
|
+
if (sessionId) {
|
|
1315
|
+
const normalizedAgent = normalizeAgentKey(agentKey);
|
|
1316
|
+
return createHash("sha256")
|
|
1317
|
+
.update(`session:${sessionId}:${normalizedAgent}`)
|
|
1318
|
+
.digest("hex")
|
|
1319
|
+
.slice(0, 16);
|
|
1320
|
+
}
|
|
1088
1321
|
return createHash("sha256")
|
|
1089
|
-
.update(
|
|
1322
|
+
.update(`${normalizeAgentKey(agentKey)}:${buildConversationFingerprint(messages)}`)
|
|
1090
1323
|
.digest("hex")
|
|
1091
1324
|
.slice(0, 16);
|
|
1092
1325
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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();
|
|
1108
1342
|
}
|
|
1109
1343
|
/** Create an SSE streaming Response that reads from a live bridge. */
|
|
1110
|
-
function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
|
|
1344
|
+
function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, metadata) {
|
|
1111
1345
|
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1112
1346
|
const created = Math.floor(Date.now() / 1000);
|
|
1113
1347
|
const stream = new ReadableStream({
|
|
@@ -1155,7 +1389,9 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1155
1389
|
totalTokens: 0,
|
|
1156
1390
|
};
|
|
1157
1391
|
const tagFilter = createThinkingTagFilter();
|
|
1392
|
+
let assistantText = metadata.assistantSeedText ?? "";
|
|
1158
1393
|
let mcpExecReceived = false;
|
|
1394
|
+
let endStreamError = null;
|
|
1159
1395
|
const processChunk = createConnectFrameParser((messageBytes) => {
|
|
1160
1396
|
try {
|
|
1161
1397
|
const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
|
|
@@ -1167,8 +1403,10 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1167
1403
|
const { content, reasoning } = tagFilter.process(text);
|
|
1168
1404
|
if (reasoning)
|
|
1169
1405
|
sendSSE(makeChunk({ reasoning_content: reasoning }));
|
|
1170
|
-
if (content)
|
|
1406
|
+
if (content) {
|
|
1407
|
+
assistantText += content;
|
|
1171
1408
|
sendSSE(makeChunk({ content }));
|
|
1409
|
+
}
|
|
1172
1410
|
}
|
|
1173
1411
|
},
|
|
1174
1412
|
// onMcpExec — the model wants to execute a tool.
|
|
@@ -1178,8 +1416,21 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1178
1416
|
const flushed = tagFilter.flush();
|
|
1179
1417
|
if (flushed.reasoning)
|
|
1180
1418
|
sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
|
|
1181
|
-
if (flushed.content)
|
|
1419
|
+
if (flushed.content) {
|
|
1420
|
+
assistantText += flushed.content;
|
|
1182
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");
|
|
1183
1434
|
const toolCallIndex = state.toolCallIndex++;
|
|
1184
1435
|
sendSSE(makeChunk({
|
|
1185
1436
|
tool_calls: [{
|
|
@@ -1199,6 +1450,11 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1199
1450
|
blobStore,
|
|
1200
1451
|
mcpTools,
|
|
1201
1452
|
pendingExecs: state.pendingExecs,
|
|
1453
|
+
modelId,
|
|
1454
|
+
metadata: {
|
|
1455
|
+
...metadata,
|
|
1456
|
+
assistantSeedText,
|
|
1457
|
+
},
|
|
1202
1458
|
});
|
|
1203
1459
|
sendSSE(makeChunk({}, "tool_calls"));
|
|
1204
1460
|
sendDone();
|
|
@@ -1215,10 +1471,16 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1215
1471
|
// Skip unparseable messages
|
|
1216
1472
|
}
|
|
1217
1473
|
}, (endStreamBytes) => {
|
|
1218
|
-
|
|
1219
|
-
if (
|
|
1220
|
-
|
|
1474
|
+
endStreamError = parseConnectEndStream(endStreamBytes);
|
|
1475
|
+
if (endStreamError) {
|
|
1476
|
+
logPluginError("Cursor stream returned Connect end-stream error", {
|
|
1477
|
+
modelId,
|
|
1478
|
+
bridgeKey,
|
|
1479
|
+
convKey,
|
|
1480
|
+
...errorDetails(endStreamError),
|
|
1481
|
+
});
|
|
1221
1482
|
}
|
|
1483
|
+
scheduleBridgeEnd(bridge);
|
|
1222
1484
|
});
|
|
1223
1485
|
bridge.onData(processChunk);
|
|
1224
1486
|
bridge.onClose((code) => {
|
|
@@ -1229,27 +1491,39 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1229
1491
|
stored.blobStore.set(k, v);
|
|
1230
1492
|
stored.lastAccessMs = Date.now();
|
|
1231
1493
|
}
|
|
1494
|
+
if (endStreamError) {
|
|
1495
|
+
activeBridges.delete(bridgeKey);
|
|
1496
|
+
if (!closed) {
|
|
1497
|
+
closed = true;
|
|
1498
|
+
controller.error(endStreamError);
|
|
1499
|
+
}
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1232
1502
|
if (!mcpExecReceived) {
|
|
1233
1503
|
const flushed = tagFilter.flush();
|
|
1234
1504
|
if (flushed.reasoning)
|
|
1235
1505
|
sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
|
|
1236
|
-
if (flushed.content)
|
|
1506
|
+
if (flushed.content) {
|
|
1507
|
+
assistantText += flushed.content;
|
|
1237
1508
|
sendSSE(makeChunk({ content: flushed.content }));
|
|
1509
|
+
}
|
|
1510
|
+
updateStoredConversationAfterCompletion(convKey, metadata, assistantText);
|
|
1238
1511
|
sendSSE(makeChunk({}, "stop"));
|
|
1239
1512
|
sendSSE(makeUsageChunk());
|
|
1240
1513
|
sendDone();
|
|
1241
1514
|
closeController();
|
|
1242
1515
|
}
|
|
1243
|
-
else
|
|
1244
|
-
// Bridge died while tool calls are pending (timeout, crash, etc.).
|
|
1245
|
-
// Close the SSE stream so the client doesn't hang forever.
|
|
1246
|
-
sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
|
|
1247
|
-
sendSSE(makeChunk({}, "stop"));
|
|
1248
|
-
sendSSE(makeUsageChunk());
|
|
1249
|
-
sendDone();
|
|
1250
|
-
closeController();
|
|
1251
|
-
// Remove stale entry so the next request doesn't try to resume it.
|
|
1516
|
+
else {
|
|
1252
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
|
+
}
|
|
1253
1527
|
}
|
|
1254
1528
|
});
|
|
1255
1529
|
},
|
|
@@ -1267,13 +1541,20 @@ async function startBridge(accessToken, requestBytes) {
|
|
|
1267
1541
|
const heartbeatTimer = setInterval(() => bridge.write(makeHeartbeatBytes()), 5_000);
|
|
1268
1542
|
return { bridge, heartbeatTimer };
|
|
1269
1543
|
}
|
|
1270
|
-
async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey) {
|
|
1544
|
+
async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, metadata) {
|
|
1271
1545
|
const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
|
|
1272
|
-
return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey);
|
|
1546
|
+
return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey, metadata);
|
|
1273
1547
|
}
|
|
1274
1548
|
/** Resume a paused bridge by sending MCP results and continuing to stream. */
|
|
1275
|
-
function handleToolResultResume(active, toolResults,
|
|
1276
|
-
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
|
+
};
|
|
1277
1558
|
// Send mcpResult for each pending exec that has a matching tool result
|
|
1278
1559
|
for (const exec of pendingExecs) {
|
|
1279
1560
|
const result = toolResults.find((r) => r.toolCallId === exec.toolCallId);
|
|
@@ -1313,12 +1594,15 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
|
|
|
1313
1594
|
});
|
|
1314
1595
|
bridge.write(toBinary(AgentClientMessageSchema, clientMessage));
|
|
1315
1596
|
}
|
|
1316
|
-
return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey);
|
|
1597
|
+
return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, resumeMetadata);
|
|
1317
1598
|
}
|
|
1318
|
-
async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
|
|
1599
|
+
async function handleNonStreamingResponse(payload, accessToken, modelId, convKey, metadata) {
|
|
1319
1600
|
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1320
1601
|
const created = Math.floor(Date.now() / 1000);
|
|
1321
|
-
const { text, usage } = await collectFullResponse(payload, accessToken, 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 };
|
|
1322
1606
|
return new Response(JSON.stringify({
|
|
1323
1607
|
id: completionId,
|
|
1324
1608
|
object: "chat.completion",
|
|
@@ -1327,16 +1611,18 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
|
|
|
1327
1611
|
choices: [
|
|
1328
1612
|
{
|
|
1329
1613
|
index: 0,
|
|
1330
|
-
message
|
|
1331
|
-
finish_reason:
|
|
1614
|
+
message,
|
|
1615
|
+
finish_reason: finishReason,
|
|
1332
1616
|
},
|
|
1333
1617
|
],
|
|
1334
1618
|
usage,
|
|
1335
1619
|
}), { headers: { "Content-Type": "application/json" } });
|
|
1336
1620
|
}
|
|
1337
|
-
async function collectFullResponse(payload, accessToken, convKey) {
|
|
1338
|
-
const { promise, resolve } = Promise.withResolvers();
|
|
1621
|
+
async function collectFullResponse(payload, accessToken, modelId, convKey, metadata) {
|
|
1622
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
1339
1623
|
let fullText = "";
|
|
1624
|
+
let endStreamError = null;
|
|
1625
|
+
const pendingToolCalls = [];
|
|
1340
1626
|
const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
|
|
1341
1627
|
const state = {
|
|
1342
1628
|
toolCallIndex: 0,
|
|
@@ -1353,7 +1639,17 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1353
1639
|
return;
|
|
1354
1640
|
const { content } = tagFilter.process(text);
|
|
1355
1641
|
fullText += content;
|
|
1356
|
-
}, () => {
|
|
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) => {
|
|
1357
1653
|
const stored = conversationStates.get(convKey);
|
|
1358
1654
|
if (stored) {
|
|
1359
1655
|
stored.checkpoint = checkpointBytes;
|
|
@@ -1364,7 +1660,17 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1364
1660
|
catch {
|
|
1365
1661
|
// Skip
|
|
1366
1662
|
}
|
|
1367
|
-
}, () => {
|
|
1663
|
+
}, (endStreamBytes) => {
|
|
1664
|
+
endStreamError = parseConnectEndStream(endStreamBytes);
|
|
1665
|
+
if (endStreamError) {
|
|
1666
|
+
logPluginError("Cursor non-streaming response returned Connect end-stream error", {
|
|
1667
|
+
modelId,
|
|
1668
|
+
convKey,
|
|
1669
|
+
...errorDetails(endStreamError),
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
scheduleBridgeEnd(bridge);
|
|
1673
|
+
}));
|
|
1368
1674
|
bridge.onClose(() => {
|
|
1369
1675
|
clearInterval(heartbeatTimer);
|
|
1370
1676
|
const stored = conversationStates.get(convKey);
|
|
@@ -1375,10 +1681,19 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1375
1681
|
}
|
|
1376
1682
|
const flushed = tagFilter.flush();
|
|
1377
1683
|
fullText += flushed.content;
|
|
1684
|
+
if (endStreamError) {
|
|
1685
|
+
reject(endStreamError);
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
if (pendingToolCalls.length === 0) {
|
|
1689
|
+
updateStoredConversationAfterCompletion(convKey, metadata, fullText);
|
|
1690
|
+
}
|
|
1378
1691
|
const usage = computeUsage(state);
|
|
1379
1692
|
resolve({
|
|
1380
1693
|
text: fullText,
|
|
1381
1694
|
usage,
|
|
1695
|
+
finishReason: pendingToolCalls.length > 0 ? "tool_calls" : "stop",
|
|
1696
|
+
toolCalls: pendingToolCalls,
|
|
1382
1697
|
});
|
|
1383
1698
|
});
|
|
1384
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",
|