@playwo/opencode-cursor-oauth 0.0.0-dev.c1bf17c092a2 → 0.0.0-dev.c1f285cb4d7e

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
@@ -133,6 +133,9 @@ export const CursorAuthPlugin = async (input) => {
133
133
  return;
134
134
  output.headers["x-opencode-session-id"] = incoming.sessionID;
135
135
  output.headers["x-session-id"] = incoming.sessionID;
136
+ if (incoming.agent) {
137
+ output.headers["x-opencode-agent"] = incoming.agent;
138
+ }
136
139
  },
137
140
  };
138
141
  };
package/dist/proxy.js CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { create, fromBinary, fromJson, toBinary, toJson } from "@bufbuild/protobuf";
16
16
  import { ValueSchema } from "@bufbuild/protobuf/wkt";
17
- import { AgentClientMessageSchema, AgentRunRequestSchema, AgentServerMessageSchema, BidiRequestIdSchema, ClientHeartbeatSchema, ConversationActionSchema, ConversationStateStructureSchema, ConversationStepSchema, AgentConversationTurnStructureSchema, ConversationTurnStructureSchema, AssistantMessageSchema, BackgroundShellSpawnResultSchema, DeleteResultSchema, DeleteRejectedSchema, DiagnosticsResultSchema, ExecClientMessageSchema, FetchErrorSchema, FetchResultSchema, GetBlobResultSchema, GrepErrorSchema, GrepResultSchema, KvClientMessageSchema, LsRejectedSchema, LsResultSchema, McpErrorSchema, McpResultSchema, McpSuccessSchema, McpTextContentSchema, McpToolDefinitionSchema, McpToolResultContentItemSchema, ModelDetailsSchema, ReadRejectedSchema, ReadResultSchema, RequestContextResultSchema, RequestContextSchema, RequestContextSuccessSchema, SetBlobResultSchema, ShellRejectedSchema, ShellResultSchema, UserMessageActionSchema, UserMessageSchema, WriteRejectedSchema, WriteResultSchema, WriteShellStdinErrorSchema, WriteShellStdinResultSchema, } from "./proto/agent_pb";
17
+ import { AgentClientMessageSchema, AgentRunRequestSchema, AgentServerMessageSchema, BidiRequestIdSchema, ClientHeartbeatSchema, ConversationActionSchema, ConversationStateStructureSchema, ConversationStepSchema, AgentConversationTurnStructureSchema, ConversationTurnStructureSchema, AssistantMessageSchema, BackgroundShellSpawnResultSchema, DeleteResultSchema, DeleteRejectedSchema, DiagnosticsResultSchema, ExecClientMessageSchema, FetchErrorSchema, FetchResultSchema, GetBlobResultSchema, GrepErrorSchema, GrepResultSchema, KvClientMessageSchema, LsRejectedSchema, LsResultSchema, McpErrorSchema, McpResultSchema, McpSuccessSchema, McpTextContentSchema, McpToolDefinitionSchema, McpToolResultContentItemSchema, ModelDetailsSchema, NameAgentRequestSchema, NameAgentResponseSchema, ReadRejectedSchema, ReadResultSchema, RequestContextResultSchema, RequestContextSchema, RequestContextSuccessSchema, SetBlobResultSchema, ShellRejectedSchema, ShellResultSchema, UserMessageActionSchema, UserMessageSchema, WriteRejectedSchema, WriteResultSchema, WriteShellStdinErrorSchema, WriteShellStdinResultSchema, } from "./proto/agent_pb";
18
18
  import { createHash } from "node:crypto";
19
19
  import { connect as connectHttp2 } from "node:http2";
20
20
  import { errorDetails, logPluginError, logPluginWarn } from "./logger";
@@ -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);
@@ -49,6 +74,26 @@ function frameConnectMessage(data, flags = 0) {
49
74
  frame.set(data, 5);
50
75
  return frame;
51
76
  }
77
+ function decodeConnectUnaryBody(payload) {
78
+ if (payload.length < 5)
79
+ return null;
80
+ let offset = 0;
81
+ while (offset + 5 <= payload.length) {
82
+ const flags = payload[offset];
83
+ const view = new DataView(payload.buffer, payload.byteOffset + offset, payload.byteLength - offset);
84
+ const messageLength = view.getUint32(1, false);
85
+ const frameEnd = offset + 5 + messageLength;
86
+ if (frameEnd > payload.length)
87
+ return null;
88
+ if ((flags & 0b0000_0001) !== 0)
89
+ return null;
90
+ if ((flags & CONNECT_END_STREAM_FLAG) === 0) {
91
+ return payload.subarray(offset + 5, frameEnd);
92
+ }
93
+ offset = frameEnd;
94
+ }
95
+ return null;
96
+ }
52
97
  function buildCursorHeaders(options, contentType, extra = {}) {
53
98
  const headers = new Headers(buildCursorHeaderValues(options, contentType, extra));
54
99
  return headers;
@@ -474,7 +519,8 @@ export async function startProxy(getAccessToken, models = []) {
474
519
  const sessionId = req.headers.get("x-opencode-session-id")
475
520
  ?? req.headers.get("x-session-id")
476
521
  ?? undefined;
477
- return handleChatCompletion(body, accessToken, { sessionId });
522
+ const agentKey = req.headers.get("x-opencode-agent") ?? undefined;
523
+ return handleChatCompletion(body, accessToken, { sessionId, agentKey });
478
524
  }
479
525
  catch (err) {
480
526
  const message = err instanceof Error ? err.message : String(err);
@@ -513,9 +559,24 @@ export function stopProxy() {
513
559
  conversationStates.clear();
514
560
  }
515
561
  function handleChatCompletion(body, accessToken, context = {}) {
516
- const { systemPrompt, userText, turns, toolResults } = parseMessages(body.messages);
562
+ const parsed = parseMessages(body.messages);
563
+ const { systemPrompt, userText, turns, toolResults, pendingAssistantSummary, completedTurnsFingerprint, } = parsed;
517
564
  const modelId = body.model;
518
- const tools = body.tools ?? [];
565
+ const normalizedAgentKey = normalizeAgentKey(context.agentKey);
566
+ const isTitleAgent = normalizedAgentKey === "title";
567
+ if (isTitleAgent) {
568
+ const titleSourceText = buildTitleSourceText(userText, turns, pendingAssistantSummary, toolResults);
569
+ if (!titleSourceText) {
570
+ return new Response(JSON.stringify({
571
+ error: {
572
+ message: "No title source text found",
573
+ type: "invalid_request_error",
574
+ },
575
+ }), { status: 400, headers: { "Content-Type": "application/json" } });
576
+ }
577
+ return handleTitleGenerationRequest(titleSourceText, accessToken, modelId, body.stream !== false);
578
+ }
579
+ const tools = selectToolsForChoice(body.tools ?? [], body.tool_choice);
519
580
  if (!userText && toolResults.length === 0) {
520
581
  return new Response(JSON.stringify({
521
582
  error: {
@@ -524,16 +585,24 @@ function handleChatCompletion(body, accessToken, context = {}) {
524
585
  },
525
586
  }), { status: 400, headers: { "Content-Type": "application/json" } });
526
587
  }
527
- // bridgeKey: model-specific, for active tool-call bridges
588
+ // bridgeKey: session/agent-scoped, for active tool-call bridges
528
589
  // convKey: model-independent, for conversation state that survives model switches
529
- const bridgeKey = deriveBridgeKey(modelId, body.messages, context.sessionId);
530
- const convKey = deriveConversationKey(body.messages, context.sessionId);
590
+ const bridgeKey = deriveBridgeKey(modelId, body.messages, context.sessionId, context.agentKey);
591
+ const convKey = deriveConversationKey(body.messages, context.sessionId, context.agentKey);
531
592
  const activeBridge = activeBridges.get(bridgeKey);
532
593
  if (activeBridge && toolResults.length > 0) {
533
594
  activeBridges.delete(bridgeKey);
534
595
  if (activeBridge.bridge.alive) {
596
+ if (activeBridge.modelId !== modelId) {
597
+ logPluginWarn("Resuming pending Cursor tool call on original model after model switch", {
598
+ requestedModelId: modelId,
599
+ resumedModelId: activeBridge.modelId,
600
+ convKey,
601
+ bridgeKey,
602
+ });
603
+ }
535
604
  // Resume the live bridge with tool results
536
- return handleToolResultResume(activeBridge, toolResults, modelId, bridgeKey, convKey);
605
+ return handleToolResultResume(activeBridge, toolResults, bridgeKey, convKey);
537
606
  }
538
607
  // Bridge died (timeout, server disconnect, etc.).
539
608
  // Clean up and fall through to start a fresh bridge.
@@ -548,28 +617,49 @@ function handleChatCompletion(body, accessToken, context = {}) {
548
617
  }
549
618
  let stored = conversationStates.get(convKey);
550
619
  if (!stored) {
551
- stored = {
552
- conversationId: crypto.randomUUID(),
553
- checkpoint: null,
554
- blobStore: new Map(),
555
- lastAccessMs: Date.now(),
556
- };
620
+ stored = createStoredConversation();
557
621
  conversationStates.set(convKey, stored);
558
622
  }
623
+ const systemPromptHash = hashString(systemPrompt);
624
+ if (stored.checkpoint
625
+ && (stored.systemPromptHash !== systemPromptHash
626
+ || (turns.length > 0 && stored.completedTurnsFingerprint !== completedTurnsFingerprint))) {
627
+ resetStoredConversation(stored);
628
+ }
629
+ stored.systemPromptHash = systemPromptHash;
630
+ stored.completedTurnsFingerprint = completedTurnsFingerprint;
559
631
  stored.lastAccessMs = Date.now();
560
632
  evictStaleConversations();
561
633
  // Build the request. When tool results are present but the bridge died,
562
634
  // we must still include the last user text so Cursor has context.
563
635
  const mcpTools = buildMcpToolDefinitions(tools);
564
- const effectiveUserText = userText || (toolResults.length > 0
565
- ? toolResults.map((r) => r.content).join("\n")
566
- : "");
567
- const payload = buildCursorRequest(modelId, systemPrompt, effectiveUserText, turns, stored.conversationId, stored.checkpoint, stored.blobStore);
636
+ const needsInitialHandoff = !stored.checkpoint && (turns.length > 0 || pendingAssistantSummary || toolResults.length > 0);
637
+ const replayTurns = needsInitialHandoff ? [] : turns;
638
+ let effectiveUserText = needsInitialHandoff
639
+ ? buildInitialHandoffPrompt(userText, turns, pendingAssistantSummary, toolResults)
640
+ : toolResults.length > 0
641
+ ? buildToolResumePrompt(userText, pendingAssistantSummary, toolResults)
642
+ : userText;
643
+ const payload = buildCursorRequest(modelId, systemPrompt, effectiveUserText, replayTurns, stored.conversationId, stored.checkpoint, stored.blobStore);
568
644
  payload.mcpTools = mcpTools;
569
645
  if (body.stream === false) {
570
- return handleNonStreamingResponse(payload, accessToken, modelId, convKey);
646
+ return handleNonStreamingResponse(payload, accessToken, modelId, convKey, {
647
+ systemPrompt,
648
+ systemPromptHash,
649
+ completedTurnsFingerprint,
650
+ turns,
651
+ userText,
652
+ agentKey: normalizedAgentKey,
653
+ });
571
654
  }
572
- return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey);
655
+ return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, {
656
+ systemPrompt,
657
+ systemPromptHash,
658
+ completedTurnsFingerprint,
659
+ turns,
660
+ userText,
661
+ agentKey: normalizedAgentKey,
662
+ });
573
663
  }
574
664
  /** Normalize OpenAI message content to a plain string. */
575
665
  function textContent(content) {
@@ -584,8 +674,6 @@ function textContent(content) {
584
674
  }
585
675
  function parseMessages(messages) {
586
676
  let systemPrompt = "You are a helpful assistant.";
587
- const pairs = [];
588
- const toolResults = [];
589
677
  // Collect system messages
590
678
  const systemParts = messages
591
679
  .filter((m) => m.role === "system")
@@ -593,40 +681,194 @@ function parseMessages(messages) {
593
681
  if (systemParts.length > 0) {
594
682
  systemPrompt = systemParts.join("\n");
595
683
  }
596
- // Separate tool results from conversation turns
597
684
  const nonSystem = messages.filter((m) => m.role !== "system");
598
- let pendingUser = "";
685
+ const parsedTurns = [];
686
+ let currentTurn;
599
687
  for (const msg of nonSystem) {
600
- if (msg.role === "tool") {
601
- toolResults.push({
602
- toolCallId: msg.tool_call_id ?? "",
603
- content: textContent(msg.content),
604
- });
688
+ if (msg.role === "user") {
689
+ if (currentTurn)
690
+ parsedTurns.push(currentTurn);
691
+ currentTurn = {
692
+ userText: textContent(msg.content),
693
+ segments: [],
694
+ };
695
+ continue;
605
696
  }
606
- else if (msg.role === "user") {
607
- if (pendingUser) {
608
- pairs.push({ userText: pendingUser, assistantText: "" });
609
- }
610
- pendingUser = textContent(msg.content);
697
+ if (!currentTurn) {
698
+ currentTurn = { userText: "", segments: [] };
611
699
  }
612
- else if (msg.role === "assistant") {
613
- // Skip assistant messages that are just tool_calls with no text
700
+ if (msg.role === "assistant") {
614
701
  const text = textContent(msg.content);
615
- if (pendingUser) {
616
- pairs.push({ userText: pendingUser, assistantText: text });
617
- pendingUser = "";
702
+ if (text) {
703
+ currentTurn.segments.push({ kind: "assistantText", text });
704
+ }
705
+ if (msg.tool_calls?.length) {
706
+ currentTurn.segments.push({
707
+ kind: "assistantToolCalls",
708
+ toolCalls: msg.tool_calls,
709
+ });
618
710
  }
711
+ continue;
619
712
  }
713
+ if (msg.role === "tool") {
714
+ currentTurn.segments.push({
715
+ kind: "toolResult",
716
+ result: {
717
+ toolCallId: msg.tool_call_id ?? "",
718
+ content: textContent(msg.content),
719
+ },
720
+ });
721
+ }
722
+ }
723
+ if (currentTurn)
724
+ parsedTurns.push(currentTurn);
725
+ let userText = "";
726
+ let toolResults = [];
727
+ let pendingAssistantSummary = "";
728
+ let completedTurnStates = parsedTurns;
729
+ const lastTurn = parsedTurns.at(-1);
730
+ if (lastTurn) {
731
+ const trailingSegments = splitTrailingToolResults(lastTurn.segments);
732
+ const hasAssistantSummary = trailingSegments.base.length > 0;
733
+ if (trailingSegments.trailing.length > 0 && hasAssistantSummary) {
734
+ completedTurnStates = parsedTurns.slice(0, -1);
735
+ userText = lastTurn.userText;
736
+ toolResults = trailingSegments.trailing.map((segment) => segment.result);
737
+ pendingAssistantSummary = summarizeTurnSegments(trailingSegments.base);
738
+ }
739
+ else if (lastTurn.userText && lastTurn.segments.length === 0) {
740
+ completedTurnStates = parsedTurns.slice(0, -1);
741
+ userText = lastTurn.userText;
742
+ }
743
+ }
744
+ const turns = completedTurnStates
745
+ .map((turn) => ({
746
+ userText: turn.userText,
747
+ assistantText: summarizeTurnSegments(turn.segments),
748
+ }))
749
+ .filter((turn) => turn.userText || turn.assistantText);
750
+ return {
751
+ systemPrompt,
752
+ userText,
753
+ turns,
754
+ toolResults,
755
+ pendingAssistantSummary,
756
+ completedTurnsFingerprint: buildCompletedTurnsFingerprint(systemPrompt, turns),
757
+ };
758
+ }
759
+ function splitTrailingToolResults(segments) {
760
+ let index = segments.length;
761
+ while (index > 0 && segments[index - 1]?.kind === "toolResult") {
762
+ index -= 1;
763
+ }
764
+ return {
765
+ base: segments.slice(0, index),
766
+ trailing: segments.slice(index).filter((segment) => segment.kind === "toolResult"),
767
+ };
768
+ }
769
+ function summarizeTurnSegments(segments) {
770
+ const parts = [];
771
+ for (const segment of segments) {
772
+ if (segment.kind === "assistantText") {
773
+ const trimmed = segment.text.trim();
774
+ if (trimmed)
775
+ parts.push(trimmed);
776
+ continue;
777
+ }
778
+ if (segment.kind === "assistantToolCalls") {
779
+ const summary = segment.toolCalls.map(formatToolCallSummary).join("\n\n");
780
+ if (summary)
781
+ parts.push(summary);
782
+ continue;
783
+ }
784
+ parts.push(formatToolResultSummary(segment.result));
785
+ }
786
+ return parts.join("\n\n").trim();
787
+ }
788
+ function formatToolCallSummary(call) {
789
+ const args = call.function.arguments?.trim();
790
+ return args
791
+ ? `[assistant requested tool ${call.function.name} id=${call.id}]\n${args}`
792
+ : `[assistant requested tool ${call.function.name} id=${call.id}]`;
793
+ }
794
+ function formatToolResultSummary(result) {
795
+ const label = result.toolCallId
796
+ ? `[tool result id=${result.toolCallId}]`
797
+ : "[tool result]";
798
+ const content = result.content.trim();
799
+ return content ? `${label}\n${content}` : label;
800
+ }
801
+ function buildCompletedTurnsFingerprint(systemPrompt, turns) {
802
+ return hashString(JSON.stringify({ systemPrompt, turns }));
803
+ }
804
+ function buildToolResumePrompt(userText, pendingAssistantSummary, toolResults) {
805
+ const parts = [userText.trim()];
806
+ if (pendingAssistantSummary.trim()) {
807
+ parts.push(`[previous assistant tool activity]\n${pendingAssistantSummary.trim()}`);
808
+ }
809
+ if (toolResults.length > 0) {
810
+ parts.push(toolResults.map(formatToolResultSummary).join("\n\n"));
811
+ }
812
+ return parts.filter(Boolean).join("\n\n");
813
+ }
814
+ function buildInitialHandoffPrompt(userText, turns, pendingAssistantSummary, toolResults) {
815
+ const transcript = turns.map((turn, index) => {
816
+ const sections = [`Turn ${index + 1}`];
817
+ if (turn.userText.trim())
818
+ sections.push(`User: ${turn.userText.trim()}`);
819
+ if (turn.assistantText.trim())
820
+ sections.push(`Assistant: ${turn.assistantText.trim()}`);
821
+ return sections.join("\n");
822
+ });
823
+ const inProgress = buildToolResumePrompt("", pendingAssistantSummary, toolResults).trim();
824
+ const history = [
825
+ ...transcript,
826
+ ...(inProgress ? [`In-progress turn\n${inProgress}`] : []),
827
+ ].join("\n\n").trim();
828
+ if (!history)
829
+ return userText;
830
+ return [
831
+ "[OpenCode session handoff]",
832
+ "You are continuing an existing session that previously ran on another provider/model.",
833
+ "Treat the transcript below as prior conversation history before answering the latest user message.",
834
+ "",
835
+ "<previous-session-transcript>",
836
+ history,
837
+ "</previous-session-transcript>",
838
+ "",
839
+ "Latest user message:",
840
+ userText.trim(),
841
+ ].filter(Boolean).join("\n");
842
+ }
843
+ function buildTitleSourceText(userText, turns, pendingAssistantSummary, toolResults) {
844
+ const history = turns.map((turn) => [turn.userText.trim(), turn.assistantText.trim()].filter(Boolean).join("\n")).filter(Boolean);
845
+ if (pendingAssistantSummary.trim()) {
846
+ history.push(pendingAssistantSummary.trim());
620
847
  }
621
- let lastUserText = "";
622
- if (pendingUser) {
623
- lastUserText = pendingUser;
848
+ if (toolResults.length > 0) {
849
+ history.push(toolResults.map(formatToolResultSummary).join("\n\n"));
624
850
  }
625
- else if (pairs.length > 0 && toolResults.length === 0) {
626
- const last = pairs.pop();
627
- lastUserText = last.userText;
851
+ if (userText.trim()) {
852
+ history.push(userText.trim());
853
+ }
854
+ return history.join("\n\n").trim();
855
+ }
856
+ function selectToolsForChoice(tools, toolChoice) {
857
+ if (!tools.length)
858
+ return [];
859
+ if (toolChoice === undefined || toolChoice === null || toolChoice === "auto" || toolChoice === "required") {
860
+ return tools;
861
+ }
862
+ if (toolChoice === "none") {
863
+ return [];
864
+ }
865
+ if (typeof toolChoice === "object") {
866
+ const choice = toolChoice;
867
+ if (choice.type === "function" && typeof choice.function?.name === "string") {
868
+ return tools.filter((tool) => tool.function.name === choice.function.name);
869
+ }
628
870
  }
629
- return { systemPrompt, userText: lastUserText, turns: pairs, toolResults };
871
+ return tools;
630
872
  }
631
873
  /** Convert OpenAI tool definitions to Cursor's MCP tool protobuf format. */
632
874
  function buildMcpToolDefinitions(tools) {
@@ -1087,31 +1329,34 @@ function sendExecResult(execMsg, messageCase, value, sendFrame) {
1087
1329
  });
1088
1330
  sendFrame(toBinary(AgentClientMessageSchema, clientMessage));
1089
1331
  }
1090
- /** Derive a key for active bridge lookup (tool-call continuations). Model-specific. */
1091
- function deriveBridgeKey(modelId, messages, sessionId) {
1332
+ /** Derive a key for active bridge lookup (tool-call continuations). */
1333
+ function deriveBridgeKey(modelId, messages, sessionId, agentKey) {
1092
1334
  if (sessionId) {
1335
+ const normalizedAgent = normalizeAgentKey(agentKey);
1093
1336
  return createHash("sha256")
1094
- .update(`bridge:${sessionId}:${modelId}`)
1337
+ .update(`bridge:${sessionId}:${normalizedAgent}`)
1095
1338
  .digest("hex")
1096
1339
  .slice(0, 16);
1097
1340
  }
1098
1341
  const firstUserMsg = messages.find((m) => m.role === "user");
1099
1342
  const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
1343
+ const normalizedAgent = normalizeAgentKey(agentKey);
1100
1344
  return createHash("sha256")
1101
- .update(`bridge:${modelId}:${firstUserText.slice(0, 200)}`)
1345
+ .update(`bridge:${normalizedAgent}:${modelId}:${firstUserText.slice(0, 200)}`)
1102
1346
  .digest("hex")
1103
1347
  .slice(0, 16);
1104
1348
  }
1105
1349
  /** Derive a key for conversation state. Model-independent so context survives model switches. */
1106
- function deriveConversationKey(messages, sessionId) {
1350
+ function deriveConversationKey(messages, sessionId, agentKey) {
1107
1351
  if (sessionId) {
1352
+ const normalizedAgent = normalizeAgentKey(agentKey);
1108
1353
  return createHash("sha256")
1109
- .update(`session:${sessionId}`)
1354
+ .update(`session:${sessionId}:${normalizedAgent}`)
1110
1355
  .digest("hex")
1111
1356
  .slice(0, 16);
1112
1357
  }
1113
1358
  return createHash("sha256")
1114
- .update(buildConversationFingerprint(messages))
1359
+ .update(`${normalizeAgentKey(agentKey)}:${buildConversationFingerprint(messages)}`)
1115
1360
  .digest("hex")
1116
1361
  .slice(0, 16);
1117
1362
  }
@@ -1121,8 +1366,114 @@ function buildConversationFingerprint(messages) {
1121
1366
  return `${message.role}:${textContent(message.content)}:${message.tool_call_id ?? ""}:${toolCallIDs}`;
1122
1367
  }).join("\n---\n");
1123
1368
  }
1369
+ function updateStoredConversationAfterCompletion(convKey, metadata, assistantText) {
1370
+ const stored = conversationStates.get(convKey);
1371
+ if (!stored)
1372
+ return;
1373
+ const nextTurns = metadata.userText
1374
+ ? [...metadata.turns, { userText: metadata.userText, assistantText: assistantText.trim() }]
1375
+ : metadata.turns;
1376
+ stored.systemPromptHash = metadata.systemPromptHash;
1377
+ stored.completedTurnsFingerprint = buildCompletedTurnsFingerprint(metadata.systemPrompt, nextTurns);
1378
+ stored.lastAccessMs = Date.now();
1379
+ }
1380
+ function deriveFallbackTitle(text) {
1381
+ const cleaned = text
1382
+ .replace(/<[^>]+>/g, " ")
1383
+ .replace(/\[[^\]]+\]/g, " ")
1384
+ .replace(/[^\p{L}\p{N}'’\-\s]+/gu, " ")
1385
+ .replace(/\s+/g, " ")
1386
+ .trim();
1387
+ if (!cleaned)
1388
+ return "";
1389
+ const words = cleaned.split(" ").filter(Boolean).slice(0, 6);
1390
+ return finalizeTitle(words.map(titleCaseWord).join(" "));
1391
+ }
1392
+ function titleCaseWord(word) {
1393
+ if (!word)
1394
+ return word;
1395
+ return word[0].toUpperCase() + word.slice(1);
1396
+ }
1397
+ function finalizeTitle(value) {
1398
+ return value
1399
+ .replace(/^#{1,6}\s*/, "")
1400
+ .replace(/[.!?,:;]+$/g, "")
1401
+ .replace(/\s+/g, " ")
1402
+ .trim()
1403
+ .slice(0, 80)
1404
+ .trim();
1405
+ }
1406
+ function createBufferedSSETextResponse(modelId, text, usage) {
1407
+ const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1408
+ const created = Math.floor(Date.now() / 1000);
1409
+ const payload = [
1410
+ {
1411
+ id: completionId,
1412
+ object: "chat.completion.chunk",
1413
+ created,
1414
+ model: modelId,
1415
+ choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
1416
+ },
1417
+ {
1418
+ id: completionId,
1419
+ object: "chat.completion.chunk",
1420
+ created,
1421
+ model: modelId,
1422
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
1423
+ },
1424
+ {
1425
+ id: completionId,
1426
+ object: "chat.completion.chunk",
1427
+ created,
1428
+ model: modelId,
1429
+ choices: [],
1430
+ usage,
1431
+ },
1432
+ ].map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + "data: [DONE]\n\n";
1433
+ return new Response(payload, { headers: SSE_HEADERS });
1434
+ }
1435
+ async function handleTitleGenerationRequest(sourceText, accessToken, modelId, stream) {
1436
+ const requestBody = toBinary(NameAgentRequestSchema, create(NameAgentRequestSchema, {
1437
+ userMessage: sourceText,
1438
+ }));
1439
+ const response = await callCursorUnaryRpc({
1440
+ accessToken,
1441
+ rpcPath: "/agent.v1.AgentService/NameAgent",
1442
+ requestBody,
1443
+ timeoutMs: 5_000,
1444
+ });
1445
+ if (response.timedOut) {
1446
+ throw new Error("Cursor title generation timed out");
1447
+ }
1448
+ if (response.exitCode !== 0) {
1449
+ throw new Error(`Cursor title generation failed with HTTP ${response.exitCode}`);
1450
+ }
1451
+ const payload = decodeConnectUnaryBody(response.body) ?? response.body;
1452
+ const decoded = fromBinary(NameAgentResponseSchema, payload);
1453
+ const title = finalizeTitle(decoded.name) || deriveFallbackTitle(sourceText) || "Untitled Session";
1454
+ const usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
1455
+ if (stream) {
1456
+ return createBufferedSSETextResponse(modelId, title, usage);
1457
+ }
1458
+ const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1459
+ const created = Math.floor(Date.now() / 1000);
1460
+ return new Response(JSON.stringify({
1461
+ id: completionId,
1462
+ object: "chat.completion",
1463
+ created,
1464
+ model: modelId,
1465
+ choices: [
1466
+ {
1467
+ index: 0,
1468
+ message: { role: "assistant", content: title },
1469
+ finish_reason: "stop",
1470
+ },
1471
+ ],
1472
+ usage,
1473
+ }), { headers: { "Content-Type": "application/json" } });
1474
+ }
1124
1475
  /** Create an SSE streaming Response that reads from a live bridge. */
1125
- function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
1476
+ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, metadata) {
1126
1477
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1127
1478
  const created = Math.floor(Date.now() / 1000);
1128
1479
  const stream = new ReadableStream({
@@ -1170,6 +1521,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1170
1521
  totalTokens: 0,
1171
1522
  };
1172
1523
  const tagFilter = createThinkingTagFilter();
1524
+ let assistantText = metadata.assistantSeedText ?? "";
1173
1525
  let mcpExecReceived = false;
1174
1526
  let endStreamError = null;
1175
1527
  const processChunk = createConnectFrameParser((messageBytes) => {
@@ -1183,8 +1535,10 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1183
1535
  const { content, reasoning } = tagFilter.process(text);
1184
1536
  if (reasoning)
1185
1537
  sendSSE(makeChunk({ reasoning_content: reasoning }));
1186
- if (content)
1538
+ if (content) {
1539
+ assistantText += content;
1187
1540
  sendSSE(makeChunk({ content }));
1541
+ }
1188
1542
  }
1189
1543
  },
1190
1544
  // onMcpExec — the model wants to execute a tool.
@@ -1194,8 +1548,21 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1194
1548
  const flushed = tagFilter.flush();
1195
1549
  if (flushed.reasoning)
1196
1550
  sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
1197
- if (flushed.content)
1551
+ if (flushed.content) {
1552
+ assistantText += flushed.content;
1198
1553
  sendSSE(makeChunk({ content: flushed.content }));
1554
+ }
1555
+ const assistantSeedText = [
1556
+ assistantText.trim(),
1557
+ formatToolCallSummary({
1558
+ id: exec.toolCallId,
1559
+ type: "function",
1560
+ function: {
1561
+ name: exec.toolName,
1562
+ arguments: exec.decodedArgs,
1563
+ },
1564
+ }),
1565
+ ].filter(Boolean).join("\n\n");
1199
1566
  const toolCallIndex = state.toolCallIndex++;
1200
1567
  sendSSE(makeChunk({
1201
1568
  tool_calls: [{
@@ -1215,6 +1582,11 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1215
1582
  blobStore,
1216
1583
  mcpTools,
1217
1584
  pendingExecs: state.pendingExecs,
1585
+ modelId,
1586
+ metadata: {
1587
+ ...metadata,
1588
+ assistantSeedText,
1589
+ },
1218
1590
  });
1219
1591
  sendSSE(makeChunk({}, "tool_calls"));
1220
1592
  sendDone();
@@ -1263,8 +1635,11 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1263
1635
  const flushed = tagFilter.flush();
1264
1636
  if (flushed.reasoning)
1265
1637
  sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
1266
- if (flushed.content)
1638
+ if (flushed.content) {
1639
+ assistantText += flushed.content;
1267
1640
  sendSSE(makeChunk({ content: flushed.content }));
1641
+ }
1642
+ updateStoredConversationAfterCompletion(convKey, metadata, assistantText);
1268
1643
  sendSSE(makeChunk({}, "stop"));
1269
1644
  sendSSE(makeUsageChunk());
1270
1645
  sendDone();
@@ -1298,13 +1673,20 @@ async function startBridge(accessToken, requestBytes) {
1298
1673
  const heartbeatTimer = setInterval(() => bridge.write(makeHeartbeatBytes()), 5_000);
1299
1674
  return { bridge, heartbeatTimer };
1300
1675
  }
1301
- async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey) {
1676
+ async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, metadata) {
1302
1677
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
1303
- return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey);
1678
+ return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey, metadata);
1304
1679
  }
1305
1680
  /** Resume a paused bridge by sending MCP results and continuing to stream. */
1306
- function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey) {
1307
- const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs } = active;
1681
+ function handleToolResultResume(active, toolResults, bridgeKey, convKey) {
1682
+ const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs, modelId, metadata } = active;
1683
+ const resumeMetadata = {
1684
+ ...metadata,
1685
+ assistantSeedText: [
1686
+ metadata.assistantSeedText?.trim() ?? "",
1687
+ toolResults.map(formatToolResultSummary).join("\n\n"),
1688
+ ].filter(Boolean).join("\n\n"),
1689
+ };
1308
1690
  // Send mcpResult for each pending exec that has a matching tool result
1309
1691
  for (const exec of pendingExecs) {
1310
1692
  const result = toolResults.find((r) => r.toolCallId === exec.toolCallId);
@@ -1344,12 +1726,15 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
1344
1726
  });
1345
1727
  bridge.write(toBinary(AgentClientMessageSchema, clientMessage));
1346
1728
  }
1347
- return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey);
1729
+ return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, resumeMetadata);
1348
1730
  }
1349
- async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
1731
+ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey, metadata) {
1350
1732
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1351
1733
  const created = Math.floor(Date.now() / 1000);
1352
- const { text, usage } = await collectFullResponse(payload, accessToken, modelId, convKey);
1734
+ const { text, usage, finishReason, toolCalls } = await collectFullResponse(payload, accessToken, modelId, convKey, metadata);
1735
+ const message = finishReason === "tool_calls"
1736
+ ? { role: "assistant", content: null, tool_calls: toolCalls }
1737
+ : { role: "assistant", content: text };
1353
1738
  return new Response(JSON.stringify({
1354
1739
  id: completionId,
1355
1740
  object: "chat.completion",
@@ -1358,17 +1743,18 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
1358
1743
  choices: [
1359
1744
  {
1360
1745
  index: 0,
1361
- message: { role: "assistant", content: text },
1362
- finish_reason: "stop",
1746
+ message,
1747
+ finish_reason: finishReason,
1363
1748
  },
1364
1749
  ],
1365
1750
  usage,
1366
1751
  }), { headers: { "Content-Type": "application/json" } });
1367
1752
  }
1368
- async function collectFullResponse(payload, accessToken, modelId, convKey) {
1753
+ async function collectFullResponse(payload, accessToken, modelId, convKey, metadata) {
1369
1754
  const { promise, resolve, reject } = Promise.withResolvers();
1370
1755
  let fullText = "";
1371
1756
  let endStreamError = null;
1757
+ const pendingToolCalls = [];
1372
1758
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
1373
1759
  const state = {
1374
1760
  toolCallIndex: 0,
@@ -1385,7 +1771,17 @@ async function collectFullResponse(payload, accessToken, modelId, convKey) {
1385
1771
  return;
1386
1772
  const { content } = tagFilter.process(text);
1387
1773
  fullText += content;
1388
- }, () => { }, (checkpointBytes) => {
1774
+ }, (exec) => {
1775
+ pendingToolCalls.push({
1776
+ id: exec.toolCallId,
1777
+ type: "function",
1778
+ function: {
1779
+ name: exec.toolName,
1780
+ arguments: exec.decodedArgs,
1781
+ },
1782
+ });
1783
+ scheduleBridgeEnd(bridge);
1784
+ }, (checkpointBytes) => {
1389
1785
  const stored = conversationStates.get(convKey);
1390
1786
  if (stored) {
1391
1787
  stored.checkpoint = checkpointBytes;
@@ -1421,10 +1817,15 @@ async function collectFullResponse(payload, accessToken, modelId, convKey) {
1421
1817
  reject(endStreamError);
1422
1818
  return;
1423
1819
  }
1820
+ if (pendingToolCalls.length === 0) {
1821
+ updateStoredConversationAfterCompletion(convKey, metadata, fullText);
1822
+ }
1424
1823
  const usage = computeUsage(state);
1425
1824
  resolve({
1426
1825
  text: fullText,
1427
1826
  usage,
1827
+ finishReason: pendingToolCalls.length > 0 ? "tool_calls" : "stop",
1828
+ toolCalls: pendingToolCalls,
1428
1829
  });
1429
1830
  });
1430
1831
  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.c1bf17c092a2",
3
+ "version": "0.0.0-dev.c1f285cb4d7e",
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",