@playwo/opencode-cursor-oauth 0.0.0-dev.65683458d3f1 → 0.0.0-dev.9b39a4eb497b

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