@playwo/opencode-cursor-oauth 0.0.0-dev.f7099c3761b9 → 0.0.0-dev.fc97acd8b777
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 +374 -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);
|
|
@@ -406,7 +431,14 @@ async function callCursorUnaryRpcOverHttp2(options, target) {
|
|
|
406
431
|
timedOut,
|
|
407
432
|
});
|
|
408
433
|
});
|
|
409
|
-
|
|
434
|
+
// Bun's node:http2 client currently breaks on end(Buffer.alloc(0)) against
|
|
435
|
+
// Cursor's HTTPS endpoint, but a header-only end() succeeds for empty unary bodies.
|
|
436
|
+
if (options.requestBody.length > 0) {
|
|
437
|
+
stream.end(Buffer.from(options.requestBody));
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
stream.end();
|
|
441
|
+
}
|
|
410
442
|
}
|
|
411
443
|
catch (error) {
|
|
412
444
|
logPluginError("Cursor unary HTTP/2 setup failed", {
|
|
@@ -464,7 +496,11 @@ export async function startProxy(getAccessToken, models = []) {
|
|
|
464
496
|
throw new Error("Cursor proxy access token provider not configured");
|
|
465
497
|
}
|
|
466
498
|
const accessToken = await proxyAccessTokenProvider();
|
|
467
|
-
|
|
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 });
|
|
468
504
|
}
|
|
469
505
|
catch (err) {
|
|
470
506
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -502,10 +538,11 @@ export function stopProxy() {
|
|
|
502
538
|
activeBridges.clear();
|
|
503
539
|
conversationStates.clear();
|
|
504
540
|
}
|
|
505
|
-
function handleChatCompletion(body, accessToken) {
|
|
506
|
-
const
|
|
541
|
+
function handleChatCompletion(body, accessToken, context = {}) {
|
|
542
|
+
const parsed = parseMessages(body.messages);
|
|
543
|
+
const { systemPrompt, userText, turns, toolResults, pendingAssistantSummary, completedTurnsFingerprint, } = parsed;
|
|
507
544
|
const modelId = body.model;
|
|
508
|
-
const tools = body.tools ?? [];
|
|
545
|
+
const tools = selectToolsForChoice(body.tools ?? [], body.tool_choice);
|
|
509
546
|
if (!userText && toolResults.length === 0) {
|
|
510
547
|
return new Response(JSON.stringify({
|
|
511
548
|
error: {
|
|
@@ -514,16 +551,24 @@ function handleChatCompletion(body, accessToken) {
|
|
|
514
551
|
},
|
|
515
552
|
}), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
516
553
|
}
|
|
517
|
-
// bridgeKey:
|
|
554
|
+
// bridgeKey: session/agent-scoped, for active tool-call bridges
|
|
518
555
|
// convKey: model-independent, for conversation state that survives model switches
|
|
519
|
-
const bridgeKey = deriveBridgeKey(modelId, body.messages);
|
|
520
|
-
const convKey = deriveConversationKey(body.messages);
|
|
556
|
+
const bridgeKey = deriveBridgeKey(modelId, body.messages, context.sessionId, context.agentKey);
|
|
557
|
+
const convKey = deriveConversationKey(body.messages, context.sessionId, context.agentKey);
|
|
521
558
|
const activeBridge = activeBridges.get(bridgeKey);
|
|
522
559
|
if (activeBridge && toolResults.length > 0) {
|
|
523
560
|
activeBridges.delete(bridgeKey);
|
|
524
561
|
if (activeBridge.bridge.alive) {
|
|
562
|
+
if (activeBridge.modelId !== modelId) {
|
|
563
|
+
logPluginWarn("Resuming pending Cursor tool call on original model after model switch", {
|
|
564
|
+
requestedModelId: modelId,
|
|
565
|
+
resumedModelId: activeBridge.modelId,
|
|
566
|
+
convKey,
|
|
567
|
+
bridgeKey,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
525
570
|
// Resume the live bridge with tool results
|
|
526
|
-
return handleToolResultResume(activeBridge, toolResults,
|
|
571
|
+
return handleToolResultResume(activeBridge, toolResults, bridgeKey, convKey);
|
|
527
572
|
}
|
|
528
573
|
// Bridge died (timeout, server disconnect, etc.).
|
|
529
574
|
// Clean up and fall through to start a fresh bridge.
|
|
@@ -538,28 +583,43 @@ function handleChatCompletion(body, accessToken) {
|
|
|
538
583
|
}
|
|
539
584
|
let stored = conversationStates.get(convKey);
|
|
540
585
|
if (!stored) {
|
|
541
|
-
stored =
|
|
542
|
-
conversationId: deterministicConversationId(convKey),
|
|
543
|
-
checkpoint: null,
|
|
544
|
-
blobStore: new Map(),
|
|
545
|
-
lastAccessMs: Date.now(),
|
|
546
|
-
};
|
|
586
|
+
stored = createStoredConversation();
|
|
547
587
|
conversationStates.set(convKey, stored);
|
|
548
588
|
}
|
|
589
|
+
const systemPromptHash = hashString(systemPrompt);
|
|
590
|
+
if (stored.checkpoint
|
|
591
|
+
&& (stored.systemPromptHash !== systemPromptHash
|
|
592
|
+
|| (turns.length > 0 && stored.completedTurnsFingerprint !== completedTurnsFingerprint))) {
|
|
593
|
+
resetStoredConversation(stored);
|
|
594
|
+
}
|
|
595
|
+
stored.systemPromptHash = systemPromptHash;
|
|
596
|
+
stored.completedTurnsFingerprint = completedTurnsFingerprint;
|
|
549
597
|
stored.lastAccessMs = Date.now();
|
|
550
598
|
evictStaleConversations();
|
|
551
599
|
// Build the request. When tool results are present but the bridge died,
|
|
552
600
|
// we must still include the last user text so Cursor has context.
|
|
553
601
|
const mcpTools = buildMcpToolDefinitions(tools);
|
|
554
|
-
const effectiveUserText =
|
|
555
|
-
?
|
|
556
|
-
:
|
|
602
|
+
const effectiveUserText = toolResults.length > 0
|
|
603
|
+
? buildToolResumePrompt(userText, pendingAssistantSummary, toolResults)
|
|
604
|
+
: userText;
|
|
557
605
|
const payload = buildCursorRequest(modelId, systemPrompt, effectiveUserText, turns, stored.conversationId, stored.checkpoint, stored.blobStore);
|
|
558
606
|
payload.mcpTools = mcpTools;
|
|
559
607
|
if (body.stream === false) {
|
|
560
|
-
return handleNonStreamingResponse(payload, accessToken, modelId, convKey
|
|
608
|
+
return handleNonStreamingResponse(payload, accessToken, modelId, convKey, {
|
|
609
|
+
systemPrompt,
|
|
610
|
+
systemPromptHash,
|
|
611
|
+
completedTurnsFingerprint,
|
|
612
|
+
turns,
|
|
613
|
+
userText,
|
|
614
|
+
});
|
|
561
615
|
}
|
|
562
|
-
return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey
|
|
616
|
+
return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, {
|
|
617
|
+
systemPrompt,
|
|
618
|
+
systemPromptHash,
|
|
619
|
+
completedTurnsFingerprint,
|
|
620
|
+
turns,
|
|
621
|
+
userText,
|
|
622
|
+
});
|
|
563
623
|
}
|
|
564
624
|
/** Normalize OpenAI message content to a plain string. */
|
|
565
625
|
function textContent(content) {
|
|
@@ -574,8 +634,6 @@ function textContent(content) {
|
|
|
574
634
|
}
|
|
575
635
|
function parseMessages(messages) {
|
|
576
636
|
let systemPrompt = "You are a helpful assistant.";
|
|
577
|
-
const pairs = [];
|
|
578
|
-
const toolResults = [];
|
|
579
637
|
// Collect system messages
|
|
580
638
|
const systemParts = messages
|
|
581
639
|
.filter((m) => m.role === "system")
|
|
@@ -583,40 +641,152 @@ function parseMessages(messages) {
|
|
|
583
641
|
if (systemParts.length > 0) {
|
|
584
642
|
systemPrompt = systemParts.join("\n");
|
|
585
643
|
}
|
|
586
|
-
// Separate tool results from conversation turns
|
|
587
644
|
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
588
|
-
|
|
645
|
+
const parsedTurns = [];
|
|
646
|
+
let currentTurn;
|
|
589
647
|
for (const msg of nonSystem) {
|
|
590
|
-
if (msg.role === "
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
648
|
+
if (msg.role === "user") {
|
|
649
|
+
if (currentTurn)
|
|
650
|
+
parsedTurns.push(currentTurn);
|
|
651
|
+
currentTurn = {
|
|
652
|
+
userText: textContent(msg.content),
|
|
653
|
+
segments: [],
|
|
654
|
+
};
|
|
655
|
+
continue;
|
|
595
656
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
pairs.push({ userText: pendingUser, assistantText: "" });
|
|
599
|
-
}
|
|
600
|
-
pendingUser = textContent(msg.content);
|
|
657
|
+
if (!currentTurn) {
|
|
658
|
+
currentTurn = { userText: "", segments: [] };
|
|
601
659
|
}
|
|
602
|
-
|
|
603
|
-
// Skip assistant messages that are just tool_calls with no text
|
|
660
|
+
if (msg.role === "assistant") {
|
|
604
661
|
const text = textContent(msg.content);
|
|
605
|
-
if (
|
|
606
|
-
|
|
607
|
-
|
|
662
|
+
if (text) {
|
|
663
|
+
currentTurn.segments.push({ kind: "assistantText", text });
|
|
664
|
+
}
|
|
665
|
+
if (msg.tool_calls?.length) {
|
|
666
|
+
currentTurn.segments.push({
|
|
667
|
+
kind: "assistantToolCalls",
|
|
668
|
+
toolCalls: msg.tool_calls,
|
|
669
|
+
});
|
|
608
670
|
}
|
|
671
|
+
continue;
|
|
609
672
|
}
|
|
673
|
+
if (msg.role === "tool") {
|
|
674
|
+
currentTurn.segments.push({
|
|
675
|
+
kind: "toolResult",
|
|
676
|
+
result: {
|
|
677
|
+
toolCallId: msg.tool_call_id ?? "",
|
|
678
|
+
content: textContent(msg.content),
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (currentTurn)
|
|
684
|
+
parsedTurns.push(currentTurn);
|
|
685
|
+
let userText = "";
|
|
686
|
+
let toolResults = [];
|
|
687
|
+
let pendingAssistantSummary = "";
|
|
688
|
+
let completedTurnStates = parsedTurns;
|
|
689
|
+
const lastTurn = parsedTurns.at(-1);
|
|
690
|
+
if (lastTurn) {
|
|
691
|
+
const trailingSegments = splitTrailingToolResults(lastTurn.segments);
|
|
692
|
+
const hasAssistantSummary = trailingSegments.base.length > 0;
|
|
693
|
+
if (trailingSegments.trailing.length > 0 && hasAssistantSummary) {
|
|
694
|
+
completedTurnStates = parsedTurns.slice(0, -1);
|
|
695
|
+
userText = lastTurn.userText;
|
|
696
|
+
toolResults = trailingSegments.trailing.map((segment) => segment.result);
|
|
697
|
+
pendingAssistantSummary = summarizeTurnSegments(trailingSegments.base);
|
|
698
|
+
}
|
|
699
|
+
else if (lastTurn.userText && lastTurn.segments.length === 0) {
|
|
700
|
+
completedTurnStates = parsedTurns.slice(0, -1);
|
|
701
|
+
userText = lastTurn.userText;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const turns = completedTurnStates
|
|
705
|
+
.map((turn) => ({
|
|
706
|
+
userText: turn.userText,
|
|
707
|
+
assistantText: summarizeTurnSegments(turn.segments),
|
|
708
|
+
}))
|
|
709
|
+
.filter((turn) => turn.userText || turn.assistantText);
|
|
710
|
+
return {
|
|
711
|
+
systemPrompt,
|
|
712
|
+
userText,
|
|
713
|
+
turns,
|
|
714
|
+
toolResults,
|
|
715
|
+
pendingAssistantSummary,
|
|
716
|
+
completedTurnsFingerprint: buildCompletedTurnsFingerprint(systemPrompt, turns),
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
function splitTrailingToolResults(segments) {
|
|
720
|
+
let index = segments.length;
|
|
721
|
+
while (index > 0 && segments[index - 1]?.kind === "toolResult") {
|
|
722
|
+
index -= 1;
|
|
723
|
+
}
|
|
724
|
+
return {
|
|
725
|
+
base: segments.slice(0, index),
|
|
726
|
+
trailing: segments.slice(index).filter((segment) => segment.kind === "toolResult"),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
function summarizeTurnSegments(segments) {
|
|
730
|
+
const parts = [];
|
|
731
|
+
for (const segment of segments) {
|
|
732
|
+
if (segment.kind === "assistantText") {
|
|
733
|
+
const trimmed = segment.text.trim();
|
|
734
|
+
if (trimmed)
|
|
735
|
+
parts.push(trimmed);
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (segment.kind === "assistantToolCalls") {
|
|
739
|
+
const summary = segment.toolCalls.map(formatToolCallSummary).join("\n\n");
|
|
740
|
+
if (summary)
|
|
741
|
+
parts.push(summary);
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
parts.push(formatToolResultSummary(segment.result));
|
|
745
|
+
}
|
|
746
|
+
return parts.join("\n\n").trim();
|
|
747
|
+
}
|
|
748
|
+
function formatToolCallSummary(call) {
|
|
749
|
+
const args = call.function.arguments?.trim();
|
|
750
|
+
return args
|
|
751
|
+
? `[assistant requested tool ${call.function.name} id=${call.id}]\n${args}`
|
|
752
|
+
: `[assistant requested tool ${call.function.name} id=${call.id}]`;
|
|
753
|
+
}
|
|
754
|
+
function formatToolResultSummary(result) {
|
|
755
|
+
const label = result.toolCallId
|
|
756
|
+
? `[tool result id=${result.toolCallId}]`
|
|
757
|
+
: "[tool result]";
|
|
758
|
+
const content = result.content.trim();
|
|
759
|
+
return content ? `${label}\n${content}` : label;
|
|
760
|
+
}
|
|
761
|
+
function buildCompletedTurnsFingerprint(systemPrompt, turns) {
|
|
762
|
+
return hashString(JSON.stringify({ systemPrompt, turns }));
|
|
763
|
+
}
|
|
764
|
+
function buildToolResumePrompt(userText, pendingAssistantSummary, toolResults) {
|
|
765
|
+
const parts = [userText.trim()];
|
|
766
|
+
if (pendingAssistantSummary.trim()) {
|
|
767
|
+
parts.push(`[previous assistant tool activity]\n${pendingAssistantSummary.trim()}`);
|
|
768
|
+
}
|
|
769
|
+
if (toolResults.length > 0) {
|
|
770
|
+
parts.push(toolResults.map(formatToolResultSummary).join("\n\n"));
|
|
610
771
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
772
|
+
return parts.filter(Boolean).join("\n\n");
|
|
773
|
+
}
|
|
774
|
+
function selectToolsForChoice(tools, toolChoice) {
|
|
775
|
+
if (!tools.length)
|
|
776
|
+
return [];
|
|
777
|
+
if (toolChoice === undefined || toolChoice === null || toolChoice === "auto" || toolChoice === "required") {
|
|
778
|
+
return tools;
|
|
779
|
+
}
|
|
780
|
+
if (toolChoice === "none") {
|
|
781
|
+
return [];
|
|
614
782
|
}
|
|
615
|
-
|
|
616
|
-
const
|
|
617
|
-
|
|
783
|
+
if (typeof toolChoice === "object") {
|
|
784
|
+
const choice = toolChoice;
|
|
785
|
+
if (choice.type === "function" && typeof choice.function?.name === "string") {
|
|
786
|
+
return tools.filter((tool) => tool.function.name === choice.function.name);
|
|
787
|
+
}
|
|
618
788
|
}
|
|
619
|
-
return
|
|
789
|
+
return tools;
|
|
620
790
|
}
|
|
621
791
|
/** Convert OpenAI tool definitions to Cursor's MCP tool protobuf format. */
|
|
622
792
|
function buildMcpToolDefinitions(tools) {
|
|
@@ -759,6 +929,12 @@ function makeHeartbeatBytes() {
|
|
|
759
929
|
});
|
|
760
930
|
return toBinary(AgentClientMessageSchema, heartbeat);
|
|
761
931
|
}
|
|
932
|
+
function scheduleBridgeEnd(bridge) {
|
|
933
|
+
queueMicrotask(() => {
|
|
934
|
+
if (bridge.alive)
|
|
935
|
+
bridge.end();
|
|
936
|
+
});
|
|
937
|
+
}
|
|
762
938
|
/**
|
|
763
939
|
* Create a stateful parser for Connect protocol frames.
|
|
764
940
|
* Handles buffering partial data across chunks.
|
|
@@ -901,6 +1077,12 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
|
|
|
901
1077
|
const blobId = kvMsg.message.value.blobId;
|
|
902
1078
|
const blobIdKey = Buffer.from(blobId).toString("hex");
|
|
903
1079
|
const blobData = blobStore.get(blobIdKey);
|
|
1080
|
+
if (!blobData) {
|
|
1081
|
+
logPluginWarn("Cursor requested missing blob", {
|
|
1082
|
+
blobId: blobIdKey,
|
|
1083
|
+
knownBlobCount: blobStore.size,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
904
1086
|
sendKvResponse(kvMsg, "getBlobResult", create(GetBlobResultSchema, blobData ? { blobData } : {}), sendFrame);
|
|
905
1087
|
}
|
|
906
1088
|
else if (kvCase === "setBlobArgs") {
|
|
@@ -1065,42 +1247,56 @@ function sendExecResult(execMsg, messageCase, value, sendFrame) {
|
|
|
1065
1247
|
});
|
|
1066
1248
|
sendFrame(toBinary(AgentClientMessageSchema, clientMessage));
|
|
1067
1249
|
}
|
|
1068
|
-
/** Derive a key for active bridge lookup (tool-call continuations).
|
|
1069
|
-
function deriveBridgeKey(modelId, messages) {
|
|
1250
|
+
/** Derive a key for active bridge lookup (tool-call continuations). */
|
|
1251
|
+
function deriveBridgeKey(modelId, messages, sessionId, agentKey) {
|
|
1252
|
+
if (sessionId) {
|
|
1253
|
+
const normalizedAgent = normalizeAgentKey(agentKey);
|
|
1254
|
+
return createHash("sha256")
|
|
1255
|
+
.update(`bridge:${sessionId}:${normalizedAgent}`)
|
|
1256
|
+
.digest("hex")
|
|
1257
|
+
.slice(0, 16);
|
|
1258
|
+
}
|
|
1070
1259
|
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
1071
1260
|
const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
|
|
1261
|
+
const normalizedAgent = normalizeAgentKey(agentKey);
|
|
1072
1262
|
return createHash("sha256")
|
|
1073
|
-
.update(`bridge:${modelId}:${firstUserText.slice(0, 200)}`)
|
|
1263
|
+
.update(`bridge:${normalizedAgent}:${modelId}:${firstUserText.slice(0, 200)}`)
|
|
1074
1264
|
.digest("hex")
|
|
1075
1265
|
.slice(0, 16);
|
|
1076
1266
|
}
|
|
1077
1267
|
/** Derive a key for conversation state. Model-independent so context survives model switches. */
|
|
1078
|
-
function deriveConversationKey(messages) {
|
|
1079
|
-
|
|
1080
|
-
|
|
1268
|
+
function deriveConversationKey(messages, sessionId, agentKey) {
|
|
1269
|
+
if (sessionId) {
|
|
1270
|
+
const normalizedAgent = normalizeAgentKey(agentKey);
|
|
1271
|
+
return createHash("sha256")
|
|
1272
|
+
.update(`session:${sessionId}:${normalizedAgent}`)
|
|
1273
|
+
.digest("hex")
|
|
1274
|
+
.slice(0, 16);
|
|
1275
|
+
}
|
|
1081
1276
|
return createHash("sha256")
|
|
1082
|
-
.update(
|
|
1277
|
+
.update(`${normalizeAgentKey(agentKey)}:${buildConversationFingerprint(messages)}`)
|
|
1083
1278
|
.digest("hex")
|
|
1084
1279
|
.slice(0, 16);
|
|
1085
1280
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1281
|
+
function buildConversationFingerprint(messages) {
|
|
1282
|
+
return messages.map((message) => {
|
|
1283
|
+
const toolCallIDs = (message.tool_calls ?? []).map((call) => call.id).join(",");
|
|
1284
|
+
return `${message.role}:${textContent(message.content)}:${message.tool_call_id ?? ""}:${toolCallIDs}`;
|
|
1285
|
+
}).join("\n---\n");
|
|
1286
|
+
}
|
|
1287
|
+
function updateStoredConversationAfterCompletion(convKey, metadata, assistantText) {
|
|
1288
|
+
const stored = conversationStates.get(convKey);
|
|
1289
|
+
if (!stored)
|
|
1290
|
+
return;
|
|
1291
|
+
const nextTurns = metadata.userText
|
|
1292
|
+
? [...metadata.turns, { userText: metadata.userText, assistantText: assistantText.trim() }]
|
|
1293
|
+
: metadata.turns;
|
|
1294
|
+
stored.systemPromptHash = metadata.systemPromptHash;
|
|
1295
|
+
stored.completedTurnsFingerprint = buildCompletedTurnsFingerprint(metadata.systemPrompt, nextTurns);
|
|
1296
|
+
stored.lastAccessMs = Date.now();
|
|
1101
1297
|
}
|
|
1102
1298
|
/** Create an SSE streaming Response that reads from a live bridge. */
|
|
1103
|
-
function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
|
|
1299
|
+
function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, metadata) {
|
|
1104
1300
|
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1105
1301
|
const created = Math.floor(Date.now() / 1000);
|
|
1106
1302
|
const stream = new ReadableStream({
|
|
@@ -1148,7 +1344,9 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1148
1344
|
totalTokens: 0,
|
|
1149
1345
|
};
|
|
1150
1346
|
const tagFilter = createThinkingTagFilter();
|
|
1347
|
+
let assistantText = metadata.assistantSeedText ?? "";
|
|
1151
1348
|
let mcpExecReceived = false;
|
|
1349
|
+
let endStreamError = null;
|
|
1152
1350
|
const processChunk = createConnectFrameParser((messageBytes) => {
|
|
1153
1351
|
try {
|
|
1154
1352
|
const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
|
|
@@ -1160,8 +1358,10 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1160
1358
|
const { content, reasoning } = tagFilter.process(text);
|
|
1161
1359
|
if (reasoning)
|
|
1162
1360
|
sendSSE(makeChunk({ reasoning_content: reasoning }));
|
|
1163
|
-
if (content)
|
|
1361
|
+
if (content) {
|
|
1362
|
+
assistantText += content;
|
|
1164
1363
|
sendSSE(makeChunk({ content }));
|
|
1364
|
+
}
|
|
1165
1365
|
}
|
|
1166
1366
|
},
|
|
1167
1367
|
// onMcpExec — the model wants to execute a tool.
|
|
@@ -1171,8 +1371,21 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1171
1371
|
const flushed = tagFilter.flush();
|
|
1172
1372
|
if (flushed.reasoning)
|
|
1173
1373
|
sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
|
|
1174
|
-
if (flushed.content)
|
|
1374
|
+
if (flushed.content) {
|
|
1375
|
+
assistantText += flushed.content;
|
|
1175
1376
|
sendSSE(makeChunk({ content: flushed.content }));
|
|
1377
|
+
}
|
|
1378
|
+
const assistantSeedText = [
|
|
1379
|
+
assistantText.trim(),
|
|
1380
|
+
formatToolCallSummary({
|
|
1381
|
+
id: exec.toolCallId,
|
|
1382
|
+
type: "function",
|
|
1383
|
+
function: {
|
|
1384
|
+
name: exec.toolName,
|
|
1385
|
+
arguments: exec.decodedArgs,
|
|
1386
|
+
},
|
|
1387
|
+
}),
|
|
1388
|
+
].filter(Boolean).join("\n\n");
|
|
1176
1389
|
const toolCallIndex = state.toolCallIndex++;
|
|
1177
1390
|
sendSSE(makeChunk({
|
|
1178
1391
|
tool_calls: [{
|
|
@@ -1192,6 +1405,11 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1192
1405
|
blobStore,
|
|
1193
1406
|
mcpTools,
|
|
1194
1407
|
pendingExecs: state.pendingExecs,
|
|
1408
|
+
modelId,
|
|
1409
|
+
metadata: {
|
|
1410
|
+
...metadata,
|
|
1411
|
+
assistantSeedText,
|
|
1412
|
+
},
|
|
1195
1413
|
});
|
|
1196
1414
|
sendSSE(makeChunk({}, "tool_calls"));
|
|
1197
1415
|
sendDone();
|
|
@@ -1208,10 +1426,16 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1208
1426
|
// Skip unparseable messages
|
|
1209
1427
|
}
|
|
1210
1428
|
}, (endStreamBytes) => {
|
|
1211
|
-
|
|
1212
|
-
if (
|
|
1213
|
-
|
|
1429
|
+
endStreamError = parseConnectEndStream(endStreamBytes);
|
|
1430
|
+
if (endStreamError) {
|
|
1431
|
+
logPluginError("Cursor stream returned Connect end-stream error", {
|
|
1432
|
+
modelId,
|
|
1433
|
+
bridgeKey,
|
|
1434
|
+
convKey,
|
|
1435
|
+
...errorDetails(endStreamError),
|
|
1436
|
+
});
|
|
1214
1437
|
}
|
|
1438
|
+
scheduleBridgeEnd(bridge);
|
|
1215
1439
|
});
|
|
1216
1440
|
bridge.onData(processChunk);
|
|
1217
1441
|
bridge.onClose((code) => {
|
|
@@ -1222,27 +1446,39 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1222
1446
|
stored.blobStore.set(k, v);
|
|
1223
1447
|
stored.lastAccessMs = Date.now();
|
|
1224
1448
|
}
|
|
1449
|
+
if (endStreamError) {
|
|
1450
|
+
activeBridges.delete(bridgeKey);
|
|
1451
|
+
if (!closed) {
|
|
1452
|
+
closed = true;
|
|
1453
|
+
controller.error(endStreamError);
|
|
1454
|
+
}
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1225
1457
|
if (!mcpExecReceived) {
|
|
1226
1458
|
const flushed = tagFilter.flush();
|
|
1227
1459
|
if (flushed.reasoning)
|
|
1228
1460
|
sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
|
|
1229
|
-
if (flushed.content)
|
|
1461
|
+
if (flushed.content) {
|
|
1462
|
+
assistantText += flushed.content;
|
|
1230
1463
|
sendSSE(makeChunk({ content: flushed.content }));
|
|
1464
|
+
}
|
|
1465
|
+
updateStoredConversationAfterCompletion(convKey, metadata, assistantText);
|
|
1231
1466
|
sendSSE(makeChunk({}, "stop"));
|
|
1232
1467
|
sendSSE(makeUsageChunk());
|
|
1233
1468
|
sendDone();
|
|
1234
1469
|
closeController();
|
|
1235
1470
|
}
|
|
1236
|
-
else
|
|
1237
|
-
// Bridge died while tool calls are pending (timeout, crash, etc.).
|
|
1238
|
-
// Close the SSE stream so the client doesn't hang forever.
|
|
1239
|
-
sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
|
|
1240
|
-
sendSSE(makeChunk({}, "stop"));
|
|
1241
|
-
sendSSE(makeUsageChunk());
|
|
1242
|
-
sendDone();
|
|
1243
|
-
closeController();
|
|
1244
|
-
// Remove stale entry so the next request doesn't try to resume it.
|
|
1471
|
+
else {
|
|
1245
1472
|
activeBridges.delete(bridgeKey);
|
|
1473
|
+
if (code !== 0 && !closed) {
|
|
1474
|
+
// Bridge died while tool calls are pending (timeout, crash, etc.).
|
|
1475
|
+
// Close the SSE stream so the client doesn't hang forever.
|
|
1476
|
+
sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
|
|
1477
|
+
sendSSE(makeChunk({}, "stop"));
|
|
1478
|
+
sendSSE(makeUsageChunk());
|
|
1479
|
+
sendDone();
|
|
1480
|
+
closeController();
|
|
1481
|
+
}
|
|
1246
1482
|
}
|
|
1247
1483
|
});
|
|
1248
1484
|
},
|
|
@@ -1260,13 +1496,20 @@ async function startBridge(accessToken, requestBytes) {
|
|
|
1260
1496
|
const heartbeatTimer = setInterval(() => bridge.write(makeHeartbeatBytes()), 5_000);
|
|
1261
1497
|
return { bridge, heartbeatTimer };
|
|
1262
1498
|
}
|
|
1263
|
-
async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey) {
|
|
1499
|
+
async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, metadata) {
|
|
1264
1500
|
const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
|
|
1265
|
-
return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey);
|
|
1501
|
+
return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey, metadata);
|
|
1266
1502
|
}
|
|
1267
1503
|
/** Resume a paused bridge by sending MCP results and continuing to stream. */
|
|
1268
|
-
function handleToolResultResume(active, toolResults,
|
|
1269
|
-
const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs } = active;
|
|
1504
|
+
function handleToolResultResume(active, toolResults, bridgeKey, convKey) {
|
|
1505
|
+
const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs, modelId, metadata } = active;
|
|
1506
|
+
const resumeMetadata = {
|
|
1507
|
+
...metadata,
|
|
1508
|
+
assistantSeedText: [
|
|
1509
|
+
metadata.assistantSeedText?.trim() ?? "",
|
|
1510
|
+
toolResults.map(formatToolResultSummary).join("\n\n"),
|
|
1511
|
+
].filter(Boolean).join("\n\n"),
|
|
1512
|
+
};
|
|
1270
1513
|
// Send mcpResult for each pending exec that has a matching tool result
|
|
1271
1514
|
for (const exec of pendingExecs) {
|
|
1272
1515
|
const result = toolResults.find((r) => r.toolCallId === exec.toolCallId);
|
|
@@ -1306,12 +1549,15 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
|
|
|
1306
1549
|
});
|
|
1307
1550
|
bridge.write(toBinary(AgentClientMessageSchema, clientMessage));
|
|
1308
1551
|
}
|
|
1309
|
-
return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey);
|
|
1552
|
+
return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, resumeMetadata);
|
|
1310
1553
|
}
|
|
1311
|
-
async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
|
|
1554
|
+
async function handleNonStreamingResponse(payload, accessToken, modelId, convKey, metadata) {
|
|
1312
1555
|
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1313
1556
|
const created = Math.floor(Date.now() / 1000);
|
|
1314
|
-
const { text, usage } = await collectFullResponse(payload, accessToken, convKey);
|
|
1557
|
+
const { text, usage, finishReason, toolCalls } = await collectFullResponse(payload, accessToken, modelId, convKey, metadata);
|
|
1558
|
+
const message = finishReason === "tool_calls"
|
|
1559
|
+
? { role: "assistant", content: null, tool_calls: toolCalls }
|
|
1560
|
+
: { role: "assistant", content: text };
|
|
1315
1561
|
return new Response(JSON.stringify({
|
|
1316
1562
|
id: completionId,
|
|
1317
1563
|
object: "chat.completion",
|
|
@@ -1320,16 +1566,18 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
|
|
|
1320
1566
|
choices: [
|
|
1321
1567
|
{
|
|
1322
1568
|
index: 0,
|
|
1323
|
-
message
|
|
1324
|
-
finish_reason:
|
|
1569
|
+
message,
|
|
1570
|
+
finish_reason: finishReason,
|
|
1325
1571
|
},
|
|
1326
1572
|
],
|
|
1327
1573
|
usage,
|
|
1328
1574
|
}), { headers: { "Content-Type": "application/json" } });
|
|
1329
1575
|
}
|
|
1330
|
-
async function collectFullResponse(payload, accessToken, convKey) {
|
|
1331
|
-
const { promise, resolve } = Promise.withResolvers();
|
|
1576
|
+
async function collectFullResponse(payload, accessToken, modelId, convKey, metadata) {
|
|
1577
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
1332
1578
|
let fullText = "";
|
|
1579
|
+
let endStreamError = null;
|
|
1580
|
+
const pendingToolCalls = [];
|
|
1333
1581
|
const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
|
|
1334
1582
|
const state = {
|
|
1335
1583
|
toolCallIndex: 0,
|
|
@@ -1346,7 +1594,17 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1346
1594
|
return;
|
|
1347
1595
|
const { content } = tagFilter.process(text);
|
|
1348
1596
|
fullText += content;
|
|
1349
|
-
}, () => {
|
|
1597
|
+
}, (exec) => {
|
|
1598
|
+
pendingToolCalls.push({
|
|
1599
|
+
id: exec.toolCallId,
|
|
1600
|
+
type: "function",
|
|
1601
|
+
function: {
|
|
1602
|
+
name: exec.toolName,
|
|
1603
|
+
arguments: exec.decodedArgs,
|
|
1604
|
+
},
|
|
1605
|
+
});
|
|
1606
|
+
scheduleBridgeEnd(bridge);
|
|
1607
|
+
}, (checkpointBytes) => {
|
|
1350
1608
|
const stored = conversationStates.get(convKey);
|
|
1351
1609
|
if (stored) {
|
|
1352
1610
|
stored.checkpoint = checkpointBytes;
|
|
@@ -1357,7 +1615,17 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1357
1615
|
catch {
|
|
1358
1616
|
// Skip
|
|
1359
1617
|
}
|
|
1360
|
-
}, () => {
|
|
1618
|
+
}, (endStreamBytes) => {
|
|
1619
|
+
endStreamError = parseConnectEndStream(endStreamBytes);
|
|
1620
|
+
if (endStreamError) {
|
|
1621
|
+
logPluginError("Cursor non-streaming response returned Connect end-stream error", {
|
|
1622
|
+
modelId,
|
|
1623
|
+
convKey,
|
|
1624
|
+
...errorDetails(endStreamError),
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
scheduleBridgeEnd(bridge);
|
|
1628
|
+
}));
|
|
1361
1629
|
bridge.onClose(() => {
|
|
1362
1630
|
clearInterval(heartbeatTimer);
|
|
1363
1631
|
const stored = conversationStates.get(convKey);
|
|
@@ -1368,10 +1636,19 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1368
1636
|
}
|
|
1369
1637
|
const flushed = tagFilter.flush();
|
|
1370
1638
|
fullText += flushed.content;
|
|
1639
|
+
if (endStreamError) {
|
|
1640
|
+
reject(endStreamError);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
if (pendingToolCalls.length === 0) {
|
|
1644
|
+
updateStoredConversationAfterCompletion(convKey, metadata, fullText);
|
|
1645
|
+
}
|
|
1371
1646
|
const usage = computeUsage(state);
|
|
1372
1647
|
resolve({
|
|
1373
1648
|
text: fullText,
|
|
1374
1649
|
usage,
|
|
1650
|
+
finishReason: pendingToolCalls.length > 0 ? "tool_calls" : "stop",
|
|
1651
|
+
toolCalls: pendingToolCalls,
|
|
1375
1652
|
});
|
|
1376
1653
|
});
|
|
1377
1654
|
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.fc97acd8b777",
|
|
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",
|