@playwo/opencode-cursor-oauth 0.0.0-dev.e795e5ffd849 → 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/dist/index.js CHANGED
@@ -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
- return handleChatCompletion(body, accessToken);
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,11 @@ export function stopProxy() {
509
538
  activeBridges.clear();
510
539
  conversationStates.clear();
511
540
  }
512
- function handleChatCompletion(body, accessToken) {
513
- const { systemPrompt, userText, turns, toolResults } = parseMessages(body.messages);
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 tools = body.tools ?? [];
545
+ const tools = selectToolsForChoice(body.tools ?? [], body.tool_choice);
516
546
  if (!userText && toolResults.length === 0) {
517
547
  return new Response(JSON.stringify({
518
548
  error: {
@@ -521,16 +551,24 @@ function handleChatCompletion(body, accessToken) {
521
551
  },
522
552
  }), { status: 400, headers: { "Content-Type": "application/json" } });
523
553
  }
524
- // bridgeKey: model-specific, for active tool-call bridges
554
+ // bridgeKey: session/agent-scoped, for active tool-call bridges
525
555
  // convKey: model-independent, for conversation state that survives model switches
526
- const bridgeKey = deriveBridgeKey(modelId, body.messages);
527
- 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);
528
558
  const activeBridge = activeBridges.get(bridgeKey);
529
559
  if (activeBridge && toolResults.length > 0) {
530
560
  activeBridges.delete(bridgeKey);
531
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
+ }
532
570
  // Resume the live bridge with tool results
533
- return handleToolResultResume(activeBridge, toolResults, modelId, bridgeKey, convKey);
571
+ return handleToolResultResume(activeBridge, toolResults, bridgeKey, convKey);
534
572
  }
535
573
  // Bridge died (timeout, server disconnect, etc.).
536
574
  // Clean up and fall through to start a fresh bridge.
@@ -545,28 +583,43 @@ function handleChatCompletion(body, accessToken) {
545
583
  }
546
584
  let stored = conversationStates.get(convKey);
547
585
  if (!stored) {
548
- stored = {
549
- conversationId: crypto.randomUUID(),
550
- checkpoint: null,
551
- blobStore: new Map(),
552
- lastAccessMs: Date.now(),
553
- };
586
+ stored = createStoredConversation();
554
587
  conversationStates.set(convKey, stored);
555
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;
556
597
  stored.lastAccessMs = Date.now();
557
598
  evictStaleConversations();
558
599
  // Build the request. When tool results are present but the bridge died,
559
600
  // we must still include the last user text so Cursor has context.
560
601
  const mcpTools = buildMcpToolDefinitions(tools);
561
- const effectiveUserText = userText || (toolResults.length > 0
562
- ? toolResults.map((r) => r.content).join("\n")
563
- : "");
602
+ const effectiveUserText = toolResults.length > 0
603
+ ? buildToolResumePrompt(userText, pendingAssistantSummary, toolResults)
604
+ : userText;
564
605
  const payload = buildCursorRequest(modelId, systemPrompt, effectiveUserText, turns, stored.conversationId, stored.checkpoint, stored.blobStore);
565
606
  payload.mcpTools = mcpTools;
566
607
  if (body.stream === false) {
567
- return handleNonStreamingResponse(payload, accessToken, modelId, convKey);
608
+ return handleNonStreamingResponse(payload, accessToken, modelId, convKey, {
609
+ systemPrompt,
610
+ systemPromptHash,
611
+ completedTurnsFingerprint,
612
+ turns,
613
+ userText,
614
+ });
568
615
  }
569
- 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
+ });
570
623
  }
571
624
  /** Normalize OpenAI message content to a plain string. */
572
625
  function textContent(content) {
@@ -581,8 +634,6 @@ function textContent(content) {
581
634
  }
582
635
  function parseMessages(messages) {
583
636
  let systemPrompt = "You are a helpful assistant.";
584
- const pairs = [];
585
- const toolResults = [];
586
637
  // Collect system messages
587
638
  const systemParts = messages
588
639
  .filter((m) => m.role === "system")
@@ -590,40 +641,152 @@ function parseMessages(messages) {
590
641
  if (systemParts.length > 0) {
591
642
  systemPrompt = systemParts.join("\n");
592
643
  }
593
- // Separate tool results from conversation turns
594
644
  const nonSystem = messages.filter((m) => m.role !== "system");
595
- let pendingUser = "";
645
+ const parsedTurns = [];
646
+ let currentTurn;
596
647
  for (const msg of nonSystem) {
597
- if (msg.role === "tool") {
598
- toolResults.push({
599
- toolCallId: msg.tool_call_id ?? "",
600
- content: textContent(msg.content),
601
- });
648
+ if (msg.role === "user") {
649
+ if (currentTurn)
650
+ parsedTurns.push(currentTurn);
651
+ currentTurn = {
652
+ userText: textContent(msg.content),
653
+ segments: [],
654
+ };
655
+ continue;
602
656
  }
603
- else if (msg.role === "user") {
604
- if (pendingUser) {
605
- pairs.push({ userText: pendingUser, assistantText: "" });
606
- }
607
- pendingUser = textContent(msg.content);
657
+ if (!currentTurn) {
658
+ currentTurn = { userText: "", segments: [] };
608
659
  }
609
- else if (msg.role === "assistant") {
610
- // Skip assistant messages that are just tool_calls with no text
660
+ if (msg.role === "assistant") {
611
661
  const text = textContent(msg.content);
612
- if (pendingUser) {
613
- pairs.push({ userText: pendingUser, assistantText: text });
614
- pendingUser = "";
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
+ });
615
670
  }
671
+ continue;
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;
616
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"));
771
+ }
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;
617
779
  }
618
- let lastUserText = "";
619
- if (pendingUser) {
620
- lastUserText = pendingUser;
780
+ if (toolChoice === "none") {
781
+ return [];
621
782
  }
622
- else if (pairs.length > 0 && toolResults.length === 0) {
623
- const last = pairs.pop();
624
- lastUserText = last.userText;
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
+ }
625
788
  }
626
- return { systemPrompt, userText: lastUserText, turns: pairs, toolResults };
789
+ return tools;
627
790
  }
628
791
  /** Convert OpenAI tool definitions to Cursor's MCP tool protobuf format. */
629
792
  function buildMcpToolDefinitions(tools) {
@@ -1084,19 +1247,34 @@ function sendExecResult(execMsg, messageCase, value, sendFrame) {
1084
1247
  });
1085
1248
  sendFrame(toBinary(AgentClientMessageSchema, clientMessage));
1086
1249
  }
1087
- /** Derive a key for active bridge lookup (tool-call continuations). Model-specific. */
1088
- 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
+ }
1089
1259
  const firstUserMsg = messages.find((m) => m.role === "user");
1090
1260
  const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
1261
+ const normalizedAgent = normalizeAgentKey(agentKey);
1091
1262
  return createHash("sha256")
1092
- .update(`bridge:${modelId}:${firstUserText.slice(0, 200)}`)
1263
+ .update(`bridge:${normalizedAgent}:${modelId}:${firstUserText.slice(0, 200)}`)
1093
1264
  .digest("hex")
1094
1265
  .slice(0, 16);
1095
1266
  }
1096
1267
  /** Derive a key for conversation state. Model-independent so context survives model switches. */
1097
- function deriveConversationKey(messages) {
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
+ }
1098
1276
  return createHash("sha256")
1099
- .update(buildConversationFingerprint(messages))
1277
+ .update(`${normalizeAgentKey(agentKey)}:${buildConversationFingerprint(messages)}`)
1100
1278
  .digest("hex")
1101
1279
  .slice(0, 16);
1102
1280
  }
@@ -1106,8 +1284,19 @@ function buildConversationFingerprint(messages) {
1106
1284
  return `${message.role}:${textContent(message.content)}:${message.tool_call_id ?? ""}:${toolCallIDs}`;
1107
1285
  }).join("\n---\n");
1108
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();
1297
+ }
1109
1298
  /** Create an SSE streaming Response that reads from a live bridge. */
1110
- function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
1299
+ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, metadata) {
1111
1300
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1112
1301
  const created = Math.floor(Date.now() / 1000);
1113
1302
  const stream = new ReadableStream({
@@ -1155,6 +1344,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1155
1344
  totalTokens: 0,
1156
1345
  };
1157
1346
  const tagFilter = createThinkingTagFilter();
1347
+ let assistantText = metadata.assistantSeedText ?? "";
1158
1348
  let mcpExecReceived = false;
1159
1349
  let endStreamError = null;
1160
1350
  const processChunk = createConnectFrameParser((messageBytes) => {
@@ -1168,8 +1358,10 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1168
1358
  const { content, reasoning } = tagFilter.process(text);
1169
1359
  if (reasoning)
1170
1360
  sendSSE(makeChunk({ reasoning_content: reasoning }));
1171
- if (content)
1361
+ if (content) {
1362
+ assistantText += content;
1172
1363
  sendSSE(makeChunk({ content }));
1364
+ }
1173
1365
  }
1174
1366
  },
1175
1367
  // onMcpExec — the model wants to execute a tool.
@@ -1179,8 +1371,21 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1179
1371
  const flushed = tagFilter.flush();
1180
1372
  if (flushed.reasoning)
1181
1373
  sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
1182
- if (flushed.content)
1374
+ if (flushed.content) {
1375
+ assistantText += flushed.content;
1183
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");
1184
1389
  const toolCallIndex = state.toolCallIndex++;
1185
1390
  sendSSE(makeChunk({
1186
1391
  tool_calls: [{
@@ -1200,6 +1405,11 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1200
1405
  blobStore,
1201
1406
  mcpTools,
1202
1407
  pendingExecs: state.pendingExecs,
1408
+ modelId,
1409
+ metadata: {
1410
+ ...metadata,
1411
+ assistantSeedText,
1412
+ },
1203
1413
  });
1204
1414
  sendSSE(makeChunk({}, "tool_calls"));
1205
1415
  sendDone();
@@ -1248,8 +1458,11 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1248
1458
  const flushed = tagFilter.flush();
1249
1459
  if (flushed.reasoning)
1250
1460
  sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
1251
- if (flushed.content)
1461
+ if (flushed.content) {
1462
+ assistantText += flushed.content;
1252
1463
  sendSSE(makeChunk({ content: flushed.content }));
1464
+ }
1465
+ updateStoredConversationAfterCompletion(convKey, metadata, assistantText);
1253
1466
  sendSSE(makeChunk({}, "stop"));
1254
1467
  sendSSE(makeUsageChunk());
1255
1468
  sendDone();
@@ -1283,13 +1496,20 @@ async function startBridge(accessToken, requestBytes) {
1283
1496
  const heartbeatTimer = setInterval(() => bridge.write(makeHeartbeatBytes()), 5_000);
1284
1497
  return { bridge, heartbeatTimer };
1285
1498
  }
1286
- async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey) {
1499
+ async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, metadata) {
1287
1500
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
1288
- return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey);
1501
+ return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey, metadata);
1289
1502
  }
1290
1503
  /** Resume a paused bridge by sending MCP results and continuing to stream. */
1291
- function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey) {
1292
- 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
+ };
1293
1513
  // Send mcpResult for each pending exec that has a matching tool result
1294
1514
  for (const exec of pendingExecs) {
1295
1515
  const result = toolResults.find((r) => r.toolCallId === exec.toolCallId);
@@ -1329,12 +1549,15 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
1329
1549
  });
1330
1550
  bridge.write(toBinary(AgentClientMessageSchema, clientMessage));
1331
1551
  }
1332
- return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey);
1552
+ return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, resumeMetadata);
1333
1553
  }
1334
- async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
1554
+ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey, metadata) {
1335
1555
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1336
1556
  const created = Math.floor(Date.now() / 1000);
1337
- const { text, usage } = await collectFullResponse(payload, accessToken, modelId, 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 };
1338
1561
  return new Response(JSON.stringify({
1339
1562
  id: completionId,
1340
1563
  object: "chat.completion",
@@ -1343,17 +1566,18 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
1343
1566
  choices: [
1344
1567
  {
1345
1568
  index: 0,
1346
- message: { role: "assistant", content: text },
1347
- finish_reason: "stop",
1569
+ message,
1570
+ finish_reason: finishReason,
1348
1571
  },
1349
1572
  ],
1350
1573
  usage,
1351
1574
  }), { headers: { "Content-Type": "application/json" } });
1352
1575
  }
1353
- async function collectFullResponse(payload, accessToken, modelId, convKey) {
1576
+ async function collectFullResponse(payload, accessToken, modelId, convKey, metadata) {
1354
1577
  const { promise, resolve, reject } = Promise.withResolvers();
1355
1578
  let fullText = "";
1356
1579
  let endStreamError = null;
1580
+ const pendingToolCalls = [];
1357
1581
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
1358
1582
  const state = {
1359
1583
  toolCallIndex: 0,
@@ -1370,7 +1594,17 @@ async function collectFullResponse(payload, accessToken, modelId, convKey) {
1370
1594
  return;
1371
1595
  const { content } = tagFilter.process(text);
1372
1596
  fullText += content;
1373
- }, () => { }, (checkpointBytes) => {
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) => {
1374
1608
  const stored = conversationStates.get(convKey);
1375
1609
  if (stored) {
1376
1610
  stored.checkpoint = checkpointBytes;
@@ -1406,10 +1640,15 @@ async function collectFullResponse(payload, accessToken, modelId, convKey) {
1406
1640
  reject(endStreamError);
1407
1641
  return;
1408
1642
  }
1643
+ if (pendingToolCalls.length === 0) {
1644
+ updateStoredConversationAfterCompletion(convKey, metadata, fullText);
1645
+ }
1409
1646
  const usage = computeUsage(state);
1410
1647
  resolve({
1411
1648
  text: fullText,
1412
1649
  usage,
1650
+ finishReason: pendingToolCalls.length > 0 ? "tool_calls" : "stop",
1651
+ toolCalls: pendingToolCalls,
1413
1652
  });
1414
1653
  });
1415
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.e795e5ffd849",
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",