From 23424e2ae3271dc5abdd2a0f3e7c660e62918cee Mon Sep 17 00:00:00 2001 From: yyyr Date: Sat, 27 Jun 2026 04:01:10 +0800 Subject: [PATCH 01/79] fix(translate): hoist leading system prefix to top-level, keep non-leading inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 169e592b changed the CC→Messages and Responses→Messages translators to emit all system/developer messages inline as `role: "system"`. Upstream Anthropic endpoints reject position-0 inline system — the most common Chat Completions pattern — so the initial system prompt must live in the top-level `system` field. Hoist the leading contiguous run of system/developer messages to `MessagesPayload.system`. Non-leading system/developer messages stay inline as `MessagesSystemMessage`, preserving their chronological position. Upstreams that reject inline system in non-leading positions can enable the existing `demote-interleaved-system-to-user` interceptor. --- .../chat-completions-via-messages/request.ts | 38 +++++++--- .../request_test.ts | 70 +++++++------------ .../src/responses-via-messages/request.ts | 47 +++++++++---- .../responses-via-messages/request_test.ts | 61 +++++++++++----- 4 files changed, 131 insertions(+), 85 deletions(-) diff --git a/packages/translate/src/chat-completions-via-messages/request.ts b/packages/translate/src/chat-completions-via-messages/request.ts index 7882788df..eb045266c 100644 --- a/packages/translate/src/chat-completions-via-messages/request.ts +++ b/packages/translate/src/chat-completions-via-messages/request.ts @@ -1,6 +1,6 @@ import { messagesThinkingBlockFromChatCompletionsScalarReasoning } from '../shared/chat-completions-and-messages/reasoning.ts'; import { parseToolArgumentsObject } from '../shared/messages/tool-arguments.ts'; -import { applyLastMessageCacheBreakpoint, applyLastToolCacheBreakpoint } from '../shared/via-messages/cache-breakpoints.ts'; +import { applyLastMessageCacheBreakpoint, applyLastToolCacheBreakpoint, EPHEMERAL_CACHE_CONTROL } from '../shared/via-messages/cache-breakpoints.ts'; import { fetchRemoteImage, type RemoteImageLoader, resolveImageUrlToMessagesImage } from '../shared/via-messages/remote-images.ts'; import type { ChatCompletionsPayload, ChatCompletionsContentPart, ChatCompletionsMessage, ChatCompletionsTool } from '@floway-dev/protocols/chat-completions'; import { MESSAGES_FALLBACK_MAX_TOKENS, type MessagesAssistantContentBlock, type MessagesMessage, type MessagesPayload, type MessagesTextBlock, type MessagesUserContentBlock } from '@floway-dev/protocols/messages'; @@ -122,11 +122,6 @@ const buildMessagesInput = async (messages: ChatCompletionsMessage[], loadRemote break; case 'system': case 'developer': - // Both Chat Completions system and developer messages map to Messages - // role: 'system' inline. The Messages role enum admits 'system' - // (https://platform.claude.com/docs/en/api/messages); developer content - // is the same intent layer and normalizes to system on the Messages - // wire. result.push({ role: 'system', content: convertSystemContent(message.content), @@ -161,12 +156,34 @@ const CHAT_TOOL_CHOICES = { } satisfies Record, MessagesPayload['tool_choice']>; export const translateChatCompletionsToMessages = async (payload: ChatCompletionsPayload, options: TranslateChatCompletionsToMessagesOptions = {}): Promise => { - // System / developer messages pass through inline as Messages role: 'system' - // items (the Messages role enum supports it). Chat Completions has no - // canonical top-level system field, so nothing is hoisted to MessagesPayload.system. - const messages = await buildMessagesInput(payload.messages, options.loadRemoteImage ?? fetchRemoteImage); + // Hoist the leading contiguous run of system/developer messages to + // MessagesPayload.system. Non-leading system/developer messages stay inline + // as MessagesSystemMessage; upstreams that reject them can be handled by the + // `demote-interleaved-system-to-user` interceptor. + const systemParts: string[] = []; + let prefixEnd = 0; + for (const message of payload.messages) { + if (message.role !== 'system' && message.role !== 'developer') break; + const text = + typeof message.content === 'string' + ? message.content + : Array.isArray(message.content) + ? message.content + .filter((part): part is Extract => part.type === 'text') + .map(part => part.text) + .join('') + : ''; + if (text) systemParts.push(text); + prefixEnd++; + } + + const messages = await buildMessagesInput(payload.messages.slice(prefixEnd), options.loadRemoteImage ?? fetchRemoteImage); const maxTokens = payload.max_tokens ?? options.fallbackMaxOutputTokens ?? MESSAGES_FALLBACK_MAX_TOKENS; + const systemText = systemParts.length > 0 ? systemParts.join('\n\n') : ''; + const systemBlocks: MessagesTextBlock[] | undefined = systemText + ? [{ type: 'text', text: systemText, cache_control: EPHEMERAL_CACHE_CONTROL }] + : undefined; const tools = payload.tools?.length ? translateChatCompletionsTools(payload.tools) : undefined; applyLastToolCacheBreakpoint(tools); applyLastMessageCacheBreakpoint(messages); @@ -204,6 +221,7 @@ export const translateChatCompletionsToMessages = async (payload: ChatCompletion model: payload.model, messages, max_tokens: maxTokens, + ...(systemBlocks ? { system: systemBlocks } : {}), ...(payload.temperature != null ? { temperature: payload.temperature } : {}), ...(payload.top_p != null ? { top_p: payload.top_p } : {}), ...(payload.stop != null diff --git a/packages/translate/src/chat-completions-via-messages/request_test.ts b/packages/translate/src/chat-completions-via-messages/request_test.ts index 8f1a51367..b202b5c6c 100644 --- a/packages/translate/src/chat-completions-via-messages/request_test.ts +++ b/packages/translate/src/chat-completions-via-messages/request_test.ts @@ -92,15 +92,7 @@ test('translateChatCompletionsToMessages omits both speed and service_tier when assertFalse('service_tier' in result); }); -// -// Chat Completions system/developer messages translate INLINE as Messages -// role: 'system' items, preserving chronology. The Messages role enum admits -// 'system' (https://platform.claude.com/docs/en/api/messages); empirical -// testing confirms native Messages upstreams accept mid-array system. The -// Messages top-level `system` field is left untouched — Chat Completions has -// no canonical top-level system slot to hoist into. - -test('system message emitted inline as role: system', async () => { +test('leading system message hoisted to top-level system field', async () => { const result = await translateChatCompletionsToMessages( mkPayload({ messages: [ @@ -109,13 +101,12 @@ test('system message emitted inline as role: system', async () => { ], }), ); - assertEquals(result.system, undefined); - assertEquals(result.messages.length, 2); - assertEquals(result.messages[0], { role: 'system', content: 'You are helpful.' }); - assertEquals(result.messages[1].role, 'user'); + assertEquals(result.system, [{ type: 'text', text: 'You are helpful.', cache_control: { type: 'ephemeral' } }]); + assertEquals(result.messages.length, 1); + assertEquals(result.messages[0].role, 'user'); }); -test('developer message normalized to role: system inline', async () => { +test('leading developer message hoisted as system', async () => { const result = await translateChatCompletionsToMessages( mkPayload({ messages: [ @@ -124,11 +115,11 @@ test('developer message normalized to role: system inline', async () => { ], }), ); - assertEquals(result.system, undefined); - assertEquals(result.messages[0], { role: 'system', content: 'Dev instructions' }); + assertEquals(result.system, [{ type: 'text', text: 'Dev instructions', cache_control: { type: 'ephemeral' } }]); + assertEquals(result.messages.length, 1); }); -test('multiple system messages preserve chronology inline', async () => { +test('non-leading system stays inline, leading is hoisted', async () => { const result = await translateChatCompletionsToMessages( mkPayload({ messages: [ @@ -139,15 +130,14 @@ test('multiple system messages preserve chronology inline', async () => { ], }), ); - assertEquals(result.system, undefined); - assertEquals(result.messages.length, 4); - assertEquals(result.messages[0], { role: 'system', content: 'First' }); - assertEquals(result.messages[1].role, 'user'); - assertEquals(result.messages[2], { role: 'system', content: 'Second' }); - assertEquals(result.messages[3].role, 'user'); + assertEquals(result.system, [{ type: 'text', text: 'First', cache_control: { type: 'ephemeral' } }]); + assertEquals(result.messages.length, 3); + assertEquals(result.messages[0].role, 'user'); + assertEquals(result.messages[1], { role: 'system', content: 'Second' }); + assertEquals(result.messages[2].role, 'user'); }); -test('empty system content emits empty inline system', async () => { +test('empty leading system content is not hoisted', async () => { const result = await translateChatCompletionsToMessages( mkPayload({ messages: [ @@ -157,10 +147,10 @@ test('empty system content emits empty inline system', async () => { }), ); assertEquals(result.system, undefined); - assertEquals(result.messages[0], { role: 'system', content: '' }); + assertEquals(result.messages.length, 1); }); -test('system with ContentPart array maps each text part to a MessagesTextBlock', async () => { +test('leading system with ContentPart array text parts joined and hoisted', async () => { const result = await translateChatCompletionsToMessages( mkPayload({ messages: [ @@ -175,14 +165,8 @@ test('system with ContentPart array maps each text part to a MessagesTextBlock', ], }), ); - assertEquals(result.system, undefined); - assertEquals(result.messages[0], { - role: 'system', - content: [ - { type: 'text', text: 'A' }, - { type: 'text', text: 'B' }, - ], - }); + assertEquals(result.system, [{ type: 'text', text: 'AB', cache_control: { type: 'ephemeral' } }]); + assertEquals(result.messages.length, 1); }); // ── Basic message mapping ── @@ -1047,14 +1031,13 @@ test('full tool use round-trip conversation', async () => { ], }), ); - assertEquals(result.system, undefined); - assertEquals(result.messages.length, 5); - assertEquals(result.messages[0], { role: 'system', content: 'You are helpful.' }); - assertEquals(result.messages[1].role, 'user'); - assertEquals(result.messages[2].role, 'assistant'); - assertEquals(result.messages[3].role, 'user'); - assertEquals(result.messages[4].role, 'assistant'); - const trBlocks = result.messages[3].content as MessagesUserContentBlock[]; + assertEquals(result.system, [{ type: 'text', text: 'You are helpful.', cache_control: { type: 'ephemeral' } }]); + assertEquals(result.messages.length, 4); + assertEquals(result.messages[0].role, 'user'); + assertEquals(result.messages[1].role, 'assistant'); + assertEquals(result.messages[2].role, 'user'); + assertEquals(result.messages[3].role, 'assistant'); + const trBlocks = result.messages[2].content as MessagesUserContentBlock[]; assertEquals(trBlocks[0].type, 'tool_result'); }); @@ -1080,8 +1063,7 @@ test('attaches ephemeral cache breakpoints to last function tool and last messag }), ); - // System messages flow through inline; no top-level system field is set. - assertEquals(result.system, undefined); + assertEquals(result.system, [{ type: 'text', text: 'You are helpful.', cache_control: { type: 'ephemeral' } }]); const tools = result.tools as MessagesClientTool[]; assertEquals(tools[0].cache_control, undefined); diff --git a/packages/translate/src/responses-via-messages/request.ts b/packages/translate/src/responses-via-messages/request.ts index c5443f4a5..bb537ac7c 100644 --- a/packages/translate/src/responses-via-messages/request.ts +++ b/packages/translate/src/responses-via-messages/request.ts @@ -107,11 +107,13 @@ const translateAssistantMessage = (message: ResponsesInputMessage): MessagesAssi return { role: 'assistant', content: content.length > 0 ? content : '' }; }; -// Both `role: 'system'` and `role: 'developer'` Responses input messages flow -// through as Messages system items. The Messages role enum admits 'system' -// directly (https://platform.claude.com/docs/en/api/messages); developer -// content is the same intent layer and normalizes to 'system' on the -// Messages wire. +const extractSystemText = (message: ResponsesInputMessage): string => { + if (typeof message.content === 'string') return message.content; + if (!Array.isArray(message.content)) return ''; + + return message.content.map(block => ('text' in block ? block.text : '')).join(''); +}; + const translateSystemMessage = (message: ResponsesInputMessage): MessagesSystemMessage => { if (typeof message.content === 'string') { return { role: 'system', content: message.content }; @@ -153,14 +155,29 @@ const unexpectedResponsesInputItem = (value: ResponsesInputItem): never => { throw new Error(`Unexpected Responses input item variant: ${JSON.stringify(value)}`); }; -const translateResponsesInput = async (input: string | ResponsesInputItem[], loadRemoteImage: RemoteImageLoader): Promise => { +const translateResponsesInput = async (input: string | ResponsesInputItem[], loadRemoteImage: RemoteImageLoader): Promise<{ messages: MessagesMessage[]; systemParts: string[] }> => { if (typeof input === 'string') { - return [{ role: 'user', content: input }]; + return { + messages: [{ role: 'user', content: input }], + systemParts: [], + }; + } + + // Hoist the leading contiguous run of system/developer input messages to + // systemParts (→ top-level Messages.system). Non-leading system/developer + // messages stay inline as MessagesSystemMessage. + const systemParts: string[] = []; + let prefixEnd = 0; + for (const item of input) { + if (item.type !== 'message' || (item.role !== 'system' && item.role !== 'developer')) break; + const text = extractSystemText(item); + if (text) systemParts.push(text); + prefixEnd++; } const messages: MessagesMessage[] = []; - for (const item of input) { + for (const item of input.slice(prefixEnd)) { switch (item.type) { case 'message': switch (item.role) { @@ -235,7 +252,7 @@ const translateResponsesInput = async (input: string | ResponsesInputItem[], loa } } - return messages; + return { messages, systemParts }; }; const translateTools = (tools: ResponsesTool[] | null | undefined, customToolNames: Set): MessagesTool[] | undefined => { @@ -299,13 +316,13 @@ const translateToolChoice = (toolChoice: ResponsesToolChoice | undefined): Messa export const translateResponsesToMessages = async (payload: ResponsesPayload, options: TranslateResponsesToMessagesOptions = {}): Promise => { const customToolNames = new Set(); - const messages = await translateResponsesInput(payload.input, options.loadRemoteImage ?? fetchRemoteImage); + const { messages, systemParts } = await translateResponsesInput(payload.input, options.loadRemoteImage ?? fetchRemoteImage); const tools = translateTools(payload.tools, customToolNames); - // `payload.instructions` is the Responses canonical system field; it maps - // to the Messages top-level `system`. Any `role: 'system'`/`'developer'` - // input items are emitted inline as MessagesSystemMessage by the input - // translator, preserving chronology. - const system = payload.instructions ?? ''; + // `payload.instructions` is the Responses canonical system field; leading + // system/developer input items are hoisted into `systemParts`. Combine + // both into the Messages top-level `system`. Non-leading system items stay + // inline as MessagesSystemMessage. + const system = [payload.instructions, ...systemParts].filter((part): part is string => Boolean(part)).join('\n\n'); const effort = payload.reasoning?.effort; const maxTokens = payload.max_output_tokens ?? options.fallbackMaxOutputTokens ?? MESSAGES_FALLBACK_MAX_TOKENS; const systemBlocks: MessagesTextBlock[] | undefined = system diff --git a/packages/translate/src/responses-via-messages/request_test.ts b/packages/translate/src/responses-via-messages/request_test.ts index 3b0eff986..af08f3fcb 100644 --- a/packages/translate/src/responses-via-messages/request_test.ts +++ b/packages/translate/src/responses-via-messages/request_test.ts @@ -603,7 +603,34 @@ test('translateResponsesToMessages drops text.format json_object (no Anthropic e assertFalse('output_config' in result.target); }); -test('translateResponsesToMessages emits in-array role:"system" inline as MessagesSystemMessage', async () => { +test('translateResponsesToMessages hoists leading role:"system" to top-level system field', async () => { + const result = await translateResponsesToMessages( + { + model: 'claude-test', + input: [ + { type: 'message', role: 'system', content: 'be terse' }, + { type: 'message', role: 'user', content: 'q1' }, + ], + instructions: null, + temperature: null, + top_p: null, + max_output_tokens: 256, + tools: null, + tool_choice: 'auto', + metadata: null, + stream: null, + store: false, + parallel_tool_calls: true, + }, + { loadRemoteImage: stubRemoteImageLoader(null) }, + ); + + assertEquals(result.target.system, [{ type: 'text', text: 'be terse', cache_control: { type: 'ephemeral' } }]); + assertEquals(result.target.messages.length, 1); + assertEquals(result.target.messages[0].role, 'user'); +}); + +test('translateResponsesToMessages keeps non-leading role:"system" inline', async () => { const result = await translateResponsesToMessages( { model: 'claude-test', @@ -626,16 +653,14 @@ test('translateResponsesToMessages emits in-array role:"system" inline as Messag { loadRemoteImage: stubRemoteImageLoader(null) }, ); - const messages = result.target.messages; - assertEquals(messages.length, 3); - assertEquals(messages[0].role, 'user'); - assertEquals(messages[1], { role: 'system', content: 'be terse' }); - assertEquals(messages[2].role, 'user'); - // No top-level instructions → no top-level Messages system field + assertEquals(result.target.messages.length, 3); + assertEquals(result.target.messages[0].role, 'user'); + assertEquals(result.target.messages[1], { role: 'system', content: 'be terse' }); + assertEquals(result.target.messages[2].role, 'user'); assertFalse('system' in result.target); }); -test('translateResponsesToMessages normalizes role:"developer" to inline role:"system"', async () => { +test('translateResponsesToMessages hoists leading role:"developer" to top-level system field', async () => { const result = await translateResponsesToMessages( { model: 'claude-test', @@ -657,16 +682,20 @@ test('translateResponsesToMessages normalizes role:"developer" to inline role:"s { loadRemoteImage: stubRemoteImageLoader(null) }, ); - assertEquals(result.target.messages[0], { role: 'system', content: 'dev rule' }); + assertEquals(result.target.messages.length, 1); + assertEquals(result.target.messages[0].role, 'user'); + assertEquals(result.target.system, [{ type: 'text', text: 'dev rule', cache_control: { type: 'ephemeral' } }]); }); -test('translateResponsesToMessages keeps payload.instructions as the Messages top-level system field', async () => { +test('translateResponsesToMessages merges payload.instructions with leading system into top-level, non-leading stays inline', async () => { const result = await translateResponsesToMessages( { model: 'claude-test', input: [ - { type: 'message', role: 'system', content: 'mid-array note' }, + { type: 'message', role: 'system', content: 'leading note' }, { type: 'message', role: 'user', content: 'hi' }, + { type: 'message', role: 'system', content: 'mid-array note' }, + { type: 'message', role: 'user', content: 'bye' }, ], instructions: 'canonical top-level instructions', temperature: null, @@ -682,11 +711,11 @@ test('translateResponsesToMessages keeps payload.instructions as the Messages to { loadRemoteImage: stubRemoteImageLoader(null) }, ); - // payload.instructions → top-level Messages.system; in-array system stays inline. - // The two layers do not get merged anymore. assertEquals(result.target.system, [ - { type: 'text', text: 'canonical top-level instructions', cache_control: { type: 'ephemeral' } }, + { type: 'text', text: 'canonical top-level instructions\n\nleading note', cache_control: { type: 'ephemeral' } }, ]); - assertEquals(result.target.messages[0], { role: 'system', content: 'mid-array note' }); - assertEquals(result.target.messages[1].role, 'user'); + assertEquals(result.target.messages.length, 3); + assertEquals(result.target.messages[0].role, 'user'); + assertEquals(result.target.messages[1], { role: 'system', content: 'mid-array note' }); + assertEquals(result.target.messages[2].role, 'user'); }); From 719013823054051419820f9960a590f50cd6f6ed Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 28 Jun 2026 23:47:30 +0800 Subject: [PATCH 02/79] fix(translate): preserve part boundaries when hoisting system; reject image-in-system content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tightens the leading system hoist introduced in this PR so the translator's output stays faithful to the caller's content shape, and turns the pre-existing silent drop of image parts in system content into an explicit rejection. Boundary preservation. The previous hoist concatenated every text part in a leading system message with empty-string join, then concatenated multiple leading system messages with `\n\n`, and wrapped the whole result in one `MessagesTextBlock`. That collapsed two layers of caller-visible structure (ContentPart array within a message; multiple messages within the prefix) into a single string — e.g. `[{text:'A'}, {text:'B'}]` became `'AB'`, glueing text fragments with no separator. Each ContentPart text and each leading message now contributes its own `MessagesTextBlock` to `MessagesPayload.system`. The same change applies to the Responses path, where `payload.instructions` and leading input system messages also stay as distinct blocks instead of being `\n\n`-joined into one. Downstream prompt cache and `applyLastSystemCacheBreakpoint` (new helper in `shared/via-messages/cache-breakpoints.ts`) place the breakpoint on the last block, mirroring how tool and message breakpoints are applied. Image-in-system rejection. Anthropic's Messages API only permits text in the system field — both top-level `MessagesPayload.system` and inline `MessagesSystemMessage.content`. The Chat Completions and Responses translators previously filtered non-text parts silently, leaving callers to wonder why their system-attached image was missing on the wire. They now throw on `image_url` (Chat Completions) and `input_image` (Responses) parts in system / developer messages. Upstream-probed wording for reference: both Bedrock and Vertex return `system.1.type: Input should be 'text'` for a top-level system image, and Bedrock additionally returns `messages.N: role 'system' supports text and tool_change blocks only` for an inline system image; the translator's error message names the caller-visible shape rather than the Anthropic field path. Comment restoration. The original comment explaining `case 'system': case 'developer':` mapping was deleted by the hoist change. We add a new comment explicitly noting that this branch only runs for non-leading system / developer messages (leading prefix is hoisted before `buildMessagesInput` runs), that developer normalizes to `role:'system'` because it is the same intent layer on the source wire, and that the gateway's `demote-interleaved-system-to-user` interceptor flag is the safety net for upstreams that reject inline `role:'system'` (Vertex unconditionally; Bedrock when placement rules are violated). `extractSystemText` in the Responses translator (used the loose `'text' in block` shape match) is replaced by a single `responsesSystemBlocks` helper that uses the same `input_text` / `output_text` whitelist as the inline `translateSystemMessage` and shares the image-rejection check, so both hoist and inline paths now agree on which content blocks contribute and on what triggers the throw. --- .../chat-completions-via-messages/request.ts | 71 ++++++++++----- .../request_test.ts | 68 +++++++++++++- .../src/responses-via-messages/request.ts | 88 ++++++++++++------- .../responses-via-messages/request_test.ts | 75 +++++++++++++++- .../shared/via-messages/cache-breakpoints.ts | 9 ++ 5 files changed, 252 insertions(+), 59 deletions(-) diff --git a/packages/translate/src/chat-completions-via-messages/request.ts b/packages/translate/src/chat-completions-via-messages/request.ts index eb045266c..1c087daf4 100644 --- a/packages/translate/src/chat-completions-via-messages/request.ts +++ b/packages/translate/src/chat-completions-via-messages/request.ts @@ -1,8 +1,8 @@ import { messagesThinkingBlockFromChatCompletionsScalarReasoning } from '../shared/chat-completions-and-messages/reasoning.ts'; import { parseToolArgumentsObject } from '../shared/messages/tool-arguments.ts'; -import { applyLastMessageCacheBreakpoint, applyLastToolCacheBreakpoint, EPHEMERAL_CACHE_CONTROL } from '../shared/via-messages/cache-breakpoints.ts'; +import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../shared/via-messages/cache-breakpoints.ts'; import { fetchRemoteImage, type RemoteImageLoader, resolveImageUrlToMessagesImage } from '../shared/via-messages/remote-images.ts'; -import type { ChatCompletionsPayload, ChatCompletionsContentPart, ChatCompletionsMessage, ChatCompletionsTool } from '@floway-dev/protocols/chat-completions'; +import type { ChatCompletionsPayload, ChatCompletionsMessage, ChatCompletionsTool } from '@floway-dev/protocols/chat-completions'; import { MESSAGES_FALLBACK_MAX_TOKENS, type MessagesAssistantContentBlock, type MessagesMessage, type MessagesPayload, type MessagesTextBlock, type MessagesUserContentBlock } from '@floway-dev/protocols/messages'; interface TranslateChatCompletionsToMessagesOptions { @@ -82,13 +82,24 @@ const convertUserContent = async (message: ChatCompletionsMessage, loadRemoteIma return blocks.length > 0 ? blocks : [{ type: 'text', text: '' }]; }; +// Anthropic's Messages system field (top-level `MessagesPayload.system` and +// inline `MessagesSystemMessage.content`) accepts only text. Image parts in +// system / developer messages are rejected here at the translator boundary so +// the caller hits an explicit failure instead of having the image silently +// dropped on the wire. const convertSystemContent = (content: ChatCompletionsMessage['content']): string | MessagesTextBlock[] => { if (typeof content === 'string') return content; if (!Array.isArray(content)) return ''; - const blocks: MessagesTextBlock[] = content - .filter((part): part is Extract => part.type === 'text') - .map(part => ({ type: 'text', text: part.text })); + const blocks: MessagesTextBlock[] = []; + for (const part of content) { + if (part.type === 'image_url') { + throw new Error('Chat Completions → Messages translator does not accept image content parts in system or developer messages — Anthropic Messages only permits text in the system field.'); + } + if (part.type === 'text') { + blocks.push({ type: 'text', text: part.text }); + } + } return blocks.length > 0 ? blocks : ''; }; @@ -122,6 +133,19 @@ const buildMessagesInput = async (messages: ChatCompletionsMessage[], loadRemote break; case 'system': case 'developer': + // Non-leading system / developer messages stay inline as + // MessagesSystemMessage: their leading contiguous prefix has already + // been hoisted to MessagesPayload.system by + // translateChatCompletionsToMessages before buildMessagesInput runs, + // so anything reaching this branch was deliberately placed mid-history + // by the caller and we preserve that chronological position. Developer + // is the same intent layer as system on the Chat Completions wire and + // normalizes to role:'system' here. Anthropic upstreams diverge on + // inline role:'system' (Bedrock accepts it under placement rules; + // Vertex rejects it outright), so the gateway's + // `demote-interleaved-system-to-user` interceptor flag is the safety + // net for any inline system that would otherwise reach an upstream + // that does not accept it. result.push({ role: 'system', content: convertSystemContent(message.content), @@ -157,34 +181,33 @@ const CHAT_TOOL_CHOICES = { export const translateChatCompletionsToMessages = async (payload: ChatCompletionsPayload, options: TranslateChatCompletionsToMessagesOptions = {}): Promise => { // Hoist the leading contiguous run of system/developer messages to - // MessagesPayload.system. Non-leading system/developer messages stay inline - // as MessagesSystemMessage; upstreams that reject them can be handled by the - // `demote-interleaved-system-to-user` interceptor. - const systemParts: string[] = []; + // MessagesPayload.system, preserving each ContentPart text as its own + // MessagesTextBlock so part boundaries survive the hoist. Non-leading + // system/developer messages stay inline as MessagesSystemMessage at their + // chronological position; the gateway's + // `demote-interleaved-system-to-user` interceptor flag handles upstreams + // that reject inline system. Empty system content contributes zero + // blocks — its prefix slot is consumed (so a subsequent non-empty leading + // system still counts as part of the same prefix) but it does not produce + // an empty top-level system entry. + const systemBlocks: MessagesTextBlock[] = []; let prefixEnd = 0; for (const message of payload.messages) { if (message.role !== 'system' && message.role !== 'developer') break; - const text = - typeof message.content === 'string' - ? message.content - : Array.isArray(message.content) - ? message.content - .filter((part): part is Extract => part.type === 'text') - .map(part => part.text) - .join('') - : ''; - if (text) systemParts.push(text); + const converted = convertSystemContent(message.content); + if (typeof converted === 'string') { + if (converted) systemBlocks.push({ type: 'text', text: converted }); + } else { + systemBlocks.push(...converted); + } prefixEnd++; } const messages = await buildMessagesInput(payload.messages.slice(prefixEnd), options.loadRemoteImage ?? fetchRemoteImage); const maxTokens = payload.max_tokens ?? options.fallbackMaxOutputTokens ?? MESSAGES_FALLBACK_MAX_TOKENS; - const systemText = systemParts.length > 0 ? systemParts.join('\n\n') : ''; - const systemBlocks: MessagesTextBlock[] | undefined = systemText - ? [{ type: 'text', text: systemText, cache_control: EPHEMERAL_CACHE_CONTROL }] - : undefined; const tools = payload.tools?.length ? translateChatCompletionsTools(payload.tools) : undefined; + applyLastSystemCacheBreakpoint(systemBlocks); applyLastToolCacheBreakpoint(tools); applyLastMessageCacheBreakpoint(messages); @@ -221,7 +244,7 @@ export const translateChatCompletionsToMessages = async (payload: ChatCompletion model: payload.model, messages, max_tokens: maxTokens, - ...(systemBlocks ? { system: systemBlocks } : {}), + ...(systemBlocks.length > 0 ? { system: systemBlocks } : {}), ...(payload.temperature != null ? { temperature: payload.temperature } : {}), ...(payload.top_p != null ? { top_p: payload.top_p } : {}), ...(payload.stop != null diff --git a/packages/translate/src/chat-completions-via-messages/request_test.ts b/packages/translate/src/chat-completions-via-messages/request_test.ts index b202b5c6c..6bc88f09c 100644 --- a/packages/translate/src/chat-completions-via-messages/request_test.ts +++ b/packages/translate/src/chat-completions-via-messages/request_test.ts @@ -150,7 +150,7 @@ test('empty leading system content is not hoisted', async () => { assertEquals(result.messages.length, 1); }); -test('leading system with ContentPart array text parts joined and hoisted', async () => { +test('leading system with ContentPart array preserves text parts as separate blocks', async () => { const result = await translateChatCompletionsToMessages( mkPayload({ messages: [ @@ -165,10 +165,74 @@ test('leading system with ContentPart array text parts joined and hoisted', asyn ], }), ); - assertEquals(result.system, [{ type: 'text', text: 'AB', cache_control: { type: 'ephemeral' } }]); + assertEquals(result.system, [ + { type: 'text', text: 'A' }, + { type: 'text', text: 'B', cache_control: { type: 'ephemeral' } }, + ]); assertEquals(result.messages.length, 1); }); +test('multiple consecutive leading system messages accumulate as separate blocks', async () => { + const result = await translateChatCompletionsToMessages( + mkPayload({ + messages: [ + { role: 'system', content: 'First' }, + { role: 'developer', content: 'Second' }, + { role: 'user', content: 'Hi' }, + ], + }), + ); + assertEquals(result.system, [ + { type: 'text', text: 'First' }, + { type: 'text', text: 'Second', cache_control: { type: 'ephemeral' } }, + ]); + assertEquals(result.messages.length, 1); +}); + +test('image content part in leading system message throws', async () => { + await assertRejects( + () => + translateChatCompletionsToMessages( + mkPayload({ + messages: [ + { + role: 'system', + content: [ + { type: 'text', text: 'You are helpful.' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' } }, + ], + }, + { role: 'user', content: 'Hi' }, + ], + }), + ), + Error, + 'does not accept image content parts in system or developer messages', + ); +}); + +test('image content part in non-leading system message throws', async () => { + await assertRejects( + () => + translateChatCompletionsToMessages( + mkPayload({ + messages: [ + { role: 'user', content: 'Hi' }, + { + role: 'system', + content: [ + { type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' } }, + ], + }, + { role: 'user', content: 'Bye' }, + ], + }), + ), + Error, + 'does not accept image content parts in system or developer messages', + ); +}); + // ── Basic message mapping ── test('simple user message → string content', async () => { diff --git a/packages/translate/src/responses-via-messages/request.ts b/packages/translate/src/responses-via-messages/request.ts index bb537ac7c..42f50bd46 100644 --- a/packages/translate/src/responses-via-messages/request.ts +++ b/packages/translate/src/responses-via-messages/request.ts @@ -1,7 +1,7 @@ import { parseToolArgumentsObject } from '../shared/messages/tool-arguments.ts'; import { responsesReasoningToMessagesUpstreamBlock } from '../shared/messages-and-responses/reasoning.ts'; import { buildCustomToolInputSchema } from '../shared/responses-via/custom-tool-wrap.ts'; -import { applyLastMessageCacheBreakpoint, applyLastToolCacheBreakpoint, EPHEMERAL_CACHE_CONTROL } from '../shared/via-messages/cache-breakpoints.ts'; +import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../shared/via-messages/cache-breakpoints.ts'; import { fetchRemoteImage, type RemoteImageLoader, resolveImageUrlToMessagesImage } from '../shared/via-messages/remote-images.ts'; import { MESSAGES_FALLBACK_MAX_TOKENS, @@ -107,26 +107,45 @@ const translateAssistantMessage = (message: ResponsesInputMessage): MessagesAssi return { role: 'assistant', content: content.length > 0 ? content : '' }; }; -const extractSystemText = (message: ResponsesInputMessage): string => { - if (typeof message.content === 'string') return message.content; - if (!Array.isArray(message.content)) return ''; - - return message.content.map(block => ('text' in block ? block.text : '')).join(''); -}; - -const translateSystemMessage = (message: ResponsesInputMessage): MessagesSystemMessage => { +// Anthropic's Messages system field (top-level `MessagesPayload.system` and +// inline `MessagesSystemMessage.content`) accepts only text. Image parts in +// system / developer Responses input messages are rejected here at the +// translator boundary so the caller hits an explicit failure instead of +// having the image silently dropped on the wire. +const responsesSystemBlocks = (message: ResponsesInputMessage): MessagesTextBlock[] => { if (typeof message.content === 'string') { - return { role: 'system', content: message.content }; + return message.content ? [{ type: 'text', text: message.content }] : []; } - const content: MessagesTextBlock[] = []; + const blocks: MessagesTextBlock[] = []; for (const block of message.content) { + if (block.type === 'input_image') { + throw new Error(`Responses → Messages translator does not accept image content parts in ${message.role} messages — Anthropic Messages only permits text in the system field.`); + } if (block.type === 'input_text' || block.type === 'output_text') { - content.push({ type: 'text', text: (block as ResponsesInputText).text }); + blocks.push({ type: 'text', text: (block as ResponsesInputText).text }); } } + return blocks; +}; - return { role: 'system', content: content.length > 0 ? content : '' }; +// Non-leading system / developer Responses input messages stay inline as +// MessagesSystemMessage at their chronological position. The leading +// contiguous prefix has already been hoisted to MessagesPayload.system by +// translateResponsesToMessages before translateResponsesInput's per-item +// loop hits this branch. Developer is the same intent layer as system on +// the Responses wire and normalizes to role:'system' here. Anthropic +// upstreams diverge on inline role:'system' (Bedrock accepts it under +// placement rules; Vertex rejects it outright), so the gateway's +// `demote-interleaved-system-to-user` interceptor flag is the safety net +// for any inline system that would otherwise reach an upstream that does +// not accept it. +const translateSystemMessage = (message: ResponsesInputMessage): MessagesSystemMessage => { + if (typeof message.content === 'string') { + return { role: 'system', content: message.content }; + } + const blocks = responsesSystemBlocks(message); + return { role: 'system', content: blocks.length > 0 ? blocks : '' }; }; const appendAssistantBlock = (messages: MessagesMessage[], block: MessagesAssistantContentBlock): void => { @@ -155,23 +174,26 @@ const unexpectedResponsesInputItem = (value: ResponsesInputItem): never => { throw new Error(`Unexpected Responses input item variant: ${JSON.stringify(value)}`); }; -const translateResponsesInput = async (input: string | ResponsesInputItem[], loadRemoteImage: RemoteImageLoader): Promise<{ messages: MessagesMessage[]; systemParts: string[] }> => { +const translateResponsesInput = async (input: string | ResponsesInputItem[], loadRemoteImage: RemoteImageLoader): Promise<{ messages: MessagesMessage[]; systemBlocks: MessagesTextBlock[] }> => { if (typeof input === 'string') { return { messages: [{ role: 'user', content: input }], - systemParts: [], + systemBlocks: [], }; } - // Hoist the leading contiguous run of system/developer input messages to - // systemParts (→ top-level Messages.system). Non-leading system/developer - // messages stay inline as MessagesSystemMessage. - const systemParts: string[] = []; + // Hoist the leading contiguous run of system/developer input messages into + // systemBlocks (→ top-level Messages.system), preserving each input_text + // part as its own MessagesTextBlock so part boundaries survive the hoist. + // Non-leading system/developer messages stay inline as MessagesSystemMessage. + // Empty system content contributes zero blocks — its prefix slot is still + // consumed so a subsequent non-empty leading system stays part of the same + // prefix. + const systemBlocks: MessagesTextBlock[] = []; let prefixEnd = 0; for (const item of input) { if (item.type !== 'message' || (item.role !== 'system' && item.role !== 'developer')) break; - const text = extractSystemText(item); - if (text) systemParts.push(text); + systemBlocks.push(...responsesSystemBlocks(item)); prefixEnd++; } @@ -252,7 +274,7 @@ const translateResponsesInput = async (input: string | ResponsesInputItem[], loa } } - return { messages, systemParts }; + return { messages, systemBlocks }; }; const translateTools = (tools: ResponsesTool[] | null | undefined, customToolNames: Set): MessagesTool[] | undefined => { @@ -316,18 +338,22 @@ const translateToolChoice = (toolChoice: ResponsesToolChoice | undefined): Messa export const translateResponsesToMessages = async (payload: ResponsesPayload, options: TranslateResponsesToMessagesOptions = {}): Promise => { const customToolNames = new Set(); - const { messages, systemParts } = await translateResponsesInput(payload.input, options.loadRemoteImage ?? fetchRemoteImage); + const { messages, systemBlocks: hoistedSystemBlocks } = await translateResponsesInput(payload.input, options.loadRemoteImage ?? fetchRemoteImage); const tools = translateTools(payload.tools, customToolNames); // `payload.instructions` is the Responses canonical system field; leading - // system/developer input items are hoisted into `systemParts`. Combine - // both into the Messages top-level `system`. Non-leading system items stay - // inline as MessagesSystemMessage. - const system = [payload.instructions, ...systemParts].filter((part): part is string => Boolean(part)).join('\n\n'); + // system/developer input items contribute additional blocks immediately + // after it. Each source — the instructions field and each leading input + // message — is preserved as its own MessagesTextBlock so the boundary + // between "canonical instructions" and "leading input system" survives + // and the downstream prompt cache sees stable per-source segments. The + // cache breakpoint lands on the last block via applyLastSystemCacheBreakpoint. + const systemBlocks: MessagesTextBlock[] = [ + ...(payload.instructions ? [{ type: 'text' as const, text: payload.instructions }] : []), + ...hoistedSystemBlocks, + ]; const effort = payload.reasoning?.effort; const maxTokens = payload.max_output_tokens ?? options.fallbackMaxOutputTokens ?? MESSAGES_FALLBACK_MAX_TOKENS; - const systemBlocks: MessagesTextBlock[] | undefined = system - ? [{ type: 'text', text: system, cache_control: EPHEMERAL_CACHE_CONTROL }] - : undefined; + applyLastSystemCacheBreakpoint(systemBlocks); applyLastToolCacheBreakpoint(tools); applyLastMessageCacheBreakpoint(messages); @@ -366,7 +392,7 @@ export const translateResponsesToMessages = async (payload: ResponsesPayload, op model: payload.model, messages, max_tokens: maxTokens, - ...(systemBlocks ? { system: systemBlocks } : {}), + ...(systemBlocks.length > 0 ? { system: systemBlocks } : {}), ...(payload.temperature != null ? { temperature: payload.temperature } : {}), ...(payload.top_p != null ? { top_p: payload.top_p } : {}), stream: true, diff --git a/packages/translate/src/responses-via-messages/request_test.ts b/packages/translate/src/responses-via-messages/request_test.ts index af08f3fcb..ceb8ab072 100644 --- a/packages/translate/src/responses-via-messages/request_test.ts +++ b/packages/translate/src/responses-via-messages/request_test.ts @@ -687,7 +687,7 @@ test('translateResponsesToMessages hoists leading role:"developer" to top-level assertEquals(result.target.system, [{ type: 'text', text: 'dev rule', cache_control: { type: 'ephemeral' } }]); }); -test('translateResponsesToMessages merges payload.instructions with leading system into top-level, non-leading stays inline', async () => { +test('translateResponsesToMessages preserves payload.instructions and leading system as separate blocks; non-leading stays inline', async () => { const result = await translateResponsesToMessages( { model: 'claude-test', @@ -712,10 +712,81 @@ test('translateResponsesToMessages merges payload.instructions with leading syst ); assertEquals(result.target.system, [ - { type: 'text', text: 'canonical top-level instructions\n\nleading note', cache_control: { type: 'ephemeral' } }, + { type: 'text', text: 'canonical top-level instructions' }, + { type: 'text', text: 'leading note', cache_control: { type: 'ephemeral' } }, ]); assertEquals(result.target.messages.length, 3); assertEquals(result.target.messages[0].role, 'user'); assertEquals(result.target.messages[1], { role: 'system', content: 'mid-array note' }); assertEquals(result.target.messages[2].role, 'user'); }); + +test('translateResponsesToMessages throws when a system input message contains an image part', async () => { + await assertRejects( + () => + translateResponsesToMessages( + { + model: 'claude-test', + input: [ + { + type: 'message', + role: 'system', + content: [ + { type: 'input_text', text: 'You are helpful.' }, + { type: 'input_image', image_url: 'data:image/png;base64,iVBORw0KGgo=', detail: 'auto' }, + ], + }, + { type: 'message', role: 'user', content: 'hi' }, + ], + instructions: null, + temperature: null, + top_p: null, + max_output_tokens: 256, + tools: null, + tool_choice: 'auto', + metadata: null, + stream: null, + store: false, + parallel_tool_calls: true, + }, + { loadRemoteImage: stubRemoteImageLoader(null) }, + ), + Error, + 'does not accept image content parts in system messages', + ); +}); + +test('translateResponsesToMessages throws when a non-leading developer input message contains an image part', async () => { + await assertRejects( + () => + translateResponsesToMessages( + { + model: 'claude-test', + input: [ + { type: 'message', role: 'user', content: 'hi' }, + { + type: 'message', + role: 'developer', + content: [ + { type: 'input_image', image_url: 'data:image/png;base64,iVBORw0KGgo=', detail: 'auto' }, + ], + }, + { type: 'message', role: 'user', content: 'bye' }, + ], + instructions: null, + temperature: null, + top_p: null, + max_output_tokens: 256, + tools: null, + tool_choice: 'auto', + metadata: null, + stream: null, + store: false, + parallel_tool_calls: true, + }, + { loadRemoteImage: stubRemoteImageLoader(null) }, + ), + Error, + 'does not accept image content parts in developer messages', + ); +}); diff --git a/packages/translate/src/shared/via-messages/cache-breakpoints.ts b/packages/translate/src/shared/via-messages/cache-breakpoints.ts index 97d18d075..c5dceab28 100644 --- a/packages/translate/src/shared/via-messages/cache-breakpoints.ts +++ b/packages/translate/src/shared/via-messages/cache-breakpoints.ts @@ -52,6 +52,15 @@ type CacheableContentBlock = MessagesTextBlock | MessagesImageBlock | MessagesTo const isCacheableBlock = (block: MessagesUserContentBlock | MessagesAssistantContentBlock): block is CacheableContentBlock => block.type === 'text' || block.type === 'image' || block.type === 'tool_use' || block.type === 'tool_result'; +// Apply ephemeral cache breakpoint to the last text block of the top-level +// system field. The system field is part of the stable per-request prefix, +// so the cache breakpoint anchors at its tail to maximise reuse across +// subsequent turns that build on the same system + tools + history chain. +export const applyLastSystemCacheBreakpoint = (system: MessagesTextBlock[] | undefined): void => { + if (!system || system.length === 0) return; + system[system.length - 1].cache_control = EPHEMERAL_CACHE_CONTROL; +}; + export const applyLastMessageCacheBreakpoint = (messages: MessagesMessage[]): void => { for (let m = messages.length - 1; m >= 0; m--) { const message = messages[m]; From 217053e777f7c4c22f3c9c62fb377813985872af Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 28 Jun 2026 23:55:33 +0800 Subject: [PATCH 03/79] test(translate): cover applyLastSystemCacheBreakpoint + leading empty/non-empty system mix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 of review-and-cleanup. Adds two test gaps for invariants the previous commit documented in code but did not exercise. - `applyLastSystemCacheBreakpoint` now has direct unit coverage (no-op on undefined / empty input; marks only the last block when multiple are present), matching the local convention in cache-breakpoints_test.ts where each helper has its own test. - Chat Completions hoist: a leading empty system followed by a leading non-empty system / developer message — the empty must consume its prefix slot without contributing a block, the non-empty must still be hoisted as part of the same contiguous prefix. --- .../request_test.ts | 15 ++++++++++++ .../via-messages/cache-breakpoints_test.ts | 23 +++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/translate/src/chat-completions-via-messages/request_test.ts b/packages/translate/src/chat-completions-via-messages/request_test.ts index 6bc88f09c..4252a4318 100644 --- a/packages/translate/src/chat-completions-via-messages/request_test.ts +++ b/packages/translate/src/chat-completions-via-messages/request_test.ts @@ -150,6 +150,21 @@ test('empty leading system content is not hoisted', async () => { assertEquals(result.messages.length, 1); }); +test('leading empty + non-empty system: empty consumes its prefix slot, non-empty contributes its block', async () => { + const result = await translateChatCompletionsToMessages( + mkPayload({ + messages: [ + { role: 'system', content: '' }, + { role: 'developer', content: 'Be terse.' }, + { role: 'user', content: 'Hi' }, + ], + }), + ); + assertEquals(result.system, [{ type: 'text', text: 'Be terse.', cache_control: { type: 'ephemeral' } }]); + assertEquals(result.messages.length, 1); + assertEquals(result.messages[0].role, 'user'); +}); + test('leading system with ContentPart array preserves text parts as separate blocks', async () => { const result = await translateChatCompletionsToMessages( mkPayload({ diff --git a/packages/translate/src/shared/via-messages/cache-breakpoints_test.ts b/packages/translate/src/shared/via-messages/cache-breakpoints_test.ts index d55fbd637..13e151ee9 100644 --- a/packages/translate/src/shared/via-messages/cache-breakpoints_test.ts +++ b/packages/translate/src/shared/via-messages/cache-breakpoints_test.ts @@ -1,8 +1,8 @@ import { test } from 'vitest'; -import { applyLastMessageCacheBreakpoint, applyLastToolCacheBreakpoint } from './cache-breakpoints.ts'; +import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from './cache-breakpoints.ts'; import { assert, assertEquals } from '../../test-assert.ts'; -import type { MessagesAssistantMessage, MessagesMessage, MessagesTool, MessagesUserMessage } from '@floway-dev/protocols/messages'; +import type { MessagesAssistantMessage, MessagesMessage, MessagesTextBlock, MessagesTool, MessagesUserMessage } from '@floway-dev/protocols/messages'; const cacheControlOf = (value: unknown): unknown => (value as { cache_control?: unknown }).cache_control; @@ -60,3 +60,22 @@ test('applyLastMessageCacheBreakpoint falls back to an earlier message when the assertEquals(cacheControlOf(userContent[0]), { type: 'ephemeral' }); assertEquals(cacheControlOf(assistantContent[0]), undefined); }); + +test('applyLastSystemCacheBreakpoint is a no-op on undefined or empty input', () => { + applyLastSystemCacheBreakpoint(undefined); + const empty: MessagesTextBlock[] = []; + applyLastSystemCacheBreakpoint(empty); + assertEquals(empty, []); +}); + +test('applyLastSystemCacheBreakpoint marks only the last block when multiple are present', () => { + const system: MessagesTextBlock[] = [ + { type: 'text', text: 'instructions' }, + { type: 'text', text: 'leading note' }, + { type: 'text', text: 'final block' }, + ]; + applyLastSystemCacheBreakpoint(system); + assertEquals(cacheControlOf(system[0]), undefined); + assertEquals(cacheControlOf(system[1]), undefined); + assertEquals(cacheControlOf(system[2]), { type: 'ephemeral' }); +}); From 5e65cb267f3e601215e3f16e7c4d86cd621e9e31 Mon Sep 17 00:00:00 2001 From: Menci Date: Sun, 28 Jun 2026 23:58:46 +0800 Subject: [PATCH 04/79] chore(translate): trim redundant comments on system hoist + cache helper Cleanup round 1. Removes the function-level prose above applyLastSystemCacheBreakpoint that restated the function name and duplicated the file-level "system block when system text is non-empty" placement note (the sibling applyLastToolCacheBreakpoint has no analogous comment), and condenses the empty-content explanation in the hoist loops on both translator pairs to just the one non-obvious clause (that an empty-content leading message still extends the contiguous prefix). --- .../translate/src/chat-completions-via-messages/request.ts | 6 ++---- packages/translate/src/responses-via-messages/request.ts | 5 ++--- .../translate/src/shared/via-messages/cache-breakpoints.ts | 4 ---- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/translate/src/chat-completions-via-messages/request.ts b/packages/translate/src/chat-completions-via-messages/request.ts index 1c087daf4..35867660a 100644 --- a/packages/translate/src/chat-completions-via-messages/request.ts +++ b/packages/translate/src/chat-completions-via-messages/request.ts @@ -186,10 +186,8 @@ export const translateChatCompletionsToMessages = async (payload: ChatCompletion // system/developer messages stay inline as MessagesSystemMessage at their // chronological position; the gateway's // `demote-interleaved-system-to-user` interceptor flag handles upstreams - // that reject inline system. Empty system content contributes zero - // blocks — its prefix slot is consumed (so a subsequent non-empty leading - // system still counts as part of the same prefix) but it does not produce - // an empty top-level system entry. + // that reject inline system. An empty-content leading message still + // extends the contiguous prefix even though it contributes no block. const systemBlocks: MessagesTextBlock[] = []; let prefixEnd = 0; for (const message of payload.messages) { diff --git a/packages/translate/src/responses-via-messages/request.ts b/packages/translate/src/responses-via-messages/request.ts index 42f50bd46..73015612d 100644 --- a/packages/translate/src/responses-via-messages/request.ts +++ b/packages/translate/src/responses-via-messages/request.ts @@ -186,9 +186,8 @@ const translateResponsesInput = async (input: string | ResponsesInputItem[], loa // systemBlocks (→ top-level Messages.system), preserving each input_text // part as its own MessagesTextBlock so part boundaries survive the hoist. // Non-leading system/developer messages stay inline as MessagesSystemMessage. - // Empty system content contributes zero blocks — its prefix slot is still - // consumed so a subsequent non-empty leading system stays part of the same - // prefix. + // An empty-content leading message still extends the contiguous prefix + // even though it contributes no block. const systemBlocks: MessagesTextBlock[] = []; let prefixEnd = 0; for (const item of input) { diff --git a/packages/translate/src/shared/via-messages/cache-breakpoints.ts b/packages/translate/src/shared/via-messages/cache-breakpoints.ts index c5dceab28..1486bb298 100644 --- a/packages/translate/src/shared/via-messages/cache-breakpoints.ts +++ b/packages/translate/src/shared/via-messages/cache-breakpoints.ts @@ -52,10 +52,6 @@ type CacheableContentBlock = MessagesTextBlock | MessagesImageBlock | MessagesTo const isCacheableBlock = (block: MessagesUserContentBlock | MessagesAssistantContentBlock): block is CacheableContentBlock => block.type === 'text' || block.type === 'image' || block.type === 'tool_use' || block.type === 'tool_result'; -// Apply ephemeral cache breakpoint to the last text block of the top-level -// system field. The system field is part of the stable per-request prefix, -// so the cache breakpoint anchors at its tail to maximise reuse across -// subsequent turns that build on the same system + tools + history chain. export const applyLastSystemCacheBreakpoint = (system: MessagesTextBlock[] | undefined): void => { if (!system || system.length === 0) return; system[system.length - 1].cache_control = EPHEMERAL_CACHE_CONTROL; From 3f07c609daa80383faa59335124a980264ea649a Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 29 Jun 2026 00:06:05 +0800 Subject: [PATCH 05/79] chore(translate): drop redundant dev/system normalization comment line Round 2 cleanup. The dual `case 'system': case 'developer':` labels falling through to a single body already convey that developer normalizes to role:'system' here, so the dedicated comment line restating that fact reads as narration of the case label. Drop it from both translator pairs; the surrounding decision-recording about non-leading inline placement and the demote-interleaved-system safety net stays. --- .../src/chat-completions-via-messages/request.ts | 8 +++----- .../translate/src/responses-via-messages/request.ts | 12 +++++------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/translate/src/chat-completions-via-messages/request.ts b/packages/translate/src/chat-completions-via-messages/request.ts index 35867660a..43d354c40 100644 --- a/packages/translate/src/chat-completions-via-messages/request.ts +++ b/packages/translate/src/chat-completions-via-messages/request.ts @@ -138,11 +138,9 @@ const buildMessagesInput = async (messages: ChatCompletionsMessage[], loadRemote // been hoisted to MessagesPayload.system by // translateChatCompletionsToMessages before buildMessagesInput runs, // so anything reaching this branch was deliberately placed mid-history - // by the caller and we preserve that chronological position. Developer - // is the same intent layer as system on the Chat Completions wire and - // normalizes to role:'system' here. Anthropic upstreams diverge on - // inline role:'system' (Bedrock accepts it under placement rules; - // Vertex rejects it outright), so the gateway's + // by the caller and we preserve that chronological position. Anthropic + // upstreams diverge on inline role:'system' (Bedrock accepts it under + // placement rules; Vertex rejects it outright), so the gateway's // `demote-interleaved-system-to-user` interceptor flag is the safety // net for any inline system that would otherwise reach an upstream // that does not accept it. diff --git a/packages/translate/src/responses-via-messages/request.ts b/packages/translate/src/responses-via-messages/request.ts index 73015612d..2e1fe158d 100644 --- a/packages/translate/src/responses-via-messages/request.ts +++ b/packages/translate/src/responses-via-messages/request.ts @@ -133,13 +133,11 @@ const responsesSystemBlocks = (message: ResponsesInputMessage): MessagesTextBloc // MessagesSystemMessage at their chronological position. The leading // contiguous prefix has already been hoisted to MessagesPayload.system by // translateResponsesToMessages before translateResponsesInput's per-item -// loop hits this branch. Developer is the same intent layer as system on -// the Responses wire and normalizes to role:'system' here. Anthropic -// upstreams diverge on inline role:'system' (Bedrock accepts it under -// placement rules; Vertex rejects it outright), so the gateway's -// `demote-interleaved-system-to-user` interceptor flag is the safety net -// for any inline system that would otherwise reach an upstream that does -// not accept it. +// loop hits this branch. Anthropic upstreams diverge on inline +// role:'system' (Bedrock accepts it under placement rules; Vertex rejects +// it outright), so the gateway's `demote-interleaved-system-to-user` +// interceptor flag is the safety net for any inline system that would +// otherwise reach an upstream that does not accept it. const translateSystemMessage = (message: ResponsesInputMessage): MessagesSystemMessage => { if (typeof message.content === 'string') { return { role: 'system', content: message.content }; From 43dc268228cd0165b6fee642da6399d4967e3c20 Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 29 Jun 2026 00:09:59 +0800 Subject: [PATCH 06/79] chore(translate): further trim hoist + inline-branch comment narration Round 2 cleanup. Drops three more lines of mechanical narration the audit flagged on the second pass: - The hoist comment's "An empty-content leading message still extends the contiguous prefix" sentence narrates the unconditional `prefixEnd++` directly below it. The behavior is visible in the loop and locked in by an explicit unit test, so the comment carries no additional information. Removed on both translator pairs. - The inline-branch comment on both translators re-described the hoist contract (which is already authoritative at the hoist call site) before getting to the Bedrock/Vertex divergence and interceptor-flag pointer that is the only load-bearing content. Compressed to one line. - The responses-via-messages top-level comment ended with "The cache breakpoint lands on the last block via applyLastSystemCacheBreakpoint", restating the next line of code. Removed. --- .../chat-completions-via-messages/request.ts | 15 +++++-------- .../src/responses-via-messages/request.ts | 21 +++++++------------ 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/packages/translate/src/chat-completions-via-messages/request.ts b/packages/translate/src/chat-completions-via-messages/request.ts index 43d354c40..fbdd01033 100644 --- a/packages/translate/src/chat-completions-via-messages/request.ts +++ b/packages/translate/src/chat-completions-via-messages/request.ts @@ -133,14 +133,10 @@ const buildMessagesInput = async (messages: ChatCompletionsMessage[], loadRemote break; case 'system': case 'developer': - // Non-leading system / developer messages stay inline as - // MessagesSystemMessage: their leading contiguous prefix has already - // been hoisted to MessagesPayload.system by - // translateChatCompletionsToMessages before buildMessagesInput runs, - // so anything reaching this branch was deliberately placed mid-history - // by the caller and we preserve that chronological position. Anthropic - // upstreams diverge on inline role:'system' (Bedrock accepts it under - // placement rules; Vertex rejects it outright), so the gateway's + // Inline path for non-leading system / developer (the leading prefix + // was hoisted earlier). Anthropic upstreams diverge on inline + // role:'system' here (Bedrock accepts it under placement rules; + // Vertex rejects it outright), so the gateway's // `demote-interleaved-system-to-user` interceptor flag is the safety // net for any inline system that would otherwise reach an upstream // that does not accept it. @@ -184,8 +180,7 @@ export const translateChatCompletionsToMessages = async (payload: ChatCompletion // system/developer messages stay inline as MessagesSystemMessage at their // chronological position; the gateway's // `demote-interleaved-system-to-user` interceptor flag handles upstreams - // that reject inline system. An empty-content leading message still - // extends the contiguous prefix even though it contributes no block. + // that reject inline system. const systemBlocks: MessagesTextBlock[] = []; let prefixEnd = 0; for (const message of payload.messages) { diff --git a/packages/translate/src/responses-via-messages/request.ts b/packages/translate/src/responses-via-messages/request.ts index 2e1fe158d..2f8ae2a30 100644 --- a/packages/translate/src/responses-via-messages/request.ts +++ b/packages/translate/src/responses-via-messages/request.ts @@ -129,15 +129,13 @@ const responsesSystemBlocks = (message: ResponsesInputMessage): MessagesTextBloc return blocks; }; -// Non-leading system / developer Responses input messages stay inline as -// MessagesSystemMessage at their chronological position. The leading -// contiguous prefix has already been hoisted to MessagesPayload.system by -// translateResponsesToMessages before translateResponsesInput's per-item -// loop hits this branch. Anthropic upstreams diverge on inline -// role:'system' (Bedrock accepts it under placement rules; Vertex rejects -// it outright), so the gateway's `demote-interleaved-system-to-user` -// interceptor flag is the safety net for any inline system that would -// otherwise reach an upstream that does not accept it. +// Inline path for non-leading system / developer Responses input messages +// (the leading prefix was hoisted earlier). Anthropic upstreams diverge on +// inline role:'system' here (Bedrock accepts it under placement rules; +// Vertex rejects it outright), so the gateway's +// `demote-interleaved-system-to-user` interceptor flag is the safety net +// for any inline system that would otherwise reach an upstream that does +// not accept it. const translateSystemMessage = (message: ResponsesInputMessage): MessagesSystemMessage => { if (typeof message.content === 'string') { return { role: 'system', content: message.content }; @@ -184,8 +182,6 @@ const translateResponsesInput = async (input: string | ResponsesInputItem[], loa // systemBlocks (→ top-level Messages.system), preserving each input_text // part as its own MessagesTextBlock so part boundaries survive the hoist. // Non-leading system/developer messages stay inline as MessagesSystemMessage. - // An empty-content leading message still extends the contiguous prefix - // even though it contributes no block. const systemBlocks: MessagesTextBlock[] = []; let prefixEnd = 0; for (const item of input) { @@ -342,8 +338,7 @@ export const translateResponsesToMessages = async (payload: ResponsesPayload, op // after it. Each source — the instructions field and each leading input // message — is preserved as its own MessagesTextBlock so the boundary // between "canonical instructions" and "leading input system" survives - // and the downstream prompt cache sees stable per-source segments. The - // cache breakpoint lands on the last block via applyLastSystemCacheBreakpoint. + // and the downstream prompt cache sees stable per-source segments. const systemBlocks: MessagesTextBlock[] = [ ...(payload.instructions ? [{ type: 'text' as const, text: payload.instructions }] : []), ...hoistedSystemBlocks, From 11090149eb63e54bb27c6900d465e235daa85f78 Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 29 Jun 2026 00:21:53 +0800 Subject: [PATCH 07/79] chore(translate): trim hoist comment + tighten test name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 cleanup. The hoist comment in chat-completions-via-messages named the demote-interleaved-system-to-user interceptor flag for the non-leading inline case, which the inline-branch comment already covers with full Bedrock/Vertex divergence context — drop the duplicate reference and let each comment own its unique content. The neighboring test name for the leading-empty + leading-non-empty case described the algorithm's internal mechanism (prefix-slot consumption) rather than the observable contract; shorten to the contract-focused form that matches the surrounding test naming style in the file. --- .../translate/src/chat-completions-via-messages/request.ts | 4 +--- .../src/chat-completions-via-messages/request_test.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/translate/src/chat-completions-via-messages/request.ts b/packages/translate/src/chat-completions-via-messages/request.ts index fbdd01033..40464a0ea 100644 --- a/packages/translate/src/chat-completions-via-messages/request.ts +++ b/packages/translate/src/chat-completions-via-messages/request.ts @@ -178,9 +178,7 @@ export const translateChatCompletionsToMessages = async (payload: ChatCompletion // MessagesPayload.system, preserving each ContentPart text as its own // MessagesTextBlock so part boundaries survive the hoist. Non-leading // system/developer messages stay inline as MessagesSystemMessage at their - // chronological position; the gateway's - // `demote-interleaved-system-to-user` interceptor flag handles upstreams - // that reject inline system. + // chronological position. const systemBlocks: MessagesTextBlock[] = []; let prefixEnd = 0; for (const message of payload.messages) { diff --git a/packages/translate/src/chat-completions-via-messages/request_test.ts b/packages/translate/src/chat-completions-via-messages/request_test.ts index 4252a4318..afce429ca 100644 --- a/packages/translate/src/chat-completions-via-messages/request_test.ts +++ b/packages/translate/src/chat-completions-via-messages/request_test.ts @@ -150,7 +150,7 @@ test('empty leading system content is not hoisted', async () => { assertEquals(result.messages.length, 1); }); -test('leading empty + non-empty system: empty consumes its prefix slot, non-empty contributes its block', async () => { +test('leading empty system is skipped, leading non-empty is still hoisted', async () => { const result = await translateChatCompletionsToMessages( mkPayload({ messages: [ From c877294288acc36e037d1debe82bc75368eee357 Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 29 Jun 2026 01:51:13 +0800 Subject: [PATCH 08/79] refactor(translate): unify system-content helpers to always return MessagesTextBlock[] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 review recommendation, applied directly in-PR. Both translator pairs' system-content helpers (convertSystemContent on the Chat Completions side, responsesSystemBlocks on the Responses side) previously had a dual return shape — string passed through verbatim; content arrays became MessagesTextBlock[] — which forced the hoist call sites to fork on `typeof converted === 'string'` and re-wrap a non-empty string as a single text block. Collapse both helpers to a single MessagesTextBlock[] return (string content "Hello" → [{type:'text', text:'Hello'}]; empty content → []) so the hoist becomes a one-line `systemBlocks.push(...convertSystemContent(message.content))` and the inline path emits the blocks directly (or '' when empty, preserving the empty-content wire shape). Wire side-effect: inline non-leading MessagesSystemMessage with a plain-string source-content now emits `{role:'system', content:[{type:'text', text:...}]}` instead of `{role:'system', content:'...'}`. Both shapes are equivalent under `MessagesSystemMessage.content: string | MessagesTextBlock[]` and any Anthropic upstream accepts both equally; the per-translator inline tests are updated to assert the block-array form. --- .../chat-completions-via-messages/request.ts | 26 +++++++++---------- .../request_test.ts | 2 +- .../src/responses-via-messages/request.ts | 3 --- .../responses-via-messages/request_test.ts | 4 +-- 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/translate/src/chat-completions-via-messages/request.ts b/packages/translate/src/chat-completions-via-messages/request.ts index 40464a0ea..7c25cdd9b 100644 --- a/packages/translate/src/chat-completions-via-messages/request.ts +++ b/packages/translate/src/chat-completions-via-messages/request.ts @@ -86,10 +86,13 @@ const convertUserContent = async (message: ChatCompletionsMessage, loadRemoteIma // inline `MessagesSystemMessage.content`) accepts only text. Image parts in // system / developer messages are rejected here at the translator boundary so // the caller hits an explicit failure instead of having the image silently -// dropped on the wire. -const convertSystemContent = (content: ChatCompletionsMessage['content']): string | MessagesTextBlock[] => { - if (typeof content === 'string') return content; - if (!Array.isArray(content)) return ''; +// dropped on the wire. Returns blocks (possibly empty) so the hoist and +// inline call sites share one shape. +const convertSystemContent = (content: ChatCompletionsMessage['content']): MessagesTextBlock[] => { + if (typeof content === 'string') { + return content ? [{ type: 'text', text: content }] : []; + } + if (!Array.isArray(content)) return []; const blocks: MessagesTextBlock[] = []; for (const part of content) { @@ -101,7 +104,7 @@ const convertSystemContent = (content: ChatCompletionsMessage['content']): strin } } - return blocks.length > 0 ? blocks : ''; + return blocks; }; const buildMessagesInput = async (messages: ChatCompletionsMessage[], loadRemoteImage: RemoteImageLoader): Promise => { @@ -132,7 +135,7 @@ const buildMessagesInput = async (messages: ChatCompletionsMessage[], loadRemote ]); break; case 'system': - case 'developer': + case 'developer': { // Inline path for non-leading system / developer (the leading prefix // was hoisted earlier). Anthropic upstreams diverge on inline // role:'system' here (Bedrock accepts it under placement rules; @@ -140,11 +143,13 @@ const buildMessagesInput = async (messages: ChatCompletionsMessage[], loadRemote // `demote-interleaved-system-to-user` interceptor flag is the safety // net for any inline system that would otherwise reach an upstream // that does not accept it. + const blocks = convertSystemContent(message.content); result.push({ role: 'system', - content: convertSystemContent(message.content), + content: blocks.length > 0 ? blocks : '', }); break; + } default: throw new Error(`Chat Completions → Messages translator does not accept ${message.role} messages.`); } @@ -183,12 +188,7 @@ export const translateChatCompletionsToMessages = async (payload: ChatCompletion let prefixEnd = 0; for (const message of payload.messages) { if (message.role !== 'system' && message.role !== 'developer') break; - const converted = convertSystemContent(message.content); - if (typeof converted === 'string') { - if (converted) systemBlocks.push({ type: 'text', text: converted }); - } else { - systemBlocks.push(...converted); - } + systemBlocks.push(...convertSystemContent(message.content)); prefixEnd++; } diff --git a/packages/translate/src/chat-completions-via-messages/request_test.ts b/packages/translate/src/chat-completions-via-messages/request_test.ts index afce429ca..b3a6103b5 100644 --- a/packages/translate/src/chat-completions-via-messages/request_test.ts +++ b/packages/translate/src/chat-completions-via-messages/request_test.ts @@ -133,7 +133,7 @@ test('non-leading system stays inline, leading is hoisted', async () => { assertEquals(result.system, [{ type: 'text', text: 'First', cache_control: { type: 'ephemeral' } }]); assertEquals(result.messages.length, 3); assertEquals(result.messages[0].role, 'user'); - assertEquals(result.messages[1], { role: 'system', content: 'Second' }); + assertEquals(result.messages[1], { role: 'system', content: [{ type: 'text', text: 'Second' }] }); assertEquals(result.messages[2].role, 'user'); }); diff --git a/packages/translate/src/responses-via-messages/request.ts b/packages/translate/src/responses-via-messages/request.ts index 2f8ae2a30..4228bacbe 100644 --- a/packages/translate/src/responses-via-messages/request.ts +++ b/packages/translate/src/responses-via-messages/request.ts @@ -137,9 +137,6 @@ const responsesSystemBlocks = (message: ResponsesInputMessage): MessagesTextBloc // for any inline system that would otherwise reach an upstream that does // not accept it. const translateSystemMessage = (message: ResponsesInputMessage): MessagesSystemMessage => { - if (typeof message.content === 'string') { - return { role: 'system', content: message.content }; - } const blocks = responsesSystemBlocks(message); return { role: 'system', content: blocks.length > 0 ? blocks : '' }; }; diff --git a/packages/translate/src/responses-via-messages/request_test.ts b/packages/translate/src/responses-via-messages/request_test.ts index ceb8ab072..4d7182f53 100644 --- a/packages/translate/src/responses-via-messages/request_test.ts +++ b/packages/translate/src/responses-via-messages/request_test.ts @@ -655,7 +655,7 @@ test('translateResponsesToMessages keeps non-leading role:"system" inline', asyn assertEquals(result.target.messages.length, 3); assertEquals(result.target.messages[0].role, 'user'); - assertEquals(result.target.messages[1], { role: 'system', content: 'be terse' }); + assertEquals(result.target.messages[1], { role: 'system', content: [{ type: 'text', text: 'be terse' }] }); assertEquals(result.target.messages[2].role, 'user'); assertFalse('system' in result.target); }); @@ -717,7 +717,7 @@ test('translateResponsesToMessages preserves payload.instructions and leading sy ]); assertEquals(result.target.messages.length, 3); assertEquals(result.target.messages[0].role, 'user'); - assertEquals(result.target.messages[1], { role: 'system', content: 'mid-array note' }); + assertEquals(result.target.messages[1], { role: 'system', content: [{ type: 'text', text: 'mid-array note' }] }); assertEquals(result.target.messages[2].role, 'user'); }); From 3574d8a6edc9fe49feb95b7db5e9b8751b1547c6 Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 29 Jun 2026 01:52:03 +0800 Subject: [PATCH 09/79] =?UTF-8?q?refactor(translate):=20preserve=20system?= =?UTF-8?q?=20block=20boundaries=20on=20Messages=E2=86=92Chat=20round=20tr?= =?UTF-8?q?ip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 review recommendation, applied directly in-PR. The Messages → Chat Completions translator previously collapsed both inline `MessagesSystemMessage` content and top-level `MessagesPayload.system` block arrays into a single Chat Completions system message with `\n\n`-joined string content — the exact boundary loss the forward direction fixes in this PR, just on the reverse trip. A CC→Messages→CC round trip would silently merge per-block segments that callers placed deliberately and that the prompt cache cares about. Switch both code paths to emit Chat Completions `ContentPart[]` text parts when the source is a block array, one part per block. The string-source branch still emits string content (no shape change for single-string sources). Also tighten the empty-system guard to skip the system message entirely when an empty array is passed (previously `system ?` was truthy on `[]` and emitted `{role:'system', content:''}`, which is wire-noise downstream models may reject). The existing "joins in-array system text blocks with double newline" test enshrined the boundary-loss behaviour; it is replaced by two tests asserting per-part preservation for both the inline and the top-level multi-block cases. --- .../messages-via-chat-completions/request.ts | 24 +++++++++----- .../request_test.ts | 32 +++++++++++++++++-- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/packages/translate/src/messages-via-chat-completions/request.ts b/packages/translate/src/messages-via-chat-completions/request.ts index 603dd78db..45233dcbf 100644 --- a/packages/translate/src/messages-via-chat-completions/request.ts +++ b/packages/translate/src/messages-via-chat-completions/request.ts @@ -210,24 +210,32 @@ const translateMessagesAssistant = (message: MessagesAssistantMessage): ChatComp return messages; }; +// Anthropic Messages system blocks are prompt boundaries; preserve each one +// as a separate Chat Completions text part so a CC→Messages→CC round trip +// does not silently merge them. Falls back to the simple string form when +// the source is already a single-string field. +const systemContentFromBlocks = (system: string | MessagesTextBlock[]): string | ChatCompletionsContentPart[] => + typeof system === 'string' + ? system + : system.map(block => ({ type: 'text', text: block.text })); + const translateMessagesSystem = (message: MessagesSystemMessage): ChatCompletionsMessage[] => [ { role: 'system', - content: typeof message.content === 'string' ? message.content : message.content.map(block => block.text).join('\n\n'), + content: systemContentFromBlocks(message.content), }, ]; const translateMessagesInput = (messages: MessagesMessage[], system: string | MessagesTextBlock[] | undefined): ChatCompletionsMessage[] => { - // Messages system blocks are prompt boundaries; keep them as separated - // paragraphs when falling back to Chat Completions. - const systemMessages: ChatCompletionsMessage[] = system - ? [ + const isEmptySystem = system == null || (typeof system === 'string' ? system === '' : system.length === 0); + const systemMessages: ChatCompletionsMessage[] = isEmptySystem + ? [] + : [ { role: 'system', - content: typeof system === 'string' ? system : system.map(block => block.text).join('\n\n'), + content: systemContentFromBlocks(system), }, - ] - : []; + ]; return [ ...systemMessages, diff --git a/packages/translate/src/messages-via-chat-completions/request_test.ts b/packages/translate/src/messages-via-chat-completions/request_test.ts index d15f43283..89120eea8 100644 --- a/packages/translate/src/messages-via-chat-completions/request_test.ts +++ b/packages/translate/src/messages-via-chat-completions/request_test.ts @@ -425,7 +425,7 @@ test('translateMessagesToChatCompletions emits in-array role:"system" inline as assertEquals(result.messages[2].role, 'user'); }); -test('translateMessagesToChatCompletions joins in-array system text blocks with double newline', () => { +test('translateMessagesToChatCompletions preserves in-array system text blocks as separate content parts', () => { const result = translateMessagesToChatCompletions({ model: 'gpt-test', max_tokens: 256, @@ -441,7 +441,35 @@ test('translateMessagesToChatCompletions joins in-array system text blocks with ], }); - assertEquals(result.messages[0], { role: 'system', content: 'Para A\n\nPara B' }); + assertEquals(result.messages[0], { + role: 'system', + content: [ + { type: 'text', text: 'Para A' }, + { type: 'text', text: 'Para B' }, + ], + }); +}); + +test('translateMessagesToChatCompletions preserves top-level system text blocks as separate content parts', () => { + const result = translateMessagesToChatCompletions({ + model: 'gpt-test', + max_tokens: 256, + system: [ + { type: 'text', text: 'instructions' }, + { type: 'text', text: 'extra context' }, + ], + messages: [ + { role: 'user', content: 'hi' }, + ], + }); + + assertEquals(result.messages[0], { + role: 'system', + content: [ + { type: 'text', text: 'instructions' }, + { type: 'text', text: 'extra context' }, + ], + }); }); test('translateMessagesToChatCompletions preserves chronology of multiple in-array system messages', () => { From 35f712f3fb5ea9d381c3da725f47354462dd53d4 Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 29 Jun 2026 01:52:34 +0800 Subject: [PATCH 10/79] fix(translate): exhaustiveness guard for unknown Responses system content blocks Round 4 cleanup audit, applied directly in-PR since responsesSystemBlocks is a helper introduced by this PR. Today ResponsesInputContent is `input_text | input_image | output_text`; the helper already handles all three (image throws; text variants accumulate). A future variant added to the union would have been silently dropped by the prior `if` chain. Replace the silent fall-through with an explicit throw mirroring the `unexpectedResponsesInputItem` exhaustiveness guard already used by `translateResponsesInput`, so any new variant forces an explicit opt-in. --- packages/translate/src/responses-via-messages/request.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/translate/src/responses-via-messages/request.ts b/packages/translate/src/responses-via-messages/request.ts index 4228bacbe..19f55ad9c 100644 --- a/packages/translate/src/responses-via-messages/request.ts +++ b/packages/translate/src/responses-via-messages/request.ts @@ -122,9 +122,14 @@ const responsesSystemBlocks = (message: ResponsesInputMessage): MessagesTextBloc if (block.type === 'input_image') { throw new Error(`Responses → Messages translator does not accept image content parts in ${message.role} messages — Anthropic Messages only permits text in the system field.`); } - if (block.type === 'input_text' || block.type === 'output_text') { - blocks.push({ type: 'text', text: (block as ResponsesInputText).text }); + if (block.type !== 'input_text' && block.type !== 'output_text') { + // Exhaustiveness guard: today ResponsesInputContent is + // input_text|input_image|output_text; a future variant must opt into + // translator behavior rather than be silently dropped from system + // content. + throw new Error(`Responses → Messages translator: unexpected content block variant ${(block as { type: string }).type} in ${message.role} message.`); } + blocks.push({ type: 'text', text: (block as ResponsesInputText).text }); } return blocks; }; From 5b631a9573e2869668d4e9479a418025e20540bc Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 29 Jun 2026 01:58:40 +0800 Subject: [PATCH 11/79] chore(translate): drop stale ResponsesInputText cast + cover empty-system-array skip Post-application review polish. The new exhaustiveness throw in responsesSystemBlocks narrows `block` to ResponsesInputText by the time the .push runs, so the `as ResponsesInputText` cast on the text-extract line is dead. Remove it. Add a unit test asserting the empty-array system-skip behavior that came with the reverse-direction boundary-preservation refactor, so the behavior change is locked in. --- .../messages-via-chat-completions/request_test.ts | 12 ++++++++++++ .../translate/src/responses-via-messages/request.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/translate/src/messages-via-chat-completions/request_test.ts b/packages/translate/src/messages-via-chat-completions/request_test.ts index 89120eea8..5a399ddd8 100644 --- a/packages/translate/src/messages-via-chat-completions/request_test.ts +++ b/packages/translate/src/messages-via-chat-completions/request_test.ts @@ -472,6 +472,18 @@ test('translateMessagesToChatCompletions preserves top-level system text blocks }); }); +test('translateMessagesToChatCompletions skips system message when top-level system is empty array', () => { + const result = translateMessagesToChatCompletions({ + model: 'gpt-test', + max_tokens: 256, + system: [], + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.messages.length, 1); + assertEquals(result.messages[0].role, 'user'); +}); + test('translateMessagesToChatCompletions preserves chronology of multiple in-array system messages', () => { const result = translateMessagesToChatCompletions({ model: 'gpt-test', diff --git a/packages/translate/src/responses-via-messages/request.ts b/packages/translate/src/responses-via-messages/request.ts index 19f55ad9c..c9c8fa554 100644 --- a/packages/translate/src/responses-via-messages/request.ts +++ b/packages/translate/src/responses-via-messages/request.ts @@ -129,7 +129,7 @@ const responsesSystemBlocks = (message: ResponsesInputMessage): MessagesTextBloc // content. throw new Error(`Responses → Messages translator: unexpected content block variant ${(block as { type: string }).type} in ${message.role} message.`); } - blocks.push({ type: 'text', text: (block as ResponsesInputText).text }); + blocks.push({ type: 'text', text: block.text }); } return blocks; }; From f9ebb7f852c7dead528c771716cc9799a89d448e Mon Sep 17 00:00:00 2001 From: yyyr Date: Wed, 1 Jul 2026 00:38:36 +0800 Subject: [PATCH 12/79] feat(provider-cursor): Cursor provider with multi-turn native MCP tool calling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Cursor agent provider (RunSSE read + BidiAppend write dual-channel HTTP/1.1, Workers-clean) — OAuth poll login, 129-model discovery, streaming chat, composer thinking, image input, and native multi-turn function calling. Provider (packages/provider-cursor): - proto/ codec, AgentTransport dual-channel transport, agent-translate, checksum, OAuth poll/import, access-token cache, models/pricing/quota, interceptors. - Multi-turn native MCP: in-process session reuse (cursor-session-state) keeps the live RunSSE stream paused at exec_mcp; the tool-result follow-up sends ExecMcpResult on the same stream (no prompt-fold). session-id encodes the sessionKey into the tool_call_id so the next request re-finds the session. - Turn end on the authoritative IU[14] turn_ended frame (heartbeat count no longer preempts it); KV blob seqno fix (handleKvMessage's incremented seqno return value was dropped, stalling the blob channel on follow-ups and yielding empty tool-result replies). Platform DurableHttpSession (cross-request HTTP response holder): - contract + Node in-process impl + Cloudflare Durable Object (first DO to own a live outbound socket) + broker + tests + wrangler v2 migration. Gateway/UI wiring: provider registry, cursor routes (authorize-url / poll / reimport / refresh-now), migration 0046, UpstreamCallOptions.apiKeyId, web CursorConfigPanel + provider-meta + upstream pages. pnpm -w typecheck/lint/test green (310 files, 3539 tests). Node e2e verified: turn1 tool_calls -> turn2 uses tool result -> turn3 conversational follow-up. --- apps/platform-cloudflare/entry.ts | 1 + apps/platform-cloudflare/src/_held-socket.ts | 26 + apps/platform-cloudflare/src/bootstrap.ts | 13 +- .../src/broadcast-do_test.ts | 6 + .../src/cloudflare-workers.d.ts | 9 +- .../src/do-durable-http-session.ts | 152 ++++ .../src/do-durable-http-session_test.ts | 140 ++++ .../src/durable-http-session-do.ts | 253 +++++++ .../src/durable-http-session-do_test.ts | 221 ++++++ apps/platform-node/src/bootstrap.ts | 3 + .../src/in-process-durable-http-session.ts | 199 +++++ .../in-process-durable-http-session_test.ts | 200 +++++ apps/web/src/api/types.ts | 31 +- apps/web/src/components/dump/RequestList.vue | 1 + .../src/components/settings/UpstreamRow.vue | 4 + .../upstream-edit/CursorConfigPanel.vue | 142 ++++ .../upstream-edit/UpstreamConfigPanel.vue | 17 +- .../upstream-edit/UpstreamEditPage.vue | 4 +- .../components/upstreams/UpstreamPicker.vue | 1 + .../src/components/upstreams/provider-meta.ts | 8 + apps/web/src/lib/pkce.ts | 2 +- eslint.config.ts | 1 + .../migrations/0046_cursor_provider.sql | 37 + packages/gateway/package.json | 1 + .../src/control-plane/data-transfer/routes.ts | 12 +- packages/gateway/src/control-plane/routes.ts | 8 +- packages/gateway/src/control-plane/schemas.ts | 36 +- .../src/control-plane/upstreams/routes.ts | 170 ++++- .../src/control-plane/upstreams/serialize.ts | 28 + .../server-tools/image-generation.ts | 4 +- .../data-plane/chat/shared/attempt-helpers.ts | 1 + .../src/data-plane/providers/registry.ts | 2 + .../data-plane/shared/passthrough-serve.ts | 1 + packages/gateway/src/repo/sql.ts | 2 +- packages/platform/src/durable-http-session.ts | 185 +++++ .../platform/src/durable-http-session_test.ts | 111 +++ packages/platform/src/index.ts | 1 + packages/provider-codex/src/fetch_test.ts | 1 + packages/provider-cursor/package.json | 22 + .../provider-cursor/src/access-token-cache.ts | 156 ++++ .../provider-cursor/src/agent-translate.ts | 280 +++++++ .../src/agent-translate_test.ts | 191 +++++ .../provider-cursor/src/agent-transport.ts | 707 ++++++++++++++++++ .../src/agent-transport_test.ts | 205 +++++ packages/provider-cursor/src/auth/import.ts | 72 ++ .../provider-cursor/src/auth/import_test.ts | 48 ++ packages/provider-cursor/src/auth/oauth.ts | 102 +++ .../provider-cursor/src/auth/oauth_test.ts | 60 ++ packages/provider-cursor/src/auth/poll.ts | 116 +++ .../provider-cursor/src/auth/poll_test.ts | 39 + packages/provider-cursor/src/checksum.ts | 61 ++ packages/provider-cursor/src/checksum_test.ts | 49 ++ packages/provider-cursor/src/config.ts | 68 ++ packages/provider-cursor/src/config_test.ts | 46 ++ packages/provider-cursor/src/constants.ts | 19 + packages/provider-cursor/src/cursor-images.ts | 48 ++ .../src/cursor-session-state.ts | 77 ++ packages/provider-cursor/src/fetch.ts | 449 +++++++++++ packages/provider-cursor/src/fetch_test.ts | 162 ++++ packages/provider-cursor/src/index.ts | 16 + .../interceptors/chat-completions/index.ts | 15 + .../inject-default-instructions.ts | 24 + .../inject-default-instructions_test.ts | 40 + .../strip-unsupported-fields.ts | 38 + .../strip-unsupported-fields_test.ts | 58 ++ .../interceptors/chat-completions/types.ts | 11 + packages/provider-cursor/src/models.ts | 98 +++ packages/provider-cursor/src/pricing.ts | 58 ++ packages/provider-cursor/src/pricing_test.ts | 31 + .../src/proto/agent-messages.ts | 230 ++++++ packages/provider-cursor/src/proto/bidi.ts | 31 + .../provider-cursor/src/proto/decoding.ts | 139 ++++ .../provider-cursor/src/proto/encoding.ts | 249 ++++++ .../provider-cursor/src/proto/envelope.ts | 127 ++++ packages/provider-cursor/src/proto/exec.ts | 615 +++++++++++++++ packages/provider-cursor/src/proto/index.ts | 108 +++ .../provider-cursor/src/proto/interaction.ts | 87 +++ packages/provider-cursor/src/proto/kv.ts | 157 ++++ .../provider-cursor/src/proto/proto_test.ts | 273 +++++++ .../provider-cursor/src/proto/tool-calls.ts | 152 ++++ packages/provider-cursor/src/proto/types.ts | 234 ++++++ packages/provider-cursor/src/provider.ts | 184 +++++ packages/provider-cursor/src/provider_test.ts | 55 ++ packages/provider-cursor/src/quota.ts | 30 + packages/provider-cursor/src/session-id.ts | 85 +++ .../provider-cursor/src/session-id_test.ts | 92 +++ packages/provider-cursor/src/state.ts | 173 +++++ packages/provider-cursor/src/state_test.ts | 52 ++ packages/provider-cursor/tsconfig.json | 4 + packages/provider-cursor/vitest.config.ts | 8 + packages/provider-ollama/src/provider_test.ts | 2 +- packages/provider/src/flags.ts | 8 +- packages/provider/src/model.ts | 2 +- packages/provider/src/provider.ts | 14 + packages/test-utils/src/stubs.ts | 4 +- pnpm-lock.yaml | 25 + wrangler.example.jsonc | 21 +- 97 files changed, 8530 insertions(+), 31 deletions(-) create mode 100644 apps/platform-cloudflare/src/_held-socket.ts create mode 100644 apps/platform-cloudflare/src/do-durable-http-session.ts create mode 100644 apps/platform-cloudflare/src/do-durable-http-session_test.ts create mode 100644 apps/platform-cloudflare/src/durable-http-session-do.ts create mode 100644 apps/platform-cloudflare/src/durable-http-session-do_test.ts create mode 100644 apps/platform-node/src/in-process-durable-http-session.ts create mode 100644 apps/platform-node/src/in-process-durable-http-session_test.ts create mode 100644 apps/web/src/components/upstream-edit/CursorConfigPanel.vue create mode 100644 packages/gateway/migrations/0046_cursor_provider.sql create mode 100644 packages/platform/src/durable-http-session.ts create mode 100644 packages/platform/src/durable-http-session_test.ts create mode 100644 packages/provider-cursor/package.json create mode 100644 packages/provider-cursor/src/access-token-cache.ts create mode 100644 packages/provider-cursor/src/agent-translate.ts create mode 100644 packages/provider-cursor/src/agent-translate_test.ts create mode 100644 packages/provider-cursor/src/agent-transport.ts create mode 100644 packages/provider-cursor/src/agent-transport_test.ts create mode 100644 packages/provider-cursor/src/auth/import.ts create mode 100644 packages/provider-cursor/src/auth/import_test.ts create mode 100644 packages/provider-cursor/src/auth/oauth.ts create mode 100644 packages/provider-cursor/src/auth/oauth_test.ts create mode 100644 packages/provider-cursor/src/auth/poll.ts create mode 100644 packages/provider-cursor/src/auth/poll_test.ts create mode 100644 packages/provider-cursor/src/checksum.ts create mode 100644 packages/provider-cursor/src/checksum_test.ts create mode 100644 packages/provider-cursor/src/config.ts create mode 100644 packages/provider-cursor/src/config_test.ts create mode 100644 packages/provider-cursor/src/constants.ts create mode 100644 packages/provider-cursor/src/cursor-images.ts create mode 100644 packages/provider-cursor/src/cursor-session-state.ts create mode 100644 packages/provider-cursor/src/fetch.ts create mode 100644 packages/provider-cursor/src/fetch_test.ts create mode 100644 packages/provider-cursor/src/index.ts create mode 100644 packages/provider-cursor/src/interceptors/chat-completions/index.ts create mode 100644 packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions.ts create mode 100644 packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions_test.ts create mode 100644 packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields.ts create mode 100644 packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields_test.ts create mode 100644 packages/provider-cursor/src/interceptors/chat-completions/types.ts create mode 100644 packages/provider-cursor/src/models.ts create mode 100644 packages/provider-cursor/src/pricing.ts create mode 100644 packages/provider-cursor/src/pricing_test.ts create mode 100644 packages/provider-cursor/src/proto/agent-messages.ts create mode 100644 packages/provider-cursor/src/proto/bidi.ts create mode 100644 packages/provider-cursor/src/proto/decoding.ts create mode 100644 packages/provider-cursor/src/proto/encoding.ts create mode 100644 packages/provider-cursor/src/proto/envelope.ts create mode 100644 packages/provider-cursor/src/proto/exec.ts create mode 100644 packages/provider-cursor/src/proto/index.ts create mode 100644 packages/provider-cursor/src/proto/interaction.ts create mode 100644 packages/provider-cursor/src/proto/kv.ts create mode 100644 packages/provider-cursor/src/proto/proto_test.ts create mode 100644 packages/provider-cursor/src/proto/tool-calls.ts create mode 100644 packages/provider-cursor/src/proto/types.ts create mode 100644 packages/provider-cursor/src/provider.ts create mode 100644 packages/provider-cursor/src/provider_test.ts create mode 100644 packages/provider-cursor/src/quota.ts create mode 100644 packages/provider-cursor/src/session-id.ts create mode 100644 packages/provider-cursor/src/session-id_test.ts create mode 100644 packages/provider-cursor/src/state.ts create mode 100644 packages/provider-cursor/src/state_test.ts create mode 100644 packages/provider-cursor/tsconfig.json create mode 100644 packages/provider-cursor/vitest.config.ts diff --git a/apps/platform-cloudflare/entry.ts b/apps/platform-cloudflare/entry.ts index 3eca4b32e..6a3f0a980 100644 --- a/apps/platform-cloudflare/entry.ts +++ b/apps/platform-cloudflare/entry.ts @@ -13,6 +13,7 @@ import { // exported name on the Worker module. The wrangler `migrations.new_sqlite_classes` // entry must match this export. export { BroadcastDO } from './src/broadcast-do.ts'; +export { DurableHttpSessionDO } from './src/durable-http-session-do.ts'; initBackgroundSchedulerResolver(c => promise => c.executionCtx.waitUntil(promise)); diff --git a/apps/platform-cloudflare/src/_held-socket.ts b/apps/platform-cloudflare/src/_held-socket.ts new file mode 100644 index 000000000..0f476f928 --- /dev/null +++ b/apps/platform-cloudflare/src/_held-socket.ts @@ -0,0 +1,26 @@ +// Internal helper (underscore prefix = not a platform export): adapts a +// DialedSocket into the DuplexStream shape `@floway-dev/http` consumes, plus +// an idempotent close. +// +// Why this exists as its own ~tiny class: the DurableHttpSessionDO is the +// first piece of code in the workspace that OWNS a live outbound socket across +// inbound requests. If a future need arises for a raw persistent TCP duplex +// (not HTTP), HeldSocket is the seam to promote into a platform `DurableSocketDial` +// contract + a `DurableSocketDO` — a mechanical refactor rather than a rewrite. + +import type { DuplexStream } from '@floway-dev/http'; +import type { DialedSocket } from '@floway-dev/platform'; + +export class HeldSocket { + constructor(private readonly dialed: DialedSocket) {} + + /** The duplex view `fetchOnStream` reads/writes. */ + asDuplex(): DuplexStream { + return { readable: this.dialed.readable, writable: this.dialed.writable }; + } + + /** Idempotent — safe to call on an already-closed/errored socket. */ + close(): Promise { + return this.dialed.close(); + } +} diff --git a/apps/platform-cloudflare/src/bootstrap.ts b/apps/platform-cloudflare/src/bootstrap.ts index 5030af132..7f04580cc 100644 --- a/apps/platform-cloudflare/src/bootstrap.ts +++ b/apps/platform-cloudflare/src/bootstrap.ts @@ -1,4 +1,5 @@ import { DurableObjectChannelBroker, type BroadcastNamespace } from './do-channel-broker.ts'; +import { DurableObjectDurableHttpSession, type DurableHttpSessionNamespace } from './do-durable-http-session.ts'; import { createCloudflareImageProcessor, type ImagesBinding } from './image-processor.ts'; import { KvImageCache, type KvNamespace } from './kv-image-cache.ts'; import { R2FileProvider, type R2BucketLike } from './r2-file-provider.ts'; @@ -10,6 +11,7 @@ import type { DumpMetadata } from '@floway-dev/gateway/dump-types'; import { addTrustedRootCAs } from '@floway-dev/http'; import { IMAGE_CACHE_POLICY, + initDurableHttpSession, initEnv, initFileProvider, initImageCacheStore, @@ -25,15 +27,17 @@ export interface CloudflareEnv { IMAGES: ImagesBinding; KV: KvNamespace; BROADCAST_DO: BroadcastNamespace; + DURABLE_HTTP_SESSION: DurableHttpSessionNamespace; [key: string]: unknown; } // Every binding declared on `CloudflareEnv` is load-bearing — D1 holds all // config and telemetry, R2 holds spilled payloads, Images compresses inline -// images, KV memoises compressed image results. A missing binding means -// wrangler.jsonc drifted from the code, so we refuse to initialise rather -// than 503 on first use of the absent binding. -const REQUIRED_BINDINGS = ['DB', 'FILES', 'IMAGES', 'KV', 'BROADCAST_DO'] as const; +// images, KV memoises compressed image results, BROADCAST_DO fans out dump +// events, DURABLE_HTTP_SESSION holds cross-request upstream response streams +// (cursor RunSSE). A missing binding means wrangler.jsonc drifted from the +// code, so we refuse to initialise rather than 503 on first use. +const REQUIRED_BINDINGS = ['DB', 'FILES', 'IMAGES', 'KV', 'BROADCAST_DO', 'DURABLE_HTTP_SESSION'] as const; export const bootstrapCloudflarePlatform = (env: CloudflareEnv): { db: SqlDatabase } => { const missing = REQUIRED_BINDINGS.filter(name => env[name] === undefined); @@ -58,5 +62,6 @@ export const bootstrapCloudflarePlatform = (env: CloudflareEnv): { db: SqlDataba addTrustedRootCAs(cloudflareRuntimeRootCAs); initDumpStore(new FileDumpStore(env.DB, files)); initDumpBroker(new DurableObjectChannelBroker(env.BROADCAST_DO, dumpCodec)); + initDurableHttpSession(new DurableObjectDurableHttpSession(env.DURABLE_HTTP_SESSION)); return { db: env.DB }; }; diff --git a/apps/platform-cloudflare/src/broadcast-do_test.ts b/apps/platform-cloudflare/src/broadcast-do_test.ts index 70876eba3..cd91cc688 100644 --- a/apps/platform-cloudflare/src/broadcast-do_test.ts +++ b/apps/platform-cloudflare/src/broadcast-do_test.ts @@ -52,6 +52,12 @@ class FakeState { push(ws: FakeWebSocket): void { this.sockets.push(ws); } + // BroadcastDO never touches storage; present only to satisfy the shared + // DurableObjectState type (DurableHttpSessionDO uses setAlarm). + storage = { + async setAlarm(): Promise {}, + async deleteAlarm(): Promise {}, + }; } test('BroadcastDO extends DurableObject so the runtime gates RPC dispatch on it', () => { diff --git a/apps/platform-cloudflare/src/cloudflare-workers.d.ts b/apps/platform-cloudflare/src/cloudflare-workers.d.ts index 303ed6402..6b6c7c0c9 100644 --- a/apps/platform-cloudflare/src/cloudflare-workers.d.ts +++ b/apps/platform-cloudflare/src/cloudflare-workers.d.ts @@ -20,9 +20,14 @@ declare module 'cloudflare:workers' { } } -// The runtime's `DurableObjectState` surface the actor touches — just the -// WebSocket Hibernation entry points. +// The runtime's `DurableObjectState` surface the actor touches — the +// WebSocket Hibernation entry points and the alarm scheduler (used by +// DurableHttpSessionDO for idle eviction). interface DurableObjectState { acceptWebSocket(server: WebSocket): void; getWebSockets(): WebSocket[]; + storage: { + setAlarm(scheduledTime: number): Promise; + deleteAlarm(): Promise; + }; } diff --git a/apps/platform-cloudflare/src/do-durable-http-session.ts b/apps/platform-cloudflare/src/do-durable-http-session.ts new file mode 100644 index 000000000..e5fce4352 --- /dev/null +++ b/apps/platform-cloudflare/src/do-durable-http-session.ts @@ -0,0 +1,152 @@ +import type { + DurableHttpSessionStartInit, + DurableHttpSessionStartResult, +} from './durable-http-session-do.ts'; +import type { + DurableHttpSession, + DurableHttpSessionAcquireOptions, + DurableHttpSessionHandle, + DurableHttpSessionInit, +} from '@floway-dev/platform'; + +// CF implementation of the DurableHttpSession contract, mirroring the +// DurableObjectChannelBroker shape: resolve a per-sessionKey DO stub, then +// drive it over RPC + a WebSocket body channel. The DO holds the live outbound +// RunSSE response; this broker exposes it as the contract's ReadableStream. + +const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; + +// Minimal namespace/stub surface — declared locally so this file stays off +// `@cloudflare/workers-types` (same convention as do-channel-broker.ts). +export interface DurableHttpSessionNamespace { + idFromName(name: string): unknown; + get(id: unknown): DurableHttpSessionStub; +} + +interface DurableHttpSessionStub { + queryOrStart( + init: DurableHttpSessionStartInit | null, + idleTimeoutMs: number, + ): Promise; + release(): Promise; + discard(reason: string): Promise; + fetch(request: Request): Promise; +} + +export class DurableObjectDurableHttpSession implements DurableHttpSession { + constructor(private readonly namespace: DurableHttpSessionNamespace) {} + + private stub(sessionKey: string): DurableHttpSessionStub { + return this.namespace.get(this.namespace.idFromName(sessionKey)); + } + + async acquire( + sessionKey: string, + init: DurableHttpSessionInit | null, + opts?: DurableHttpSessionAcquireOptions, + ): Promise { + const stub = this.stub(sessionKey); + const idleTimeoutMs = opts?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + + const meta = await stub.queryOrStart(init, idleTimeoutMs); + if (!meta) return null; // miss + init=null → caller degrades + + const wsResponse = await stub.fetch(new Request('https://durable-http.do/body', { + headers: { Upgrade: 'websocket' }, + })); + if (wsResponse.status !== 101) { + throw new Error(`DurableHttpSessionDO body upgrade returned HTTP ${wsResponse.status} instead of 101`); + } + const socket = (wsResponse as Response & { webSocket?: WebSocket }).webSocket; + if (!socket) throw new Error('DurableHttpSessionDO returned 101 without a webSocket'); + socket.accept(); + + const body = wsToByteStream(socket, opts?.signal); + + return { + status: meta.status, + headers: new Headers(meta.headers), + body, + async release(): Promise { + try { socket.close(1000, 'released'); } catch { /* already closed */ } + await stub.release(); + }, + async discard(reason: string): Promise { + try { socket.close(1000, 'discarded'); } catch { /* already closed */ } + await stub.discard(reason); + }, + }; + } +} + +// Wrap the body WebSocket as a ReadableStream. Binary frames are +// the body bytes; a close ends the stream; an error rejects the pending read. +// Aborting the caller signal closes the socket and ends the stream — it does +// NOT discard the DO session (sibling acquires keep working), matching the +// contract's signal semantics. +const wsToByteStream = ( + socket: WebSocket, + signal: AbortSignal | undefined, +): ReadableStream => { + const queue: Uint8Array[] = []; + let pull: ((v: { value?: Uint8Array; done: boolean }) => void) | null = null; + let pendingError: unknown = null; + let closed = false; + + const deliver = (chunk: Uint8Array | null): void => { + if (pull) { + const r = pull; + pull = null; + r(chunk ? { value: chunk, done: false } : { done: true }); + } else if (chunk) { + queue.push(chunk); + } + }; + + const end = (): void => { + if (closed) return; + closed = true; + deliver(null); + try { socket.close(1000, 'consumer done'); } catch { /* already closed */ } + }; + + socket.addEventListener('message', event => { + if (closed) return; + const raw = (event as MessageEvent).data as string | ArrayBuffer | Uint8Array; + let bytes: Uint8Array; + if (typeof raw === 'string') bytes = new TextEncoder().encode(raw); + else if (raw instanceof Uint8Array) bytes = raw; + else bytes = new Uint8Array(raw); + deliver(bytes); + }); + socket.addEventListener('close', () => { closed = true; deliver(null); }); + socket.addEventListener('error', () => { + if (pendingError === null) pendingError = new Error('DurableHttpSession body socket error'); + closed = true; + deliver(null); + }); + + if (signal) { + if (signal.aborted) end(); + else signal.addEventListener('abort', end, { once: true }); + } + + return new ReadableStream({ + async pull(controller): Promise { + if (queue.length > 0) { controller.enqueue(queue.shift()!); return; } + if (closed) { + if (pendingError) { controller.error(pendingError); return; } + controller.close(); + return; + } + const result = await new Promise<{ value?: Uint8Array; done: boolean }>(resolve => { pull = resolve; }); + if (result.done) { + if (pendingError) controller.error(pendingError); + else controller.close(); + return; + } + if (result.value) controller.enqueue(result.value); + }, + cancel(): void { end(); }, + }); +}; diff --git a/apps/platform-cloudflare/src/do-durable-http-session_test.ts b/apps/platform-cloudflare/src/do-durable-http-session_test.ts new file mode 100644 index 000000000..96a51fdb7 --- /dev/null +++ b/apps/platform-cloudflare/src/do-durable-http-session_test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { DurableObjectDurableHttpSession, type DurableHttpSessionNamespace } from './do-durable-http-session.ts'; + +// Fake WebSocket the broker treats as the DO body channel. Tests drive it by +// calling emitMessage / emitClose after the broker has wired its listeners. +class FakeWebSocket { + readonly listeners = new Map void>>(); + closed: { code: number; reason: string } | null = null; + accepted = false; + accept(): void { this.accepted = true; } + close(code = 1000, reason = ''): void { + if (this.closed) return; + this.closed = { code, reason }; + this.fire('close', { code, reason }); + } + addEventListener(type: string, fn: (ev: unknown) => void): void { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type)!.add(fn); + } + removeEventListener(type: string, fn: (ev: unknown) => void): void { + this.listeners.get(type)?.delete(fn); + } + private fire(type: string, ev: unknown): void { + for (const fn of this.listeners.get(type) ?? []) fn(ev); + } + emitMessage(data: Uint8Array | string): void { this.fire('message', { data }); } + emitClose(): void { this.close(1000, 'upstream ended'); } +} + +interface StubCalls { + queryOrStart: Array<{ init: unknown; idleTimeoutMs: number }>; + released: number; + discarded: string[]; +} + +function makeNamespace(opts: { + meta: { status: number; headers: [string, string][] } | null; + socket?: FakeWebSocket; +}): { ns: DurableHttpSessionNamespace; calls: StubCalls; idFromName: ReturnType } { + const calls: StubCalls = { queryOrStart: [], released: 0, discarded: [] }; + const socket = opts.socket ?? new FakeWebSocket(); + const stub = { + async queryOrStart(init: unknown, idleTimeoutMs: number) { + calls.queryOrStart.push({ init, idleTimeoutMs }); + return opts.meta; + }, + async release() { calls.released++; }, + async discard(reason: string) { calls.discarded.push(reason); }, + async fetch() { + // CF returns 101 + webSocket on a hibernation upgrade; Node's Response + // refuses status 101, so hand back a structural stand-in. + return { status: 101, webSocket: socket } as unknown as Response; + }, + }; + const idFromName = vi.fn((name: string) => `id:${name}`); + const ns: DurableHttpSessionNamespace = { idFromName, get: () => stub }; + return { ns, calls, idFromName }; +} + +const drain = async (body: ReadableStream): Promise => { + const out: Uint8Array[] = []; + const reader = body.getReader(); + while (true) { const { done, value } = await reader.read(); if (done) break; if (value) out.push(value); } + return out; +}; + +describe('DurableObjectDurableHttpSession.acquire', () => { + test('miss + init=null returns null (queryOrStart said no session)', async () => { + const { ns, calls } = makeNamespace({ meta: null }); + const broker = new DurableObjectDurableHttpSession(ns); + const handle = await broker.acquire('cursor:up:key:auto:abc', null, { idleTimeoutMs: 1000 }); + expect(handle).toBeNull(); + expect(calls.queryOrStart).toHaveLength(1); + expect(calls.queryOrStart[0]!.idleTimeoutMs).toBe(1000); + }); + + test('hit returns a handle exposing status/headers and streaming body', async () => { + const socket = new FakeWebSocket(); + const { ns } = makeNamespace({ meta: { status: 200, headers: [['x-test', 'hi']] }, socket }); + const broker = new DurableObjectDurableHttpSession(ns); + + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + expect(handle).not.toBeNull(); + expect(handle!.status).toBe(200); + expect(handle!.headers.get('x-test')).toBe('hi'); + expect(socket.accepted).toBe(true); + + const collected = drain(handle!.body); + socket.emitMessage(new Uint8Array([1, 2, 3])); + socket.emitMessage(new Uint8Array([4])); + socket.emitClose(); + const chunks = await collected; + expect(chunks.flatMap(c => Array.from(c))).toEqual([1, 2, 3, 4]); + }); + + test('release closes the body socket and calls stub.release', async () => { + const socket = new FakeWebSocket(); + const { ns, calls } = makeNamespace({ meta: { status: 200, headers: [] }, socket }); + const broker = new DurableObjectDurableHttpSession(ns); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + await handle!.release(); + expect(calls.released).toBe(1); + expect(socket.closed).not.toBeNull(); + }); + + test('discard closes the body socket and calls stub.discard with the reason', async () => { + const socket = new FakeWebSocket(); + const { ns, calls } = makeNamespace({ meta: { status: 200, headers: [] }, socket }); + const broker = new DurableObjectDurableHttpSession(ns); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + await handle!.discard('framing error'); + expect(calls.discarded).toEqual(['framing error']); + expect(socket.closed).not.toBeNull(); + }); + + test('aborting the caller signal ends the body stream without discarding', async () => { + const socket = new FakeWebSocket(); + const { ns, calls } = makeNamespace({ meta: { status: 200, headers: [] }, socket }); + const broker = new DurableObjectDurableHttpSession(ns); + const ac = new AbortController(); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }, { signal: ac.signal }); + + const collected = drain(handle!.body); + socket.emitMessage(new Uint8Array([9])); + ac.abort(); + const chunks = await collected; + expect(chunks.flatMap(c => Array.from(c))).toEqual([9]); + expect(socket.closed).not.toBeNull(); + expect(calls.discarded).toHaveLength(0); // abort != discard + }); + + test('idFromName is keyed by sessionKey so each conversation maps to its own actor', async () => { + const { ns, idFromName } = makeNamespace({ meta: { status: 200, headers: [] } }); + const broker = new DurableObjectDurableHttpSession(ns); + await broker.acquire('cursor:up:keyA:auto:1', null); + await broker.acquire('cursor:up:keyB:auto:2', null); + expect(idFromName.mock.calls.map(c => c[0])).toEqual(['cursor:up:keyA:auto:1', 'cursor:up:keyB:auto:2']); + }); +}); diff --git a/apps/platform-cloudflare/src/durable-http-session-do.ts b/apps/platform-cloudflare/src/durable-http-session-do.ts new file mode 100644 index 000000000..953625622 --- /dev/null +++ b/apps/platform-cloudflare/src/durable-http-session-do.ts @@ -0,0 +1,253 @@ +import { DurableObject } from 'cloudflare:workers'; + +import { HeldSocket } from './_held-socket.ts'; +import { cloudflareSocketDial } from './socket-dial.ts'; +import { fetchOnStream } from '@floway-dev/http'; + +// DurableHttpSessionDO — one instance per sessionKey. It is the first actor in +// the workspace that owns a live OUTBOUND socket: it dials the upstream with +// `cloudflare:sockets`, runs one HTTP/1.1 request over it via fetchOnStream, +// and holds the still-streaming response body open across many inbound Worker +// requests. The outbound socket keeps the DO alive (CF 2026-06-19: outbound +// connections keep DOs alive, up to a 15-minute cap); an idle alarm evicts an +// abandoned session before then. Semantic upgrade over BroadcastDO (a pure +// message bus that touches no external resource) — documented here and in +// wrangler.example.jsonc. +// +// The body bytes reach the broker (which may live in another isolate) over a +// WebSocket: the DO pumps response.body into the attached consumer socket, and +// buffers between consumers so a release()→re-acquire() continues mid-stream +// (the DurableHttpSession contract: releasing a handle must not cancel the +// upstream read). Protocol decoding stays entirely with the caller. + +// `start` reply / RPC-friendly shapes (structured-clone-safe). +export interface DurableHttpSessionStartInit { + method: 'POST' | 'GET' | 'PUT' | 'DELETE'; + url: string; + headers: Record; + body?: Uint8Array; +} + +export interface DurableHttpSessionStartResult { + status: number; + headers: [string, string][]; +} + +const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; +// Hard cap on bytes buffered while no consumer is attached. Overflow means a +// consumer fell behind or vanished mid-stream — discard so the next acquire +// starts fresh rather than letting the actor grow unbounded. +const MAX_BUFFERED_BYTES = 1 << 20; // 1 MiB + +export class DurableHttpSessionDO extends DurableObject { + private held: HeldSocket | null = null; + private reader: ReadableStreamDefaultReader | null = null; + private statusCode = 0; + private headerList: [string, string][] = []; + private started = false; + private done = false; + private errored = false; + // Bytes read off the upstream while no consumer is attached. + private buffer: Uint8Array[] = []; + private bufferedBytes = 0; + private consumer: WebSocket | null = null; + private idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS; + private lastActivityAt = 0; + + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env); + } + + /** + * Broker entrypoint. Returns the response meta if a session is live (hit), + * starts one from `init` if not (miss + init), or returns null (miss + + * init=null) so the caller can degrade to a new conversation. + */ + async queryOrStart( + init: DurableHttpSessionStartInit | null, + idleTimeoutMs: number, + ): Promise { + if (this.started && !this.errored) { + this.touch(); + return { status: this.statusCode, headers: this.headerList }; + } + if (!init) return null; + return await this.start(init, idleTimeoutMs); + } + + private async start( + init: DurableHttpSessionStartInit, + idleTimeoutMs: number, + ): Promise { + this.idleTimeoutMs = idleTimeoutMs > 0 ? idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS; + const url = new URL(init.url); + const tls = url.protocol === 'https:'; + const port = url.port ? Number(url.port) : (tls ? 443 : 80); + + const dialed = await cloudflareSocketDial.connect(url.hostname, port, { tls }); + this.held = new HeldSocket(dialed); + + const path = `${url.pathname}${url.search}`; + // Host is mandatory on HTTP/1.1; derive it from the URL rather than + // trusting the caller to pass it (fetchOnStream validates/forwards the rest). + const headers: Record = { Host: url.host, ...init.headers }; + let response: Response; + try { + response = await fetchOnStream(this.held.asDuplex(), { + method: init.method, + path, + headers, + body: init.body, + }); + } catch (err) { + await this.discard(`upstream dial/request failed: ${err instanceof Error ? err.message : String(err)}`); + throw err; + } + if (!response.body) { + await this.discard('upstream returned no body'); + throw new Error(`DurableHttpSessionDO: ${init.method} ${init.url} returned no body`); + } + + this.statusCode = response.status; + this.headerList = [...response.headers]; + this.reader = response.body.getReader(); + this.started = true; + this.touch(); + this.startPump(); + await this.ctx.storage.setAlarm(Date.now() + this.idleTimeoutMs); + return { status: this.statusCode, headers: this.headerList }; + } + + // Continuously drain the upstream reader. With a consumer attached, frames + // are forwarded live; otherwise they accumulate in the buffer (bounded by + // MAX_BUFFERED_BYTES) for the next consumer to pick up mid-stream. + private startPump(): void { + const pump = async (): Promise => { + try { + while (true) { + const { done, value } = await this.reader!.read(); + if (done) { + this.done = true; + this.consumer?.close(1000, 'upstream ended'); + return; + } + if (!value || value.byteLength === 0) continue; + if (this.consumer) { + this.safeSend(this.consumer, value); + } else { + this.buffer.push(value); + this.bufferedBytes += value.byteLength; + if (this.bufferedBytes > MAX_BUFFERED_BYTES) { + await this.discard('buffer overflow with no consumer'); + return; + } + } + } + } catch (err) { + this.errored = true; + this.done = true; + this.consumer?.close(1011, `upstream error: ${err instanceof Error ? err.message : String(err)}`); + } + }; + void pump(); + } + + private safeSend(ws: WebSocket, bytes: Uint8Array): void { + try { + ws.send(bytes); + } catch { + // Consumer socket went away between the open check and the send; drop it + // so the pump falls back to buffering for the next acquire. + if (this.consumer === ws) this.consumer = null; + } + } + + /** + * WebSocket upgrade from the broker. Accepts the server side, flushes any + * buffered bytes, then forwards live frames until the consumer disconnects. + */ + async fetch(_request: Request): Promise { + if (!this.started) { + return new Response('session not started', { status: 409 }); + } + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + server.accept(); + this.attachConsumer(server); + return new Response(null, { status: 101, webSocket: client }); + } + + private attachConsumer(ws: WebSocket): void { + this.consumer = ws; + this.touch(); + ws.addEventListener('close', () => { + if (this.consumer === ws) { + this.consumer = null; + this.touch(); + } + }); + ws.addEventListener('error', () => { + if (this.consumer === ws) this.consumer = null; + }); + // Flush whatever accumulated since the last consumer detached. + for (const chunk of this.buffer) this.safeSend(ws, chunk); + this.buffer = []; + this.bufferedBytes = 0; + if (this.done) ws.close(1000, this.errored ? 'upstream error' : 'upstream ended'); + } + + /** + * Detach the current consumer's hold. The upstream read keeps flowing into + * the buffer so the next acquire continues mid-stream. Idempotent — the + * consumer reference also clears on its own WS 'close' event. + */ + async release(): Promise { + this.touch(); + } + + /** Forcibly tear down the upstream connection and clear all state. Idempotent. */ + async discard(reason: string): Promise { + this.done = true; + try { this.consumer?.close(1000, reason); } catch { /* already closing */ } + this.consumer = null; + const reader = this.reader; + this.reader = null; + if (reader) { try { await reader.cancel(reason); } catch { /* already done */ } } + const held = this.held; + this.held = null; + if (held) { try { await held.close(); } catch { /* idempotent */ } } + this.buffer = []; + this.bufferedBytes = 0; + this.started = false; + try { await this.ctx.storage.deleteAlarm(); } catch { /* no alarm set */ } + } + + // Idle eviction. Re-arms while a consumer is attached or activity is recent; + // otherwise tears the session down so an abandoned conversation does not pin + // the outbound socket to the 15-minute runtime cap. + async alarm(): Promise { + const idleMs = Date.now() - this.lastActivityAt; + if (this.consumer === null && idleMs >= this.idleTimeoutMs) { + await this.discard('idle timeout'); + return; + } + await this.ctx.storage.setAlarm(Date.now() + this.idleTimeoutMs); + } + + private touch(): void { + this.lastActivityAt = Date.now(); + } + + // Hibernation close hooks (mirrors BroadcastDO): complete the close + // handshake from the actor side so a consumer never sees a 1006 abnormal + // closure, and drop our reference to the gone socket. + async webSocketClose(ws: WebSocket, code: number, reason: string, _wasClean: boolean): Promise { + if (this.consumer === ws) this.consumer = null; + try { ws.close(code, reason); } catch { /* already closed */ } + } + + async webSocketError(ws: WebSocket, _err: unknown): Promise { + if (this.consumer === ws) this.consumer = null; + } +} diff --git a/apps/platform-cloudflare/src/durable-http-session-do_test.ts b/apps/platform-cloudflare/src/durable-http-session-do_test.ts new file mode 100644 index 000000000..4f6345997 --- /dev/null +++ b/apps/platform-cloudflare/src/durable-http-session-do_test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +// vi.mock factories are hoisted above module init, so the mock fns + the +// control state they read must live in vi.hoisted (also hoisted) rather than +// in plain top-level consts. +// +// socket-dial is mocked so this test never imports `cloudflare:sockets` (the +// vitest config only aliases `cloudflare:workers`). fetchOnStream is mocked so +// we drive the upstream response body directly — the DO's job under test is the +// session / buffer / consumer plumbing, not HTTP parsing. +const h = vi.hoisted(() => { + const ctl = { + bodyController: null as ReadableStreamDefaultController | null, + status: 200, + headers: { 'x-test': 'hi' } as Record, + hasBody: true, + }; + const socketClose = vi.fn(async () => {}); + const connect = vi.fn(async () => ({ + readable: new ReadableStream(), + writable: new WritableStream(), + close: socketClose, + })); + const fetchOnStream = vi.fn(async () => { + const body = ctl.hasBody + ? new ReadableStream({ start(c) { ctl.bodyController = c; } }) + : null; + return new Response(body, { status: ctl.status, headers: ctl.headers }); + }); + return { ctl, socketClose, connect, fetchOnStream }; +}); +vi.mock('./socket-dial.ts', () => ({ cloudflareSocketDial: { connect: h.connect } })); +vi.mock('@floway-dev/http', () => ({ fetchOnStream: h.fetchOnStream })); + +import { DurableHttpSessionDO } from './durable-http-session-do.ts'; + +// Minimal CF runtime surface. WebSocketPair / WebSocket are workerd globals; +// stub just enough for the consumer-attach path. +class FakeWebSocket { + readonly sent: Uint8Array[] = []; + readonly listeners = new Map void>>(); + closed: { code: number; reason: string } | null = null; + accept(): void {} + send(data: Uint8Array): void { this.sent.push(data); } + close(code = 1000, reason = ''): void { + if (this.closed) return; + this.closed = { code, reason }; + for (const fn of this.listeners.get('close') ?? []) fn({ code, reason }); + } + addEventListener(type: string, fn: (ev: unknown) => void): void { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type)!.add(fn); + } +} + +const createdPairs: [FakeWebSocket, FakeWebSocket][] = []; + +class FakeAlarmStorage { + setAlarmCalls = 0; + deleteAlarmCalls = 0; + async setAlarm(): Promise { this.setAlarmCalls++; } + async deleteAlarm(): Promise { this.deleteAlarmCalls++; } +} + +class FakeState { + readonly accepted: WebSocket[] = []; + readonly storage = new FakeAlarmStorage(); + acceptWebSocket(ws: WebSocket): void { this.accepted.push(ws); } + getWebSockets(): WebSocket[] { return this.accepted; } +} + +const flushMicrotasks = (): Promise => new Promise(resolve => setTimeout(resolve, 0)); +const POST_INIT = { method: 'POST' as const, url: 'https://api2.cursor.sh/RunSSE', headers: {} }; + +// CF lets `new Response(null, { status: 101, webSocket })` carry a hibernation +// upgrade; Node's Response refuses status 101. Shim only that case so the DO's +// fetch() upgrade path runs under Node; everything else delegates to the real +// Response (the fetchOnStream mock needs a real streaming body). +const RealResponse = globalThis.Response; +function ShimResponse(body?: BodyInit | null, init?: ResponseInit & { webSocket?: unknown }): Response { + if (init?.status === 101) { + return { status: 101, webSocket: init.webSocket, body: null, headers: new Headers() } as unknown as Response; + } + return new RealResponse(body ?? null, init); +} + +beforeEach(() => { + h.ctl.bodyController = null; + h.ctl.status = 200; + h.ctl.headers = { 'x-test': 'hi' }; + h.ctl.hasBody = true; + createdPairs.length = 0; + h.connect.mockClear(); + h.socketClose.mockClear(); + h.fetchOnStream.mockClear(); + (globalThis as { Response: unknown }).Response = ShimResponse; + (globalThis as { WebSocketPair?: unknown }).WebSocketPair = class { + constructor() { + const pair: [FakeWebSocket, FakeWebSocket] = [new FakeWebSocket(), new FakeWebSocket()]; + createdPairs.push(pair); + return pair as unknown as FakeWebSocket; + } + }; +}); + +afterEach(() => { + (globalThis as { Response: unknown }).Response = RealResponse; + delete (globalThis as { WebSocketPair?: unknown }).WebSocketPair; +}); + +const makeDO = (): { actor: DurableHttpSessionDO; state: FakeState } => { + const state = new FakeState(); + const actor = new DurableHttpSessionDO(state as unknown as DurableObjectState, {}); + return { actor, state }; +}; + +describe('DurableHttpSessionDO.queryOrStart', () => { + test('miss + init=null returns null without dialing', async () => { + const { actor } = makeDO(); + expect(await actor.queryOrStart(null, 1000)).toBeNull(); + expect(h.connect).not.toHaveBeenCalled(); + }); + + test('miss + init dials, runs the request, returns status/headers, arms the alarm', async () => { + const { actor, state } = makeDO(); + const meta = await actor.queryOrStart(POST_INIT, 1000); + expect(meta).toEqual({ status: 200, headers: [['x-test', 'hi']] }); + expect(h.connect).toHaveBeenCalledTimes(1); + expect(h.connect).toHaveBeenCalledWith('api2.cursor.sh', 443, { tls: true }); + expect(state.storage.setAlarmCalls).toBe(1); + }); + + test('hit (already started) returns meta without dialing again', async () => { + const { actor } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + h.connect.mockClear(); + const meta = await actor.queryOrStart(POST_INIT, 1000); + expect(meta).toEqual({ status: 200, headers: [['x-test', 'hi']] }); + expect(h.connect).not.toHaveBeenCalled(); + }); + + test('upstream with no body discards and throws', async () => { + h.ctl.hasBody = false; + const { actor } = makeDO(); + await expect(actor.queryOrStart(POST_INIT, 1000)).rejects.toThrow('returned no body'); + expect(h.socketClose).toHaveBeenCalled(); + }); +}); + +describe('DurableHttpSessionDO body channel + lifecycle', () => { + test('fetch before start returns 409', async () => { + const { actor } = makeDO(); + const resp = await actor.fetch(new Request('https://durable-http.do/body')); + expect(resp.status).toBe(409); + }); + + test('fetch after start upgrades to 101 and flushes buffered + live bytes', async () => { + const { actor } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + + h.ctl.bodyController!.enqueue(new Uint8Array([1, 2])); + await flushMicrotasks(); + + const resp = await actor.fetch(new Request('https://durable-http.do/body')); + expect(resp.status).toBe(101); + const server = createdPairs[0]![1]; + expect(server.sent.flatMap(c => Array.from(c))).toEqual([1, 2]); + + h.ctl.bodyController!.enqueue(new Uint8Array([3])); + await flushMicrotasks(); + expect(server.sent.flatMap(c => Array.from(c))).toEqual([1, 2, 3]); + }); + + test('upstream end closes the consumer socket', async () => { + const { actor } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + await actor.fetch(new Request('https://durable-http.do/body')); + const server = createdPairs[0]![1]; + h.ctl.bodyController!.close(); + await flushMicrotasks(); + expect(server.closed).not.toBeNull(); + }); + + test('discard cancels reader, closes the socket, deletes the alarm, resets to miss', async () => { + const { actor, state } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + await actor.discard('framing error'); + expect(h.socketClose).toHaveBeenCalled(); + expect(state.storage.deleteAlarmCalls).toBe(1); + expect(await actor.queryOrStart(null, 1000)).toBeNull(); + }); + + test('buffer overflow with no consumer discards the session', async () => { + const { actor } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + h.ctl.bodyController!.enqueue(new Uint8Array(1_100_000)); + await flushMicrotasks(); + expect(h.socketClose).toHaveBeenCalled(); + }); +}); + +describe('DurableHttpSessionDO.alarm', () => { + test('re-arms (does not discard) while activity is recent', async () => { + const { actor, state } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + state.storage.setAlarmCalls = 0; + await actor.alarm(); + expect(state.storage.setAlarmCalls).toBe(1); + expect(h.socketClose).not.toHaveBeenCalled(); + }); + + test('re-arms while a consumer is attached', async () => { + const { actor, state } = makeDO(); + await actor.queryOrStart(POST_INIT, 1000); + await actor.fetch(new Request('https://durable-http.do/body')); + state.storage.setAlarmCalls = 0; + await actor.alarm(); + expect(state.storage.setAlarmCalls).toBe(1); + expect(h.socketClose).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/platform-node/src/bootstrap.ts b/apps/platform-node/src/bootstrap.ts index ce297de0f..ae81ba9a7 100644 --- a/apps/platform-node/src/bootstrap.ts +++ b/apps/platform-node/src/bootstrap.ts @@ -1,5 +1,6 @@ import { EventTargetChannelBroker } from './event-target-channel-broker.ts'; import { FsFileProvider } from './fs-file-provider.ts'; +import { InProcessDurableHttpSession } from './in-process-durable-http-session.ts'; import { createNodeSqliteDatabase } from './node-sqlite-database.ts'; import { createSharpImageProcessor } from './sharp-image-processor.ts'; import { nodeSocketDial } from './socket-dial.ts'; @@ -12,6 +13,7 @@ import { addTrustedRootCAs } from '@floway-dev/http'; import { getEnvOptional, IMAGE_CACHE_POLICY, + initDurableHttpSession, initEnv, initFileProvider, initImageCacheStore, @@ -37,5 +39,6 @@ export const bootstrapNodePlatform = (): { db: SqlDatabase } => { initImageProcessor(createSharpImageProcessor()); initDumpStore(new FileDumpStore(db, files)); initDumpBroker(new EventTargetChannelBroker(dumpCodec)); + initDurableHttpSession(new InProcessDurableHttpSession()); return { db }; }; diff --git a/apps/platform-node/src/in-process-durable-http-session.ts b/apps/platform-node/src/in-process-durable-http-session.ts new file mode 100644 index 000000000..b42c49bc0 --- /dev/null +++ b/apps/platform-node/src/in-process-durable-http-session.ts @@ -0,0 +1,199 @@ +import { + type DurableHttpSession, + type DurableHttpSessionAcquireOptions, + type DurableHttpSessionHandle, + type DurableHttpSessionInit, +} from '@floway-dev/platform'; + +const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; + +interface Entry { + response: Response; + reader: ReadableStreamDefaultReader; + /** Buffered chunks not yet consumed by the current handle. */ + buffer: Uint8Array[]; + /** Resolves the next dequeue when a chunk arrives or the stream ends. */ + waiter: { resolve: (v: { done: boolean; value?: Uint8Array }) => void } | null; + done: boolean; + error: unknown; + inFlight: Promise | null; + idleTimer: NodeJS.Timeout | null; + lastActivityAt: number; +} + +export class InProcessDurableHttpSession implements DurableHttpSession { + private readonly entries = new Map(); + + async acquire( + sessionKey: string, + init: DurableHttpSessionInit | null, + opts?: DurableHttpSessionAcquireOptions, + ): Promise { + const idleTimeoutMs = opts?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; + + const existingInFlight = this.entries.get(sessionKey)?.inFlight; + if (existingInFlight) await existingInFlight.catch(() => {}); + + let entry = this.entries.get(sessionKey); + + if (!entry) { + if (init === null) return null; + const lock = (async (): Promise => { + const response = await globalThis.fetch(init.url, { + method: init.method, + headers: init.headers, + body: init.body ? (init.body as BodyInit) : null, + }); + if (!response.body) { + throw new Error(`InProcessDurableHttpSession: ${init.method} ${init.url} returned no body`); + } + const reader = response.body.getReader(); + const e: Entry = { + response, + reader, + buffer: [], + waiter: null, + done: false, + error: null, + inFlight: null, + idleTimer: null, + lastActivityAt: Date.now(), + }; + this.startPump(e); + return e; + })(); + const placeholder: Entry = { + response: undefined as unknown as Response, + reader: undefined as unknown as ReadableStreamDefaultReader, + buffer: [], + waiter: null, + done: false, + error: null, + inFlight: lock, + idleTimer: null, + lastActivityAt: Date.now(), + }; + this.entries.set(sessionKey, placeholder); + try { + entry = await lock; + this.entries.set(sessionKey, entry); + } catch (err) { + this.entries.delete(sessionKey); + throw err; + } + } + + entry.lastActivityAt = Date.now(); + this.armIdleTimer(sessionKey, entry, idleTimeoutMs); + return this.makeHandle(sessionKey, entry, opts?.signal); + } + + private startPump(entry: Entry): void { + const pump = async (): Promise => { + try { + while (true) { + const { done, value } = await entry.reader.read(); + if (done) { + entry.done = true; + if (entry.waiter) { + entry.waiter.resolve({ done: true }); + entry.waiter = null; + } + return; + } + if (value) { + if (entry.waiter) { + entry.waiter.resolve({ done: false, value }); + entry.waiter = null; + } else { + entry.buffer.push(value); + } + } + } + } catch (err) { + entry.error = err; + entry.done = true; + if (entry.waiter) { + entry.waiter.resolve({ done: true }); + entry.waiter = null; + } + } + }; + void pump(); + } + + private dequeue(entry: Entry): Promise<{ done: boolean; value?: Uint8Array }> { + if (entry.buffer.length > 0) { + return Promise.resolve({ done: false, value: entry.buffer.shift()! }); + } + if (entry.done) { + return Promise.resolve({ done: true }); + } + return new Promise(resolve => { entry.waiter = { resolve }; }); + } + + private armIdleTimer(sessionKey: string, entry: Entry, idleTimeoutMs: number): void { + if (entry.idleTimer) clearTimeout(entry.idleTimer); + entry.idleTimer = setTimeout(() => this.evict(sessionKey, 'idle timeout'), idleTimeoutMs); + entry.idleTimer.unref?.(); + } + + private evict(sessionKey: string, _reason: string): void { + const entry = this.entries.get(sessionKey); + if (!entry) return; + this.entries.delete(sessionKey); + if (entry.idleTimer) clearTimeout(entry.idleTimer); + try { entry.reader.releaseLock(); } catch { /* already released */ } + void entry.response.body?.cancel(_reason).catch(() => {}); + } + + private makeHandle( + sessionKey: string, + entry: Entry, + signal: AbortSignal | undefined, + ): DurableHttpSessionHandle { + const self = this; + let released = false; + const onAbort = (): void => { released = true; }; + if (signal) { + if (signal.aborted) released = true; + else signal.addEventListener('abort', onAbort, { once: true }); + } + + const body = new ReadableStream({ + async pull(controller): Promise { + if (released) { controller.close(); return; } + const result = await self.dequeue(entry); + if (released || result.done) { controller.close(); return; } + if (result.value) controller.enqueue(result.value); + }, + cancel(): void { + released = true; + if (signal) signal.removeEventListener('abort', onAbort); + }, + }); + + return { + status: entry.response.status, + headers: entry.response.headers, + body, + async release(): Promise { + released = true; + if (signal) signal.removeEventListener('abort', onAbort); + }, + async discard(reason: string): Promise { + released = true; + if (signal) signal.removeEventListener('abort', onAbort); + self.evict(sessionKey, reason); + }, + }; + } + + evictAllForTesting(): void { + for (const key of [...this.entries.keys()]) this.evict(key, 'test reset'); + } + + sizeForTesting(): number { + return this.entries.size; + } +} diff --git a/apps/platform-node/src/in-process-durable-http-session_test.ts b/apps/platform-node/src/in-process-durable-http-session_test.ts new file mode 100644 index 000000000..37224b19a --- /dev/null +++ b/apps/platform-node/src/in-process-durable-http-session_test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { InProcessDurableHttpSession } from './in-process-durable-http-session.ts'; + +const mockFetch = (factory: () => Response | Promise): typeof globalThis.fetch => + vi.fn(async () => await factory()) as unknown as typeof globalThis.fetch; + +const collectBody = async (body: ReadableStream): Promise => { + const chunks: Uint8Array[] = []; + const reader = body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + const out = new Uint8Array(chunks.reduce((s, c) => s + c.length, 0)); + let off = 0; + for (const c of chunks) { out.set(c, off); off += c.length; } + return out; +}; + +const makeResponse = (status: number, headers: Record, chunks: Uint8Array[]): Response => { + const stream = new ReadableStream({ + start(controller) { + for (const c of chunks) controller.enqueue(c); + controller.close(); + }, + }); + return new Response(stream, { status, headers }); +}; + +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('InProcessDurableHttpSession.acquire', () => { + test('returns null on miss when init is null', async () => { + const broker = new InProcessDurableHttpSession(); + const handle = await broker.acquire('k', null); + expect(handle).toBeNull(); + expect(broker.sizeForTesting()).toBe(0); + }); + + test('seeds a new entry on miss + init non-null and exposes status/headers/body', async () => { + globalThis.fetch = mockFetch(() => makeResponse(200, { 'x-test': 'hi' }, [new Uint8Array([1, 2, 3])])); + const broker = new InProcessDurableHttpSession(); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + + expect(handle).not.toBeNull(); + expect(handle!.status).toBe(200); + expect(handle!.headers.get('x-test')).toBe('hi'); + expect(Array.from(await collectBody(handle!.body))).toEqual([1, 2, 3]); + expect(broker.sizeForTesting()).toBe(1); + }); + + test('hit returns a handle to the cached entry on subsequent acquire(key, null)', async () => { + let calls = 0; + globalThis.fetch = mockFetch(() => { + calls++; + return makeResponse(201, {}, [new Uint8Array([1])]); + }); + const broker = new InProcessDurableHttpSession(); + const h1 = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + expect(h1!.status).toBe(201); + const h2 = await broker.acquire('k', null); + expect(h2).not.toBeNull(); + expect(h2!.status).toBe(201); + expect(calls).toBe(1); // upstream fetch called once + }); + + test('hit + init non-null ignores init (existing session wins)', async () => { + globalThis.fetch = mockFetch(() => makeResponse(200, {}, [])); + const broker = new InProcessDurableHttpSession(); + await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + const fetchCallsBefore = (globalThis.fetch as unknown as { mock: { calls: unknown[] } }).mock.calls.length; + await broker.acquire('k', { method: 'POST', url: 'https://other', headers: {} }); + const fetchCallsAfter = (globalThis.fetch as unknown as { mock: { calls: unknown[] } }).mock.calls.length; + expect(fetchCallsAfter).toBe(fetchCallsBefore); // no second upstream fetch + }); + + test('discard evicts the entry so the next acquire(key, null) misses', async () => { + globalThis.fetch = mockFetch(() => makeResponse(200, {}, [])); + const broker = new InProcessDurableHttpSession(); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + await handle!.discard('done'); + const next = await broker.acquire('k', null); + expect(next).toBeNull(); + expect(broker.sizeForTesting()).toBe(0); + }); + + test('idle TTL evicts the entry after the configured timeout', async () => { + vi.useFakeTimers(); + try { + globalThis.fetch = mockFetch(() => makeResponse(200, {}, [])); + const broker = new InProcessDurableHttpSession(); + await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }, { idleTimeoutMs: 1000 }); + expect(broker.sizeForTesting()).toBe(1); + vi.advanceTimersByTime(1500); + // microtask flush so the timer callback's evict runs + await Promise.resolve(); + expect(broker.sizeForTesting()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + test('concurrent acquires for the same key trigger only one upstream fetch', async () => { + let calls = 0; + globalThis.fetch = mockFetch(async () => { + calls++; + // simulate slow upstream so two acquires race + await new Promise(resolve => setTimeout(resolve, 50)); + return makeResponse(200, {}, []); + }); + const broker = new InProcessDurableHttpSession(); + const init = { method: 'POST' as const, url: 'https://x', headers: {} }; + const [a, b] = await Promise.all([ + broker.acquire('k', init), + broker.acquire('k', init), + ]); + expect(a).not.toBeNull(); + expect(b).not.toBeNull(); + expect(calls).toBe(1); // dedup'd + }); + + test('release is a no-op at the session level (next acquire still hits)', async () => { + globalThis.fetch = mockFetch(() => makeResponse(200, {}, [])); + const broker = new InProcessDurableHttpSession(); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + await handle!.release(); + const next = await broker.acquire('k', null); + expect(next).not.toBeNull(); + }); + + test('upstream returning no body throws on create', async () => { + globalThis.fetch = mockFetch(() => new Response(null, { status: 204 })); + const broker = new InProcessDurableHttpSession(); + await expect( + broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }), + ).rejects.toThrow('returned no body'); + expect(broker.sizeForTesting()).toBe(0); + }); +}); + +describe('InProcessDurableHttpSession — Response GC anchor', () => { + // This test verifies the architectural fix: the entry holds the Response + // object itself, not just response.body. Without anchoring, V8 GC of the + // Response causes "Response object has been garbage collected" errors on + // subsequent reads. Running with --expose-gc lets us force the collection + // to assert the entry survives. + // + // Skipped when --expose-gc was not passed; the test still serves as + // documentation and runs in CI where the harness exposes gc. + const gc = (globalThis as { gc?: () => void }).gc; + const maybeIt = gc ? test : test.skip; + + maybeIt('survives forced GC between writes and a subsequent acquire', async () => { + // Build an upstream that streams chunks asynchronously so the body is + // not synchronously closed before we force GC. + let resolveSecondChunk: ((c: Uint8Array | null) => void) | null = null; + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + resolveSecondChunk = c => { + if (c) controller.enqueue(c); + controller.close(); + }; + }, + }); + globalThis.fetch = mockFetch(() => new Response(upstreamBody, { status: 200 })); + + const broker = new InProcessDurableHttpSession(); + let first: { body: ReadableStream } | null = + await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + const firstReader = first!.body.getReader(); + const firstChunk = await firstReader.read(); + expect(Array.from(firstChunk.value!)).toEqual([1, 2, 3]); + firstReader.releaseLock(); + + // Drop the local handle and force GC. If the entry weren't anchoring + // the Response, the next acquire would fail when reading. + first = null; + gc!(); + gc!(); + await new Promise(resolve => setTimeout(resolve, 0)); + + const second = await broker.acquire('k', null); + expect(second).not.toBeNull(); + const secondReader = second!.body.getReader(); + resolveSecondChunk!(new Uint8Array([9])); + const secondChunk = await secondReader.read(); + expect(Array.from(secondChunk.value!)).toEqual([9]); + }); +}); diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 088891e1b..e07afa95c 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -12,7 +12,7 @@ import type { AddressableForm, ModelPrefixConfig } from '@floway-dev/provider/mo export type { BillingDimension, ModelEndpointKey, ModelEndpoints, ModelKind, ModelPricing }; export type { AddressableForm, ModelPrefixConfig }; -export type UpstreamProviderKind = 'custom' | 'azure' | 'copilot' | 'codex' | 'claude-code' | 'ollama'; +export type UpstreamProviderKind = 'custom' | 'azure' | 'copilot' | 'codex' | 'claude-code' | 'cursor' | 'ollama'; export interface ProxyFallbackEntry { id: string; @@ -137,6 +137,34 @@ export interface CodexUpstreamState { accounts: CodexAccountCredentialState[]; } +export interface CursorAccountIdentity { + email: string; + userId: string; +} + +export interface CursorUpstreamConfig { + accounts: [CursorAccountIdentity]; +} + +export interface CursorAccessTokenState { + expiresAt: number; + refreshedAt: string; +} + +export interface CursorAccountCredentialState { + userId: string; + state: 'active' | 'session_terminated' | 'refresh_failed'; + state_message?: string; + state_updated_at: string; + refresh_token_set: boolean; + accessToken: CursorAccessTokenState | null; + quotaSnapshot?: unknown; +} + +export interface CursorUpstreamState { + accounts: CursorAccountCredentialState[]; +} + export interface CodexQuotaSnapshot { observed_at: string; active_limit?: string; @@ -291,6 +319,7 @@ export type UpstreamRecord = | (UpstreamRecordBase & { provider: 'copilot'; config: CopilotUpstreamConfig; state: CopilotUpstreamState | null }) | (UpstreamRecordBase & { provider: 'codex'; config: CodexUpstreamConfig; state: CodexUpstreamState | null; codex_quota?: CodexQuotaSnapshot | null }) | (UpstreamRecordBase & { provider: 'claude-code'; config: ClaudeCodeUpstreamConfig; state: ClaudeCodeUpstreamState | null }) + | (UpstreamRecordBase & { provider: 'cursor'; config: CursorUpstreamConfig; state: CursorUpstreamState | null }) | (UpstreamRecordBase & { provider: 'ollama'; config: OllamaUpstreamConfig; state: null }); export interface FlagDef { diff --git a/apps/web/src/components/dump/RequestList.vue b/apps/web/src/components/dump/RequestList.vue index 11f15da04..4fddc65d6 100644 --- a/apps/web/src/components/dump/RequestList.vue +++ b/apps/web/src/components/dump/RequestList.vue @@ -77,6 +77,7 @@ const upstreamKindTextClass = (kind: string): string => { case 'azure': return 'text-accent-emerald'; case 'custom': return 'text-accent-amber'; case 'ollama': return 'text-accent-rose'; + case 'cursor': return 'text-accent-violet'; default: return 'text-gray-500'; } }; diff --git a/apps/web/src/components/settings/UpstreamRow.vue b/apps/web/src/components/settings/UpstreamRow.vue index 3d2684729..7ce074e97 100644 --- a/apps/web/src/components/settings/UpstreamRow.vue +++ b/apps/web/src/components/settings/UpstreamRow.vue @@ -48,6 +48,10 @@ const subtitle = computed(() => { return subscription ? `${label} · ${subscription}` : label; } case 'ollama': return u.config.baseUrl ?? 'Ollama endpoint'; + case 'cursor': { + const account = u.config.accounts[0]; + return account.email; + } } return assertNever(u); }); diff --git a/apps/web/src/components/upstream-edit/CursorConfigPanel.vue b/apps/web/src/components/upstream-edit/CursorConfigPanel.vue new file mode 100644 index 000000000..ac8ae05c2 --- /dev/null +++ b/apps/web/src/components/upstream-edit/CursorConfigPanel.vue @@ -0,0 +1,142 @@ + + + diff --git a/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue b/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue index a2efe888f..64a18c249 100644 --- a/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue +++ b/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue @@ -5,6 +5,7 @@ import AzureConfigPanel from './AzureConfigPanel.vue'; import ClaudeCodeConfigPanel from './ClaudeCodeConfigPanel.vue'; import CodexConfigPanel from './CodexConfigPanel.vue'; import CopilotConfigPanel from './CopilotConfigPanel.vue'; +import CursorConfigPanel from './CursorConfigPanel.vue'; import type { AzureDraft, CustomDraft, OllamaDraft } from './customConfig.ts'; import CustomConfigPanel from './CustomConfigPanel.vue'; import FlagOverridesEditor from './FlagOverridesEditor.vue'; @@ -69,6 +70,7 @@ defineEmits<{ type CodexRecord = Extract; type ClaudeCodeRecord = Extract; type CopilotRecord = Extract; +type CursorRecord = Extract; type PanelMode = { mode: 'create'; record: null } | { mode: 'edit'; record: R }; const codexPanel = computed | null>(() => { @@ -83,6 +85,10 @@ const copilotPanel = computed | null>(() => { if (props.mode === 'create') return { mode: 'create', record: null }; return props.record.provider === 'copilot' ? { mode: 'edit', record: props.record } : null; }); +const cursorPanel = computed | null>(() => { + if (props.mode === 'create') return { mode: 'create', record: null }; + return props.record.provider === 'cursor' ? { mode: 'edit', record: props.record } : null; +}); // Intrinsic floor for the aside: smallest height at which every // non-flag-editor section is fully laid out AND the flag editor still has @@ -145,7 +151,7 @@ onBeforeUnmount(() => floorObserver?.disconnect());
-
+
@@ -225,6 +231,15 @@ onBeforeUnmount(() => floorObserver?.disconnect()); />
+
+ +
+
diff --git a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue index bef3d1a6c..39f1542bb 100644 --- a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue +++ b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue @@ -458,7 +458,7 @@ const autoForActive = computed(() => { const upstreamIdLabelForActive = computed(() => activeProvider.value === 'azure' ? 'Deployment' : 'Upstream Model ID'); // Provider import panels (copilot/codex/claude-code) land the row themselves on create, so the page-level Save button stays hidden until they emit. -const showSaveButton = computed(() => props.mode === 'edit' || (activeProvider.value !== 'copilot' && activeProvider.value !== 'codex' && activeProvider.value !== 'claude-code')); +const showSaveButton = computed(() => props.mode === 'edit' || (activeProvider.value !== 'copilot' && activeProvider.value !== 'codex' && activeProvider.value !== 'claude-code' && activeProvider.value !== 'cursor')); // The cache-status panel reads the row's `modelsCache` summary and offers a // force-refresh shortcut. Azure is the one provider whose catalog is pure @@ -590,7 +590,7 @@ const workbenchStyle = computed(() => ({ '--right-pane-h': `${Math.ceil(rightCon :upstream-flag-overrides="flagOverrides" :flag-provider-kind="activeProvider" :upstream-id-label="upstreamIdLabelForActive" - :read-only="activeProvider === 'copilot' || activeProvider === 'codex' || activeProvider === 'claude-code'" + :read-only="activeProvider === 'copilot' || activeProvider === 'codex' || activeProvider === 'claude-code' || activeProvider === 'cursor'" :all-manual="activeProvider === 'azure'" @update:invalid="v => modelsPanelInvalid = v" /> diff --git a/apps/web/src/components/upstreams/UpstreamPicker.vue b/apps/web/src/components/upstreams/UpstreamPicker.vue index fef623557..fa621da16 100644 --- a/apps/web/src/components/upstreams/UpstreamPicker.vue +++ b/apps/web/src/components/upstreams/UpstreamPicker.vue @@ -70,6 +70,7 @@ const providerMeta = (provider: UpstreamProviderKind | null): ProviderMeta => { case 'codex': return { tone: 'cyan', label: 'Codex' }; case 'claude-code': return { tone: 'rose', label: 'Claude Code' }; case 'ollama': return { tone: 'rose', label: 'Ollama' }; + case 'cursor': return { tone: 'cyan', label: 'Cursor' }; } return assertNever(provider); }; diff --git a/apps/web/src/components/upstreams/provider-meta.ts b/apps/web/src/components/upstreams/provider-meta.ts index 06c17d0fc..4395dd70e 100644 --- a/apps/web/src/components/upstreams/provider-meta.ts +++ b/apps/web/src/components/upstreams/provider-meta.ts @@ -59,6 +59,14 @@ export const PROVIDER_META: readonly ProviderMeta[] = [ defaultName: 'Claude Code', icon: 'i-simple-icons-claudecode', }, + { + kind: 'cursor', + label: 'Cursor', + subtitle: 'Cursor Pro / Business subscription', + tone: 'violet', + defaultName: 'Cursor', + icon: 'i-simple-icons-cursor', + }, { kind: 'ollama', label: 'Ollama', diff --git a/apps/web/src/lib/pkce.ts b/apps/web/src/lib/pkce.ts index 943a2c895..122516861 100644 --- a/apps/web/src/lib/pkce.ts +++ b/apps/web/src/lib/pkce.ts @@ -85,7 +85,7 @@ export const parseCallbackPaste = (input: string): { code: string; state: string // preparing one does not overwrite another in-flight one. codex has a // single flow; claude-code has two (`oauth`, `setup-token`) — pass the // kind there. -export const pkceStorageKey = (provider: 'codex' | 'claude-code', kind?: string): string => +export const pkceStorageKey = (provider: 'codex' | 'claude-code' | 'cursor', kind?: string): string => kind ? `floway:pkce:${provider}:${kind}` : `floway:pkce:${provider}`; interface StashedPkce { diff --git a/eslint.config.ts b/eslint.config.ts index 1217b601e..ca01a5008 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -20,6 +20,7 @@ const projectList = [ './packages/provider-azure/tsconfig.json', './packages/provider-claude-code/tsconfig.json', './packages/provider-codex/tsconfig.json', + './packages/provider-cursor/tsconfig.json', './packages/provider-copilot/tsconfig.json', './packages/provider-custom/tsconfig.json', './packages/provider-ollama/tsconfig.json', diff --git a/packages/gateway/migrations/0046_cursor_provider.sql b/packages/gateway/migrations/0046_cursor_provider.sql new file mode 100644 index 000000000..4b9546793 --- /dev/null +++ b/packages/gateway/migrations/0046_cursor_provider.sql @@ -0,0 +1,37 @@ +-- SQLite CHECK constraints on `upstreams.provider` are immutable; the standard +-- pattern in this repo is to rebuild the table (see 0027_codex_provider.sql, +-- 0034_ollama_provider.sql, 0038_claude_code_provider.sql). The new value +-- 'cursor' joins the existing six kinds; no row data changes. + +CREATE TABLE upstreams_new ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL CHECK (provider IN ('copilot', 'custom', 'azure', 'codex', 'ollama', 'claude-code', 'cursor')), + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + config_json TEXT NOT NULL, + state_json TEXT NULL, + flag_overrides TEXT NOT NULL DEFAULT '[]', + disabled_public_model_ids TEXT NOT NULL DEFAULT '[]', + proxy_fallback_list_json TEXT NOT NULL DEFAULT '[]', + model_prefix_json TEXT NULL +); + +INSERT INTO upstreams_new + (id, provider, name, enabled, sort_order, created_at, updated_at, + config_json, state_json, flag_overrides, disabled_public_model_ids, + proxy_fallback_list_json, model_prefix_json) +SELECT + id, provider, name, enabled, sort_order, created_at, updated_at, + config_json, state_json, flag_overrides, disabled_public_model_ids, + proxy_fallback_list_json, model_prefix_json +FROM upstreams; + +DROP TABLE upstreams; +ALTER TABLE upstreams_new RENAME TO upstreams; + +CREATE INDEX idx_upstreams_sort ON upstreams (sort_order, created_at); +CREATE INDEX idx_upstreams_provider_enabled_sort + ON upstreams (provider, enabled, sort_order, created_at); \ No newline at end of file diff --git a/packages/gateway/package.json b/packages/gateway/package.json index 0e763374d..32f8038fd 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -37,6 +37,7 @@ "@floway-dev/provider-azure": "workspace:*", "@floway-dev/provider-claude-code": "workspace:*", "@floway-dev/provider-codex": "workspace:*", + "@floway-dev/provider-cursor": "workspace:*", "@floway-dev/provider-copilot": "workspace:*", "@floway-dev/provider-custom": "workspace:*", "@floway-dev/provider-ollama": "workspace:*", diff --git a/packages/gateway/src/control-plane/data-transfer/routes.ts b/packages/gateway/src/control-plane/data-transfer/routes.ts index 4bc656cb8..d01352a9c 100644 --- a/packages/gateway/src/control-plane/data-transfer/routes.ts +++ b/packages/gateway/src/control-plane/data-transfer/routes.ts @@ -35,6 +35,7 @@ import type { ProxyFallbackEntry, UpstreamProviderKind, UpstreamRecord } from '@ import { assertAzureUpstreamRecord } from '@floway-dev/provider-azure'; import { assertClaudeCodeUpstreamRecord, assertClaudeCodeUpstreamState } from '@floway-dev/provider-claude-code'; import { assertCodexUpstreamRecord, assertCodexUpstreamState } from '@floway-dev/provider-codex'; +import { assertCursorUpstreamRecord, assertCursorUpstreamState } from '@floway-dev/provider-cursor'; import { assertCustomUpstreamRecord } from '@floway-dev/provider-custom'; import { parseProxyUri } from '@floway-dev/proxy'; @@ -93,6 +94,10 @@ const normalizeUpstreamConfig = (record: UpstreamRecord): unknown => { assertClaudeCodeUpstreamRecord(record); return record.config; } + if (record.provider === 'cursor') { + assertCursorUpstreamRecord(record); + return record.config; + } return copilotConfigField(record.config, importErrorBuilder); }; @@ -104,12 +109,13 @@ const normalizeUpstreamConfig = (record: UpstreamRecord): unknown => { // runtime uses so a corrupt or hand-edited import can't smuggle unknown // fields onto the column. const normalizeUpstreamState = (provider: UpstreamProviderKind, value: unknown): unknown => { - if (provider !== 'codex' && provider !== 'claude-code') return null; + if (provider !== 'codex' && provider !== 'claude-code' && provider !== 'cursor') return null; if (value === null || value === undefined) { throw new Error(`${provider} upstream is missing state — re-export with current code`); } if (provider === 'codex') assertCodexUpstreamState(value); - else assertClaudeCodeUpstreamState(value); + else if (provider === 'claude-code') assertClaudeCodeUpstreamState(value); + else assertCursorUpstreamState(value); return value; }; @@ -150,7 +156,7 @@ const parseUpstreamRecords = (value: unknown): { type: 'ok'; records: UpstreamRe throw new Error("legacy 'enabled_fixes' field is no longer supported; re-export with current code"); } if (typeof item.provider !== 'string' || !UPSTREAM_PROVIDERS.has(item.provider as UpstreamProviderKind)) { - throw new Error('provider must be one of custom, azure, copilot, codex, claude-code'); + throw new Error('provider must be one of custom, azure, copilot, codex, claude-code, cursor'); } if (typeof item.enabled !== 'boolean') throw new Error('enabled must be a boolean'); if (typeof item.sort_order !== 'number' || !Number.isFinite(item.sort_order)) throw new Error('sort_order must be a finite number'); diff --git a/packages/gateway/src/control-plane/routes.ts b/packages/gateway/src/control-plane/routes.ts index 94b5f06ff..0130b2212 100644 --- a/packages/gateway/src/control-plane/routes.ts +++ b/packages/gateway/src/control-plane/routes.ts @@ -8,11 +8,11 @@ import { dumpRoutes } from './dump.ts'; import { controlPlaneModels } from './models/routes.ts'; import { performanceOverview, performanceTelemetry } from './performance/routes.ts'; import { createProxy, deleteProxy, listAllBackoffs, listProxies, listProxyBackoffs, resetProxyBackoffs, testProxy, updateProxy } from './proxies/routes.ts'; -import { authLoginBody, changeOwnPasswordBody, claudeCodeAuthorizeUrlBody, claudeCodeImportBody, claudeCodeProbeQuotaBody, claudeCodeRefreshNowBody, claudeCodeReimportBody, claudeCodeSetupTokenImportBody, claudeCodeSetupTokenReimportBody, codexAuthorizeUrlBody, codexImportBody, codexRefreshNowBody, codexReimportBody, copilotAuthPollBody, createKeyBody, createProxyBody, createUpstreamBody, createUserBody, exportQuery, fetchModelsBody, importBody, performanceQuery, resetBackoffBody, searchConfigSchema, searchUsageQuery, testProxyBody, tokenUsageQuery, updateKeyBody, updateProxyBody, updateUpstreamBody, updateUserBody } from './schemas.ts'; +import { authLoginBody, changeOwnPasswordBody, claudeCodeAuthorizeUrlBody, claudeCodeImportBody, claudeCodeProbeQuotaBody, claudeCodeRefreshNowBody, claudeCodeReimportBody, claudeCodeSetupTokenImportBody, claudeCodeSetupTokenReimportBody, codexAuthorizeUrlBody, codexImportBody, codexRefreshNowBody, codexReimportBody, copilotAuthPollBody, createKeyBody, createProxyBody, createUpstreamBody, cursorAuthorizeUrlBody, cursorPollBody, cursorReimportBody, cursorRefreshNowBody, createUserBody, exportQuery, fetchModelsBody, importBody, performanceQuery, resetBackoffBody, searchConfigSchema, searchUsageQuery, testProxyBody, tokenUsageQuery, updateKeyBody, updateProxyBody, updateUpstreamBody, updateUserBody } from './schemas.ts'; import { getSearchConfigRoute, putSearchConfigRoute, testSearchConfigRoute } from './search-config/routes.ts'; import { searchUsage } from './search-usage/routes.ts'; import { tokenUsage } from './token-usage/routes.ts'; -import { claudeCodeAuthorizeUrl, claudeCodeImport, claudeCodeProbeQuota, claudeCodeRefreshNow, claudeCodeReimport, claudeCodeSetupTokenImport, claudeCodeSetupTokenReimport, codexAuthorizeUrl, codexImport, codexRefreshNow, codexReimport, copilotAuthPoll, copilotAuthStart, createUpstream, deleteUpstream, fetchModels, listOptionalFlags, listUpstreamModels, listUpstreamOptions, listUpstreams, updateUpstream } from './upstreams/routes.ts'; +import { claudeCodeAuthorizeUrl, claudeCodeImport, claudeCodeProbeQuota, claudeCodeRefreshNow, claudeCodeReimport, claudeCodeSetupTokenImport, claudeCodeSetupTokenReimport, codexAuthorizeUrl, codexImport, codexRefreshNow, codexReimport, copilotAuthPoll, copilotAuthStart, createUpstream, cursorAuthorizeUrl, cursorPoll, cursorReimport, cursorRefreshNow, deleteUpstream, fetchModels, listOptionalFlags, listUpstreamModels, listUpstreamOptions, listUpstreams, updateUpstream } from './upstreams/routes.ts'; import { changeOwnPassword, createUser, deleteUser, listUsers, updateUser } from './users/routes.ts'; import { type AuthedContext, type AuthVars, userFromContext } from '../middleware/auth.ts'; import { zValidator } from '../middleware/zod-validator.ts'; @@ -81,6 +81,10 @@ export const controlPlaneRoutes = new Hono<{ Variables: AuthVars }>() .post('/upstreams/:id/claude-code-probe-quota', zValidator('json', claudeCodeProbeQuotaBody), claudeCodeProbeQuota) .post('/upstreams/claude-code-setup-token-import', zValidator('json', claudeCodeSetupTokenImportBody), claudeCodeSetupTokenImport) .post('/upstreams/:id/claude-code-setup-token-reimport', zValidator('json', claudeCodeSetupTokenReimportBody), claudeCodeSetupTokenReimport) + .post('/upstreams/cursor-authorize-url', zValidator('json', cursorAuthorizeUrlBody), cursorAuthorizeUrl) + .post('/upstreams/cursor-poll', zValidator('json', cursorPollBody), cursorPoll) + .post('/upstreams/:id/cursor-reimport', zValidator('json', cursorReimportBody), cursorReimport) + .post('/upstreams/:id/cursor-refresh-now', zValidator('json', cursorRefreshNowBody), cursorRefreshNow) .post('/upstreams/fetch-models', zValidator('json', fetchModelsBody), fetchModels) .post('/upstreams', zValidator('json', createUpstreamBody), createUpstream) .get('/upstreams/:id/copilot/quota', copilotQuota) diff --git a/packages/gateway/src/control-plane/schemas.ts b/packages/gateway/src/control-plane/schemas.ts index f718539ee..063e8bbbb 100644 --- a/packages/gateway/src/control-plane/schemas.ts +++ b/packages/gateway/src/control-plane/schemas.ts @@ -313,7 +313,7 @@ const upstreamBaseFields = { // `sort_order` are optional — the handler defaults them to `true` and // `nextSortOrder()` respectively when omitted. // -// `codex` and `claude-code` are listed here so the handler can return the +// `codex`, `claude-code`, and `cursor` are listed here so the handler can return the // canonical "use POST /api/upstreams/-import" 400 instead of the // cryptic zod "invalid discriminator value" message. The `config` slot is // `unknown()` because the real config is derived from the OAuth flow, not @@ -324,6 +324,7 @@ export const createUpstreamBody = z.discriminatedUnion('provider', [ z.object({ provider: z.literal('copilot'), ...upstreamBaseFields, config: copilotConfigSchema }), z.object({ provider: z.literal('codex'), ...upstreamBaseFields, config: z.unknown() }), z.object({ provider: z.literal('claude-code'), ...upstreamBaseFields, config: z.unknown() }), + z.object({ provider: z.literal('cursor'), ...upstreamBaseFields, config: z.unknown() }), z.object({ provider: z.literal('ollama'), ...upstreamBaseFields, config: ollamaConfigSchema }), ]); @@ -337,7 +338,7 @@ export const createUpstreamBody = z.discriminatedUnion('provider', [ // without this field the schema would silently strip it and the API would // look like it had accepted the change. export const updateUpstreamBody = z.object({ - provider: z.enum(['custom', 'azure', 'copilot', 'codex', 'claude-code', 'ollama']).optional(), + provider: z.enum(['custom', 'azure', 'copilot', 'codex', 'claude-code', 'cursor', 'ollama']).optional(), name: z.string().min(1).optional(), enabled: z.boolean().optional(), sort_order: z.number().int().optional(), @@ -453,6 +454,37 @@ export const codexRefreshNowBody = z.object({ proxy_fallback_list: proxyFallbackListSchema.optional(), }); +// --- cursor import / authorize-url / poll / refresh --- +// +// Cursor login is poll-based (not callback-paste): the dashboard gets an +// authorize URL + a uuid/verifier pair, opens the URL, then polls the gateway +// which in turn polls api2.cursor.sh/auth/poll until the operator completes +// login. PKCE state (verifier + uuid) is generated server-side by +// buildCursorAuthorizeUrl and returned to the dashboard so the poll step can +// echo it back. +export const cursorAuthorizeUrlBody = z.object({ + proxy_fallback_list: proxyFallbackListSchema.optional(), +}); + +export const cursorPollBody = z.object({ + uuid: z.string().min(1), + verifier: z.string().min(1), + name: z.string().min(1).optional(), + sort_order: z.number().int().optional(), + proxy_fallback_list: proxyFallbackListSchema.optional(), +}); + +export const cursorReimportBody = z.object({ + uuid: z.string().min(1), + verifier: z.string().min(1), + name: z.string().min(1).optional(), + proxy_fallback_list: proxyFallbackListSchema.optional(), +}); + +export const cursorRefreshNowBody = z.object({ + proxy_fallback_list: proxyFallbackListSchema.optional(), +}); + // --- claude-code import / authorize-url / refresh --- // // Same shape rationale as the codex routes above: the generic create / update diff --git a/packages/gateway/src/control-plane/upstreams/routes.ts b/packages/gateway/src/control-plane/upstreams/routes.ts index bd6d73365..07d8b72e0 100644 --- a/packages/gateway/src/control-plane/upstreams/routes.ts +++ b/packages/gateway/src/control-plane/upstreams/routes.ts @@ -15,7 +15,7 @@ import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; import { getCurrentColo } from '../../runtime/runtime-info.ts'; import { shortId } from '../../shared/short-id.ts'; import { fetchGitHubUser, pollGitHubDeviceFlow, startGitHubDeviceFlow } from '../auth/github-device-flow.ts'; -import type { claudeCodeAuthorizeUrlBody, claudeCodeImportBody, claudeCodeProbeQuotaBody, claudeCodeRefreshNowBody, claudeCodeReimportBody, claudeCodeSetupTokenImportBody, claudeCodeSetupTokenReimportBody, codexAuthorizeUrlBody, codexImportBody, codexRefreshNowBody, codexReimportBody, copilotAuthPollBody, createUpstreamBody, fetchModelsBody, updateUpstreamBody } from '../schemas.ts'; +import type { claudeCodeAuthorizeUrlBody, claudeCodeImportBody, claudeCodeProbeQuotaBody, claudeCodeRefreshNowBody, claudeCodeReimportBody, claudeCodeSetupTokenImportBody, claudeCodeSetupTokenReimportBody, codexAuthorizeUrlBody, codexImportBody, codexRefreshNowBody, codexReimportBody, copilotAuthPollBody, createUpstreamBody, cursorAuthorizeUrlBody, cursorPollBody, cursorReimportBody, cursorRefreshNowBody, fetchModelsBody, updateUpstreamBody } from '../schemas.ts'; import { copilotConfigField, type CopilotUpstreamConfig, isRecord } from '../shared/field-validators.ts'; import { directFetcher, @@ -62,6 +62,18 @@ import { refreshCodexAccessToken, } from '@floway-dev/provider-codex'; import { clearInProcessCopilotTokenCache, exchangeCopilotToken, readCopilotUpstreamState, type CopilotUpstreamState } from '@floway-dev/provider-copilot'; +import { + buildCursorAuthorizeUrl, + pollCursorAuth, + deriveCursorIdentity, + buildCursorImportConfig, + buildCursorImportState, + refreshCursorAccessToken, + CursorSessionTerminatedError, + assertCursorUpstreamState, + type CursorUpstreamConfig, + type CursorUpstreamState, +} from '@floway-dev/provider-cursor'; import { assertCustomUpstreamRecord, fetchCustomModels } from '@floway-dev/provider-custom'; import { assertOllamaUpstreamRecord, createOllamaProvider } from '@floway-dev/provider-ollama'; @@ -247,6 +259,11 @@ export const createUpstream = async (c: CtxWithJson) if (body.provider === 'claude-code') { return c.json({ error: 'Use POST /api/upstreams/claude-code-import for claude-code provider' }, 400); } + // Same rationale for cursor: the row carries an OAuth refresh token derived + // from the poll-based login flow, not a config the operator can type in. + if (body.provider === 'cursor') { + return c.json({ error: 'Use POST /api/upstreams/cursor-authorize-url + /api/upstreams/cursor-poll for cursor provider' }, 400); + } const proxyFallbackList = normalizeProxyFallbackList(body.proxy_fallback_list ?? []); const fallbackCheck = await validateProxyFallbackList(proxyFallbackList); @@ -306,6 +323,9 @@ export const updateUpstream = async (c: CtxWithJson) => { + const params = await buildCursorAuthorizeUrl(); + return c.json({ authorize_url: params.loginUrl, uuid: params.uuid, verifier: params.verifier }); +}; + +const ingestCursorPoll = async ( + uuid: string, + verifier: string, + fetcher: Fetcher, +): Promise<{ ok: true; config: CursorUpstreamConfig; state: CursorUpstreamState } | { ok: false; error: string }> => { + try { + const tokens = await pollCursorAuth(uuid, verifier, fetcher); + const config = buildCursorImportConfig(deriveCursorIdentity(tokens.accessToken)); + const state = buildCursorImportState(tokens); + return { ok: true, config, state }; + } catch (err) { + return { ok: false, error: errorMessage(err) }; + } +}; + +export const cursorPoll = async (c: CtxWithJson) => { + const body = c.req.valid('json'); + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ override: body.proxy_fallback_list, currentColo: getCurrentColo(c.req.raw) }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + const ingestion = await ingestCursorPoll(body.uuid, body.verifier, fetcher); + if (!ingestion.ok) return c.json({ error: ingestion.error }, 400); + + const existing = await getRepo().upstreams.list(); + const now = new Date().toISOString(); + const defaultName = `Cursor (${ingestion.config.accounts[0].email})`; + const upstream: UpstreamRecord = { + id: newId(), + provider: 'cursor', + name: body.name ?? defaultName, + enabled: true, + sortOrder: body.sort_order ?? nextSortOrder(existing), + createdAt: now, + updatedAt: now, + flagOverrides: {}, + disabledPublicModelIds: [], + proxyFallbackList: body.proxy_fallback_list !== undefined ? normalizeProxyFallbackList(body.proxy_fallback_list) : [], + modelPrefix: null, + config: ingestion.config, + state: ingestion.state, + }; + await getRepo().upstreams.save(upstream); + await warmModelsCache(upstream, c); + return c.json(await serializeForResponse(upstream), 201); +}; + +export const cursorReimport = async (c: CtxWithJson) => { + const id = c.req.param('id'); + const existing = await getRepo().upstreams.getById(id); + if (existing?.provider !== 'cursor') { + return c.json({ error: 'Cursor upstream not found' }, 404); + } + const body = c.req.valid('json'); + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ override: body.proxy_fallback_list, upstreamId: id, currentColo: getCurrentColo(c.req.raw) }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + const ingestion = await ingestCursorPoll(body.uuid, body.verifier, fetcher); + if (!ingestion.ok) return c.json({ error: ingestion.error }, 400); + + const next: UpstreamRecord = { + ...existing, + updatedAt: new Date().toISOString(), + name: body.name ?? existing.name, + proxyFallbackList: body.proxy_fallback_list !== undefined ? normalizeProxyFallbackList(body.proxy_fallback_list) : existing.proxyFallbackList, + config: ingestion.config, + state: ingestion.state, + }; + await getRepo().upstreams.save(next); + await warmModelsCache(next, c); + return c.json(await serializeForResponse(next)); +}; + +export const cursorRefreshNow = async (c: CtxWithJson) => { + const id = c.req.param('id'); + const existing = await getRepo().upstreams.getById(id); + if (existing?.provider !== 'cursor') { + return c.json({ error: 'Cursor upstream not found' }, 404); + } + assertCursorUpstreamState(existing.state); + const state = existing.state; + const account = state.accounts[0]; + if (account.state !== 'active') { + return c.json({ error: `Cursor upstream is ${account.state}; re-import to recover` }, 400); + } + + const body = c.req.valid('json'); + let fetcher: Fetcher; + try { + fetcher = await resolveControlPlaneFetcher({ override: body.proxy_fallback_list, upstreamId: id, currentColo: getCurrentColo(c.req.raw) }); + } catch (err) { + return c.json({ error: errorMessage(err) }, 400); + } + + try { + const tokens = await refreshCursorAccessToken(account.refresh_token, fetcher); + const now = new Date(); + const nextAccount = { + ...account, + refresh_token: tokens.refresh_token, + accessToken: { + token: tokens.access_token, + expiresAt: tokens.expires_at, + refreshedAt: now.toISOString(), + }, + }; + const nextState: CursorUpstreamState = { accounts: [nextAccount] }; + const result = await getRepo().upstreams.saveState(id, nextState, { expectedState: state }); + if (!result.updated) { + return c.json({ error: 'Concurrent state mutation; refresh aborted' }, 409); + } + return await respondWithFreshRow(id, c); + } catch (err) { + if (err instanceof CursorSessionTerminatedError) { + const failedAccount = { + ...account, + state: 'refresh_failed' as const, + state_message: err.upstreamMessage, + state_updated_at: new Date().toISOString(), + accessToken: null, + }; + const failedState: CursorUpstreamState = { accounts: [failedAccount] }; + await getRepo().upstreams.saveState(id, failedState, { expectedState: state }); + return c.json({ error: `Cursor refresh failed: ${err.upstreamMessage}. Re-import the credential to recover.` }, 400); + } + return c.json({ error: errorMessage(err) }, 502); + } +}; + // Stateless authorize-URL builder shared by both OAuth and Setup-Token // import flows. PKCE state is owned by the dashboard (verifier + state are // generated client-side and stashed in sessionStorage); the server only diff --git a/packages/gateway/src/control-plane/upstreams/serialize.ts b/packages/gateway/src/control-plane/upstreams/serialize.ts index df28588f9..816775718 100644 --- a/packages/gateway/src/control-plane/upstreams/serialize.ts +++ b/packages/gateway/src/control-plane/upstreams/serialize.ts @@ -103,6 +103,14 @@ const redactedConfig = (upstream: UpstreamRecord): unknown => { ...(a.rateLimitTier !== undefined ? { rateLimitTier: clone(a.rateLimitTier) } : {}), })), }; + case 'cursor': + // refresh_token lives in state and is redacted by redactedState. + return { + accounts: assertAccountsArray(upstream, config.accounts).map(a => ({ + ...(a.email !== undefined ? { email: clone(a.email) } : {}), + ...(a.userId !== undefined ? { userId: clone(a.userId) } : {}), + })), + }; case 'ollama': return { ...(config.baseUrl !== undefined ? { baseUrl: clone(config.baseUrl) } : {}), @@ -159,6 +167,26 @@ const redactedState = (upstream: UpstreamRecord): unknown => { }; }), }; + case 'cursor': + return { + accounts: assertAccountsArray(upstream, state.accounts).map(a => { + // accessToken.token is dropped; expiresAt + refreshedAt are surfaced to the dashboard. + const accessToken = a.accessToken === null + ? null + : isRecord(a.accessToken) + ? { expiresAt: clone(a.accessToken.expiresAt), refreshedAt: clone(a.accessToken.refreshedAt) } + : (() => { throw new Error(`Upstream ${upstream.id} (${upstream.provider}) has malformed accessToken: expected object or null`); })(); + return { + ...(a.userId !== undefined ? { userId: clone(a.userId) } : {}), + ...(a.state !== undefined ? { state: clone(a.state) } : {}), + ...(a.state_message !== undefined ? { state_message: clone(a.state_message) } : {}), + state_updated_at: clone(a.state_updated_at), + refresh_token_set: hasSecret(a.refresh_token), + accessToken, + quotaSnapshot: serializeOpaqueRecord(upstream, 'quotaSnapshot', a.quotaSnapshot), + }; + }), + }; case 'copilot': { // Expose only the per-tier baseUrl the dashboard renders an account-type // badge from. Bearer token + expiry stay server-side: short-lived auth diff --git a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation.ts b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation.ts index a66fe9995..613c757ca 100644 --- a/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation.ts +++ b/packages/gateway/src/data-plane/chat/responses/interceptors/server-tools/image-generation.ts @@ -607,8 +607,8 @@ const issueImageCall = async ( for (let attempt = 0; ; attempt++) { const recorder = createUpstreamLatencyRecorder(); const { response, modelKey } = await (isEdit - ? binding.provider.callImagesEdits(binding.upstreamModel, buildEditsForm(prompt, state.config, sources, stream), state.downstreamAbortSignal, { fetcher, recordUpstreamLatency: recorder.record, waitUntil: state.backgroundScheduler, headers: new Headers() }) - : binding.provider.callImagesGenerations(binding.upstreamModel, buildGenerationsBody(prompt, state.config, stream), state.downstreamAbortSignal, { fetcher, recordUpstreamLatency: recorder.record, waitUntil: state.backgroundScheduler, headers: new Headers() })); + ? binding.provider.callImagesEdits(binding.upstreamModel, buildEditsForm(prompt, state.config, sources, stream), state.downstreamAbortSignal, { fetcher, recordUpstreamLatency: recorder.record, waitUntil: state.backgroundScheduler, headers: new Headers(), apiKeyId: state.apiKeyId }) + : binding.provider.callImagesGenerations(binding.upstreamModel, buildGenerationsBody(prompt, state.config, stream), state.downstreamAbortSignal, { fetcher, recordUpstreamLatency: recorder.record, waitUntil: state.backgroundScheduler, headers: new Headers(), apiKeyId: state.apiKeyId })); const context: PerformanceTelemetryContext = { keyId: state.apiKeyId, model: binding.upstreamModel.id, diff --git a/packages/gateway/src/data-plane/chat/shared/attempt-helpers.ts b/packages/gateway/src/data-plane/chat/shared/attempt-helpers.ts index 64a072059..8ae357369 100644 --- a/packages/gateway/src/data-plane/chat/shared/attempt-helpers.ts +++ b/packages/gateway/src/data-plane/chat/shared/attempt-helpers.ts @@ -34,6 +34,7 @@ export const buildUpstreamCallOptions = ( recordUpstreamLatency, waitUntil: ctx.backgroundScheduler, headers, + apiKeyId: ctx.apiKeyId, }); // Lifts a provider's streaming-call result into the attempt's ExecuteResult diff --git a/packages/gateway/src/data-plane/providers/registry.ts b/packages/gateway/src/data-plane/providers/registry.ts index 79a4f1c5a..e7f5600d9 100644 --- a/packages/gateway/src/data-plane/providers/registry.ts +++ b/packages/gateway/src/data-plane/providers/registry.ts @@ -7,6 +7,7 @@ import { createAzureProvider } from '@floway-dev/provider-azure'; import { createClaudeCodeProvider } from '@floway-dev/provider-claude-code'; import { createCodexProvider } from '@floway-dev/provider-codex'; import { createCopilotProvider } from '@floway-dev/provider-copilot'; +import { createCursorProvider } from '@floway-dev/provider-cursor'; import { createCustomProvider } from '@floway-dev/provider-custom'; import { createOllamaProvider } from '@floway-dev/provider-ollama'; @@ -30,6 +31,7 @@ const providerFactories: Record = { azure: createAzureProvider, codex: createCodexProvider, 'claude-code': createClaudeCodeProvider, + cursor: createCursorProvider, ollama: createOllamaProvider, }; diff --git a/packages/gateway/src/data-plane/shared/passthrough-serve.ts b/packages/gateway/src/data-plane/shared/passthrough-serve.ts index 1add1a115..697ec9c18 100644 --- a/packages/gateway/src/data-plane/shared/passthrough-serve.ts +++ b/packages/gateway/src/data-plane/shared/passthrough-serve.ts @@ -144,6 +144,7 @@ export const passthroughServe = async (input: PassthroughServeContext): Promise< recordUpstreamLatency: recorder.record, waitUntil: ctx.backgroundScheduler, headers: inboundHeadersForUpstream(c), + apiKeyId: ctx.apiKeyId, }); const upstreamDurationMs = requireRecordedDurationMs(recorder, 'passthrough upstream call'); // Telemetry keys on `match.id` (the upstream's bare catalog id); diff --git a/packages/gateway/src/repo/sql.ts b/packages/gateway/src/repo/sql.ts index b716d07e4..e3375ca31 100644 --- a/packages/gateway/src/repo/sql.ts +++ b/packages/gateway/src/repo/sql.ts @@ -1254,7 +1254,7 @@ const toUpstreamRecord = (row: UpstreamRow): UpstreamRecord => { }; const assertUpstreamProviderKind = (provider: string): UpstreamProviderKind => { - if (provider === 'copilot' || provider === 'custom' || provider === 'azure' || provider === 'codex' || provider === 'claude-code' || provider === 'ollama') return provider; + if (provider === 'copilot' || provider === 'custom' || provider === 'azure' || provider === 'codex' || provider === 'claude-code' || provider === 'ollama' || provider === 'cursor') return provider; throw new TypeError(`Invalid upstream provider kind: ${provider}`); }; diff --git a/packages/platform/src/durable-http-session.ts b/packages/platform/src/durable-http-session.ts new file mode 100644 index 000000000..88620e40c --- /dev/null +++ b/packages/platform/src/durable-http-session.ts @@ -0,0 +1,185 @@ +// Cross-request HTTP response holder. Each runtime supplies a concrete impl +// via initDurableHttpSession; callers obtain it via getDurableHttpSession(). +// +// Distinct from SocketDial in two ways: +// 1. Lifetime: SocketDial returns a connection valid for one request; +// DurableHttpSession keeps a request/response pair alive across many +// inbound HTTP requests until idle timeout or explicit discard. +// 2. Granularity: SocketDial deals in bytes; DurableHttpSession deals in +// an in-progress HTTP/1.1 response (status + headers + still-streaming +// body). Protocol decoding stays with the caller. +// +// Use case: providers whose upstream protocol needs a single long-lived +// response body that outlives the inbound HTTP request that triggered it — +// today only cursor's RunSSE stream, which receives ExecMcpResult-driven +// continuations from later inbound requests on the same logical +// conversation. + +export interface DurableHttpSessionInit { + method: 'POST' | 'GET' | 'PUT' | 'DELETE'; + url: string; + headers: Record; + body?: Uint8Array; +} + +export interface DurableHttpSessionHandle { + /** Response status from the upstream. */ + readonly status: number; + /** Response headers from the upstream. */ + readonly headers: Headers; + /** + * The response body stream. Each acquire returns a fresh ReadableStream + * view; releasing this handle does NOT cancel the underlying upstream + * read — the next acquire continues from wherever the stream is. Calling + * cancel() on this stream releases this consumer's hold but the + * underlying read keeps flowing into the broker's buffer. + */ + readonly body: ReadableStream; + /** + * Release this handle's hold on the session. The session itself stays + * alive in the pool for the next acquire. Idempotent. + */ + release(): Promise; + /** + * Forcibly close the underlying upstream connection and evict the + * session from the pool. Idempotent. Use when the protocol layer + * detects an unrecoverable state (framing error, upstream RST, etc.) + * so the next acquire must start a fresh upstream request. + */ + discard(reason: string): Promise; +} + +export interface DurableHttpSessionAcquireOptions { + /** + * Idle TTL — when no acquire holds the session for this many ms, the + * impl closes the upstream connection and evicts the entry. Default + * is impl-defined (Node InProcess uses 5 min; CF DO uses an alarm). + */ + idleTimeoutMs?: number; + /** + * Caller-supplied cancellation. Aborting the signal releases this + * acquire's handle. It does NOT discard the session — sibling acquires + * keep working. + */ + signal?: AbortSignal; +} + +export interface DurableHttpSession { + /** + * Acquire a handle to the session keyed by sessionKey. + * + * Semantics: + * - hit + init=null: return a handle to the existing session + * - hit + init non-null: ignore init (existing session wins; callers + * wanting to force a new upstream request must + * discard first) + * - miss + init non-null: open a new upstream request and seed the + * session before returning a handle + * - miss + init=null: return null (caller decides degradation — + * typically "treat as new conversation") + * + * Concurrent acquires for the same sessionKey are serialized inside the + * impl (Node uses a promise lock; CF DO actor is single-threaded by + * runtime). Callers do not need to synchronize. + */ + acquire( + sessionKey: string, + init: DurableHttpSessionInit | null, + opts?: DurableHttpSessionAcquireOptions, + ): Promise; +} + +let current: DurableHttpSession | null = null; + +export const initDurableHttpSession = (impl: DurableHttpSession): void => { + current = impl; +}; + +export const getDurableHttpSession = (): DurableHttpSession => { + if (!current) throw new Error('DurableHttpSession not initialized — call initDurableHttpSession() first'); + return current; +}; + +/** Test-only: clears the module singleton. */ +export const resetDurableHttpSessionForTesting = (): void => { + current = null; +}; + +// --------------------------------------------------------------------------- +// In-memory test fake. Same colocation convention as MemoryFileProvider in +// file-provider.ts — the fake travels with the contract so any consumer that +// imports the contract can also import the fake without a peer dependency. +// --------------------------------------------------------------------------- + +/** + * Scripted body for a fake session: an async iterable of byte chunks the + * fake will hand out through the body stream, plus the status/headers it + * reports. The same script is replayed for each fresh-create acquire; an + * acquire that hits an existing session yields a new ReadableStream + * draining what is left of the script. + */ +export interface FakeDurableHttpSessionScript { + status: number; + headers: Record; + /** Each yielded chunk is one ReadableStream enqueue. */ + body: AsyncIterable | Iterable; +} + +/** + * In-memory DurableHttpSession for unit tests. Captures every acquire/ + * release/discard with full init so tests can assert what the provider + * asked the broker to do, and reproduces multi-turn session reuse: an + * acquire(key, null) after a prior acquire(key, init) hits the cached + * entry and replays the same script. + */ +export class FakeDurableHttpSession implements DurableHttpSession { + /** Override what script to return for a given sessionKey. Set before the test runs. */ + readonly scripts = new Map(); + /** Audit log of every acquire call, in order. */ + readonly acquired: Array<{ sessionKey: string; init: DurableHttpSessionInit | null }> = []; + /** Audit log of every release call, in order. */ + readonly released: string[] = []; + /** Audit log of every discard call, in order. */ + readonly discarded: Array<{ sessionKey: string; reason: string }> = []; + + private readonly entries = new Map(); + + async acquire( + sessionKey: string, + init: DurableHttpSessionInit | null, + ): Promise { + this.acquired.push({ sessionKey, init }); + + let script = this.entries.get(sessionKey); + if (!script) { + if (init === null) return null; // miss + no init → caller must degrade + const seed = this.scripts.get(sessionKey); + if (!seed) { + throw new Error(`FakeDurableHttpSession: no script registered for sessionKey ${sessionKey}`); + } + script = seed; + this.entries.set(sessionKey, script); + } + + const body = new ReadableStream({ + async start(controller): Promise { + for await (const chunk of script.body) controller.enqueue(chunk); + controller.close(); + }, + }); + + const self = this; + return { + status: script.status, + headers: new Headers(script.headers), + body, + async release(): Promise { + self.released.push(sessionKey); + }, + async discard(reason: string): Promise { + self.discarded.push({ sessionKey, reason }); + self.entries.delete(sessionKey); + }, + }; + } +} diff --git a/packages/platform/src/durable-http-session_test.ts b/packages/platform/src/durable-http-session_test.ts new file mode 100644 index 000000000..afe091498 --- /dev/null +++ b/packages/platform/src/durable-http-session_test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { + FakeDurableHttpSession, + getDurableHttpSession, + initDurableHttpSession, + resetDurableHttpSessionForTesting, + type DurableHttpSession, + type DurableHttpSessionHandle, +} from './durable-http-session.ts'; + +describe('DurableHttpSession singleton', () => { + beforeEach(() => { + resetDurableHttpSessionForTesting(); + }); + + it('throws when used before init', () => { + expect(() => getDurableHttpSession()).toThrow('DurableHttpSession not initialized'); + }); + + it('returns the registered impl after init', () => { + const fake: DurableHttpSession = { + acquire: async (): Promise => null, + }; + initDurableHttpSession(fake); + expect(getDurableHttpSession()).toBe(fake); + }); + + it('overwrites the previously registered impl on re-init', () => { + const first: DurableHttpSession = { acquire: async () => null }; + const second: DurableHttpSession = { acquire: async () => null }; + initDurableHttpSession(first); + initDurableHttpSession(second); + expect(getDurableHttpSession()).toBe(second); + }); + + it('resets to uninitialized after resetDurableHttpSessionForTesting()', () => { + initDurableHttpSession({ acquire: async () => null }); + expect(getDurableHttpSession()).toBeDefined(); + resetDurableHttpSessionForTesting(); + expect(() => getDurableHttpSession()).toThrow('DurableHttpSession not initialized'); + }); +}); + +describe('FakeDurableHttpSession', () => { + it('returns null on miss when init is null', async () => { + const fake = new FakeDurableHttpSession(); + const handle = await fake.acquire('key', null); + expect(handle).toBeNull(); + expect(fake.acquired).toEqual([{ sessionKey: 'key', init: null }]); + }); + + it('seeds a new entry from registered scripts on miss + init non-null', async () => { + const fake = new FakeDurableHttpSession(); + fake.scripts.set('key', { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + body: [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5])], + }); + const handle = await fake.acquire('key', { method: 'POST', url: 'https://x', headers: {} }); + expect(handle).not.toBeNull(); + expect(handle!.status).toBe(200); + expect(handle!.headers.get('content-type')).toBe('application/octet-stream'); + + const chunks: Uint8Array[] = []; + const reader = handle!.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + expect(chunks.map(c => Array.from(c))).toEqual([[1, 2, 3], [4, 5]]); + }); + + it('hits the cached entry on a subsequent acquire(key, null)', async () => { + const fake = new FakeDurableHttpSession(); + fake.scripts.set('key', { status: 201, headers: {}, body: [new Uint8Array([9])] }); + await fake.acquire('key', { method: 'POST', url: 'https://x', headers: {} }); + const handle = await fake.acquire('key', null); + expect(handle).not.toBeNull(); + expect(handle!.status).toBe(201); + }); + + it('records release and discard calls in order', async () => { + const fake = new FakeDurableHttpSession(); + fake.scripts.set('a', { status: 200, headers: {}, body: [] }); + fake.scripts.set('b', { status: 200, headers: {}, body: [] }); + const a = await fake.acquire('a', { method: 'GET', url: 'https://x', headers: {} }); + const b = await fake.acquire('b', { method: 'GET', url: 'https://x', headers: {} }); + await a!.release(); + await b!.discard('protocol error'); + expect(fake.released).toEqual(['a']); + expect(fake.discarded).toEqual([{ sessionKey: 'b', reason: 'protocol error' }]); + }); + + it('discard evicts the entry so the next acquire(key, null) returns null', async () => { + const fake = new FakeDurableHttpSession(); + fake.scripts.set('k', { status: 200, headers: {}, body: [] }); + const h = await fake.acquire('k', { method: 'GET', url: 'https://x', headers: {} }); + await h!.discard('done'); + const next = await fake.acquire('k', null); + expect(next).toBeNull(); + }); + + it('throws when acquiring with init for a key that has no registered script', async () => { + const fake = new FakeDurableHttpSession(); + await expect( + fake.acquire('unknown', { method: 'GET', url: 'https://x', headers: {} }), + ).rejects.toThrow('no script registered'); + }); +}); diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index 35457993f..cf350aafb 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -1,4 +1,5 @@ export * from './background.ts'; +export * from './durable-http-session.ts'; export * from './env.ts'; export * from './file-provider.ts'; export * from './image-cache-store.ts'; diff --git a/packages/provider-codex/src/fetch_test.ts b/packages/provider-codex/src/fetch_test.ts index 94d2cfc61..69e538a12 100644 --- a/packages/provider-codex/src/fetch_test.ts +++ b/packages/provider-codex/src/fetch_test.ts @@ -319,6 +319,7 @@ const enforcingRecorder = () => { recordUpstreamLatency: record, waitUntil: () => {}, headers: new Headers(), + apiKeyId: 'test-api-key', }, invocations: () => wrappedPromises.length, durationMs: (): number => { diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json new file mode 100644 index 000000000..518560be3 --- /dev/null +++ b/packages/provider-cursor/package.json @@ -0,0 +1,22 @@ +{ + "name": "@floway-dev/provider-cursor", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": { "import": "./src/index.ts", "types": "./src/index.ts" } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@floway-dev/http": "workspace:*", + "@floway-dev/interceptor": "workspace:*", + "@floway-dev/platform": "workspace:*", + "@floway-dev/protocols": "workspace:*", + "@floway-dev/provider": "workspace:*" + }, + "devDependencies": { + "@floway-dev/test-utils": "workspace:*" + } +} \ No newline at end of file diff --git a/packages/provider-cursor/src/access-token-cache.ts b/packages/provider-cursor/src/access-token-cache.ts new file mode 100644 index 000000000..655a40b1a --- /dev/null +++ b/packages/provider-cursor/src/access-token-cache.ts @@ -0,0 +1,156 @@ +import { CursorSessionTerminatedError, refreshCursorAccessToken } from './auth/oauth.ts'; +import { readCursorUpstreamState, type CursorAccessTokenEntry, type CursorUpstreamState } from './state.ts'; +import { getProviderRepo, type Fetcher } from '@floway-dev/provider'; + +export type { CursorAccessTokenEntry }; + +// Refresh window: a cached token within this much of expiry counts as +// already-expired so the next call mints a fresh one rather than racing the +// upstream clock. +const REFRESH_SKEW_MS = 5 * 60 * 1000; + +const isAccessTokenFresh = (entry: CursorAccessTokenEntry): boolean => + entry.expiresAt > Date.now() + REFRESH_SKEW_MS; + +const findAccountIndex = (state: CursorUpstreamState, userId: string): number => + state.accounts.findIndex(a => a.userId === userId); + +const replaceAccountAccessToken = ( + state: CursorUpstreamState, + index: number, + entry: CursorAccessTokenEntry | null, +): CursorUpstreamState => ({ + ...state, + accounts: state.accounts.map((account, i) => (i === index ? { ...account, accessToken: entry } : account)), +}); + +export const getCursorAccessToken = async ( + upstreamId: string, + userId: string, +): Promise => { + const fresh = await getProviderRepo().upstreams.getById(upstreamId); + if (!fresh) return null; + const state = readCursorUpstreamState(fresh.state); + const account = state.accounts.find(a => a.userId === userId); + if (!account?.accessToken) return null; + if (!isAccessTokenFresh(account.accessToken)) return null; + return account.accessToken; +}; + +// A losing CAS is not an error — saveState reports it via `updated: false`, +// and the next call re-reads state and refreshes if needed. Genuine storage +// failures propagate. +const persistAccessToken = async ( + upstreamId: string, + userId: string, + entry: CursorAccessTokenEntry | null, + where: string, +): Promise => { + const fresh = await getProviderRepo().upstreams.getById(upstreamId); + if (!fresh) { + console.warn(`${where}: Cursor upstream ${upstreamId} disappeared mid-request`); + return; + } + const state = readCursorUpstreamState(fresh.state); + const idx = findAccountIndex(state, userId); + if (idx < 0) { + console.warn(`${where}: Cursor account ${userId} not found in upstream ${upstreamId}`); + return; + } + if (entry === null && state.accounts[idx]!.accessToken === null) return; + const next = replaceAccountAccessToken(state, idx, entry); + await getProviderRepo().upstreams.saveState(upstreamId, next, { expectedState: fresh.state }); +}; + +export const putCursorAccessToken = async ( + upstreamId: string, + userId: string, + entry: CursorAccessTokenEntry, +): Promise => { + await persistAccessToken(upstreamId, userId, entry, 'putCursorAccessToken'); +}; + +export const invalidateCursorAccessToken = async ( + upstreamId: string, + userId: string, +): Promise => { + await persistAccessToken(upstreamId, userId, null, 'invalidateCursorAccessToken'); +}; + +// Reads, mints, and persists. Refresh-race recovery: Cursor's refresh endpoint +// returns 401/403 for both a genuinely dead refresh_token AND a sibling-won +// rotation (our copy is now stale). recoverFromRefreshRace re-reads state and +// compares the refresh token; if a sibling rotated, we use their cached access +// token. If nothing moved, the session is really dead and we re-raise. +export const ensureCursorAccessToken = async ( + upstreamId: string, + userId: string, + mint: (refreshToken: string) => Promise, +): Promise => await ensureCursorAccessTokenInner(upstreamId, userId, mint, true); + +const ensureCursorAccessTokenInner = async ( + upstreamId: string, + userId: string, + mint: (refreshToken: string) => Promise, + recoveryAllowed: boolean, +): Promise => { + const fresh = await getProviderRepo().upstreams.getById(upstreamId); + if (!fresh) throw new Error(`Cursor upstream ${upstreamId} not found`); + const state = readCursorUpstreamState(fresh.state); + const account = state.accounts.find(a => a.userId === userId); + if (!account) throw new Error(`Cursor account ${userId} not found in upstream ${upstreamId}`); + if (account.accessToken && isAccessTokenFresh(account.accessToken)) { + return account.accessToken; + } + + let minted; + try { + minted = await mint(account.refresh_token); + } catch (err) { + if (err instanceof CursorSessionTerminatedError && recoveryAllowed) { + const recovered = await recoverFromRefreshRace(upstreamId, userId, account.refresh_token, mint); + if (recovered) return recovered; + } + throw err; + } + await persistAccessToken(upstreamId, userId, minted, 'ensureCursorAccessToken'); + return minted; +}; + +const recoverFromRefreshRace = async ( + upstreamId: string, + userId: string, + usedRefreshToken: string, + mint: (refreshToken: string) => Promise, +): Promise => { + const reread = await getProviderRepo().upstreams.getById(upstreamId); + if (!reread) return null; + const rereadState = readCursorUpstreamState(reread.state); + const rereadAccount = rereadState.accounts.find(a => a.userId === userId); + if (!rereadAccount) return null; + if (rereadAccount.state !== 'active') return null; + if (rereadAccount.refresh_token === usedRefreshToken) return null; + console.info( + `Cursor refresh-race recovered for upstream ${upstreamId} account ${userId}: sibling rotated, using their access token`, + ); + if (rereadAccount.accessToken && isAccessTokenFresh(rereadAccount.accessToken)) { + return rereadAccount.accessToken; + } + return await ensureCursorAccessTokenInner(upstreamId, userId, mint, false); +}; + +// Mints a fresh access token via /auth/exchange_user_api_key and routes the +// rotated refresh_token through the caller's CAS hook. +export const mintCursorAccessToken = async ( + refreshToken: string, + fetcher: Fetcher, + persistRefreshTokenRotation: (newRefreshToken: string) => Promise, +): Promise => { + const tokens = await refreshCursorAccessToken(refreshToken, fetcher); + await persistRefreshTokenRotation(tokens.refresh_token); + return { + token: tokens.access_token, + expiresAt: tokens.expires_at, + refreshedAt: new Date().toISOString(), + }; +}; diff --git a/packages/provider-cursor/src/agent-translate.ts b/packages/provider-cursor/src/agent-translate.ts new file mode 100644 index 000000000..abb73426d --- /dev/null +++ b/packages/provider-cursor/src/agent-translate.ts @@ -0,0 +1,280 @@ +/** + * Cursor Agent → OpenAI Chat Completions stream translator. + * + * Consumes AgentStreamChunk events from agent-transport and emits + * ChatCompletionsStreamEvent frames. The caller (fetch.ts) wraps each event + * with eventFrame/doneFrame into a ProviderStreamResult. + * + * Composer thinking-as-content: the composer-* family puts the visible reply + * inside the `thinking` field after a final `` sentinel, optionally + * wrapped in `<|final|>`/`<|final|>` tags. The chain-of-thought prefix is + * hidden; only the suffix is surfaced as `content`. Non-composer models map + * `thinking` straight to `reasoning_text`. + */ + +import type { AgentStreamChunk, ExecRequest } from './proto/index.ts'; +import { wrapToolCallId } from './session-id.ts'; +import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; + +const COMPOSER_THINK_END = ''; + +const COMPOSER_OPEN_MARKER = /^\s*<[||]\s*final\s*[||]>\s*/i; +const COMPOSER_CLOSE_MARKER = /\s*<[||]\s*\/\s*final\s*[||]>\s*$/i; +const COMPOSER_PARTIAL_OPEN = /^\s*<(?![||/])/; +const COMPOSER_PARTIAL_OPEN_PIPE = /^\s*<[||][^>]*$/; + +export function isComposerModel(model: string | undefined | null): boolean { + const id = String(model ?? '') + .split('/') + .pop(); + return /^composer(?:-|$)/i.test(id ?? ''); +} + +/** + * Extract the user-facing reply from an accumulated composer thinking buffer: + * everything after the last ``, with optional `<|final|>` wrapper + * stripped. Returns "" until the sentinel has arrived (so partial marker + * fragments don't leak as content). + */ +export function visibleComposerContentFromThinking(thinking: string): string { + if (!thinking) return ''; + const endIdx = thinking.lastIndexOf(COMPOSER_THINK_END); + if (endIdx < 0) return ''; + let visible = thinking.slice(endIdx + COMPOSER_THINK_END.length).trimStart(); + if (COMPOSER_OPEN_MARKER.test(visible)) { + visible = visible.replace(COMPOSER_OPEN_MARKER, ''); + } else if (COMPOSER_PARTIAL_OPEN.test(visible) || COMPOSER_PARTIAL_OPEN_PIPE.test(visible)) { + return ''; + } + return visible.replace(COMPOSER_CLOSE_MARKER, '').trim(); +} + +/** The chain-of-thought prefix before the last `` (null until sentinel). */ +export function composerReasoningRemainder(thinking: string): string | null { + if (!thinking) return null; + const endIdx = thinking.lastIndexOf(COMPOSER_THINK_END); + if (endIdx < 0) return null; + return thinking.slice(0, endIdx); +} + +export interface TranslatorOptions { + id: string; + model: string; + created: number; + composer?: boolean; + /** + * When set, tool_call_id values emitted in tool_calls deltas are wrapped + * with `sess___` so the OpenAI client echoes them back on the + * next turn, enabling session correlation. Leave undefined to emit cursor's + * raw tool_call_id (backward-compatible, no session persistence). + */ + sessionKey?: string; +} + +export interface AgentTranslator { + /** Map one transport chunk to 0..N stream events. */ + translate(chunk: AgentStreamChunk): ChatCompletionsStreamEvent[]; + /** Emit the terminal finish_reason chunk. Idempotent. */ + finalize(): ChatCompletionsStreamEvent[]; +} + +type FinishReason = 'stop' | 'length' | 'tool_calls' | 'content_filter'; + +export function createAgentTranslator(opts: TranslatorOptions): AgentTranslator { + const composer = opts.composer ?? isComposerModel(opts.model); + + // Cursor packs two ids into one toolCallId string, separated by a newline: + // an OpenAI-style `call_…` token first, then a Responses-API-style `fc_…` + // token. OpenAI clients expect a single id with no embedded whitespace, so + // we surface only the leading call_… form. Trim defensively in case the + // backend ever omits the second id. When a sessionKey is present, further + // wrap with the session prefix so the OpenAI client echoes it back. + const cleanCallId = (raw: string): string => { + const cleaned = raw.split('\n')[0]!.trim(); + return opts.sessionKey ? wrapToolCallId(opts.sessionKey, cleaned) : cleaned; + }; + + let emittedRole = false; + let toolCallIndex = 0; + let currentToolCallIndex = 0; + let emittedToolCalls = 0; + let endReason: 'tool_calls' | null = null; + let finalized = false; + + // Composer thinking-as-content accumulation. + let thinkingText = ''; + let composerVisibleEmittedLength = 0; + + const makeEvent = ( + delta: NonNullable, + finishReason: FinishReason | null = null, + ): ChatCompletionsStreamEvent => { + if (!emittedRole) { + delta = { role: 'assistant', ...delta }; + emittedRole = true; + } + return { + id: opts.id, + object: 'chat.completion.chunk', + created: opts.created, + model: opts.model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + }; + }; + + const emitToolCallStart = (index: number, id: string, name: string): ChatCompletionsStreamEvent => { + return makeEvent({ + tool_calls: [{ index, id, type: 'function', function: { name } }], + }); + }; + + const translate = (chunk: AgentStreamChunk): ChatCompletionsStreamEvent[] => { + switch (chunk.type) { + case 'text': { + if (!chunk.content) return []; + return [makeEvent({ content: chunk.content })]; + } + + case 'kv_blob_assistant': { + if (!chunk.blobContent) return []; + return [makeEvent({ content: chunk.blobContent })]; + } + + case 'thinking': { + if (!chunk.content) return []; + if (!composer) { + return [makeEvent({ reasoning_text: chunk.content })]; + } + // Composer: accumulate, surface only the visible suffix incrementally. + thinkingText += chunk.content; + const visible = visibleComposerContentFromThinking(thinkingText); + if (visible.length <= composerVisibleEmittedLength) return []; + const delta = visible.slice(composerVisibleEmittedLength); + composerVisibleEmittedLength = visible.length; + return [makeEvent({ content: delta })]; + } + + case 'tool_call_started': { + const tc = chunk.toolCall; + if (!tc) return []; + // Cursor's tool_call_started + partial_tool_call + tool_call_completed + // for MCP tools are internal pre-warm signals: the toolCall.name is + // always "mcp" (the cursor TOOL_FIELD_MAP entry for mcp_tool_call) and + // the real user-facing tool name + args only land on the matching + // exec_request that follows. Translating these would double-emit each + // tool call with a placeholder name and break the OpenAI tool_calls + // contract (one delta per call, with the real name). Skip them and let + // exec_request own the translation. + if (tc.toolType === 'mcp_tool_call' || tc.name === 'mcp') return []; + const index = toolCallIndex++; + currentToolCallIndex = index; + emittedToolCalls++; + endReason = 'tool_calls'; + return [emitToolCallStart(index, cleanCallId(tc.callId), tc.name)]; + } + + case 'tool_call_completed': { + const tc = chunk.toolCall; + if (!tc) return []; + // Same rationale as tool_call_started: cursor's MCP completion echoes + // the internal "mcp" type; the authoritative completion is the + // exec_request that already produced the tool_calls delta. + if (tc.toolType === 'mcp_tool_call' || tc.name === 'mcp') return []; + // If no started chunk preceded it, claim a fresh index. + if (emittedToolCalls === 0) { + const index = toolCallIndex++; + currentToolCallIndex = index; + emittedToolCalls++; + endReason = 'tool_calls'; + } + return [ + makeEvent({ + tool_calls: [ + { + index: currentToolCallIndex, + id: cleanCallId(tc.callId), + type: 'function', + function: { name: tc.name, arguments: tc.arguments ?? '' }, + }, + ], + }), + ]; + } + + case 'partial_tool_call': { + // Skip until a real tool_call_started has run — without an index of our + // own, an early partial would attach to the wrong tool. Cursor's MCP + // partials are pre-warm noise (empty argsTextDelta) anyway; the real + // arguments arrive whole on exec_request. + if (emittedToolCalls === 0) return []; + return [ + makeEvent({ + tool_calls: [ + { + index: currentToolCallIndex, + function: { arguments: chunk.partialArgs ?? '' }, + }, + ], + }), + ]; + } + + case 'exec_request': { + return translateExecRequest(chunk.execRequest); + } + + case 'checkpoint': + case 'heartbeat': + case 'interaction_query': + case 'exec_server_abort': + case 'token': + return []; + + case 'done': + case 'error': + // done/error are handled by the caller (finalize / error surfacing). + return []; + } + return []; + }; + + /** + * MCP exec requests become a downstream tool_calls delta (stateless passthru: + * we do NOT reply on the BidiAppend channel — the next turn inlines the tool + * result). Built-in tool exec requests are not translated here; the caller + * rejects them on the write channel so the model keeps streaming. + */ + const translateExecRequest = (execRequest: ExecRequest | undefined): ChatCompletionsStreamEvent[] => { + if (!execRequest) return []; + if (execRequest.type !== 'mcp') return []; + + const index = toolCallIndex++; + currentToolCallIndex = index; + emittedToolCalls++; + endReason = 'tool_calls'; + return [ + makeEvent({ + tool_calls: [ + { + index, + id: cleanCallId(execRequest.toolCallId), + type: 'function', + function: { + name: execRequest.toolName || execRequest.name, + arguments: JSON.stringify(execRequest.args ?? {}), + }, + }, + ], + }), + ]; + }; + + const finalize = (): ChatCompletionsStreamEvent[] => { + if (finalized) return []; + finalized = true; + const finishReason: FinishReason = endReason === 'tool_calls' ? 'tool_calls' : 'stop'; + return [makeEvent({}, finishReason)]; + }; + + return { translate, finalize }; +} diff --git a/packages/provider-cursor/src/agent-translate_test.ts b/packages/provider-cursor/src/agent-translate_test.ts new file mode 100644 index 000000000..c91876aff --- /dev/null +++ b/packages/provider-cursor/src/agent-translate_test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from 'vitest'; + +import { + createAgentTranslator, + isComposerModel, + visibleComposerContentFromThinking, + composerReasoningRemainder, +} from './agent-translate.ts'; +import type { AgentStreamChunk, ExecRequest } from './proto/index.ts'; + +const mk = (model: string) => createAgentTranslator({ id: 'chatcmpl-1', model, created: 100 }); + +const deltaOf = (evs: ReturnType['translate']>[number]) => evs.choices[0]!.delta; +const finishOf = (evs: ReturnType['finalize']>[number]) => evs.choices[0]!.finish_reason; + +describe('isComposerModel', () => { + test.each([ + ['composer-2.5', true], + ['composer', true], + ['cursor/composer-2.5', true], + ['gpt-4o', false], + ['claude-3.7-sonnet', false], + ['', false], + ])('%s → %s', (model, expected) => { + expect(isComposerModel(model)).toBe(expected); + }); +}); + +describe('visibleComposerContentFromThinking', () => { + test('returns "" before the sentinel arrives', () => { + expect(visibleComposerContentFromThinking('chain of thought...')).toBe(''); + }); + test('returns the suffix after the last sentinel', () => { + expect(visibleComposerContentFromThinking('cot partvisible reply')).toBe('visible reply'); + }); + test('strips the <|final|> open marker', () => { + expect(visibleComposerContentFromThinking('cot<|final|>real answer')).toBe('real answer'); + }); + test('strips ASCII <|final|> markers', () => { + expect(visibleComposerContentFromThinking('cot<|final|>real<|/final|>')).toBe('real'); + }); + test('holds back a partial open marker', () => { + // Only a fragment of the marker arrived — must not leak as content yet. + expect(visibleComposerContentFromThinking('cot<|fi')).toBe(''); + }); +}); + +describe('composerReasoningRemainder', () => { + test('null before sentinel', () => { + expect(composerReasoningRemainder('thinking...')).toBeNull(); + }); + test('prefix before sentinel after it arrives', () => { + expect(composerReasoningRemainder('the cotthe reply')).toBe('the cot'); + }); +}); + +describe('createAgentTranslator — text', () => { + test('emits content with role on the first chunk', () => { + const t = mk('gpt-4o'); + const evs = t.translate({ type: 'text', content: 'hi' }); + expect(evs).toHaveLength(1); + expect(deltaOf(evs[0]!)).toEqual({ role: 'assistant', content: 'hi' }); + expect(evs[0]!.choices[0]!.finish_reason).toBeNull(); + }); + + test('subsequent text chunks omit role', () => { + const t = mk('gpt-4o'); + t.translate({ type: 'text', content: 'a' }); + const evs = t.translate({ type: 'text', content: 'b' }); + expect(deltaOf(evs[0]!)).toEqual({ content: 'b' }); + }); + + test('kv_blob_assistant maps to content', () => { + const t = mk('gpt-4o'); + const evs = t.translate({ type: 'kv_blob_assistant', blobContent: 'blob reply' }); + expect(deltaOf(evs[0]!)).toEqual({ role: 'assistant', content: 'blob reply' }); + }); +}); + +describe('createAgentTranslator — thinking', () => { + test('non-composer maps thinking to reasoning_text', () => { + const t = mk('claude-3.7-sonnet'); + const evs = t.translate({ type: 'thinking', content: 'reasoning...' }); + expect(deltaOf(evs[0]!)).toEqual({ role: 'assistant', reasoning_text: 'reasoning...' }); + }); + + test('composer hides cot and surfaces visible suffix incrementally', () => { + const t = mk('composer-2.5'); + // Before sentinel: nothing visible yet. + expect(t.translate({ type: 'thinking', content: 'private cot' })).toEqual([]); + // Sentinel arrives with the visible reply. + const evs = t.translate({ type: 'thinking', content: 'hello world' }); + expect(deltaOf(evs[0]!).content).toBe('hello world'); + }); + + test('composer emits only the incremental tail on subsequent chunks', () => { + const t = mk('composer-2.5'); + t.translate({ type: 'thinking', content: 'cothello' }); + const evs = t.translate({ type: 'thinking', content: ' world' }); + expect(deltaOf(evs[0]!).content).toBe(' world'); + }); +}); + +describe('createAgentTranslator — tool calls', () => { + test('tool_call_started emits an indexed tool_calls delta', () => { + const t = mk('gpt-4o'); + const evs = t.translate({ + type: 'tool_call_started', + toolCall: { callId: 'c1', modelCallId: 'm1', toolType: 'shell_tool_call', name: 'bash', arguments: '' }, + }); + expect(deltaOf(evs[0]!).tool_calls?.[0]).toEqual({ + index: 0, + id: 'c1', + type: 'function', + function: { name: 'bash' }, + }); + }); + + test('partial_tool_call appends arguments to the current index', () => { + const t = mk('gpt-4o'); + t.translate({ type: 'tool_call_started', toolCall: { callId: 'c1', modelCallId: 'm', toolType: 'x', name: 'search', arguments: '' } }); + const evs = t.translate({ type: 'partial_tool_call', partialArgs: '{"q":"x"' }); + expect(deltaOf(evs[0]!).tool_calls?.[0]).toEqual({ index: 0, function: { arguments: '{"q":"x"' } }); + }); + + test('exec_request mcp becomes a tool_calls delta with toolName + JSON args', () => { + const t = mk('gpt-4o'); + const execRequest: Extract = { + type: 'mcp', + id: 1, + execId: 'e1', + name: 'cursor-tools-search', + args: { q: 'x', n: 3 }, + toolCallId: 'call_1', + providerIdentifier: 'cursor-tools', + toolName: 'search', + }; + const evs = t.translate({ type: 'exec_request', execRequest }); + expect(deltaOf(evs[0]!).tool_calls?.[0]).toEqual({ + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'search', arguments: JSON.stringify({ q: 'x', n: 3 }) }, + }); + }); + + test('exec_request built-in tools are not translated', () => { + const t = mk('gpt-4o'); + const evs = t.translate({ type: 'exec_request', execRequest: { type: 'shell', id: 1, command: 'ls', cwd: '/' } }); + expect(evs).toEqual([]); + }); +}); + +describe('createAgentTranslator.finalize', () => { + test('emits stop when no tool calls were produced', () => { + const t = mk('gpt-4o'); + t.translate({ type: 'text', content: 'hi' }); + const fin = t.finalize(); + expect(fin).toHaveLength(1); + expect(finishOf(fin[0]!)).toBe('stop'); + expect(deltaOf(fin[0]!)).toEqual({}); + }); + + test('emits tool_calls finish_reason after an mcp exec', () => { + const t = mk('gpt-4o'); + t.translate({ + type: 'exec_request', + execRequest: { type: 'mcp', id: 1, name: 'x', args: {}, toolCallId: 'c1', providerIdentifier: 'p', toolName: 'x' }, + }); + expect(finishOf(t.finalize()[0]!)).toBe('tool_calls'); + }); + + test('is idempotent', () => { + const t = mk('gpt-4o'); + t.finalize(); + expect(t.finalize()).toEqual([]); + }); +}); + +describe('createAgentTranslator — passthrough chunks', () => { + test('checkpoint/heartbeat/interaction_query/exec_server_abort yield nothing', () => { + const t = mk('gpt-4o'); + const chunks: AgentStreamChunk[] = [ + { type: 'checkpoint' }, + { type: 'heartbeat' }, + { type: 'interaction_query', queryId: 1, queryType: 'web_search' }, + { type: 'exec_server_abort' }, + ]; + for (const c of chunks) expect(t.translate(c)).toEqual([]); + }); +}); diff --git a/packages/provider-cursor/src/agent-transport.ts b/packages/provider-cursor/src/agent-transport.ts new file mode 100644 index 000000000..b03b05938 --- /dev/null +++ b/packages/provider-cursor/src/agent-transport.ts @@ -0,0 +1,707 @@ +/** + * Cursor Agent transport — HTTP/1.1 dual-channel driver. + * + * RunSSE (server-streaming read) + BidiAppend (unary write) over plain fetch, + * no node:http2. Workers + Node dual-target. Produces AgentStreamChunk events + * consumed by agent-translate; the caller bridges those into Floway's + * ProviderStreamResult. + * + * Workers-clean: crypto.randomUUID, bytesToHex, Uint8Array fetch bodies, + * DecompressionStream for gzip. Environment facts come via RequestContextEnv. + */ + +import { + bytesToHex, + addConnectEnvelope, + readConnectFrame, + isTrailerFrame, + isCompressedFrame, + parseTrailerMetadata, + decompressGzip, + parseProtoFields, + parseInteractionUpdate, + parseExecServerMessage, + parseKvServerMessage, + analyzeBlobData, + extractAssistantContent, + buildKvClientMessage, + buildAgentClientMessageWithKv, + buildExecClientMessageWithMcpResult, + buildExecClientMessageWithShellResult, + buildExecClientMessageWithLsResult, + buildExecClientMessageWithRequestContextResult, + buildExecClientMessageWithReadResult, + buildExecClientMessageWithGrepResult, + buildExecClientMessageWithWriteResult, + buildExecClientMessageWithRejectedTool, + buildAgentClientMessageWithExec, + buildExecClientControlMessage, + buildAgentClientMessageWithExecControl, + encodeBidiRequestId, + encodeBidiAppendRequest, + buildRequestContext, + encodeUserMessage, + encodeUserMessageAction, + encodeConversationAction, + encodeModelDetails, + encodeAgentRunRequest, + encodeAgentClientMessage, + encodeConversationActionWithResume, + encodeAgentClientMessageWithConversationAction, + AgentMode, + type AgentStreamChunk, + type AgentChatRequest, + type ExecRequest, + type KvServerMessage, + type McpResult, + type RequestContextEnv, +} from './proto/index.ts'; + +const RUN_SSE_PATH = '/agent.v1.AgentService/RunSSE'; +const BIDI_APPEND_PATH = '/aiserver.v1.BidiService/BidiAppend'; +const USER_AGENT = 'connect-es/1.4.0'; +const GRPC_WEB_PROTO = 'application/grpc-web+proto'; + +const MAX_BLOB_STORE_SIZE = 64; + +const DEFAULT_HEARTBEAT = { + // No progress yet: be conservative — close fast if the backend stalls before + // first output so we don't burn a Workers subrequest on a dead turn. + idleBeforeProgressMs: 30_000, + maxBeforeProgress: 15, + // After first output: the turn ends authoritatively on the IU[14] turn_ended + // frame (or an exec pause). This idle window is only a stall safety net for a + // genuinely wedged upstream that stops sending turn_ended AND stops streaming + // — kept generous so a long mid-answer reasoning pause (which emits + // heartbeats) is never mistaken for the end. + idleAfterProgressMs: 30_000, +}; + +const DEFAULT_REQUEST_TIMEOUT_MS = 300_000; + +export interface AgentTransportOptions { + accessToken: string; + baseUrl: string; + env: RequestContextEnv; + clientVersion: string; + /** Privacy/ghost-mode toggle sent as x-ghost-mode. */ + privacyMode?: boolean; + /** Supplies the x-cursor-checksum header (pure-compute, see checksum.ts). */ + getChecksum: () => string; + /** Optional fetch injection (tests / per-upstream proxy). Defaults to global. */ + fetch?: typeof fetch; + /** BidiAppend retry count for transient network errors. Default 1. */ + maxRetries?: number; + requestTimeoutMs?: number; + heartbeat?: Partial; +} + +const RETRYABLE_NETWORK_HINTS = [ + 'socket', + 'connection', + 'econnreset', + 'etimedout', + 'epipe', + 'network', + 'fetch', +]; + +function isRetryableNetworkError(message: string): boolean { + const lower = message.toLowerCase(); + return RETRYABLE_NETWORK_HINTS.some(hint => lower.includes(hint)); +} + +/** + * Cursor Agent dual-channel transport. One instance per chat turn — the blob + * store and seqno are turn-scoped and cleared on openChatStream. + */ +export class AgentTransport { + private readonly accessToken: string; + private readonly baseUrl: string; + private readonly env: RequestContextEnv; + private readonly clientVersion: string; + private readonly privacyMode: boolean; + private readonly getChecksum: () => string; + private readonly fetchFn: typeof fetch; + private readonly maxRetries: number; + private readonly requestTimeoutMs: number; + private readonly heartbeat: typeof DEFAULT_HEARTBEAT; + + private readonly blobStore = new Map(); + private readonly blobStoreOrder: string[] = []; + + // Turn-scoped state, valid while openChatStream is running. + private currentRequestId: string | null = null; + private currentAppendSeqno = 0n; + private controller: AbortController | null = null; + + constructor(opts: AgentTransportOptions) { + this.accessToken = opts.accessToken; + this.baseUrl = opts.baseUrl.replace(/\/$/, ''); + this.env = opts.env; + this.clientVersion = opts.clientVersion; + this.privacyMode = opts.privacyMode ?? true; + this.getChecksum = opts.getChecksum; + this.fetchFn = opts.fetch ?? globalThis.fetch; + this.maxRetries = opts.maxRetries ?? 1; + this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + this.heartbeat = { ...DEFAULT_HEARTBEAT, ...opts.heartbeat }; + } + + private getHeaders(requestId?: string): Record { + const headers: Record = { + authorization: `Bearer ${this.accessToken}`, + 'content-type': GRPC_WEB_PROTO, + 'user-agent': USER_AGENT, + 'x-cursor-checksum': this.getChecksum(), + 'x-cursor-client-version': this.clientVersion, + 'x-cursor-client-type': 'cli', + 'x-cursor-timezone': this.env.timezone, + 'x-ghost-mode': this.privacyMode ? 'true' : 'false', + // Ask the backend to stream text back over RunSSE instead of stashing + // assistant responses in KV blobs. + 'x-cursor-streaming': 'true', + }; + if (requestId) headers['x-request-id'] = requestId; + return headers; + } + + private blobIdToKey(blobId: Uint8Array): string { + return bytesToHex(blobId); + } + + private storeBlob(key: string, data: Uint8Array): void { + const existingIndex = this.blobStoreOrder.indexOf(key); + if (existingIndex !== -1) this.blobStoreOrder.splice(existingIndex, 1); + + while (this.blobStore.size >= MAX_BLOB_STORE_SIZE && this.blobStoreOrder.length > 0) { + const oldestKey = this.blobStoreOrder.shift(); + if (oldestKey) this.blobStore.delete(oldestKey); + } + + this.blobStore.set(key, data); + this.blobStoreOrder.push(key); + } + + private clearBlobStore(): void { + this.blobStore.clear(); + this.blobStoreOrder.length = 0; + } + + /** + * POST one AgentClientMessage on the BidiAppend write channel. Retries + * transient network errors up to maxRetries with exponential backoff. + */ + async bidiAppend(requestId: string, appendSeqno: bigint, data: Uint8Array): Promise { + const hexData = bytesToHex(data); + const appendRequest = encodeBidiAppendRequest(hexData, requestId, appendSeqno); + const envelope = addConnectEnvelope(appendRequest); + const url = `${this.baseUrl}${BIDI_APPEND_PATH}`; + + let lastError: Error | null = null; + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + try { + const response = await this.fetchFn(url, { + method: 'POST', + headers: this.getHeaders(requestId), + body: envelope as BodyInit, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`BidiAppend failed: ${response.status} - ${errorText}`); + } + + // Drain the (usually empty) response body. + await response.arrayBuffer(); + return; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (isRetryableNetworkError(lastError.message) && attempt < this.maxRetries) { + const delay = Math.min(1000 * 2 ** attempt, 4000); + await new Promise(resolve => setTimeout(resolve, delay)); + continue; + } + throw lastError; + } + } + throw lastError ?? new Error('BidiAppend failed with unknown error'); + } + + private buildChatMessage(request: AgentChatRequest): Uint8Array { + const messageId = crypto.randomUUID(); + const conversationId = request.conversationId ?? crypto.randomUUID(); + const model = request.model ?? 'gpt-4o'; + const mode = request.mode ?? AgentMode.AGENT; + + const requestContext = buildRequestContext(this.env, request.tools); + const userMessage = encodeUserMessage(request.message, messageId, mode); + const userMessageAction = encodeUserMessageAction(userMessage, requestContext); + const conversationAction = encodeConversationAction(userMessageAction); + const modelDetails = encodeModelDetails(model); + const agentRunRequest = encodeAgentRunRequest( + conversationAction, + modelDetails, + conversationId, + request.tools, + this.env.workspacePath, + ); + return encodeAgentClientMessage(agentRunRequest); + } + + private async handleKvMessage(kvMsg: KvServerMessage, requestId: string): Promise { + const seqno = this.currentAppendSeqno; + + if (kvMsg.messageType === 'get_blob_args' && kvMsg.blobId) { + const key = this.blobIdToKey(kvMsg.blobId); + const data = this.blobStore.get(key); + const result = data ?? new Uint8Array(0); + const kvClientMsg = buildKvClientMessage(kvMsg.id, 'get_blob_result', result); + const responseMsg = buildAgentClientMessageWithKv(kvClientMsg); + await this.bidiAppend(requestId, seqno, responseMsg); + return seqno + 1n; + } + + if (kvMsg.messageType === 'set_blob_args' && kvMsg.blobId && kvMsg.blobData) { + const key = this.blobIdToKey(kvMsg.blobId); + this.storeBlob(key, kvMsg.blobData); + + const result = new Uint8Array(0); + const kvClientMsg = buildKvClientMessage(kvMsg.id, 'set_blob_result', result); + const responseMsg = buildAgentClientMessageWithKv(kvClientMsg); + await this.bidiAppend(requestId, seqno, responseMsg); + return seqno + 1n; + } + + return seqno; + } + + /** + * Send an ExecClientMessage result then the stream-close control frame. + * Used by every tool-result path (mcp, rejected built-in, request_context). + */ + private async sendExecAndClose(id: number, execId: string | undefined, execClientMessage: Uint8Array): Promise { + if (!this.currentRequestId) throw new Error('No active chat stream — cannot send exec result'); + + const responseMsg = buildAgentClientMessageWithExec(execClientMessage); + await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, responseMsg); + this.currentAppendSeqno += 1n; + + const controlMsg = buildExecClientControlMessage(id); + const controlResponseMsg = buildAgentClientMessageWithExecControl(controlMsg); + await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, controlResponseMsg); + this.currentAppendSeqno += 1n; + } + + /** + * Send an ExecClientMessage result WITHOUT a stream-close control frame. + * Cursor's MCP protocol treats a single mcp_result as the complete tool + * output and auto-resumes the model turn on the same RunSSE read channel — + * an extra exec-stream-close (or a ResumeAction) makes the server finalize + * the turn instead of continuing, yielding an empty follow-up reply. This + * mirrors the validated HTTP/2 reference (OmniRoute cursorSessionManager + * .sendToolResult → encodeExecMcpResult, single frame, no close/resume). + */ + private async sendExecResultNoClose(execClientMessage: Uint8Array): Promise { + if (!this.currentRequestId) throw new Error('No active chat stream — cannot send exec result'); + const responseMsg = buildAgentClientMessageWithExec(execClientMessage); + await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, responseMsg); + this.currentAppendSeqno += 1n; + } + + /** + * Reply to an MCP exec request with a result. Single frame, no close — the + * server resumes the model turn automatically (see sendExecResultNoClose). + */ + async sendMcpResult(execRequest: Extract, result: McpResult): Promise { + const execClientMsg = buildExecClientMessageWithMcpResult(execRequest.id, execRequest.execId, result); + await this.sendExecResultNoClose(execClientMsg); + } + + /** Reject a built-in tool request so the model can adapt and keep streaming. */ + async sendRejectedTool(execRequest: ExecRequest, reason: string): Promise { + const execClientMsg = buildExecClientMessageWithRejectedTool(execRequest, reason); + await this.sendExecAndClose(execRequest.id, execRequest.execId, execClientMsg); + } + + /** Answer a request_context exec with the gateway environment. */ + async sendRequestContextResult(id: number, execId: string | undefined): Promise { + const execClientMsg = buildExecClientMessageWithRequestContextResult(id, execId, this.env); + await this.sendExecAndClose(id, execId, execClientMsg); + } + + async sendShellResult( + id: number, + execId: string | undefined, + command: string, + cwd: string, + stdout: string, + stderr: string, + exitCode: number, + executionTimeMs?: number, + ): Promise { + const execClientMsg = buildExecClientMessageWithShellResult(id, execId, command, cwd, stdout, stderr, exitCode, executionTimeMs); + await this.sendExecAndClose(id, execId, execClientMsg); + } + + async sendLsResult(id: number, execId: string | undefined, filesString: string): Promise { + const execClientMsg = buildExecClientMessageWithLsResult(id, execId, filesString); + await this.sendExecAndClose(id, execId, execClientMsg); + } + + async sendReadResult( + id: number, + execId: string | undefined, + content: string, + path: string, + totalLines?: number, + fileSize?: bigint, + truncated?: boolean, + ): Promise { + const execClientMsg = buildExecClientMessageWithReadResult(id, execId, content, path, totalLines, fileSize, truncated); + await this.sendExecAndClose(id, execId, execClientMsg); + } + + async sendGrepResult(id: number, execId: string | undefined, pattern: string, path: string, files: string[]): Promise { + const execClientMsg = buildExecClientMessageWithGrepResult(id, execId, pattern, path, files); + await this.sendExecAndClose(id, execId, execClientMsg); + } + + async sendWriteResult( + id: number, + execId: string | undefined, + result: { success?: { path: string; linesCreated: number; fileSize: number; fileContentAfterWrite?: string }; error?: { path: string; error: string } }, + ): Promise { + const execClientMsg = buildExecClientMessageWithWriteResult(id, execId, result); + await this.sendExecAndClose(id, execId, execClientMsg); + } + + /** Tell the backend to resume streaming after tool results. */ + async sendResumeAction(): Promise { + if (!this.currentRequestId) throw new Error('No active chat stream — cannot send resume action'); + const conversationAction = encodeConversationActionWithResume(); + const agentClientMessage = encodeAgentClientMessageWithConversationAction(conversationAction); + await this.bidiAppend(this.currentRequestId, this.currentAppendSeqno, agentClientMessage); + this.currentAppendSeqno += 1n; + } + + /** Abort the active RunSSE read (e.g. after yielding a tool_calls chunk). */ + abort(): void { + this.controller?.abort(); + } + + /** + * Drive a full chat turn over the dual channel: open RunSSE, send the + * RunRequest on BidiAppend, then pump AgentServerMessage frames as + * AgentStreamChunk events until turn_ended / idle-timeout / abort / error. + * + * The caller drives exec_request disposition: on an mcp exec it typically + * translates to a downstream tool_calls chunk and breaks (aborting the + * read); on a built-in exec it calls sendRejectedTool / sendRequestContextResult + * and lets the loop continue. + */ + async *openChatStream(request: AgentChatRequest): AsyncGenerator { + this.clearBlobStore(); + const requestId = crypto.randomUUID(); + this.currentRequestId = requestId; + this.currentAppendSeqno = 0n; + + const messageBody = this.buildChatMessage(request); + + let lastProgressAt = Date.now(); + let heartbeatSinceProgress = 0; + let hasProgress = false; + const markProgress = (): void => { + heartbeatSinceProgress = 0; + lastProgressAt = Date.now(); + hasProgress = true; + }; + + const bidiRequestId = encodeBidiRequestId(requestId); + const envelope = addConnectEnvelope(bidiRequestId); + const sseUrl = `${this.baseUrl}${RUN_SSE_PATH}`; + + this.controller = new AbortController(); + const timeout = setTimeout(() => this.controller?.abort(), this.requestTimeoutMs); + + const pendingAssistantBlobs: Array<{ blobId: string; content: string }> = []; + let hasStreamedText = false; + + try { + // ssePromise is intentionally kicked off without await so we can fire the + // initial BidiAppend RunRequest concurrently. Without an immediate .catch + // attached, a rejection (DNS failure, connection refused, abort) that + // resolves before the later `await ssePromise` surfaces as an + // unhandledRejection — Node 25 escalates that to a process exit. Attach + // a noop catch so the rejection is observed; the real await below still + // sees the same error. + const ssePromise = this.fetchFn(sseUrl, { + method: 'POST', + headers: this.getHeaders(requestId), + body: envelope as BodyInit, + signal: this.controller.signal, + }); + ssePromise.catch(() => {}); + + // Send the initial RunRequest on the write channel, concurrent with RunSSE. + // BidiAppend can fail for the same reasons RunSSE can (DNS, abort, refused); + // any rejection here is caught by the outer try below, but we surface it + // as an error chunk before falling out so the consumer sees a clean event + // instead of a thrown promise. + try { + await this.bidiAppend(requestId, this.currentAppendSeqno, messageBody); + this.currentAppendSeqno += 1n; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + yield { type: 'error', error: `BidiAppend initial RunRequest failed: ${message}` }; + return; + } + + const sseResponse = await ssePromise; + if (!sseResponse.ok) { + const errorText = await sseResponse.text(); + yield { type: 'error', error: `SSE stream failed: ${sseResponse.status} - ${errorText}` }; + return; + } + if (!sseResponse.body) { + yield { type: 'error', error: 'No response body from SSE stream' }; + return; + } + + const reader = sseResponse.body.getReader(); + let buffer = new Uint8Array(0); + let turnEnded = false; + + try { + while (!turnEnded) { + const { done, value } = await reader.read(); + if (done) { + yield { type: 'done' }; + break; + } + if (!value) continue; + + const newBuffer = new Uint8Array(buffer.length + value.length); + newBuffer.set(buffer); + newBuffer.set(value, buffer.length); + buffer = newBuffer; + + let offset = 0; + // Parse as many complete connect frames as the buffer currently holds. + + while (true) { + const frame = readConnectFrame(buffer, offset); + if (!frame) break; + offset = frame.nextOffset; + + let payload = frame.payload; + if (isCompressedFrame(frame.flags)) { + try { + payload = await decompressGzip(frame.payload); + } catch { + // leave payload compressed; field parsing will likely skip it + } + } + + if (isTrailerFrame(frame.flags)) { + const trailer = new TextDecoder().decode(payload); + const meta = parseTrailerMetadata(trailer); + const grpcStatus = Number(meta['grpc-status'] ?? '0'); + if (grpcStatus !== 0) { + const grpcMessage = meta['grpc-message'] ? decodeURIComponent(meta['grpc-message']) : 'Unknown gRPC error'; + yield { type: 'error', error: `${grpcMessage} (grpc-status ${grpcStatus})` }; + } + continue; + } + + const serverMsgFields = parseProtoFields(payload); + for (const field of serverMsgFields) { + try { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + const parsed = parseInteractionUpdate(field.value); + + if (parsed.text) { + hasStreamedText = true; + markProgress(); + yield { type: 'text', content: parsed.text }; + } + if (parsed.thinking) { + markProgress(); + yield { type: 'thinking', content: parsed.thinking }; + } + if (parsed.toolCallStarted) { + markProgress(); + yield { + type: 'tool_call_started', + toolCall: { + callId: parsed.toolCallStarted.callId, + modelCallId: parsed.toolCallStarted.modelCallId, + toolType: parsed.toolCallStarted.toolType, + name: parsed.toolCallStarted.name, + arguments: parsed.toolCallStarted.arguments, + }, + }; + } + if (parsed.toolCallCompleted) { + markProgress(); + yield { + type: 'tool_call_completed', + toolCall: { + callId: parsed.toolCallCompleted.callId, + modelCallId: parsed.toolCallCompleted.modelCallId, + toolType: parsed.toolCallCompleted.toolType, + name: parsed.toolCallCompleted.name, + arguments: parsed.toolCallCompleted.arguments, + }, + }; + } + if (parsed.partialToolCall) { + markProgress(); + yield { + type: 'partial_tool_call', + toolCall: { + callId: parsed.partialToolCall.callId, + toolType: 'partial', + name: 'partial', + arguments: '', + }, + partialArgs: parsed.partialToolCall.argsTextDelta, + }; + } + if (parsed.isComplete) { + // InteractionUpdate field 14 (turn_ended) — cursor's + // authoritative end-of-turn marker. Verified on the wire to + // arrive after the final answer text (after the closing KV + // checkpoints and the field-17 usage update). This is the + // signal we close the session on; everything below is a + // stall-safety fallback, not a normal end path. + turnEnded = true; + } + if (parsed.isHeartbeat) { + heartbeatSinceProgress++; + const idleMs = Date.now() - lastProgressAt; + if (hasProgress) { + // The model has already produced output. Heartbeats here + // are keep-alives during cursor's own pauses (KV + // checkpointing, the gap before turn_ended, mid-answer + // reasoning). They must NOT preempt the turn_ended frame — + // a raw beat count would truncate short answers whose + // turn_ended lags a beat or two behind the last text + // (observed). So after progress we only close on a long + // *time* idle (a genuine upstream stall), never on count. + if (idleMs >= this.heartbeat.idleAfterProgressMs) { + turnEnded = true; + } else { + yield { type: 'heartbeat' }; + } + } else if (heartbeatSinceProgress >= this.heartbeat.maxBeforeProgress || idleMs >= this.heartbeat.idleBeforeProgressMs) { + // Before any output: a stream that never produces is + // detected by either a beat count or an idle window. + turnEnded = true; + } else { + yield { type: 'heartbeat' }; + } + } + } else if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + // conversation_checkpoint_update — NOT a completion signal; + // exec_server_message can follow. Just mark progress. + markProgress(); + yield { type: 'checkpoint' }; + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + const execRequest = parseExecServerMessage(field.value); + if (execRequest) { + markProgress(); + yield { type: 'exec_request', execRequest }; + } + } else if (field.fieldNumber === 4 && field.wireType === 2 && field.value instanceof Uint8Array) { + const kvMsg = parseKvServerMessage(field.value); + if (kvMsg.messageType === 'set_blob_args' && kvMsg.blobId && kvMsg.blobData) { + const key = this.blobIdToKey(kvMsg.blobId); + const analysis = analyzeBlobData(kvMsg.blobData); + for (const item of extractAssistantContent(analysis, key)) { + pendingAssistantBlobs.push(item); + } + } + // kv_server_message: cursor checkpointing conversation state + // (it pushes set_blob frames for us to store, and get_blob + // frames to re-read on a follow-up). NOT a turn-end signal — + // turn termination is the authoritative IU[14] turn_ended + // frame (or an exec pause), which always arrives after the + // closing KV checkpoints. We answer KV here (blob store) so + // the model can finish; a stalled reply pins the seqno and + // wedges the whole turn (see handleKvMessage). + try { + // Assign the returned next-seqno back: handleKvMessage sends + // a kv_client_message on the shared BidiAppend channel and + // returns seqno+1. Dropping the return value pinned the + // seqno, so multiple blob requests in one turn (common on a + // tool-result follow-up, where cursor re-fetches conversation + // blobs) all replied at the same seqno — cursor ignores the + // duplicates, the blob channel stalls, and the model emits an + // empty continuation. + this.currentAppendSeqno = await this.handleKvMessage(kvMsg, requestId); + } catch (kvErr) { + const msg = kvErr instanceof Error ? kvErr.message : String(kvErr); + if (isRetryableNetworkError(msg)) { + yield { type: 'error', error: `Network error during KV: ${msg}` }; + turnEnded = true; + } + // non-network KV errors: swallow, keep streaming + } + } else if (field.fieldNumber === 5 && field.wireType === 2 && field.value instanceof Uint8Array) { + markProgress(); + yield { type: 'exec_server_abort' }; + } else if (field.fieldNumber === 7 && field.wireType === 2 && field.value instanceof Uint8Array) { + const queryFields = parseProtoFields(field.value); + let queryId = 0; + let queryType = 'unknown'; + for (const qf of queryFields) { + if (qf.fieldNumber === 1 && qf.wireType === 0) queryId = Number(qf.value); + else if (qf.fieldNumber === 2) queryType = 'web_search'; + else if (qf.fieldNumber === 3) queryType = 'ask_question'; + else if (qf.fieldNumber === 4) queryType = 'switch_mode'; + else if (qf.fieldNumber === 5) queryType = 'exa_search'; + else if (qf.fieldNumber === 6) queryType = 'exa_fetch'; + } + markProgress(); + yield { type: 'interaction_query', queryId, queryType }; + } + } catch (parseErr) { + const msg = parseErr instanceof Error ? parseErr.message : String(parseErr); + if (isRetryableNetworkError(msg)) { + yield { type: 'error', error: `Network error: ${msg}` }; + turnEnded = true; + } else { + yield { type: 'error', error: `Parse error in field ${field.fieldNumber}: ${msg}` }; + } + } + } + + if (turnEnded) break; + } + buffer = buffer.slice(offset); + } + + if (turnEnded) { + this.controller?.abort(); + if (!hasStreamedText && pendingAssistantBlobs.length > 0) { + for (const blob of pendingAssistantBlobs) { + yield { type: 'kv_blob_assistant', blobContent: blob.content }; + } + } + yield { type: 'done' }; + } + } finally { + reader.releaseLock(); + } + } catch (err: unknown) { + const error = err as Error & { name?: string }; + if (error.name === 'AbortError') return; // normal termination + yield { type: 'error', error: error.message || String(err) }; + } finally { + clearTimeout(timeout); + this.controller?.abort(); + this.currentRequestId = null; + } + } +} diff --git a/packages/provider-cursor/src/agent-transport_test.ts b/packages/provider-cursor/src/agent-transport_test.ts new file mode 100644 index 000000000..924882c1a --- /dev/null +++ b/packages/provider-cursor/src/agent-transport_test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { AgentTransport } from './agent-transport.ts'; +import { addConnectEnvelope, encodeMessageField, encodeStringField } from './proto/index.ts'; +import type { AgentChatRequest, RequestContextEnv } from './proto/index.ts'; + +const ENV: RequestContextEnv = { workspacePath: '/tmp', osVersion: 'darwin 24.0.0', shell: '/bin/zsh', timezone: 'UTC' }; + +function makeTransport(fetchMock: unknown): AgentTransport { + return new AgentTransport({ + accessToken: 'tok', + baseUrl: 'https://api2.cursor.sh', + env: ENV, + clientVersion: 'cli-test', + getChecksum: () => 'checksum-value', + fetch: fetchMock as typeof fetch, + maxRetries: 1, + requestTimeoutMs: 5000, + }); +} + +function okEmptyResponse(): Response { + return new Response(new Uint8Array(0), { status: 200 }); +} + +// AgentServerMessage { field 1: InteractionUpdate { field 1: TextDeltaUpdate { field 1: text } } } +function textFrame(text: string): Uint8Array { + const interactionUpdate = encodeMessageField(1, encodeStringField(1, text)); + const serverMsg = encodeMessageField(1, interactionUpdate); + return addConnectEnvelope(serverMsg); +} + +function turnEndedFrame(): Uint8Array { + // InteractionUpdate.field 14 (turn_ended), wire type 0, value 0 — presence matters + const interactionUpdate = new Uint8Array([(14 << 3) | 0, 0]); + const serverMsg = encodeMessageField(1, interactionUpdate); + return addConnectEnvelope(serverMsg); +} + +function heartbeatFrame(): Uint8Array { + // InteractionUpdate.field 13 (heartbeat), wire type 0, value 0 + const interactionUpdate = new Uint8Array([(13 << 3) | 0, 0]); + const serverMsg = encodeMessageField(1, interactionUpdate); + return addConnectEnvelope(serverMsg); +} + +function streamResponse(...frames: Uint8Array[]): Response { + const stream = new ReadableStream({ + start(controller) { + for (const f of frames) controller.enqueue(f); + controller.close(); + }, + }); + return new Response(stream, { status: 200, headers: { 'content-type': 'application/grpc-web+proto' } }); +} + +async function collect(gen: AsyncGenerator): Promise { + const out: unknown[] = []; + for await (const chunk of gen) out.push(chunk); + return out; +} + +const BIDI_URL = 'https://api2.cursor.sh/aiserver.v1.BidiService/BidiAppend'; + +describe('AgentTransport.bidiAppend', () => { + test('POSTs the envelope on the write channel', async () => { + const fetchMock = vi.fn(async () => okEmptyResponse()); + const transport = makeTransport(fetchMock); + await transport.bidiAppend('req-1', 0n, new Uint8Array([1, 2, 3])); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [unknown, RequestInit]; + expect(url).toBe(BIDI_URL); + expect(init.method).toBe('POST'); + const headers = init.headers as Record; + expect(headers['authorization']).toBe('Bearer tok'); + expect(headers['x-cursor-checksum']).toBe('checksum-value'); + expect(headers['content-type']).toBe('application/grpc-web+proto'); + }); + + test('retries on transient network errors', async () => { + const fetchMock = vi.fn(async () => { + if (fetchMock.mock.calls.length === 1) throw new Error('ECONNRESET socket closed'); + return okEmptyResponse(); + }); + const transport = makeTransport(fetchMock); + await transport.bidiAppend('req-1', 0n, new Uint8Array([1])); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test('does not retry on non-network errors', async () => { + const fetchMock = vi.fn(async () => { + if (fetchMock.mock.calls.length === 1) throw new Error('invalid argument'); + return okEmptyResponse(); + }); + const transport = makeTransport(fetchMock); + await expect(transport.bidiAppend('req-1', 0n, new Uint8Array([1]))).rejects.toThrow('invalid argument'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test('surfaces non-ok status as an error', async () => { + const fetchMock = vi.fn(async () => new Response('nope', { status: 401 })); + const transport = makeTransport(fetchMock); + await expect(transport.bidiAppend('req-1', 0n, new Uint8Array([1]))).rejects.toThrow('BidiAppend failed: 401'); + }); +}); + +describe('AgentTransport.openChatStream', () => { + function dualChannelMock(runSseResponse: Response): ReturnType { + return vi.fn(async (url: string) => { + if (String(url).includes('RunSSE')) return runSseResponse; + return okEmptyResponse(); // BidiAppend + }); + } + + test('yields text then done on a clean turn', async () => { + const fetchMock = dualChannelMock(streamResponse(textFrame('hello'), turnEndedFrame())); + const transport = makeTransport(fetchMock); + + const chunks = await collect(transport.openChatStream({ message: 'hi', model: 'gpt-4o' } as AgentChatRequest)); + + const types = chunks.map(c => (c as { type: string }).type); + expect(types).toContain('text'); + expect((chunks.find(c => (c as { type: string }).type === 'text') as { content?: string })?.content).toBe('hello'); + expect(types[types.length - 1]).toBe('done'); + }); + + test('heartbeats after text do not preempt turn_ended (authoritative end)', async () => { + // Regression: a raw heartbeat count must NOT end the turn after output has + // started — cursor interleaves keep-alive heartbeats and KV checkpoints + // between the final text and the turn_ended (IU field 14) marker. Closing + // on a beat count truncated short answers whose turn_ended lagged behind. + const fetchMock = dualChannelMock(streamResponse( + textFrame('the answer is 42'), + heartbeatFrame(), heartbeatFrame(), heartbeatFrame(), + heartbeatFrame(), heartbeatFrame(), heartbeatFrame(), + turnEndedFrame(), + )); + const transport = makeTransport(fetchMock); + + const chunks = await collect(transport.openChatStream({ message: 'hi', model: 'gpt-4o' } as AgentChatRequest)); + const types = chunks.map(c => (c as { type: string }).type); + + // The full text survives, and the turn ends on the turn_ended frame (done + // is the last chunk), not cut short by the six intervening heartbeats. + expect((chunks.find(c => (c as { type: string }).type === 'text') as { content?: string })?.content).toBe('the answer is 42'); + expect(types[types.length - 1]).toBe('done'); + }); + + test('sends the initial RunRequest on BidiAppend concurrently with RunSSE', async () => { + const fetchMock = dualChannelMock(streamResponse(textFrame('hi'), turnEndedFrame())); + const transport = makeTransport(fetchMock); + await collect(transport.openChatStream({ message: 'hi', model: 'gpt-4o' } as AgentChatRequest)); + + const bidiCalls = fetchMock.mock.calls.filter(c => String(c[0]).includes('BidiAppend')); + const runSseCalls = fetchMock.mock.calls.filter(c => String(c[0]).includes('RunSSE')); + expect(runSseCalls).toHaveLength(1); + // At least the initial RunRequest BidiAppend + expect(bidiCalls.length).toBeGreaterThanOrEqual(1); + }); + + test('yields an error chunk on non-ok RunSSE', async () => { + const fetchMock = dualChannelMock(new Response('upstream down', { status: 503 })); + const transport = makeTransport(fetchMock); + const chunks = await collect(transport.openChatStream({ message: 'hi', model: 'gpt-4o' } as AgentChatRequest)); + const err = chunks.find(c => (c as { type: string }).type === 'error') as { error?: string }; + expect(err).toBeDefined(); + expect(err!.error).toContain('503'); + }); + + test('yields an error chunk when RunSSE has no body', async () => { + const fetchMock = dualChannelMock(new Response(null, { status: 200 })); + const transport = makeTransport(fetchMock); + const chunks = await collect(transport.openChatStream({ message: 'hi', model: 'gpt-4o' } as AgentChatRequest)); + const err = chunks.find(c => (c as { type: string }).type === 'error') as { error?: string }; + expect(err?.error).toContain('No response body'); + }); +}); + +describe('AgentTransport.sendRejectedTool', () => { + test('sends a result frame then a stream-close control (2 BidiAppends)', async () => { + // Open a stream first so currentRequestId is set (sendExecAndClose guards it). + const runSseFetch = vi.fn(async (url: string) => { + if (String(url).includes('RunSSE')) return streamResponse(textFrame('hi'), turnEndedFrame()); + return okEmptyResponse(); + }); + const transport2 = makeTransport(runSseFetch); + // Drive the stream just enough to set currentRequestId, then reject a shell tool. + const gen = transport2.openChatStream({ message: 'hi', model: 'gpt-4o' } as AgentChatRequest); + await gen.next(); // text chunk, currentRequestId now set + + const bidiBefore = runSseFetch.mock.calls.filter(c => String(c[0]).includes('BidiAppend')).length; + await transport2.sendRejectedTool({ type: 'shell', id: 9, execId: 'e1', command: 'rm', cwd: '/' }, 'no shell in gateway'); + const bidiAfter = runSseFetch.mock.calls.filter(c => String(c[0]).includes('BidiAppend')).length; + expect(bidiAfter - bidiBefore).toBe(2); // exec result + control close + await gen.return(undefined); + }); + + test('throws when no active stream is open', async () => { + const transport = makeTransport(vi.fn(async () => okEmptyResponse())); + await expect( + transport.sendRejectedTool({ type: 'shell', id: 1, command: 'x', cwd: '/' }, 'reason'), + ).rejects.toThrow('No active chat stream'); + }); +}); diff --git a/packages/provider-cursor/src/auth/import.ts b/packages/provider-cursor/src/auth/import.ts new file mode 100644 index 000000000..255ec54ae --- /dev/null +++ b/packages/provider-cursor/src/auth/import.ts @@ -0,0 +1,72 @@ +/** + * Cursor upstream import flow — poll-based PKCE login. + * + * Unlike Codex/Claude (callback paste), Cursor login is poll-based: the + * operator opens the authorize URL in a browser, and the gateway polls + * api2.cursor.sh/auth/poll until it returns tokens. This module owns the + * authorize-url generation and the tokens → persisted config/state mapping; + * the control-plane routes (Step 7) wire pollCursorAuth in between. + */ + +import { getTokenExpiry } from './oauth.ts'; +import { generateCursorAuthParams, type CursorAuthParams, type CursorPollTokens } from './poll.ts'; +import type { CursorAccountIdentity, CursorUpstreamConfig } from '../config.ts'; +import type { CursorAccessTokenEntry, CursorUpstreamState } from '../state.ts'; + +export type { CursorAuthParams, CursorPollTokens }; + +/** Start a login: returns the authorize URL for the operator + the verifier/uuid to poll with. */ +export const buildCursorAuthorizeUrl = async (): Promise => await generateCursorAuthParams(); + +/** + * Derive the account identity from the access token JWT. Cursor's JWT payload + * fields are not fully documented; we read `sub` as the userId and `email` when + * present, falling back to a stable placeholder so the (email, userId) identity + * pair is always populated for the config tuple. + */ +export const deriveCursorIdentity = (accessToken: string): CursorAccountIdentity => { + let userId = 'cursor-user'; + let email = 'cursor-user'; + try { + const parts = accessToken.split('.'); + if (parts.length === 3 && parts[1]) { + const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) as Record; + if (typeof payload['sub'] === 'string' && payload['sub'] !== '') userId = payload['sub']; + if (typeof payload['email'] === 'string' && payload['email'] !== '') email = payload['email']; + else if (typeof payload['name'] === 'string' && payload['name'] !== '') email = payload['name']; + } + } catch { + // not a JWT — keep placeholders + } + return { email, userId }; +}; + +/** Build the persisted config (identity tuple) from a derived identity. */ +export const buildCursorImportConfig = (identity: CursorAccountIdentity): CursorUpstreamConfig => ({ + accounts: [identity], +}); + +/** + * Build the initial persisted state from poll tokens: an active credential + * with the refresh token, a cached access token entry, and no quota snapshot. + */ +export const buildCursorImportState = (tokens: CursorPollTokens): CursorUpstreamState => { + const identity = deriveCursorIdentity(tokens.accessToken); + const accessTokenEntry: CursorAccessTokenEntry = { + token: tokens.accessToken, + expiresAt: getTokenExpiry(tokens.accessToken), + refreshedAt: new Date().toISOString(), + }; + return { + accounts: [ + { + userId: identity.userId, + refresh_token: tokens.refreshToken, + state: 'active', + state_updated_at: new Date().toISOString(), + accessToken: accessTokenEntry, + quotaSnapshot: null, + }, + ], + }; +}; diff --git a/packages/provider-cursor/src/auth/import_test.ts b/packages/provider-cursor/src/auth/import_test.ts new file mode 100644 index 000000000..eca801566 --- /dev/null +++ b/packages/provider-cursor/src/auth/import_test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest'; + +import { deriveCursorIdentity, buildCursorImportConfig, buildCursorImportState } from './import.ts'; + +const jwt = (payload: object): string => `header.${btoa(JSON.stringify(payload))}.sig`; + +describe('deriveCursorIdentity', () => { + test('reads sub + email from the JWT', () => { + const id = deriveCursorIdentity(jwt({ sub: 'u1', email: 'a@b.com' })); + expect(id.userId).toBe('u1'); + expect(id.email).toBe('a@b.com'); + }); + + test('falls back to name when email is absent', () => { + const id = deriveCursorIdentity(jwt({ sub: 'u1', name: 'Alice' })); + expect(id.email).toBe('Alice'); + }); + + test('placeholders for a non-JWT token', () => { + const id = deriveCursorIdentity('not-a-jwt'); + expect(id.userId).toBe('cursor-user'); + expect(id.email).toBe('cursor-user'); + }); +}); + +describe('buildCursorImportConfig', () => { + test('wraps an identity in a 1-tuple', () => { + const cfg = buildCursorImportConfig({ email: 'a@b', userId: 'u1' }); + expect(cfg.accounts).toHaveLength(1); + expect(cfg.accounts[0]!.userId).toBe('u1'); + }); +}); + +describe('buildCursorImportState', () => { + test('builds an active credential with a cached access token', () => { + const tokens = { accessToken: jwt({ sub: 'u1', email: 'a@b' }), refreshToken: 'ref' }; + const state = buildCursorImportState(tokens); + expect(state.accounts).toHaveLength(1); + const acc = state.accounts[0]!; + expect(acc.userId).toBe('u1'); + expect(acc.refresh_token).toBe('ref'); + expect(acc.state).toBe('active'); + expect(acc.state_updated_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(acc.accessToken!.token).toBe(tokens.accessToken); + expect(acc.accessToken!.expiresAt).toBeGreaterThan(Date.now()); + expect(acc.quotaSnapshot).toBeNull(); + }); +}); diff --git a/packages/provider-cursor/src/auth/oauth.ts b/packages/provider-cursor/src/auth/oauth.ts new file mode 100644 index 000000000..ee9d64fd0 --- /dev/null +++ b/packages/provider-cursor/src/auth/oauth.ts @@ -0,0 +1,102 @@ +/** + * Cursor OAuth token refresh. + * + * Cursor's refresh endpoint is `POST /auth/exchange_user_api_key` with the + * refresh token as a Bearer token and an empty JSON body — distinct from the + * standard OAuth2 /token grant_type=refresh_token flow used by Codex/Claude. + * Returns { accessToken, refreshToken }. + */ + +import type { Fetcher } from '@floway-dev/provider'; + +const CURSOR_REFRESH_URL = 'https://api2.cursor.sh/auth/exchange_user_api_key'; + +export interface CursorOAuthTokens { + access_token: string; + refresh_token: string; + /** Absolute expiry in ms epoch (exp - 5min safety margin). */ + expires_at: number; +} + +/** + * Terminal error: the refresh token is dead and the operator must re-import. + * Cursor's refresh endpoint doesn't return a structured OAuth error code, so + * we classify by HTTP status — 401/403 unambiguously mean the session is gone. + */ +export class CursorSessionTerminatedError extends Error { + readonly status: number; + readonly upstreamMessage: string; + constructor(args: { status: number; message: string }) { + super(`Cursor OAuth session terminated: ${args.message}`); + this.name = 'CursorSessionTerminatedError'; + this.status = args.status; + this.upstreamMessage = args.message; + } +} + +const isTerminalStatus = (status: number): boolean => status === 401 || status === 403; + +export const refreshCursorAccessToken = async (refreshToken: string, fetcher: Fetcher): Promise => { + const response = await fetcher(CURSOR_REFRESH_URL, { + method: 'POST', + headers: { + authorization: `Bearer ${refreshToken}`, + 'content-type': 'application/json', + }, + body: '{}', + }); + + const rawText = await response.text(); + let parsed: unknown; + try { + parsed = rawText.length > 0 ? JSON.parse(rawText) : {}; + } catch { + parsed = { _nonJsonBody: rawText }; + } + + const root = typeof parsed === 'object' && parsed !== null ? (parsed as Record) : null; + + if (!response.ok) { + const message = typeof root?.['message'] === 'string' ? (root['message'] as string) : rawText.slice(0, 256); + if (isTerminalStatus(response.status)) { + throw new CursorSessionTerminatedError({ status: response.status, message }); + } + throw new Error(`Cursor token refresh failed: ${response.status} - ${message}`); + } + + if (root === null) throw new Error('Cursor token refresh response is not an object'); + if (typeof root['accessToken'] !== 'string' || root['accessToken'] === '') { + throw new Error('Cursor token refresh response missing accessToken'); + } + + const accessToken = root['accessToken'] as string; + const refreshTokenNew = typeof root['refreshToken'] === 'string' && root['refreshToken'] !== '' ? (root['refreshToken'] as string) : refreshToken; + + return { + access_token: accessToken, + refresh_token: refreshTokenNew, + expires_at: getTokenExpiry(accessToken), + }; +}; + +/** + * Extract JWT expiry with a 5-minute safety margin. Falls back to 1 hour from + * now if the token can't be parsed as a JWT. + */ +export function getTokenExpiry(token: string): number { + try { + const parts = token.split('.'); + if (parts.length !== 3 || !parts[1]) return Date.now() + 3600 * 1000; + const decoded = JSON.parse(atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))); + if (decoded && typeof decoded === 'object' && typeof (decoded as { exp?: unknown }).exp === 'number') { + return ((decoded as { exp: number }).exp * 1000) - 5 * 60 * 1000; + } + } catch { + // not a JWT + } + return Date.now() + 3600 * 1000; +} + +export function isTokenExpired(expiresAt: number): boolean { + return Date.now() >= expiresAt; +} diff --git a/packages/provider-cursor/src/auth/oauth_test.ts b/packages/provider-cursor/src/auth/oauth_test.ts new file mode 100644 index 000000000..48742ee50 --- /dev/null +++ b/packages/provider-cursor/src/auth/oauth_test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'vitest'; + +import { refreshCursorAccessToken, CursorSessionTerminatedError, getTokenExpiry } from './oauth.ts'; +import type { Fetcher } from '@floway-dev/provider'; + +const mkFetcher = (fn: (url: string) => Response | Promise): Fetcher => fn as unknown as Fetcher; + +const jsonRes = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +describe('refreshCursorAccessToken', () => { + test('returns tokens on 200', async () => { + const fetcher = mkFetcher(async () => jsonRes({ accessToken: 'acc', refreshToken: 'ref' })); + const tokens = await refreshCursorAccessToken('old-ref', fetcher); + expect(tokens.access_token).toBe('acc'); + expect(tokens.refresh_token).toBe('ref'); + expect(tokens.expires_at).toBeGreaterThan(Date.now()); + }); + + test('preserves the old refresh token when the response omits it', async () => { + const fetcher = mkFetcher(async () => jsonRes({ accessToken: 'acc' })); + const tokens = await refreshCursorAccessToken('old-ref', fetcher); + expect(tokens.refresh_token).toBe('old-ref'); + }); + + test('throws CursorSessionTerminatedError on 401', async () => { + const fetcher = mkFetcher(async () => new Response('unauthorized', { status: 401 })); + await expect(refreshCursorAccessToken('ref', fetcher)).rejects.toBeInstanceOf(CursorSessionTerminatedError); + }); + + test('throws CursorSessionTerminatedError on 403', async () => { + const fetcher = mkFetcher(async () => new Response('forbidden', { status: 403 })); + await expect(refreshCursorAccessToken('ref', fetcher)).rejects.toBeInstanceOf(CursorSessionTerminatedError); + }); + + test('throws a plain Error (not terminal) on 500', async () => { + const fetcher = mkFetcher(async () => new Response('boom', { status: 500 })); + await expect(refreshCursorAccessToken('ref', fetcher)).rejects.not.toBeInstanceOf(CursorSessionTerminatedError); + }); + + test('rejects a 200 missing accessToken', async () => { + const fetcher = mkFetcher(async () => jsonRes({ refreshToken: 'r' })); + await expect(refreshCursorAccessToken('ref', fetcher)).rejects.toThrow('missing accessToken'); + }); +}); + +describe('getTokenExpiry', () => { + test('parses JWT exp with a 5-minute margin', () => { + const payload = { exp: Math.floor(Date.now() / 1000) + 3600 }; + const jwt = `header.${btoa(JSON.stringify(payload))}.sig`; + const expiry = getTokenExpiry(jwt); + // exp is 1h out; expiry is exp - 5min, so ~55min from now. + expect(expiry).toBeLessThan(Date.now() + 3600 * 1000); + expect(expiry).toBeGreaterThan(Date.now() + 50 * 60 * 1000); + }); + + test('falls back to ~1h for a non-JWT string', () => { + expect(getTokenExpiry('not-a-jwt')).toBeGreaterThan(Date.now() + 3500 * 1000); + }); +}); diff --git a/packages/provider-cursor/src/auth/poll.ts b/packages/provider-cursor/src/auth/poll.ts new file mode 100644 index 000000000..464a09a98 --- /dev/null +++ b/packages/provider-cursor/src/auth/poll.ts @@ -0,0 +1,116 @@ +/** + * Cursor PKCE poll-based OAuth login. + * + * Unlike Codex/Claude (callback-paste), Cursor CLI login is poll-based: + * 1. generate an opaque uuid + PKCE verifier/challenge + * 2. open cursor.com/loginDeepControl?challenge=&uuid=&mode=login&redirectTarget=cli + * 3. poll api2.cursor.sh/auth/poll?uuid=&verifier= until it returns tokens + * + * Workers-clean: crypto.getRandomValues / crypto.subtle / crypto.randomUUID, + * bytesToBase64Url from checksum.ts instead of Buffer. + */ + +import { bytesToBase64Url } from '../checksum.ts'; +import type { Fetcher } from '@floway-dev/provider'; + +const CURSOR_LOGIN_URL = 'https://cursor.com/loginDeepControl'; +const CURSOR_POLL_URL = 'https://api2.cursor.sh/auth/poll'; + +const POLL_MAX_ATTEMPTS = 150; +const POLL_BASE_DELAY = 1000; +const POLL_MAX_DELAY = 10_000; +const POLL_BACKOFF_MULTIPLIER = 1.2; +const POLL_MAX_CONSECUTIVE_ERRORS = 10; + +export interface CursorAuthParams { + verifier: string; + challenge: string; + uuid: string; + loginUrl: string; +} + +export interface CursorPollTokens { + accessToken: string; + refreshToken: string; +} + +async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { + const verifierBytes = new Uint8Array(96); + crypto.getRandomValues(verifierBytes); + const verifier = bytesToBase64Url(verifierBytes); + + const data = new TextEncoder().encode(verifier); + const hashBuffer = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(data))); + const challenge = bytesToBase64Url(hashBuffer); + + return { verifier, challenge }; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Generate the PKCE pair + uuid + login URL. The operator opens loginUrl in a + * browser; the gateway polls with the returned verifier+uuid. + */ +export async function generateCursorAuthParams(): Promise { + const { verifier, challenge } = await generatePKCE(); + const uuid = crypto.randomUUID(); + + const params = new URLSearchParams({ + challenge, + uuid, + mode: 'login', + redirectTarget: 'cli', + }); + + const loginUrl = `${CURSOR_LOGIN_URL}?${params.toString()}`; + return { verifier, challenge, uuid, loginUrl }; +} + +/** + * Poll the Cursor auth endpoint until the operator completes login. 404 means + * "not ready yet" (backoff and continue); 200 returns the tokens. Other errors + * are counted against a consecutive-error cap so a transient network storm + * doesn't abort a legitimate pending login. + */ +export async function pollCursorAuth(uuid: string, verifier: string, fetcher: Fetcher): Promise { + let delay = POLL_BASE_DELAY; + let consecutiveErrors = 0; + let lastError: string | undefined; + + for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) { + await sleep(delay); + + try { + const response = await fetcher(`${CURSOR_POLL_URL}?uuid=${uuid}&verifier=${verifier}`, {}); + + if (response.status === 404) { + consecutiveErrors = 0; + delay = Math.min(delay * POLL_BACKOFF_MULTIPLIER, POLL_MAX_DELAY); + continue; + } + + if (response.ok) { + const data = (await response.json()) as { accessToken?: string; refreshToken?: string }; + if (typeof data.accessToken !== 'string' || typeof data.refreshToken !== 'string') { + throw new Error('Poll response missing accessToken/refreshToken'); + } + return { accessToken: data.accessToken, refreshToken: data.refreshToken }; + } + + const errorBody = await response.text().catch(() => ''); + throw new Error(`Poll failed: ${response.status}${errorBody ? ` - ${errorBody}` : ''}`); + } catch (err) { + consecutiveErrors++; + lastError = err instanceof Error ? err.message : String(err); + if (consecutiveErrors >= POLL_MAX_CONSECUTIVE_ERRORS) { + throw new Error(`Too many consecutive errors during Cursor auth polling (last: ${lastError})`); + } + delay = Math.min(delay * POLL_BACKOFF_MULTIPLIER, POLL_MAX_DELAY); + } + } + + throw new Error('Cursor authentication polling timeout'); +} diff --git a/packages/provider-cursor/src/auth/poll_test.ts b/packages/provider-cursor/src/auth/poll_test.ts new file mode 100644 index 000000000..074c99f6c --- /dev/null +++ b/packages/provider-cursor/src/auth/poll_test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'vitest'; + +import { generateCursorAuthParams, pollCursorAuth } from './poll.ts'; +import type { Fetcher } from '@floway-dev/provider'; + +describe('generateCursorAuthParams', () => { + test('produces verifier/challenge/uuid/loginUrl', async () => { + const p = await generateCursorAuthParams(); + expect(p.verifier.length).toBeGreaterThan(0); + expect(p.challenge.length).toBeGreaterThan(0); + expect(p.uuid).toMatch(/^[0-9a-f-]{36}$/i); + expect(p.loginUrl).toContain('cursor.com/loginDeepControl'); + expect(p.loginUrl).toContain(`uuid=${p.uuid}`); + expect(p.loginUrl).toContain(`challenge=${encodeURIComponent(p.challenge)}`); + expect(p.loginUrl).toContain('mode=login'); + expect(p.loginUrl).toContain('redirectTarget=cli'); + }); + + test('challenge is the base64url sha256 of the verifier', async () => { + const p = await generateCursorAuthParams(); + const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(p.verifier))); + // base64url of hash, no padding + const expected = btoa(String.fromCharCode(...hash)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(p.challenge).toBe(expected); + }); +}); + +describe('pollCursorAuth', () => { + test('returns tokens when the poll resolves 200', async () => { + const fetcher: Fetcher = (async () => + new Response(JSON.stringify({ accessToken: 'a', refreshToken: 'r' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as unknown as Fetcher; + const tokens = await pollCursorAuth('uuid', 'verifier', fetcher); + expect(tokens.accessToken).toBe('a'); + expect(tokens.refreshToken).toBe('r'); + }); +}); diff --git a/packages/provider-cursor/src/checksum.ts b/packages/provider-cursor/src/checksum.ts new file mode 100644 index 000000000..e16efaf12 --- /dev/null +++ b/packages/provider-cursor/src/checksum.ts @@ -0,0 +1,61 @@ +/** + * x-cursor-checksum header — pure-compute, no machine fingerprint. + * + * Ported from Cursor-To-OpenAI / yet-another-opencode-cursor-auth's + * generateChecksum, Workers-clean: Uint8Array instead of Buffer, Web Crypto + * sha256Hex instead of node:crypto createHash. Async because crypto.subtle + * digest is async — callers precompute once per turn/window and inject the + * string into AgentTransport via getChecksum. + * + * Format: `/` + * where hex1 = sha256(salt[1]).slice(0,8), hex2 = sha256(token).slice(0,8), + * and the timestamp is the current time floored to a 30-minute window, + * divided by 1e6, big-endian into 6 bytes, then run through the XOR/offset + * obfuscation pass. + */ + +import { sha256Hex } from '@floway-dev/platform'; + +/** Base64url-encode a byte buffer (no padding), Workers-clean. */ +export function bytesToBase64Url(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function obfuscate(data: Uint8Array): void { + let t = 165; + for (let i = 0; i < data.length; i++) { + data[i] = ((data[i]! ^ t) + i) & 0xff; + t = data[i]!; + } +} + +async function shortSha256Hex(input: string): Promise { + const hex = await sha256Hex(new TextEncoder().encode(input)); + return hex.slice(0, 8); +} + +export async function generateCursorChecksum(token: string): Promise { + const salt = token.split('.'); + + // Floor the current time to a 30-minute window, then encode as 6 big-endian + // bytes (value = ms / 1e6). The windowing makes the checksum stable within + // each half-hour so repeated requests in a turn share a value. + const now = new Date(); + now.setMinutes(30 * Math.floor(now.getMinutes() / 30), 0, 0); + const timestamp = Math.floor(now.getTime() / 1e6); + + const tsBuf = new Uint8Array(6); + let temp = timestamp; + for (let i = 5; i >= 0; i--) { + tsBuf[i] = temp & 0xff; + temp = Math.floor(temp / 256); + } + obfuscate(tsBuf); + + const hex1 = salt[1] ? await shortSha256Hex(salt[1]!) : '00000000'; + const hex2 = await shortSha256Hex(token); + + return `${bytesToBase64Url(tsBuf)}${hex1}/${hex2}`; +} diff --git a/packages/provider-cursor/src/checksum_test.ts b/packages/provider-cursor/src/checksum_test.ts new file mode 100644 index 000000000..74f286a62 --- /dev/null +++ b/packages/provider-cursor/src/checksum_test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest'; + +import { generateCursorChecksum, bytesToBase64Url } from './checksum.ts'; +import { sha256Hex } from '@floway-dev/platform'; + +describe('bytesToBase64Url', () => { + test('encodes a single zero byte without padding', () => { + expect(bytesToBase64Url(new Uint8Array([0]))).toBe('AA'); + }); + test('is url-safe (0xff -> _w, not /w)', () => { + expect(bytesToBase64Url(new Uint8Array([0xff]))).toBe('_w'); + }); +}); + +describe('generateCursorChecksum', () => { + test('has the / shape', async () => { + const cs = await generateCursorChecksum('a.b.c'); + expect(cs).toMatch(/^[A-Za-z0-9_-]+[0-9a-f]{8}\/[0-9a-f]{8}$/); + }); + + test('deterministic for the same token within a window', async () => { + const a = await generateCursorChecksum('token.x.y'); + const b = await generateCursorChecksum('token.x.y'); + expect(a).toBe(b); + }); + + test('differs for different tokens', async () => { + const a = await generateCursorChecksum('token.one'); + const b = await generateCursorChecksum('token.two'); + expect(a).not.toBe(b); + }); + + test('hex2 matches sha256(token).slice(0,8)', async () => { + const token = 'header.payload.sig'; + const cs = await generateCursorChecksum(token); + const hex2 = cs.split('/')[1]!; + const expected = (await sha256Hex(new TextEncoder().encode(token))).slice(0, 8); + expect(hex2).toBe(expected); + }); + + test('hex1 matches sha256(salt[1]).slice(0,8)', async () => { + const token = 'header.saltbody.sig'; + const cs = await generateCursorChecksum(token); + const tail = cs.split('/')[0]!; + const hex1 = tail.slice(-8); + const expected = (await sha256Hex(new TextEncoder().encode('saltbody'))).slice(0, 8); + expect(hex1).toBe(expected); + }); +}); diff --git a/packages/provider-cursor/src/config.ts b/packages/provider-cursor/src/config.ts new file mode 100644 index 000000000..f5477df42 --- /dev/null +++ b/packages/provider-cursor/src/config.ts @@ -0,0 +1,68 @@ +import type { UpstreamRecord } from '@floway-dev/provider'; + +// One Cursor account's operator-managed identity, derived from the access +// token JWT at import. Mutating credentials (refresh_token, access_token, +// credential health) live in CursorUpstreamState instead. +export interface CursorAccountIdentity { + email: string; + userId: string; +} + +// Cursor config is an account pool. v1 always carries exactly one entry — +// typed as a 1-tuple so callers can index accounts[0] without a nullable +// cushion. The wire shape stays array-of-accounts so a future fan-out / +// round-robin pool feature can widen the tuple without a schema migration. +export interface CursorUpstreamConfig { + accounts: [CursorAccountIdentity]; +} + +export type CursorUpstreamRecord = UpstreamRecord & { + provider: 'cursor'; + config: CursorUpstreamConfig; +}; + +function assertCursorUpstreamConfig(value: unknown): asserts value is CursorUpstreamConfig { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('CursorUpstreamConfig must be a plain object'); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (key !== 'accounts') { + throw new TypeError(`CursorUpstreamConfig has unexpected key '${key}'`); + } + } + if (!Array.isArray(obj.accounts)) { + throw new TypeError('CursorUpstreamConfig.accounts must be an array'); + } + if (obj.accounts.length !== 1) { + throw new TypeError(`CursorUpstreamConfig.accounts must hold exactly one account (got ${obj.accounts.length})`); + } + const identityKeys: readonly (keyof CursorAccountIdentity)[] = ['email', 'userId']; + const allowedKeys = new Set(identityKeys); + for (let i = 0; i < obj.accounts.length; i++) { + const where = `CursorUpstreamConfig.accounts[${i}]`; + const account = obj.accounts[i]; + if (typeof account !== 'object' || account === null || Array.isArray(account)) { + throw new TypeError(`${where} must be a plain object`); + } + const acc = account as Record; + for (const key of Object.keys(acc)) { + if (!allowedKeys.has(key)) { + throw new TypeError(`${where} has unexpected key '${key}'`); + } + } + for (const key of identityKeys) { + const v = acc[key]; + if (typeof v !== 'string' || v === '') { + throw new TypeError(`${where}.${key} must be a non-empty string`); + } + } + } +} + +export function assertCursorUpstreamRecord(record: UpstreamRecord): asserts record is CursorUpstreamRecord { + if (record.provider !== 'cursor') { + throw new TypeError(`Expected provider 'cursor', got '${record.provider}'`); + } + assertCursorUpstreamConfig(record.config); +} diff --git a/packages/provider-cursor/src/config_test.ts b/packages/provider-cursor/src/config_test.ts new file mode 100644 index 000000000..feb6bc09a --- /dev/null +++ b/packages/provider-cursor/src/config_test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest'; + +import { assertCursorUpstreamRecord } from './config.ts'; +import type { UpstreamRecord } from '@floway-dev/provider'; + +const goodAccount = { email: 'a@b.com', userId: 'u1' }; +const good = { accounts: [goodAccount] }; + +const wrap = (config: unknown): UpstreamRecord => ({ + id: 'up', + provider: 'cursor', + name: 'n', + enabled: true, + sortOrder: 0, + createdAt: '', + updatedAt: '', + config: config as UpstreamRecord['config'], + state: null, + flagOverrides: {}, + disabledPublicModelIds: [], + proxyFallbackList: [], + modelPrefix: null, +}); + +describe('assertCursorUpstreamRecord (config validation)', () => { + test('accepts a complete config', () => { + expect(() => assertCursorUpstreamRecord(wrap(good))).not.toThrow(); + }); + + test('rejects the wrong provider', () => { + expect(() => assertCursorUpstreamRecord({ ...wrap(good), provider: 'codex' })).toThrow("Expected provider 'cursor'"); + }); + + test.each([ + ['email empty', { accounts: [{ ...goodAccount, email: '' }] }], + ['email type', { accounts: [{ ...goodAccount, email: 123 }] }], + ['userId missing', { accounts: [{ ...goodAccount, userId: undefined }] }], + ['extra field on account', { accounts: [{ ...goodAccount, extra: 1 }] }], + ['extra top-level field', { ...good, extra: 1 }], + ['accounts not an array', { accounts: goodAccount }], + ['empty accounts array', { accounts: [] }], + ['two accounts (v1 invariant)', { accounts: [goodAccount, { ...goodAccount, userId: 'u2' }] }], + ])('rejects %s', (_label, value) => { + expect(() => assertCursorUpstreamRecord(wrap(value))).toThrow(); + }); +}); diff --git a/packages/provider-cursor/src/constants.ts b/packages/provider-cursor/src/constants.ts new file mode 100644 index 000000000..fe376eac8 --- /dev/null +++ b/packages/provider-cursor/src/constants.ts @@ -0,0 +1,19 @@ +// All Cursor upstream constants. Do NOT make these operator-configurable — +// the client-version header is part of how Cursor identifies CLI traffic. + +// Cursor's backend. RunSSE + BidiAppend both live here in HTTP/1.1 mode. +export const CURSOR_BACKEND_BASE = 'https://api2.cursor.sh'; + +export const CURSOR_RUN_SSE_PATH = '/agent.v1.AgentService/RunSSE'; +export const CURSOR_BIDI_APPEND_PATH = '/aiserver.v1.BidiService/BidiAppend'; +export const CURSOR_USABLE_MODELS_PATH = '/aiserver.v1.AiService/GetUsableModels'; + +// Cursor CLI client version we impersonate on the data plane. Pinned from +// yet-another-opencode-cursor-auth / opencode-cursor-proxy; newer models may +// gate behind a minimal client version, so bump against the latest CLI tag. +export const CURSOR_CLIENT_VERSION = 'cli-2025.11.25-d5b3271'; + +// connect-es UA — matches what the Cursor CLI's connect-rpc client sends. +export const CURSOR_USER_AGENT = 'connect-es/1.4.0'; + +export const CURSOR_GRPC_WEB_CONTENT_TYPE = 'application/grpc-web+proto'; diff --git a/packages/provider-cursor/src/cursor-images.ts b/packages/provider-cursor/src/cursor-images.ts new file mode 100644 index 000000000..b05b8accb --- /dev/null +++ b/packages/provider-cursor/src/cursor-images.ts @@ -0,0 +1,48 @@ +/** + * OpenAI image_url → Cursor SelectedImage parsing. + * + * Skeleton: the Cursor UserMessage SelectedImage proto field number and the + * remote-image SSRF policy are pending a real capture (plan risk #2). The + * data-URL path is implemented; remote URLs are deferred. fetch.ts does not + * yet inject images into the AgentRunRequest — this module is a placeholder + * for when the field layout is confirmed. + */ + +import type { ChatCompletionsMessage } from '@floway-dev/protocols/chat-completions'; + +export interface CursorImageInput { + // Raw image bytes (decoded from a data: URL). + data: Uint8Array; + // A hint dimension if the source carried one. + detail?: 'low' | 'high' | 'auto'; +} + +const DATA_URL_RE = /^data:image\/([a-zA-Z0-9.+-]+);base64,(.*)$/s; + +/** + * Extract data-URL images from a message's content parts. Remote image_url + * entries are skipped (TODO: fetch + SSRF guard once SelectedImage field + * layout is confirmed). + */ +export const parseCursorImages = (message: ChatCompletionsMessage): CursorImageInput[] => { + const content = message.content; + if (!Array.isArray(content)) return []; + + const out: CursorImageInput[] = []; + for (const part of content) { + if (part.type !== 'image_url') continue; + const url = part.image_url.url; + const match = DATA_URL_RE.exec(url); + if (!match) continue; // remote URL — deferred + const base64 = match[2]!; + try { + const bin = atob(base64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + out.push({ data: bytes, detail: part.image_url.detail }); + } catch { + // malformed base64 — skip + } + } + return out; +}; diff --git a/packages/provider-cursor/src/cursor-session-state.ts b/packages/provider-cursor/src/cursor-session-state.ts new file mode 100644 index 000000000..1269df44c --- /dev/null +++ b/packages/provider-cursor/src/cursor-session-state.ts @@ -0,0 +1,77 @@ +/** + * Cursor session protocol state — per-session generator + transport + seqno. + * + * Separate from DurableHttpSession because what we actually persist is the + * **live async generator** (the transport's openChatStream iterator) — not + * raw HTTP bytes. On Node this is a plain Map; on CF (future) the session + * will be reconstructed from the DurableHttpSession body bytes. + * + * Follows the same "in-process Map" pattern as provider-copilot/src/auth.ts:42 + * (inProcessTokenCache) and opencode-cursor-proxy/src/lib/session-reuse.ts + * (sessionMap holding session.iterator). + */ + +import type { AgentStreamChunk } from './proto/index.ts'; + +export interface CursorSessionEntry { + /** The live transport generator — continue pulling for follow-up chunks. */ + gen: AsyncGenerator; + /** Send tool result via BidiAppend on the same session. */ + sendMcpResult: (execId: number, mcpExecId: string | undefined, result: { success?: { content: string; isError?: boolean }; error?: string }) => Promise; + /** Tell cursor to resume streaming after tool results. */ + sendResumeAction: () => Promise; + /** Pending exec requests (tool_call_id → exec info) for matching results. */ + pendingExecs: Map; + /** session key for back-reference. */ + sessionKey: string; + /** Timestamp of last activity for idle eviction. */ + lastActivityAt: number; +} + +const sessions = new Map(); + +const IDLE_TTL_MS = 5 * 60 * 1000; + +export function getCursorSession(sessionKey: string): CursorSessionEntry | null { + const entry = sessions.get(sessionKey); + if (!entry) return null; + entry.lastActivityAt = Date.now(); + return entry; +} + +export function putCursorSession(entry: CursorSessionEntry): void { + entry.lastActivityAt = Date.now(); + sessions.set(entry.sessionKey, entry); +} + +export function deleteCursorSession(sessionKey: string): void { + const entry = sessions.get(sessionKey); + if (entry) { + // Close the generator to release the transport's RunSSE read. + void entry.gen.return(undefined); + sessions.delete(sessionKey); + } +} + +// Lazy idle sweep — runs on every get/put; evicts stale entries. +function sweepIdle(): void { + const now = Date.now(); + for (const [key, entry] of sessions) { + if (now - entry.lastActivityAt > IDLE_TTL_MS) { + void entry.gen.return(undefined); + sessions.delete(key); + } + } +} + +// Run sweep periodically (piggyback on get/put). +let lastSweep = 0; +function maybeSweep(): void { + const now = Date.now(); + if (now - lastSweep > 30_000) { + lastSweep = now; + sweepIdle(); + } +} + +export { maybeSweep as _sweepHook }; diff --git a/packages/provider-cursor/src/fetch.ts b/packages/provider-cursor/src/fetch.ts new file mode 100644 index 000000000..3ebc59dbb --- /dev/null +++ b/packages/provider-cursor/src/fetch.ts @@ -0,0 +1,449 @@ +/** + * Cursor Chat Completions data-plane call. + * + * Self-constructs a ProviderStreamResult (async generator of + * ProtocolFrame) — bypasses streamingProviderCall, + * which hard-requires a Response+text/event-stream, because Cursor's stream is + * a synthesized dual-channel (RunSSE read + BidiAppend write) rather than a + * single SSE body. The gateway's respond.ts re-encodes these frames downstream. + */ + +import { ensureCursorAccessToken, mintCursorAccessToken } from './access-token-cache.ts'; +import { createAgentTranslator, isComposerModel } from './agent-translate.ts'; +import { AgentTransport } from './agent-transport.ts'; +import { CursorSessionTerminatedError } from './auth/oauth.ts'; +import { generateCursorChecksum } from './checksum.ts'; +import { CURSOR_BACKEND_BASE, CURSOR_CLIENT_VERSION } from './constants.ts'; +import { getCursorSession, putCursorSession, deleteCursorSession } from './cursor-session-state.ts'; +import { AgentMode, type RequestContextEnv, type OpenAIToolDefinition } from './proto/index.ts'; +import { isCursorRateLimited } from './quota.ts'; +import { deriveSessionKey, mintSessionKey, unwrapToolCallId } from './session-id.ts'; +import type { CursorAccountCredential } from './state.ts'; +import { getDurableHttpSession, type DurableHttpSessionHandle } from '@floway-dev/platform'; +import type { ChatCompletionsStreamEvent, ChatCompletionsPayload, ChatCompletionsMessage, ChatCompletionsTool } from '@floway-dev/protocols/chat-completions'; +import { eventFrame, doneFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { type ProviderStreamResult, type UpstreamCallOptions, type UpstreamModel } from '@floway-dev/provider'; + +export interface CursorCallEffects { + persistRefreshTokenRotation(newRefreshToken: string): Promise; + persistTerminalState(state: 'session_terminated' | 'refresh_failed', message: string): Promise; +} + +interface CursorChatCallBase { + upstreamId: string; + account: CursorAccountCredential; + model: UpstreamModel; + headers: Headers; + signal?: AbortSignal; + effects: CursorCallEffects; + call: UpstreamCallOptions; +} + +export interface CallCursorChatCompletionsOptions extends CursorChatCallBase { + body: Omit; +} + +// Gateway environment reported to Cursor's request_context exec. The gateway +// has no real workspace (it rejects built-in tool exec), so these are stable +// placeholders. timezone is the only operator-relevant value. +const gatewayEnv: RequestContextEnv = { + workspacePath: '/workspace', + osVersion: 'darwin 24.0.0', + shell: '/bin/zsh', + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', +}; + +const mintAccessToken = (opts: CursorChatCallBase, refreshToken: string) => + mintCursorAccessToken(refreshToken, opts.call.fetcher, opts.effects.persistRefreshTokenRotation); + +// Pre-fetch gates + access-token mint + checksum precompute. The checksum is +// stable within a 30-minute window for a given token, so one precompute per +// turn is correct. +const prepareCursorCall = async ( + opts: CursorChatCallBase, +): Promise<{ ok: true; accessToken: string; checksum: string } | { ok: false; response: Response }> => { + const wrapSynthetic = (response: Response) => opts.call.recordUpstreamLatency(Promise.resolve(response)); + + if (opts.account.state !== 'active') { + return { ok: false, response: await wrapSynthetic(synthetic503(`Cursor upstream is ${opts.account.state}`)) }; + } + + if (isCursorRateLimited(opts.account.quotaSnapshot?.data ? 429 : 200)) { + // quota parsing is a placeholder; this branch is unreachable until real + // 429 headers are captured. Kept so the gate is wired. + return { ok: false, response: await wrapSynthetic(synthetic429('Cursor upstream rate-limited')) }; + } + + try { + const entry = await ensureCursorAccessToken(opts.upstreamId, opts.account.userId, refresh => mintAccessToken(opts, refresh)); + const checksum = await generateCursorChecksum(entry.token); + return { ok: true, accessToken: entry.token, checksum }; + } catch (err) { + if (err instanceof CursorSessionTerminatedError) { + await opts.effects.persistTerminalState('refresh_failed', err.upstreamMessage); + return { ok: false, response: await wrapSynthetic(synthetic503(`Cursor refresh failed: ${err.upstreamMessage}`)) }; + } + throw err; + } +}; + +// Cursor agent is stateless from the gateway's view: each fresh RunSSE carries +// an empty conversation state, so the full transcript is inlined into the +// single user message. System → prefix, history → role-tagged turns. +// +// Tool calling does NOT go through this transcript. Cursor's MCP protocol +// carries tool outputs over the BidiAppend ExecMcpResult write channel on a +// live session (see performCursorSessionFollowUp) — folding a tool result into +// the prompt as "[Tool result] {...}" severely degrades the model (it reads an +// unaddressed JSON blob and either ignores it or re-runs the tool). So we never +// flatten tool results here: role:'tool' messages are dropped, and an assistant +// turn that only carried tool_calls contributes just its own text (if any). +// +// This path serves (a) genuine new conversations and (b) the cold-resume +// fallback when a live session was lost (cross-instance / evicted / CF cold +// start). In case (b) the tools list stays advertised so cursor re-runs the +// agent loop natively — an honest "fresh turn" rather than a degraded prompt +// fold. +const flattenMessages = (messages: ChatCompletionsMessage[]): string => { + const parts: string[] = []; + for (const m of messages) { + // Tool results live on the MCP write channel, never in the transcript. + if (m.role === 'tool') continue; + + const text = messageText(m); + if (!text) continue; + const tag = m.role === 'system' || m.role === 'developer' ? 'System' + : m.role === 'user' ? 'User' + : m.role === 'assistant' ? 'Assistant' + : m.role; + parts.push(`[${tag}]\n${text}`); + } + + return parts.join('\n\n'); +}; + +const messageText = (m: ChatCompletionsMessage): string => { + if (typeof m.content === 'string') return m.content; + if (Array.isArray(m.content)) { + return m.content.filter(p => p.type === 'text').map(p => p.text).join('\n'); + } + // An assistant turn carrying only tool_calls (no text) contributes nothing to + // the transcript — the call itself is replayed natively via the tools list, + // not narrated into the prompt. + return ''; +}; + +const toAgentTools = (tools: ChatCompletionsTool[] | null | undefined): OpenAIToolDefinition[] | undefined => { + if (!tools || tools.length === 0) return undefined; + return tools.map(t => ({ + type: 'function' as const, + function: { + name: t.function.name, + ...(t.function.description ? { description: t.function.description } : {}), + ...(t.function.parameters ? { parameters: t.function.parameters } : {}), + }, + })); +}; + +const synthetic503 = (message: string): Response => + new Response(JSON.stringify({ error: { type: 'cursor_upstream_unavailable', message } }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }); + +const synthetic429 = (message: string): Response => + new Response(JSON.stringify({ error: { type: 'cursor_rate_limited', message } }), { + status: 429, + headers: { 'content-type': 'application/json' }, + }); + +export const callCursorChatCompletions = async ( + opts: CallCursorChatCompletionsOptions, +): Promise> => { + const ready = await prepareCursorCall(opts); + if (!ready.ok) return { ok: false, modelKey: opts.model.id, response: ready.response }; + + // Session correlation: derive sessionKey from inbound tool_call_id or header. + const { sessionKey: derived, isFollowUp } = deriveSessionKey( + opts.upstreamId, opts.call.apiKeyId, opts.headers, opts.body.messages, + ); + const sessionKey = derived ?? mintSessionKey(opts.upstreamId, opts.call.apiKeyId); + + // Try to reuse an in-process session (tool-call follow-up continuation). + // Priority: in-process generator cache (Node fast path) > DurableHttpSession + // broker (CF path, future). If neither hits, fall through to new-session. + if (isFollowUp) { + const inProcess = getCursorSession(sessionKey); + if (inProcess) { + return await performCursorSessionFollowUp(opts, ready.accessToken, ready.checksum, null, sessionKey); + } + try { + const broker = getDurableHttpSession(); + const session = await broker.acquire(sessionKey, null, { signal: opts.signal }); + if (session) { + return await performCursorSessionFollowUp(opts, ready.accessToken, ready.checksum, session, sessionKey); + } + } catch { + // broker uninitialized or acquire error → fall through to new-session path + } + } + + return await performCursorChatCall(opts, ready.accessToken, ready.checksum, sessionKey); +}; + +// Build the AgentTransport + self-construct the event stream. The generator: +// - text/thinking/tool_call_* → translator → eventFrame +// - mcp exec_request → translator emits tool_calls, then BREAK (stateless +// passthru: the downstream client returns the tool result in the next +// turn, which Floway inlines into a fresh RunSSE) +// - request_context exec → answer with the gateway env, keep streaming +// - other built-in exec → reject on the write channel, keep streaming +// - done → finalize (finish_reason) + doneFrame +// - error → throw (the gateway surfaces a 502) +// +// 401 retry: prepareCursorCall already mints a fresh token, so a 401 here is +// rare. Mid-stream 401 retry is deferred (TODO cursor): the lazy generator +// can't cleanly retry once events have flowed. Step 9 will add a peek-then- +// stream retry once the real 401 shape is captured. +const performCursorChatCall = async ( + opts: CallCursorChatCompletionsOptions, + accessToken: string, + checksum: string, + sessionKey: string, +): Promise> => { + const message = flattenMessages(opts.body.messages); + // Always advertise the tools. On a cold-resume (a tool-result follow-up that + // missed the live session) this lets cursor re-run the agent loop natively + // and re-issue the tool call, rather than degrading to a prompt-folded + // result — see flattenMessages. The native follow-up path + // (performCursorSessionFollowUp) handles the happy case on the live session. + const tools = toAgentTools(opts.body.tools); + + // Wrap the proxy-aware Fetcher so per-call latency recording still wraps each + // outbound RunSSE/BidiAppend, even though transport owns the fetch calls. + const fetchWrapper = (url: string, init: RequestInit): Promise => + opts.call.fetcher(url, init, opts.call.recordUpstreamLatency); + + const transport = new AgentTransport({ + accessToken, + baseUrl: CURSOR_BACKEND_BASE, + env: gatewayEnv, + clientVersion: CURSOR_CLIENT_VERSION, + getChecksum: () => checksum, + fetch: fetchWrapper as unknown as typeof fetch, + }); + + const id = `chatcmpl-cursor-${crypto.randomUUID()}`; + const created = Math.floor(Date.now() / 1000); + + const translator = createAgentTranslator({ + id, + model: opts.model.id, + created, + composer: isComposerModel(opts.model.id), + sessionKey, + }); + + // AGENT mode keeps the turn open (the backend expects an agent loop to + // continue — tool calls, checkpoints — and does not emit turn_ended on a + // simple reply). ASK mode is the direct Q&A turn shape: the backend emits + // text then turn_ended, which is what a Chat Completions caller expects. + // Tool-calling turns stay in AGENT mode so the model can drive its loop. + const mode = tools && tools.length > 0 ? AgentMode.AGENT : AgentMode.ASK; + const gen = transport.openChatStream({ message, model: opts.model.id, tools, mode }); + + // Kick off the generator before returning ok=true: the first .next() awaits + // the RunSSE fetch + the initial BidiAppend RunRequest, so the gateway's + // recordUpstreamLatency wraps a real upstream round-trip. The recorder is + // asserted at the ok=true boundary in providerStreamResultToExecuteResult, + // so a fully-lazy generator (fetch deferred until the consumer pulls) would + // fail that check. The first chunk is held back and re-yielded so streaming + // stays intact. + const first = await gen.next(); + + const events = async function* (): AsyncGenerator> { + let sessionSaved = false; + try { + // Pull from gen manually instead of for-await — for-await would call + // gen.return() on break, killing the transport's RunSSE read. We need + // the gen to stay alive after a tool_calls break so the next turn can + // continue reading from it. + let iterResult = first; + while (true) { + if (iterResult.done) break; + const chunk = iterResult.value; + if (chunk.type === 'error') { + throw new Error(chunk.error ?? 'Cursor agent stream error'); + } + if (chunk.type === 'done') break; + + if (chunk.type === 'exec_request' && chunk.execRequest) { + const exec = chunk.execRequest; + if (exec.type === 'mcp') { + for (const ev of translator.translate(chunk)) yield eventFrame(ev); + // Save the live session for follow-up tool result continuation. + // The gen stays open (NOT returned) so the RunSSE read stays alive. + // Key pendingExecs by the cleaned tool_call_id (no newline) — + // matches what the translator emits (cleanCallId) and what the + // client will echo back (after unwrap). + const cleanedTcId = exec.toolCallId.split('\n')[0]!.trim(); + const pendingExecs = new Map(); + pendingExecs.set(cleanedTcId, { id: exec.id, execId: exec.execId }); + putCursorSession({ + gen, + sendMcpResult: (id, execId, result) => transport.sendMcpResult({ type: 'mcp', id, execId, name: exec.name, args: exec.args, toolCallId: exec.toolCallId, providerIdentifier: exec.providerIdentifier, toolName: exec.toolName }, result), + sendResumeAction: () => transport.sendResumeAction(), + pendingExecs, + sessionKey, + lastActivityAt: Date.now(), + }); + sessionSaved = true; + break; + } + if (exec.type === 'request_context') { + await transport.sendRequestContextResult(exec.id, exec.execId); + continue; + } + await transport.sendRejectedTool(exec, 'Floway gateway cannot execute built-in tools'); + continue; + } + + for (const ev of translator.translate(chunk)) yield eventFrame(ev); + + // Advance to the next chunk manually (no for-await, no auto-return). + iterResult = await gen.next(); + } + } finally { + // Only abort the transport if we did NOT save the session for follow-up. + // When session is saved, the generator stays open for the next turn. + if (!sessionSaved) { + await gen.return(undefined); + } + } + + for (const ev of translator.finalize()) yield eventFrame(ev); + yield doneFrame(); + }; + + return { ok: true, events: events(), modelKey: opts.model.id }; +}; + +/** + * Follow-up on an existing in-process session (tool result continuation). + * The transport's gen is still alive in cursor-session-state; we send + * ExecMcpResult via the saved transport methods, then sendResumeAction + * so cursor continues streaming, and pump the gen for the follow-up reply. + */ +const performCursorSessionFollowUp = async ( + opts: CallCursorChatCompletionsOptions, + _accessToken: string, + _checksum: string, + session: DurableHttpSessionHandle | null, + sessionKey: string, +): Promise> => { + // Release the DurableHttpSession handle if present — we use the in-process gen. + if (session) await session.release(); + + const entry = getCursorSession(sessionKey); + if (!entry) { + // Session evicted between acquire and here — fallback to new session. + return await performCursorChatCall(opts, _accessToken, _checksum, sessionKey); + } + + // Match incoming role:'tool' messages to pending exec requests. + // Wrap the BidiAppend calls in this request's recordUpstreamLatency so the + // gateway's recorder sees a real upstream round-trip on this request (not + // the first request's recorder, which belongs to a different UpstreamCallOptions). + const toolMessages = opts.body.messages.filter(m => m.role === 'tool' && typeof m.tool_call_id === 'string'); + + const sendToolResults = async (): Promise => { + for (const toolMsg of toolMessages) { + const wrappedId = toolMsg.tool_call_id!; + const unwrapped = unwrapToolCallId(wrappedId); + const pending = entry.pendingExecs.get(unwrapped) ?? entry.pendingExecs.get(wrappedId); + if (!pending) continue; + + const content = typeof toolMsg.content === 'string' ? toolMsg.content : JSON.stringify(toolMsg.content); + await entry.sendMcpResult(pending.id, pending.execId, { success: { content } }); + entry.pendingExecs.delete(unwrapped); + entry.pendingExecs.delete(wrappedId); + } + // NO ResumeAction: cursor auto-resumes the model turn on the live RunSSE + // read channel once it receives the mcp_result (matches the validated + // HTTP/2 reference — see AgentTransport.sendExecResultNoClose). Sending a + // ResumeAction here makes the server finalize the turn instead, producing + // an empty follow-up reply. + }; + + // Record the BidiAppend round-trips against THIS request's latency recorder. + await opts.call.recordUpstreamLatency(sendToolResults()); + + // Now pump the gen for the continuation. + const id = `chatcmpl-cursor-${crypto.randomUUID()}`; + const created = Math.floor(Date.now() / 1000); + const translator = createAgentTranslator({ + id, + model: opts.model.id, + created, + composer: isComposerModel(opts.model.id), + sessionKey, + }); + + // Kick off the first read to satisfy recordUpstreamLatency requirement. + // The bidiAppend calls above already went through fetcher (latency recorded). + const first = await entry.gen.next(); + + const events = async function* (): AsyncGenerator> { + let sessionSavedAgain = false; + try { + // Manual pull (NOT for-await): for-await calls entry.gen.return() on + // break, which kills the transport's RunSSE read and prevents any + // further tool-result continuation on this session. + let iterResult = first; + while (true) { + if (iterResult.done) break; + const chunk = iterResult.value; + if (chunk.type === 'error') { + deleteCursorSession(sessionKey); + throw new Error(chunk.error ?? 'Cursor agent stream error'); + } + if (chunk.type === 'done') break; + + if (chunk.type === 'exec_request' && chunk.execRequest) { + const exec = chunk.execRequest; + if (exec.type === 'mcp') { + for (const ev of translator.translate(chunk)) yield eventFrame(ev); + // Save again for the next follow-up. Key by the cleaned id (no + // embedded newline) to match the translator's emitted tool_call_id + // and what the client echoes back after unwrap. + const cleanedTcId = exec.toolCallId.split('\n')[0]!.trim(); + entry.pendingExecs.set(cleanedTcId, { id: exec.id, execId: exec.execId }); + entry.lastActivityAt = Date.now(); + sessionSavedAgain = true; + break; + } + if (exec.type === 'request_context') { + // request_context only appears on the first turn (the agent loop is + // already bootstrapped on a follow-up). Skip — rare in practice. + iterResult = await entry.gen.next(); + continue; + } + iterResult = await entry.gen.next(); + continue; + } + + for (const ev of translator.translate(chunk)) yield eventFrame(ev); + iterResult = await entry.gen.next(); + } + } finally { + if (!sessionSavedAgain) { + deleteCursorSession(sessionKey); + } + } + + for (const ev of translator.finalize()) yield eventFrame(ev); + yield doneFrame(); + }; + + return { ok: true, events: events(), modelKey: opts.model.id }; +}; diff --git a/packages/provider-cursor/src/fetch_test.ts b/packages/provider-cursor/src/fetch_test.ts new file mode 100644 index 000000000..1824a975b --- /dev/null +++ b/packages/provider-cursor/src/fetch_test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { callCursorChatCompletions, type CursorCallEffects } from './fetch.ts'; +import { addConnectEnvelope, encodeMessageField, encodeStringField } from './proto/index.ts'; +import type { CursorAccessTokenEntry, CursorAccountCredential, CursorUpstreamState } from './state.ts'; +import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; +import { initProviderRepo, type UpstreamModel, type UpstreamRecord } from '@floway-dev/provider'; +import { noopUpstreamCallOptions, stubUpstreamModel } from '@floway-dev/test-utils'; + +const makeEffects = (): CursorCallEffects => ({ + persistRefreshTokenRotation: vi.fn(async () => {}), + persistTerminalState: vi.fn(async () => {}), +}); + +const farFutureAccessToken: CursorAccessTokenEntry = { + token: 'at.cursor.test', + expiresAt: Date.now() + 24 * 60 * 60 * 1000, + refreshedAt: '2026-01-01T00:00:00Z', +}; + +const activeAccount: CursorAccountCredential = { + userId: 'u1', + refresh_token: 'rt_v1', + state: 'active', + state_updated_at: '2026-01-01T00:00:00Z', + accessToken: farFutureAccessToken, + quotaSnapshot: null, +}; + +const model: UpstreamModel = stubUpstreamModel({ id: 'gpt-4o', display_name: 'gpt-4o', endpoints: { chatCompletions: {} } }); +const upstreamId = 'up_a'; + +const makeRecord = (state: CursorUpstreamState): UpstreamRecord => ({ + id: upstreamId, + provider: 'cursor', + name: 'Cursor', + enabled: true, + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + config: { accounts: [{ email: 'a@b.com', userId: 'u1' }] }, + state, + flagOverrides: {}, + disabledPublicModelIds: [], + proxyFallbackList: [], + modelPrefix: null, +}); + +let currentRecord: UpstreamRecord; + +beforeEach(() => { + vi.useRealTimers(); + currentRecord = makeRecord({ accounts: [{ ...activeAccount }] }); + initProviderRepo(() => ({ + upstreams: { + getById: async () => currentRecord, + saveState: async (_id, newState) => { + currentRecord = { ...currentRecord, state: newState as CursorUpstreamState }; + return { updated: true }; + }, + }, + })); +}); + +afterEach(() => vi.restoreAllMocks()); + +// AgentServerMessage { field 1: InteractionUpdate { field 1: TextDeltaUpdate { field 1: text } } } +function textFrame(text: string): Uint8Array { + const interactionUpdate = encodeMessageField(1, encodeStringField(1, text)); + const serverMsg = encodeMessageField(1, interactionUpdate); + return addConnectEnvelope(serverMsg); +} + +function turnEndedFrame(): Uint8Array { + const interactionUpdate = new Uint8Array([(14 << 3) | 0, 0]); + const serverMsg = encodeMessageField(1, interactionUpdate); + return addConnectEnvelope(serverMsg); +} + +function streamResponse(...frames: Uint8Array[]): Response { + const stream = new ReadableStream({ + start(controller) { + for (const f of frames) controller.enqueue(f); + controller.close(); + }, + }); + return new Response(stream, { status: 200, headers: { 'content-type': 'application/grpc-web+proto' } }); +} + +const mockCursorFetch = (runSse: Response): ReturnType => + vi.spyOn(globalThis, 'fetch').mockImplementation(async input => { + const url = String(typeof input === 'string' ? input : (input as URL).toString()); + if (url.includes('RunSSE')) return runSse; + if (url.includes('BidiAppend')) return new Response(new Uint8Array(0), { status: 200 }); + return new Response('', { status: 404 }); + }); + +const collectEvents = async (result: { ok: true; events: AsyncIterable> }): Promise => { + const events: ChatCompletionsStreamEvent[] = []; + for await (const frame of result.events) { + if (frame.type === 'event') events.push(frame.event); + else break; + } + return events; +}; + +describe('callCursorChatCompletions', () => { + test('refuses non-active state with synthetic 503', async () => { + const result = await callCursorChatCompletions({ + upstreamId, + account: { ...activeAccount, state: 'session_terminated' }, + model, + body: { messages: [{ role: 'user', content: 'hi' }] }, + headers: new Headers(), + effects: makeEffects(), + call: noopUpstreamCallOptions(), + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.response.status).toBe(503); + expect(await result.response.text()).toMatch(/session_terminated/); + } + }); + + test('streams text + finish_reason=stop on a clean turn', async () => { + mockCursorFetch(streamResponse(textFrame('hello'), turnEndedFrame())); + const result = await callCursorChatCompletions({ + upstreamId, + account: activeAccount, + model, + body: { messages: [{ role: 'user', content: 'hi' }] }, + headers: new Headers(), + effects: makeEffects(), + call: noopUpstreamCallOptions(), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const events = await collectEvents(result); + const contents = events.map(e => e.choices[0]?.delta?.content ?? '').filter(c => c !== ''); + expect(contents).toContain('hello'); + const last = events[events.length - 1]!; + expect(last.choices[0]?.finish_reason).toBe('stop'); + }); + + test('surfaces a non-ok RunSSE as a thrown stream error', async () => { + mockCursorFetch(new Response('upstream down', { status: 503 })); + const result = await callCursorChatCompletions({ + upstreamId, + account: activeAccount, + model, + body: { messages: [{ role: 'user', content: 'hi' }] }, + headers: new Headers(), + effects: makeEffects(), + call: noopUpstreamCallOptions(), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + await expect(collectEvents(result)).rejects.toThrow(/SSE stream failed: 503/); + }); +}); diff --git a/packages/provider-cursor/src/index.ts b/packages/provider-cursor/src/index.ts new file mode 100644 index 000000000..2219838c6 --- /dev/null +++ b/packages/provider-cursor/src/index.ts @@ -0,0 +1,16 @@ +export * from './access-token-cache.ts'; +export * from './auth/import.ts'; +export * from './auth/oauth.ts'; +export * from './auth/poll.ts'; +export * from './checksum.ts'; +export * from './config.ts'; +export * from './constants.ts'; +export * from './models.ts'; +export * from './pricing.ts'; +export * from './quota.ts'; +export * from './state.ts'; +export { createCursorProvider } from './provider.ts'; +export type { CursorCallEffects, CallCursorChatCompletionsOptions } from './fetch.ts'; +export type { AgentTransport, AgentTransportOptions } from './agent-transport.ts'; +export type { ChatCompletionsBoundaryCtx } from './interceptors/chat-completions/types.ts'; +export type { CursorRawModel, fetchCursorCatalog, cursorRawToUpstreamModel } from './models.ts'; diff --git a/packages/provider-cursor/src/interceptors/chat-completions/index.ts b/packages/provider-cursor/src/interceptors/chat-completions/index.ts new file mode 100644 index 000000000..e6b1900ff --- /dev/null +++ b/packages/provider-cursor/src/interceptors/chat-completions/index.ts @@ -0,0 +1,15 @@ +// Cursor Chat Completions interceptors. The chain runs inside the provider's +// callChatCompletions, so the gateway main flow is unaware of it. + +import { injectDefaultInstructions } from './inject-default-instructions.ts'; +import { stripUnsupportedFields } from './strip-unsupported-fields.ts'; +import type { ChatCompletionsBoundaryCtx } from './types.ts'; +import type { Interceptor } from '@floway-dev/interceptor'; + +// Order: inject-default-instructions first so the system message is present +// before strip runs (strip does not touch messages). Neither interceptor reads +// a field the other writes, so the order is positional only. +export const cursorChatCompletionsChain = (): readonly Interceptor[] => [ + injectDefaultInstructions, + stripUnsupportedFields, +]; diff --git a/packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions.ts b/packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions.ts new file mode 100644 index 000000000..314b8a594 --- /dev/null +++ b/packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions.ts @@ -0,0 +1,24 @@ +import type { ChatCompletionsBoundaryCtx } from './types.ts'; + +// Cursor agent runs in AGENT mode and benefits from a system prompt. Native +// chat-completions callers may omit a system message; source-protocol +// translators synthesize one only when the caller supplied it. When no system +// message is present we prepend a neutral default so every request shape +// satisfies the agent's expectation of grounded instructions. +export const injectDefaultInstructions = async ( + ctx: ChatCompletionsBoundaryCtx, + _request: object, + run: () => Promise, +): Promise => { + const hasSystem = ctx.payload.messages.some(m => m.role === 'system' || m.role === 'developer'); + if (!hasSystem) { + ctx.payload = { + ...ctx.payload, + messages: [ + { role: 'system', content: "You're a helpful assistant." }, + ...ctx.payload.messages, + ], + }; + } + return await run(); +}; diff --git a/packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions_test.ts b/packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions_test.ts new file mode 100644 index 000000000..23b30f074 --- /dev/null +++ b/packages/provider-cursor/src/interceptors/chat-completions/inject-default-instructions_test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'vitest'; + +import { injectDefaultInstructions } from './inject-default-instructions.ts'; +import type { ChatCompletionsBoundaryCtx } from './types.ts'; +import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; +import type { UpstreamModel } from '@floway-dev/provider'; + +const mkCtx = (messages: ChatCompletionsPayload['messages']): ChatCompletionsBoundaryCtx => ({ + payload: { model: 'm', messages }, + headers: new Headers(), + model: { id: 'm' } as UpstreamModel, +}); + +describe('injectDefaultInstructions', () => { + test('prepends a default system message when none exists', async () => { + const ctx = mkCtx([{ role: 'user', content: 'hi' }]); + await injectDefaultInstructions(ctx, {}, async () => 'ok'); + expect(ctx.payload.messages[0]!.role).toBe('system'); + expect(ctx.payload.messages[1]!.content).toBe('hi'); + }); + + test('leaves messages unchanged when a system message exists', async () => { + const ctx = mkCtx([{ role: 'system', content: 'sys' }, { role: 'user', content: 'hi' }]); + await injectDefaultInstructions(ctx, {}, async () => 'ok'); + expect(ctx.payload.messages).toHaveLength(2); + expect(ctx.payload.messages[0]!.content).toBe('sys'); + }); + + test('treats developer as a system message', async () => { + const ctx = mkCtx([{ role: 'developer', content: 'dev' }, { role: 'user', content: 'hi' }]); + await injectDefaultInstructions(ctx, {}, async () => 'ok'); + expect(ctx.payload.messages).toHaveLength(2); + }); + + test('runs the inner chain and returns its result', async () => { + const ctx = mkCtx([{ role: 'user', content: 'hi' }]); + const result = await injectDefaultInstructions(ctx, {}, async () => 'done'); + expect(result).toBe('done'); + }); +}); diff --git a/packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields.ts b/packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields.ts new file mode 100644 index 000000000..dcb9b9e2b --- /dev/null +++ b/packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields.ts @@ -0,0 +1,38 @@ +import type { ChatCompletionsBoundaryCtx } from './types.ts'; + +// OpenAI Chat Completions fields the Cursor agent endpoint does not honor. +// Cursor's RunSSE+BidiAppend is an agent loop, not a raw completion API: +// sampling params (temperature/top_p) and turn limits (max_tokens) are +// agent-decided, and structured-output / advanced knobs are rejected. We strip +// them at the Cursor target boundary so source-protocol translators can keep +// setting them for other providers. `tools` / `tool_choice` / `stream` / +// `messages` / `model` are retained. +const CURSOR_UNSUPPORTED_BODY_FIELDS = [ + 'response_format', + 'seed', + 'n', + 'user', + 'metadata', + 'frequency_penalty', + 'presence_penalty', + 'service_tier', + 'temperature', + 'top_p', + 'max_tokens', + 'reasoning_effort', + 'prompt_cache_key', + 'safety_identifier', + 'parallel_tool_calls', + 'stream_options', +] as const; + +export const stripUnsupportedFields = async ( + ctx: ChatCompletionsBoundaryCtx, + _request: object, + run: () => Promise, +): Promise => { + const next: Record = { ...(ctx.payload as unknown as Record) }; + for (const key of CURSOR_UNSUPPORTED_BODY_FIELDS) delete next[key]; + ctx.payload = next as unknown as typeof ctx.payload; + return await run(); +}; diff --git a/packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields_test.ts b/packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields_test.ts new file mode 100644 index 000000000..a07316d1d --- /dev/null +++ b/packages/provider-cursor/src/interceptors/chat-completions/strip-unsupported-fields_test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'vitest'; + +import { stripUnsupportedFields } from './strip-unsupported-fields.ts'; +import type { ChatCompletionsBoundaryCtx } from './types.ts'; +import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; +import type { UpstreamModel } from '@floway-dev/provider'; + +const mkCtx = (payload: Partial): ChatCompletionsBoundaryCtx => ({ + payload: { model: 'm', messages: [{ role: 'user', content: 'hi' }], ...payload } as ChatCompletionsPayload, + headers: new Headers(), + model: { id: 'm' } as UpstreamModel, +}); + +describe('stripUnsupportedFields', () => { + test('removes unsupported sampling / structured-output knobs', async () => { + const ctx = mkCtx({ + temperature: 0.7, + top_p: 0.9, + max_tokens: 100, + response_format: { type: 'json_object' }, + seed: 42, + n: 2, + frequency_penalty: 1, + presence_penalty: 1, + stream_options: { include_usage: true }, + }); + await stripUnsupportedFields(ctx, {}, async () => 'ok'); + const p = ctx.payload as unknown as Record; + expect(p['temperature']).toBeUndefined(); + expect(p['top_p']).toBeUndefined(); + expect(p['max_tokens']).toBeUndefined(); + expect(p['response_format']).toBeUndefined(); + expect(p['seed']).toBeUndefined(); + expect(p['n']).toBeUndefined(); + expect(p['stream_options']).toBeUndefined(); + }); + + test('keeps messages, tools, model, stream, tool_choice', async () => { + const ctx = mkCtx({ + stream: true, + tools: [{ type: 'function', function: { name: 'search' } }], + tool_choice: 'auto', + }); + await stripUnsupportedFields(ctx, {}, async () => 'ok'); + const p = ctx.payload as unknown as Record; + expect(p['model']).toBe('m'); + expect(p['stream']).toBe(true); + expect(Array.isArray(p['tools'])).toBe(true); + expect(p['tool_choice']).toBe('auto'); + expect(Array.isArray(p['messages'])).toBe(true); + }); + + test('runs the inner chain and returns its result', async () => { + const ctx = mkCtx({}); + const result = await stripUnsupportedFields(ctx, {}, async () => 'done'); + expect(result).toBe('done'); + }); +}); diff --git a/packages/provider-cursor/src/interceptors/chat-completions/types.ts b/packages/provider-cursor/src/interceptors/chat-completions/types.ts new file mode 100644 index 000000000..18fbf22c6 --- /dev/null +++ b/packages/provider-cursor/src/interceptors/chat-completions/types.ts @@ -0,0 +1,11 @@ +import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; +import type { UpstreamModel } from '@floway-dev/provider'; + +// Boundary ctx for Cursor Chat Completions interceptors. The payload is the +// OpenAI ChatCompletions request body; interceptors mutate it before fetch.ts +// flattens it into the Cursor AgentRunRequest. +export interface ChatCompletionsBoundaryCtx { + payload: ChatCompletionsPayload; + headers: Headers; + readonly model: UpstreamModel; +} diff --git a/packages/provider-cursor/src/models.ts b/packages/provider-cursor/src/models.ts new file mode 100644 index 000000000..0e4bef207 --- /dev/null +++ b/packages/provider-cursor/src/models.ts @@ -0,0 +1,98 @@ +import { generateCursorChecksum } from './checksum.ts'; +import { CURSOR_BACKEND_BASE, CURSOR_CLIENT_VERSION, CURSOR_USABLE_MODELS_PATH, CURSOR_USER_AGENT } from './constants.ts'; +import { pricingForCursorModelKey } from './pricing.ts'; +import { type Fetcher, type UpstreamModel } from '@floway-dev/provider'; + +export interface CursorRawModel { + id: string; + display_name: string; + aliases?: readonly string[]; +} + +// GetUsableModels is called over Connect JSON (not grpc-web) — the same +// endpoint the Cursor CLI hits for its model picker. `fetcher` is required so +// the catalog refresh traverses the same proxy/dial chain as request traffic. +export const fetchCursorCatalog = async (opts: { + accessToken: string; + timezone: string; + signal?: AbortSignal; + fetcher: Fetcher; +}): Promise => { + const checksum = await generateCursorChecksum(opts.accessToken); + const response = await opts.fetcher(`${CURSOR_BACKEND_BASE}${CURSOR_USABLE_MODELS_PATH}`, { + method: 'POST', + headers: { + authorization: `Bearer ${opts.accessToken}`, + 'content-type': 'application/json', + accept: 'application/json', + 'connect-protocol-version': '1', + 'user-agent': CURSOR_USER_AGENT, + 'x-cursor-checksum': checksum, + 'x-cursor-client-version': CURSOR_CLIENT_VERSION, + 'x-cursor-client-type': 'cli', + 'x-cursor-timezone': opts.timezone, + 'x-ghost-mode': 'true', + }, + body: '{}', + signal: opts.signal, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Cursor GetUsableModels fetch failed: ${response.status} ${body.slice(0, 200)}`); + } + + const parsed = (await response.json()) as { models?: unknown }; + if (!Array.isArray(parsed.models)) throw new Error('Cursor GetUsableModels response missing models array'); + return parsed.models.map(assertRawModel); +}; + +const isPlainRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null; + +const assertRawModel = (value: unknown): CursorRawModel => { + if (!isPlainRecord(value)) throw new TypeError('Cursor model entry is not an object'); + const modelId = typeof value.modelId === 'string' ? value.modelId : undefined; + const displayModelId = typeof value.displayModelId === 'string' ? value.displayModelId : undefined; + const id = modelId ?? displayModelId; + if (!id) throw new TypeError('Cursor model entry missing modelId/displayModelId'); + + const displayName = typeof value.displayName === 'string' ? value.displayName : undefined; + const displayNameShort = typeof value.displayNameShort === 'string' ? value.displayNameShort : undefined; + const display_name = displayName ?? displayNameShort ?? id; + + const raw: CursorRawModel = { id, display_name }; + + if (value.aliases !== undefined) { + if (!Array.isArray(value.aliases)) throw new TypeError(`Cursor model entry ${id} aliases not an array`); + const out: string[] = []; + for (const a of value.aliases) { + if (typeof a !== 'string') throw new TypeError(`Cursor model entry ${id} alias not a string`); + if (!out.includes(a)) out.push(a); + } + raw.aliases = out; + } + + return raw; +}; + +// Cursor exposes only the Chat Completions endpoint (RunSSE+BidiAppend). +// Pricing is looked up from the per-model notional table in pricing.ts so the +// dashboard can report value consumed vs. the flat Cursor subscription. +// +// Modalities / reasoning config are not surfaced by GetUsableModels; left +// unset here and refined once a real capture documents per-model capabilities. +export const cursorRawToUpstreamModel = (raw: CursorRawModel, enabledFlags: ReadonlySet): UpstreamModel => { + const cost = pricingForCursorModelKey(raw.id); + return { + id: raw.id, + display_name: raw.display_name, + owned_by: 'cursor', + kind: 'chat', + // GetUsableModels does not surface a context window. Use a conservative + // default until a real capture documents per-model limits. + limits: { max_context_window_tokens: 200_000 }, + endpoints: { chatCompletions: {} }, + enabledFlags, + ...(cost ? { cost } : {}), + }; +}; diff --git a/packages/provider-cursor/src/pricing.ts b/packages/provider-cursor/src/pricing.ts new file mode 100644 index 000000000..a66347a71 --- /dev/null +++ b/packages/provider-cursor/src/pricing.ts @@ -0,0 +1,58 @@ +// Per-model notional pricing for the Cursor (subscription) provider. Cursor +// bills as a flat-fee subscription, but the gateway tracks usage cost as if +// the operator were paying the underlying model's public API rates — so the +// dashboard can surface "value consumed vs. flat fee". Values are USD per +// million tokens, aligned with the `Cost` schema in models.dev. +// +// Cursor exposes models from multiple vendors (Anthropic / OpenAI / Google / +// its own composer-* family). The table keys model ids as returned by +// GetUsableModels. New ids the upstream rolls out should be added here. +// Source of truth for public API prices: the respective vendor pricing pages. +// Refresh procedure: .agents/skills/fetching-models-pricing/. + +import type { ModelPricing } from '@floway-dev/protocols/common'; + +const CLAUDE_3_7_SONNET_PRICING: ModelPricing = { + input: 3, + input_cache_read: 0.3, + output: 15, +}; + +const GPT_5_4_PRICING: ModelPricing = { + input: 2.5, + input_cache_read: 0.25, + output: 15, +}; + +const GEMINI_2_5_PRO_PRICING: ModelPricing = { + input: 1.25, + input_cache_read: 0.31, + output: 10, +}; + +// composer-* is Cursor's own family; no public per-token price surface. +// Notional clone of claude-3.7-sonnet (closest analogue in capability). +const COMPOSER_PRICING: ModelPricing = CLAUDE_3_7_SONNET_PRICING; + +const CURSOR_MODEL_PRICING: readonly (readonly [key: string | RegExp, pricing: ModelPricing])[] = [ + ['auto', CLAUDE_3_7_SONNET_PRICING], + [/^composer/, COMPOSER_PRICING], + [/^claude-3[._-]?7-sonnet/i, CLAUDE_3_7_SONNET_PRICING], + [/^claude-3[._-]?5-sonnet/i, { input: 3, input_cache_read: 0.3, output: 15 }], + // mini before base so the more-specific variant wins; base is anchored to + // end-of-string so gpt-5.4-pro / other suffixes don't silently pick up the + // base rate. + [/^gpt-5[._-]?4-mini/i, { input: 0.75, input_cache_read: 0.075, output: 4.5 }], + [/^gpt-5[._-]?4$/i, GPT_5_4_PRICING], + [/^gemini-2[._-]?5-pro/i, GEMINI_2_5_PRO_PRICING], + [/^gemini-2[._-]?5-flash/i, { input: 0.15, input_cache_read: 0.0375, output: 0.6 }], +]; + +export const pricingForCursorModelKey = (modelKey: string): ModelPricing | null => { + for (const [key, pricing] of CURSOR_MODEL_PRICING) { + if (typeof key === 'string' ? modelKey === key : key.test(modelKey)) { + return pricing; + } + } + return null; +}; diff --git a/packages/provider-cursor/src/pricing_test.ts b/packages/provider-cursor/src/pricing_test.ts new file mode 100644 index 000000000..4cee10856 --- /dev/null +++ b/packages/provider-cursor/src/pricing_test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest'; + +import { pricingForCursorModelKey } from './pricing.ts'; + +describe('pricingForCursorModelKey', () => { + test('matches claude-3.7-sonnet', () => { + expect(pricingForCursorModelKey('claude-3.7-sonnet')?.input).toBe(3); + expect(pricingForCursorModelKey('claude-3.7-sonnet')?.output).toBe(15); + }); + + test('matches composer-* (notional)', () => { + expect(pricingForCursorModelKey('composer-2.5')?.input).toBe(3); + }); + + test('matches gpt-5.4 but not gpt-5.4-mini as the base rate', () => { + expect(pricingForCursorModelKey('gpt-5.4')?.input).toBe(2.5); + expect(pricingForCursorModelKey('gpt-5.4-mini')?.input).toBe(0.75); + }); + + test('matches gemini-2.5-pro', () => { + expect(pricingForCursorModelKey('gemini-2.5-pro')?.input).toBe(1.25); + }); + + test('matches auto', () => { + expect(pricingForCursorModelKey('auto')?.input).toBe(3); + }); + + test('returns null for an unknown model', () => { + expect(pricingForCursorModelKey('some-unknown-model')).toBeNull(); + }); +}); diff --git a/packages/provider-cursor/src/proto/agent-messages.ts b/packages/provider-cursor/src/proto/agent-messages.ts new file mode 100644 index 000000000..0351ccd6a --- /dev/null +++ b/packages/provider-cursor/src/proto/agent-messages.ts @@ -0,0 +1,230 @@ +import { + encodeStringField, + encodeInt32Field, + encodeMessageField, + encodeBoolField, + concatBytes, + encodeProtobufValue, +} from './encoding.ts'; +import { AgentMode } from './types.ts'; +import type { OpenAIToolDefinition } from './types.ts'; + +/** + * AgentClientMessage + AgentRunRequest encoding. + * + * Workers-clean: environment facts (os/shell/cwd/timezone) are passed in via + * RequestContextEnv — no process.cwd() / node:os / process.env reads here. + */ + +const MCP_PROVIDER = 'cursor-tools'; + +export interface RequestContextEnv { + workspacePath: string; + osVersion: string; + shell: string; + timezone: string; +} + +export function encodeMcpToolDefinition(tool: OpenAIToolDefinition, providerIdentifier = MCP_PROVIDER): Uint8Array { + const toolName = tool.function.name; + const combinedName = `${providerIdentifier}-${toolName}`; + const description = tool.function.description ?? ''; + const inputSchema = tool.function.parameters ?? { type: 'object', properties: {} }; + + const parts: Uint8Array[] = [ + encodeStringField(1, combinedName), + encodeStringField(2, description), + ]; + + if (inputSchema) { + const schemaValue = encodeProtobufValue(inputSchema); + parts.push(encodeMessageField(3, schemaValue)); + } + + parts.push(encodeStringField(4, providerIdentifier)); + parts.push(encodeStringField(5, toolName)); + + return concatBytes(...parts); +} + +export function buildRequestContextEnv(env: RequestContextEnv): Uint8Array { + return concatBytes( + encodeStringField(1, env.osVersion), + encodeStringField(2, env.workspacePath), + encodeStringField(3, env.shell), + encodeStringField(10, env.timezone), + encodeStringField(11, env.workspacePath), + ); +} + +export function encodeMcpInstructions(serverName: string, instructions: string): Uint8Array { + return concatBytes(encodeStringField(1, serverName), encodeStringField(2, instructions)); +} + +export function buildRequestContext(env: RequestContextEnv, tools?: OpenAIToolDefinition[]): Uint8Array { + const parts: Uint8Array[] = []; + + const envBytes = buildRequestContextEnv(env); + parts.push(encodeMessageField(4, envBytes)); + + if (tools && tools.length > 0) { + for (const tool of tools) { + const mcpTool = encodeMcpToolDefinition(tool, MCP_PROVIDER); + parts.push(encodeMessageField(7, mcpTool)); + } + + const toolDescriptions = tools + .map(t => `- ${t.function.name}: ${t.function.description ?? 'No description'}`) + .join('\n'); + const instructions = `You have access to the following tools:\n${toolDescriptions}\n\nUse these tools when appropriate to help the user.`; + + const mcpInstr = encodeMcpInstructions(MCP_PROVIDER, instructions); + parts.push(encodeMessageField(14, mcpInstr)); + } + + return concatBytes(...parts); +} + +export function encodeUserMessage(text: string, messageId: string, mode: AgentMode = AgentMode.ASK): Uint8Array { + return concatBytes( + encodeStringField(1, text), + encodeStringField(2, messageId), + encodeInt32Field(4, mode), + ); +} + +export function encodeUserMessageAction(userMessage: Uint8Array, requestContext: Uint8Array): Uint8Array { + return concatBytes(encodeMessageField(1, userMessage), encodeMessageField(2, requestContext)); +} + +export function encodeConversationAction(userMessageAction: Uint8Array): Uint8Array { + return encodeMessageField(1, userMessageAction); +} + +export function encodeResumeAction(): Uint8Array { + return new Uint8Array(0); +} + +export function encodeConversationActionWithResume(): Uint8Array { + const resumeAction = encodeResumeAction(); + return encodeMessageField(2, resumeAction); +} + +export function encodeAgentClientMessageWithConversationAction(conversationAction: Uint8Array): Uint8Array { + return encodeMessageField(4, conversationAction); +} + +export function encodeModelDetails(modelId: string): Uint8Array { + return encodeStringField(1, modelId); +} + +export function encodeEmptyConversationState(): Uint8Array { + return new Uint8Array(0); +} + +export function encodeMcpTools(tools: OpenAIToolDefinition[]): Uint8Array { + const parts: Uint8Array[] = []; + for (const tool of tools) { + const mcpTool = encodeMcpToolDefinition(tool, MCP_PROVIDER); + parts.push(encodeMessageField(1, mcpTool)); + } + return concatBytes(...parts); +} + +export function encodeMcpDescriptor( + serverName: string, + serverIdentifier: string, + folderPath?: string, + serverUseInstructions?: string, +): Uint8Array { + const parts: Uint8Array[] = [encodeStringField(1, serverName), encodeStringField(2, serverIdentifier)]; + + if (folderPath) { + parts.push(encodeStringField(3, folderPath)); + } + + if (serverUseInstructions) { + parts.push(encodeStringField(4, serverUseInstructions)); + } + + return concatBytes(...parts); +} + +export interface McpDescriptorInput { + serverName: string; + serverIdentifier: string; + folderPath?: string; + serverUseInstructions?: string; +} + +export function encodeMcpFileSystemOptions( + enabled: boolean, + workspaceProjectDir: string, + mcpDescriptors: McpDescriptorInput[], +): Uint8Array { + const parts: Uint8Array[] = []; + + if (enabled) { + parts.push(encodeBoolField(1, true)); + } + + if (workspaceProjectDir) { + parts.push(encodeStringField(2, workspaceProjectDir)); + } + + for (const descriptor of mcpDescriptors) { + const encodedDescriptor = encodeMcpDescriptor( + descriptor.serverName, + descriptor.serverIdentifier, + descriptor.folderPath, + descriptor.serverUseInstructions, + ); + parts.push(encodeMessageField(3, encodedDescriptor)); + } + + return concatBytes(...parts); +} + +export function encodeAgentRunRequest( + action: Uint8Array, + modelDetails: Uint8Array, + conversationId: string | undefined, + tools: OpenAIToolDefinition[] | undefined, + workspacePath: string | undefined, +): Uint8Array { + const conversationState = encodeEmptyConversationState(); + + const parts: Uint8Array[] = [ + encodeMessageField(1, conversationState), + encodeMessageField(2, action), + encodeMessageField(3, modelDetails), + ]; + + if (tools && tools.length > 0) { + const mcpToolsWrapper = encodeMcpTools(tools); + parts.push(encodeMessageField(4, mcpToolsWrapper)); + } + + if (conversationId) { + parts.push(encodeStringField(5, conversationId)); + } + + if (tools && tools.length > 0 && workspacePath) { + const mcpDescriptors: McpDescriptorInput[] = [ + { + serverName: 'Cursor Tools', + serverIdentifier: MCP_PROVIDER, + folderPath: workspacePath, + serverUseInstructions: 'Use these tools to assist the user with their coding tasks.', + }, + ]; + const mcpFsOptions = encodeMcpFileSystemOptions(true, workspacePath, mcpDescriptors); + parts.push(encodeMessageField(6, mcpFsOptions)); + } + + return concatBytes(...parts); +} + +export function encodeAgentClientMessage(runRequest: Uint8Array): Uint8Array { + return encodeMessageField(1, runRequest); +} diff --git a/packages/provider-cursor/src/proto/bidi.ts b/packages/provider-cursor/src/proto/bidi.ts new file mode 100644 index 000000000..e44fd297e --- /dev/null +++ b/packages/provider-cursor/src/proto/bidi.ts @@ -0,0 +1,31 @@ +/** + * BidiSse Protocol Encoding + * + * Encoding of the two HTTP/1.1 control messages that pair the RunSSE read + * channel with the BidiAppend write channel. + */ + +import { encodeStringField, encodeMessageField, encodeInt64Field, concatBytes } from './encoding.ts'; + +/** + * Encode BidiRequestId — the body of the RunSSE POST. + * - request_id: field 1 (string) + */ +export function encodeBidiRequestId(requestId: string): Uint8Array { + return encodeStringField(1, requestId); +} + +/** + * Encode BidiAppendRequest — the body of each BidiAppend POST. + * - data: field 1 (string, hex-encoded AgentClientMessage; empty for heartbeat) + * - request_id: field 2 (BidiRequestId message) + * - append_seqno: field 3 (int64, monotonic per request) + */ +export function encodeBidiAppendRequest(data: string, requestId: string, appendSeqno: bigint): Uint8Array { + const requestIdMsg = encodeBidiRequestId(requestId); + return concatBytes( + encodeStringField(1, data), + encodeMessageField(2, requestIdMsg), + encodeInt64Field(3, appendSeqno), + ); +} diff --git a/packages/provider-cursor/src/proto/decoding.ts b/packages/provider-cursor/src/proto/decoding.ts new file mode 100644 index 000000000..a552ddca7 --- /dev/null +++ b/packages/provider-cursor/src/proto/decoding.ts @@ -0,0 +1,139 @@ +/** + * Protobuf Decoding Helpers + * + * Low-level utilities for decoding protobuf wire format: varint decoding, + * field parsing, and google.protobuf.Value decoding. Pure Uint8Array + + * DataView — no Buffer. + */ + +export interface ParsedField { + fieldNumber: number; + wireType: number; + value: Uint8Array | number | bigint; +} + +export function decodeVarint(data: Uint8Array, offset: number): { value: number; bytesRead: number } { + let value = 0; + let shift = 0; + let bytesRead = 0; + + while (offset + bytesRead < data.length) { + const byte = data[offset + bytesRead]; + if (byte === undefined) break; + value |= (byte & 0x7f) << shift; + bytesRead++; + + if ((byte & 0x80) === 0) { + break; + } + shift += 7; + } + + return { value, bytesRead }; +} + +export function parseProtoFields(data: Uint8Array): ParsedField[] { + const fields: ParsedField[] = []; + let offset = 0; + + while (offset < data.length) { + const tagInfo = decodeVarint(data, offset); + offset += tagInfo.bytesRead; + + const fieldNumber = tagInfo.value >> 3; + const wireType = tagInfo.value & 0x7; + + if (wireType === 2) { + const lengthInfo = decodeVarint(data, offset); + offset += lengthInfo.bytesRead; + const value = data.slice(offset, offset + lengthInfo.value); + offset += lengthInfo.value; + fields.push({ fieldNumber, wireType, value }); + } else if (wireType === 0) { + const valueInfo = decodeVarint(data, offset); + offset += valueInfo.bytesRead; + fields.push({ fieldNumber, wireType, value: valueInfo.value }); + } else if (wireType === 1) { + const value = data.slice(offset, offset + 8); + offset += 8; + fields.push({ fieldNumber, wireType, value }); + } else if (wireType === 5) { + const value = data.slice(offset, offset + 4); + offset += 4; + fields.push({ fieldNumber, wireType, value }); + } else { + break; + } + } + + return fields; +} + +export function parseProtobufValue(data: Uint8Array): unknown { + const fields = parseProtoFields(data); + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 0) { + return null; + } + if (field.fieldNumber === 2 && field.wireType === 1 && field.value instanceof Uint8Array) { + const view = new DataView(field.value.buffer, field.value.byteOffset, 8); + return view.getFloat64(0, true); + } + if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + return new TextDecoder().decode(field.value); + } + if (field.fieldNumber === 4 && field.wireType === 0) { + return field.value === 1; + } + if (field.fieldNumber === 5 && field.wireType === 2 && field.value instanceof Uint8Array) { + return parseProtobufStruct(field.value); + } + if (field.fieldNumber === 6 && field.wireType === 2 && field.value instanceof Uint8Array) { + return parseProtobufListValue(field.value); + } + } + + return undefined; +} + +export function parseProtobufStruct(data: Uint8Array): Record { + const fields = parseProtoFields(data); + const result: Record = {}; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + const entryFields = parseProtoFields(field.value); + let key = ''; + let value: unknown = undefined; + + for (const ef of entryFields) { + if (ef.fieldNumber === 1 && ef.wireType === 2 && ef.value instanceof Uint8Array) { + key = new TextDecoder().decode(ef.value); + } + if (ef.fieldNumber === 2 && ef.wireType === 2 && ef.value instanceof Uint8Array) { + value = parseProtobufValue(ef.value); + } + } + + if (key) { + result[key] = value; + } + } + } + + return result; +} + +export function parseProtobufListValue(data: Uint8Array): unknown[] { + const fields = parseProtoFields(data); + const result: unknown[] = []; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + result.push(parseProtobufValue(field.value)); + } + } + + return result; +} diff --git a/packages/provider-cursor/src/proto/encoding.ts b/packages/provider-cursor/src/proto/encoding.ts new file mode 100644 index 000000000..601909c56 --- /dev/null +++ b/packages/provider-cursor/src/proto/encoding.ts @@ -0,0 +1,249 @@ +/** + * Protobuf Encoding Helpers + * + * Low-level utilities for encoding protobuf wire format: + * - Varint encoding (wire type 0) + * - Length-delimited encoding (wire type 2) + * - Fixed-width encoding (wire types 1, 5) + * - google.protobuf.Value encoding for dynamic JSON-like data + * + * Workers-clean: pure Uint8Array + DataView, no Buffer. + */ + +// --- Basic Varint and Field Encoding --- + +/** + * Encode a varint (variable-length integer) for protobuf. + * Supports both number and bigint for large values (e.g. append_seqno). + */ +export function encodeVarint(value: number | bigint): Uint8Array { + const bytes: number[] = []; + let v = BigInt(value); + if (v < 0n) { + // Negative ints encode as two's-complement 64-bit varints (10 bytes). We + // never emit negatives on the Cursor wire, but keep this defensive so a + // stray negative doesn't loop forever. + v = v + (1n << 64n); + } + while (v > 127n) { + bytes.push(Number(v & 0x7fn) | 0x80); + v >>= 7n; + } + bytes.push(Number(v)); + return new Uint8Array(bytes); +} + +/** + * Encode a string field in protobuf format. + * Field format: (field_number << 3) | wire_type; string wire type = 2. + */ +export function encodeStringField(fieldNumber: number, value: string): Uint8Array { + if (!value) return new Uint8Array(0); + + const fieldTag = (fieldNumber << 3) | 2; + const encoded = new TextEncoder().encode(value); + const length = encodeVarint(encoded.length); + + const result = new Uint8Array(1 + length.length + encoded.length); + result[0] = fieldTag; + result.set(length, 1); + result.set(encoded, 1 + length.length); + + return result; +} + +/** + * Encode a uint32 field (varint, wire type 0). Omitted when zero — Cursor's + * proto uses default-zero semantics, so a zero field tag is round-trip + * equivalent and keeps frames minimal. + */ +export function encodeUint32Field(fieldNumber: number, value: number): Uint8Array { + if (value === 0) return new Uint8Array(0); + + const fieldTag = (fieldNumber << 3) | 0; + const encoded = encodeVarint(value); + + const result = new Uint8Array(1 + encoded.length); + result[0] = fieldTag; + result.set(encoded, 1); + + return result; +} + +/** + * Encode an int32 field (varint, wire type 0). Omitted when zero. + */ +export function encodeInt32Field(fieldNumber: number, value: number): Uint8Array { + if (value === 0) return new Uint8Array(0); + + const fieldTag = (fieldNumber << 3) | 0; + const encoded = encodeVarint(value); + + const result = new Uint8Array(1 + encoded.length); + result[0] = fieldTag; + result.set(encoded, 1); + + return result; +} + +/** + * Encode an int64 field (varint, wire type 0). Always emitted — a zero + * seqno is meaningful on the Cursor wire. + */ +export function encodeInt64Field(fieldNumber: number, value: bigint): Uint8Array { + const fieldTag = (fieldNumber << 3) | 0; + const encoded = encodeVarint(value); + + const result = new Uint8Array(1 + encoded.length); + result[0] = fieldTag; + result.set(encoded, 1); + + return result; +} + +/** + * Encode a nested message field (length-delimited, wire type 2). + */ +export function encodeMessageField(fieldNumber: number, data: Uint8Array): Uint8Array { + const fieldTag = (fieldNumber << 3) | 2; + const length = encodeVarint(data.length); + + const result = new Uint8Array(1 + length.length + data.length); + result[0] = fieldTag; + result.set(length, 1); + result.set(data, 1 + length.length); + + return result; +} + +/** + * Encode a bool field (varint, wire type 0). + */ +export function encodeBoolField(fieldNumber: number, value: boolean): Uint8Array { + const fieldTag = (fieldNumber << 3) | 0; + return new Uint8Array([fieldTag, value ? 1 : 0]); +} + +/** + * Encode a double field (64-bit, wire type 1, little-endian). + */ +export function encodeDoubleField(fieldNumber: number, value: number): Uint8Array { + const fieldTag = (fieldNumber << 3) | 1; + const buffer = new ArrayBuffer(9); + const view = new DataView(buffer); + view.setUint8(0, fieldTag); + view.setFloat64(1, value, true); + return new Uint8Array(buffer); +} + +/** + * Concatenate multiple Uint8Arrays into one fresh ArrayBuffer-backed array. + */ +export function concatBytes(...arrays: Uint8Array[]): Uint8Array { + const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} + +// --- google.protobuf.Value Encoding --- + +/** + * Encode a JavaScript value as google.protobuf.Value. + * + * oneof: + * field 1: null_value (enum NullValue = 0) + * field 2: number_value (double) + * field 3: string_value (string) + * field 4: bool_value (bool) + * field 5: struct_value (Struct) + * field 6: list_value (ListValue) + */ +export function encodeProtobufValue(value: unknown): Uint8Array { + if (value === null || value === undefined) { + // NullValue enum = 0. Emit the field explicitly (tag + 0) rather than via + // encodeUint32Field, which omits zero values — an omitted oneof field + // decodes as "no value" under a presence-keyed parser. Cursor's proto3 + // decoder reads either form as NULL_VALUE. + return new Uint8Array([(1 << 3) | 0, 0]); + } + + if (typeof value === 'number') { + return encodeDoubleField(2, value); + } + + if (typeof value === 'string') { + return encodeStringField(3, value); + } + + if (typeof value === 'boolean') { + return encodeBoolField(4, value); + } + + if (Array.isArray(value)) { + const listBytes: Uint8Array[] = []; + for (const item of value) { + const itemValue = encodeProtobufValue(item); + listBytes.push(encodeMessageField(1, itemValue)); + } + const listValue = concatBytes(...listBytes); + return encodeMessageField(6, listValue); + } + + if (typeof value === 'object') { + const structBytes: Uint8Array[] = []; + for (const [key, val] of Object.entries(value as Record)) { + const keyBytes = encodeStringField(1, key); + const valBytes = encodeMessageField(2, encodeProtobufValue(val)); + const mapEntry = concatBytes(keyBytes, valBytes); + structBytes.push(encodeMessageField(1, mapEntry)); + } + const structValue = concatBytes(...structBytes); + return encodeMessageField(5, structValue); + } + + return encodeStringField(3, String(value)); +} + +// --- Hex helpers (Workers-clean replacement for Buffer.from(...).toString('hex')) --- + +const HEX_DIGITS = '0123456789abcdef'; + +/** Encode a Uint8Array as a lowercase hex string. */ +export function bytesToHex(bytes: Uint8Array): string { + let out = ''; + for (const b of bytes) { + out += HEX_DIGITS[(b >>> 4) & 0xf]! + HEX_DIGITS[b & 0xf]!; + } + return out; +} + +/** Decode a lowercase/uppercase hex string into a Uint8Array. */ +export function hexToBytes(hex: string): Uint8Array { + const clean = hex.trim(); + const len = clean.length; + if (len % 2 !== 0) { + throw new TypeError(`hexToBytes: odd-length hex string (${len})`); + } + const out = new Uint8Array(len / 2); + for (let i = 0; i < out.length; i++) { + const hi = hexDigitValue(clean.charCodeAt(i * 2)); + const lo = hexDigitValue(clean.charCodeAt(i * 2 + 1)); + if (hi < 0 || lo < 0) { + throw new TypeError(`hexToBytes: invalid hex digit at index ${i * 2}`); + } + out[i] = (hi << 4) | lo; + } + return out; +} + +function hexDigitValue(code: number): number { + if (code >= 0x30 && code <= 0x39) return code - 0x30; + if (code >= 0x61 && code <= 0x66) return code - 0x61 + 10; + if (code >= 0x41 && code <= 0x46) return code - 0x41 + 10; + return -1; +} diff --git a/packages/provider-cursor/src/proto/envelope.ts b/packages/provider-cursor/src/proto/envelope.ts new file mode 100644 index 000000000..cac736f46 --- /dev/null +++ b/packages/provider-cursor/src/proto/envelope.ts @@ -0,0 +1,127 @@ +/** + * Connect-RPC envelope framing. + * + * Cursor's RunSSE / BidiAppend use the gRPC-Web / connect framing: a 5-byte + * header (1 flag byte + 4 big-endian uint32 length) prefixing each proto + * payload. RunSSE is a stream of these frames; BidiAppend is a single frame + * request and a (usually empty) single-frame response. + * + * Flag bits: + * 0x01 — payload gzip-compressed + * 0x02 — end-stream error (trailer carries grpc-status/message) + * 0x80 — trailer frame (headers: grpc-status, grpc-message, ...) + * + * Workers-clean: Uint8Array + DataView; gzip via DecompressionStream (available + * on Workers and Node 22+). + */ + +export const FLAG_COMPRESSED = 0x01; +export const FLAG_END_STREAM = 0x02; +export const FLAG_TRAILER = 0x80; + +/** Wrap a proto payload in a 5-byte connect envelope. */ +export function addConnectEnvelope(data: Uint8Array, flags = 0): Uint8Array { + const result = new Uint8Array(5 + data.length); + result[0] = flags; + const length = data.length; + result[1] = (length >>> 24) & 0xff; + result[2] = (length >>> 16) & 0xff; + result[3] = (length >>> 8) & 0xff; + result[4] = length & 0xff; + result.set(data, 5); + return result; +} + +export interface ConnectFrame { + flags: number; + payload: Uint8Array; + nextOffset: number; +} + +/** + * Try to read one connect frame starting at `offset` in `buffer`. Returns null + * when the buffer doesn't yet hold a complete frame — the caller should read + * more bytes and retry from the same offset. + */ +export function readConnectFrame(buffer: Uint8Array, offset: number): ConnectFrame | null { + if (offset + 5 > buffer.length) return null; + + const flags = buffer[offset]!; + const b1 = buffer[offset + 1]!; + const b2 = buffer[offset + 2]!; + const b3 = buffer[offset + 3]!; + const b4 = buffer[offset + 4]!; + // Unsigned 32-bit big-endian length. Use >>> to keep it non-negative. + const length = ((b1 << 24) | (b2 << 16) | (b3 << 8) | b4) >>> 0; + + const frameEnd = offset + 5 + length; + if (frameEnd > buffer.length) return null; + + const payload = buffer.slice(offset + 5, frameEnd); + return { flags, payload, nextOffset: frameEnd }; +} + +/** True if a frame's flag byte marks it as a trailer (end-stream metadata). */ +export function isTrailerFrame(flags: number): boolean { + return (flags & FLAG_TRAILER) !== 0; +} + +/** True if a frame's payload is gzip-compressed. */ +export function isCompressedFrame(flags: number): boolean { + return (flags & FLAG_COMPRESSED) !== 0; +} + +/** Parse a trailer frame's header block into a lowercased key->value map. */ +export function parseTrailerMetadata(trailer: string): Record { + const meta: Record = {}; + + for (const rawLine of trailer.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const idx = line.indexOf(':'); + if (idx === -1) continue; + + const key = line.slice(0, idx).trim().toLowerCase(); + const value = line.slice(idx + 1).trim(); + if (!key) continue; + meta[key] = value; + } + + return meta; +} + +/** + * Gunzip a payload using the Web Streams DecompressionStream. No-op on empty + * input. Throws if the runtime lacks DecompressionStream. + */ +export async function decompressGzip(payload: Uint8Array): Promise { + if (payload.length === 0) return payload; + const ds = new DecompressionStream('gzip'); + const writer = ds.writable.getWriter(); + // Copy into a fresh ArrayBuffer-backed view so the writable's BufferSource + // constraint is satisfied regardless of the caller's backing storage + // (SharedArrayBuffer-backed views fail the ArrayBuffer-typed buffer check). + void writer.write(new Uint8Array(payload)); + await writer.close(); + + const reader = ds.readable.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + chunks.push(value); + total += value.length; + } + } + + const out = new Uint8Array(total); + let off = 0; + for (const c of chunks) { + out.set(c, off); + off += c.length; + } + return out; +} diff --git a/packages/provider-cursor/src/proto/exec.ts b/packages/provider-cursor/src/proto/exec.ts new file mode 100644 index 000000000..c56dcfdce --- /dev/null +++ b/packages/provider-cursor/src/proto/exec.ts @@ -0,0 +1,615 @@ +/** + * ExecServerMessage / ExecClientMessage encoding. + * + * The Cursor backend sends ExecServerMessage (AgentServerMessage field 2) to + * request a tool execution; the client replies with ExecClientMessage + * (AgentClientMessage field 2) carrying the result, then a stream-close + * control message (AgentClientMessage field 5). + * + * Workers-clean: environment facts come via RequestContextEnv — no + * process.cwd() / node:os / process.env reads here. + */ + +import type { RequestContextEnv } from './agent-messages.ts'; +import { parseProtoFields, parseProtobufValue } from './decoding.ts'; +import { + encodeStringField, + encodeUint32Field, + encodeInt32Field, + encodeInt64Field, + encodeMessageField, + encodeBoolField, + concatBytes, +} from './encoding.ts'; +import type { + ExecRequest, + McpExecRequest, + ShellExecRequest, + LsExecRequest, + ReadExecRequest, + GrepExecRequest, + WriteExecRequest, + McpResult, + WriteResult, +} from './types.ts'; + +function parseShellArgs(data: Uint8Array): { command: string; cwd?: string } { + const fields = parseProtoFields(data); + let command = ''; + let cwd: string | undefined; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + command = new TextDecoder().decode(field.value); + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + cwd = new TextDecoder().decode(field.value); + } + } + + return { command, cwd }; +} + +function parseLsArgs(data: Uint8Array): { path: string } { + const fields = parseProtoFields(data); + let path = ''; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + path = new TextDecoder().decode(field.value); + } + } + + return { path }; +} + +function parseReadArgs(data: Uint8Array): { path: string } { + const fields = parseProtoFields(data); + let path = ''; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + path = new TextDecoder().decode(field.value); + } + } + + return { path }; +} + +function parseGrepArgs(data: Uint8Array): { pattern: string; path?: string; glob?: string } { + const fields = parseProtoFields(data); + let pattern = ''; + let path: string | undefined; + let glob: string | undefined; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + pattern = new TextDecoder().decode(field.value); + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + path = new TextDecoder().decode(field.value); + } else if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + glob = new TextDecoder().decode(field.value); + } + } + + return { pattern, path, glob }; +} + +function parseWriteArgs(data: Uint8Array): { + path: string; + fileText: string; + toolCallId?: string; + returnFileContentAfterWrite?: boolean; + fileBytes?: Uint8Array; +} { + const fields = parseProtoFields(data); + let path = ''; + let fileText = ''; + let toolCallId: string | undefined; + let returnFileContentAfterWrite: boolean | undefined; + let fileBytes: Uint8Array | undefined; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + path = new TextDecoder().decode(field.value); + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + fileText = new TextDecoder().decode(field.value); + } else if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + toolCallId = new TextDecoder().decode(field.value); + } else if (field.fieldNumber === 4 && field.wireType === 0) { + returnFileContentAfterWrite = field.value === 1; + } else if (field.fieldNumber === 5 && field.wireType === 2 && field.value instanceof Uint8Array) { + fileBytes = field.value; + } + } + + return { path, fileText, toolCallId, returnFileContentAfterWrite, fileBytes }; +} + +function parseMcpArgs(data: Uint8Array): Omit { + const fields = parseProtoFields(data); + let name = ''; + const args: Record = {}; + let toolCallId = ''; + let providerIdentifier = ''; + let toolName = ''; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + name = new TextDecoder().decode(field.value); + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + const entryFields = parseProtoFields(field.value); + let key = ''; + let value: unknown = undefined; + + for (const ef of entryFields) { + if (ef.fieldNumber === 1 && ef.wireType === 2 && ef.value instanceof Uint8Array) { + key = new TextDecoder().decode(ef.value); + } + if (ef.fieldNumber === 2 && ef.wireType === 2 && ef.value instanceof Uint8Array) { + value = parseProtobufValue(ef.value); + } + } + + if (key) { + args[key] = value; + } + } else if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + toolCallId = new TextDecoder().decode(field.value); + } else if (field.fieldNumber === 4 && field.wireType === 2 && field.value instanceof Uint8Array) { + providerIdentifier = new TextDecoder().decode(field.value); + } else if (field.fieldNumber === 5 && field.wireType === 2 && field.value instanceof Uint8Array) { + toolName = new TextDecoder().decode(field.value); + } + } + + return { name, args, toolCallId, providerIdentifier, toolName }; +} + +/** + * Parse an ExecServerMessage into a typed ExecRequest. + * + * Field 1 = id (uint32), field 15 = exec_id (string). The oneof tool payload + * is carried in a tool-type-specific field (2/14 shell, 3 write, 5 grep, + * 7 read, 8 ls, 10 request_context, 11 mcp). + */ +export function parseExecServerMessage(data: Uint8Array): ExecRequest | null { + const fields = parseProtoFields(data); + let id = 0; + let execId: string | undefined = undefined; + let result: ExecRequest | null = null; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 0) { + id = field.value as number; + } else if (field.fieldNumber === 15 && field.wireType === 2 && field.value instanceof Uint8Array) { + execId = new TextDecoder().decode(field.value); + } + } + + for (const field of fields) { + if (field.wireType !== 2 || !(field.value instanceof Uint8Array)) continue; + + switch (field.fieldNumber) { + case 2: + case 14: { + const shellArgs = parseShellArgs(field.value); + result = { type: 'shell', id, execId, command: shellArgs.command, cwd: shellArgs.cwd } as ShellExecRequest; + break; + } + case 3: { + const writeArgs = parseWriteArgs(field.value); + result = { + type: 'write', + id, + execId, + path: writeArgs.path, + fileText: writeArgs.fileText, + toolCallId: writeArgs.toolCallId, + returnFileContentAfterWrite: writeArgs.returnFileContentAfterWrite, + fileBytes: writeArgs.fileBytes, + } as WriteExecRequest; + break; + } + case 5: { + const grepArgs = parseGrepArgs(field.value); + result = { + type: 'grep', + id, + execId, + pattern: grepArgs.pattern, + path: grepArgs.path, + glob: grepArgs.glob, + } as GrepExecRequest; + break; + } + case 7: { + const readArgs = parseReadArgs(field.value); + result = { type: 'read', id, execId, path: readArgs.path } as ReadExecRequest; + break; + } + case 8: { + const lsArgs = parseLsArgs(field.value); + result = { type: 'ls', id, execId, path: lsArgs.path } as LsExecRequest; + break; + } + case 10: { + result = { type: 'request_context', id, execId }; + break; + } + case 11: { + const mcpArgs = parseMcpArgs(field.value); + result = { type: 'mcp', id, execId, ...mcpArgs } as McpExecRequest & { type: 'mcp' }; + break; + } + } + + if (result) break; + } + + return result; +} + +// --- Result encoders --- + +function encodeMcpTextContent(text: string): Uint8Array { + return encodeStringField(1, text); +} + +function encodeMcpToolResultContentItem(text: string): Uint8Array { + const textContent = encodeMcpTextContent(text); + return encodeMessageField(1, textContent); +} + +function encodeMcpSuccess(content: string, isError = false): Uint8Array { + const parts: Uint8Array[] = []; + const contentItem = encodeMcpToolResultContentItem(content); + parts.push(encodeMessageField(1, contentItem)); + if (isError) { + parts.push(encodeBoolField(2, true)); + } + return concatBytes(...parts); +} + +function encodeMcpError(error: string): Uint8Array { + return encodeStringField(1, error); +} + +function encodeMcpResult(result: McpResult): Uint8Array { + if (result.success) { + const success = encodeMcpSuccess(result.success.content, result.success.isError); + return encodeMessageField(1, success); + } + if (result.error) { + const error = encodeMcpError(result.error); + return encodeMessageField(2, error); + } + return encodeMessageField(1, encodeMcpSuccess('')); +} + +export function buildExecClientMessageWithMcpResult( + id: number, + execId: string | undefined, + result: McpResult, +): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeUint32Field(1, id)); + if (execId) { + parts.push(encodeStringField(15, execId)); + } + const mcpResult = encodeMcpResult(result); + parts.push(encodeMessageField(11, mcpResult)); + return concatBytes(...parts); +} + +function encodeShellResult( + command: string, + cwd: string, + stdout: string, + stderr: string, + exitCode: number, + executionTimeMs?: number, +): Uint8Array { + const shellOutcome = concatBytes( + encodeStringField(1, command), + encodeStringField(2, cwd), + encodeInt32Field(3, exitCode), + encodeStringField(4, ''), + encodeStringField(5, stdout), + encodeStringField(6, stderr), + executionTimeMs ? encodeInt32Field(7, executionTimeMs) : new Uint8Array(0), + ); + const resultField = exitCode === 0 ? 1 : 2; + return encodeMessageField(resultField, shellOutcome); +} + +export function buildExecClientMessageWithShellResult( + id: number, + execId: string | undefined, + command: string, + cwd: string, + stdout: string, + stderr: string, + exitCode: number, + executionTimeMs?: number, +): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeUint32Field(1, id)); + if (execId) { + parts.push(encodeStringField(15, execId)); + } + parts.push(encodeMessageField(2, encodeShellResult(command, cwd, stdout, stderr, exitCode, executionTimeMs))); + return concatBytes(...parts); +} + +function encodeLsResult(filesString: string): Uint8Array { + const lsSuccess = encodeStringField(1, filesString); + return encodeMessageField(1, lsSuccess); +} + +export function buildExecClientMessageWithLsResult( + id: number, + execId: string | undefined, + filesString: string, +): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeUint32Field(1, id)); + if (execId) { + parts.push(encodeStringField(15, execId)); + } + parts.push(encodeMessageField(8, encodeLsResult(filesString))); + return concatBytes(...parts); +} + +function encodeRequestContextResult(env: RequestContextEnv): Uint8Array { + const envBytes = concatBytes( + encodeStringField(1, env.osVersion), + encodeStringField(2, env.workspacePath), + encodeStringField(3, env.shell), + encodeStringField(10, env.timezone), + encodeStringField(11, env.workspacePath), + ); + + const requestContext = encodeMessageField(4, envBytes); + const success = encodeMessageField(1, requestContext); + return encodeMessageField(1, success); +} + +export function buildExecClientMessageWithRequestContextResult( + id: number, + execId: string | undefined, + env: RequestContextEnv, +): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeUint32Field(1, id)); + if (execId) { + parts.push(encodeStringField(15, execId)); + } + parts.push(encodeMessageField(10, encodeRequestContextResult(env))); + return concatBytes(...parts); +} + +function encodeReadResult( + content: string, + path: string, + totalLines?: number, + fileSize?: bigint, + truncated?: boolean, +): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeStringField(1, path)); + parts.push(encodeStringField(2, content)); + if (totalLines !== undefined && totalLines > 0) { + parts.push(encodeInt32Field(3, totalLines)); + } + if (fileSize !== undefined) { + parts.push(encodeInt64Field(4, fileSize)); + } + if (truncated) { + parts.push(encodeBoolField(6, true)); + } + const readSuccess = concatBytes(...parts); + return encodeMessageField(1, readSuccess); +} + +export function buildExecClientMessageWithReadResult( + id: number, + execId: string | undefined, + content: string, + path: string, + totalLines?: number, + fileSize?: bigint, + truncated?: boolean, +): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeUint32Field(1, id)); + if (execId) { + parts.push(encodeStringField(15, execId)); + } + parts.push(encodeMessageField(7, encodeReadResult(content, path, totalLines, fileSize, truncated))); + return concatBytes(...parts); +} + +function encodeGrepFilesResult(files: string[], totalFiles: number, truncated = false): Uint8Array { + const parts: Uint8Array[] = []; + for (const file of files) { + parts.push(encodeStringField(1, file)); + } + parts.push(encodeInt32Field(2, totalFiles)); + if (truncated) { + parts.push(encodeBoolField(3, true)); + } + return concatBytes(...parts); +} + +function encodeGrepUnionResult(files: string[], totalFiles: number, truncated = false): Uint8Array { + const filesResult = encodeGrepFilesResult(files, totalFiles, truncated); + return encodeMessageField(2, filesResult); +} + +function encodeGrepSuccess(pattern: string, path: string, files: string[]): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeStringField(1, pattern)); + parts.push(encodeStringField(2, path)); + parts.push(encodeStringField(3, 'files_with_matches')); + const unionResult = encodeGrepUnionResult(files, files.length); + const mapEntry = concatBytes(encodeStringField(1, path), encodeMessageField(2, unionResult)); + parts.push(encodeMessageField(4, mapEntry)); + return concatBytes(...parts); +} + +function encodeGrepResult(pattern: string, path: string, files: string[]): Uint8Array { + const success = encodeGrepSuccess(pattern, path, files); + return encodeMessageField(1, success); +} + +export function buildExecClientMessageWithGrepResult( + id: number, + execId: string | undefined, + pattern: string, + path: string, + files: string[], +): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeUint32Field(1, id)); + if (execId) { + parts.push(encodeStringField(15, execId)); + } + parts.push(encodeMessageField(5, encodeGrepResult(pattern, path, files))); + return concatBytes(...parts); +} + +function encodeWriteSuccess( + path: string, + linesCreated: number, + fileSize: number, + fileContentAfterWrite?: string, +): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeStringField(1, path)); + parts.push(encodeInt32Field(2, linesCreated)); + parts.push(encodeInt32Field(3, fileSize)); + if (fileContentAfterWrite) { + parts.push(encodeStringField(4, fileContentAfterWrite)); + } + return concatBytes(...parts); +} + +function encodeWriteError(path: string, error: string): Uint8Array { + return concatBytes(encodeStringField(1, path), encodeStringField(2, error)); +} + +function encodeWriteResult(result: WriteResult): Uint8Array { + if (result.success) { + const success = encodeWriteSuccess( + result.success.path, + result.success.linesCreated, + result.success.fileSize, + result.success.fileContentAfterWrite, + ); + return encodeMessageField(1, success); + } + if (result.error) { + const error = encodeWriteError(result.error.path, result.error.error); + return encodeMessageField(5, error); + } + return encodeMessageField(1, encodeWriteSuccess('', 0, 0)); +} + +export function buildExecClientMessageWithWriteResult( + id: number, + execId: string | undefined, + result: WriteResult, +): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(encodeUint32Field(1, id)); + if (execId) { + parts.push(encodeStringField(15, execId)); + } + parts.push(encodeMessageField(3, encodeWriteResult(result))); + return concatBytes(...parts); +} + +// --- Rejected-tool encoders --- +// +// Floway is a stateless gateway: it cannot execute Cursor's built-in agent +// tools (shell/read/write/grep/ls). When the backend requests one, we reply +// with a result that signals "unavailable" so the model can adapt and keep +// streaming, then close the exec stream. request_context is environmental +// metadata, not a tool — it gets a real (placeholder-env) response upstream, +// not a rejection. + +/** + * Build an ExecClientMessage that rejects a built-in tool request with the + * given reason. `mcp` and `request_context` are not rejected here — mcp is + * translated to downstream tool_calls (no reply sent on this channel), and + * request_context is answered with the gateway env. + */ +export function buildExecClientMessageWithRejectedTool( + execRequest: ExecRequest, + reason: string, +): Uint8Array { + switch (execRequest.type) { + case 'shell': + return buildExecClientMessageWithShellResult( + execRequest.id, + execRequest.execId, + execRequest.command, + execRequest.cwd ?? '', + '', + reason, + 1, + ); + case 'read': + return buildExecClientMessageWithReadResult( + execRequest.id, + execRequest.execId, + `[tool unavailable: ${reason}]`, + execRequest.path, + ); + case 'grep': + return buildExecClientMessageWithGrepResult( + execRequest.id, + execRequest.execId, + execRequest.pattern, + execRequest.path ?? '', + [], + ); + case 'ls': + return buildExecClientMessageWithLsResult( + execRequest.id, + execRequest.execId, + `[tool unavailable: ${reason}]`, + ); + case 'write': + return buildExecClientMessageWithWriteResult(execRequest.id, execRequest.execId, { + error: { path: execRequest.path, error: reason }, + }); + case 'mcp': + return buildExecClientMessageWithMcpResult(execRequest.id, execRequest.execId, { + error: reason, + }); + case 'request_context': + // Caller should answer with env, not reject. Fall through to mcp-style + // error as a defensive last resort. + return buildExecClientMessageWithMcpResult(execRequest.id, execRequest.execId, { error: reason }); + } +} + +// --- Control / wrapper messages --- + +export function buildAgentClientMessageWithExec(execClientMessage: Uint8Array): Uint8Array { + return encodeMessageField(2, execClientMessage); +} + +function encodeExecClientStreamClose(id: number): Uint8Array { + return encodeUint32Field(1, id); +} + +export function buildExecClientControlMessage(id: number): Uint8Array { + const streamClose = encodeExecClientStreamClose(id); + return encodeMessageField(1, streamClose); +} + +export function buildAgentClientMessageWithExecControl(execClientControlMessage: Uint8Array): Uint8Array { + return encodeMessageField(5, execClientControlMessage); +} diff --git a/packages/provider-cursor/src/proto/index.ts b/packages/provider-cursor/src/proto/index.ts new file mode 100644 index 000000000..5a40575b8 --- /dev/null +++ b/packages/provider-cursor/src/proto/index.ts @@ -0,0 +1,108 @@ +/** + * Cursor Agent protobuf codec barrel. + * + * 1:1 mapping of opencode-cursor-proxy's proto/ module, Workers-clean: + * Uint8Array + DataView + Web Crypto, no Buffer / node:os / process.cwd. + */ + +export { + encodeVarint, + encodeStringField, + encodeUint32Field, + encodeInt32Field, + encodeInt64Field, + encodeMessageField, + encodeBoolField, + encodeDoubleField, + concatBytes, + encodeProtobufValue, + bytesToHex, + hexToBytes, +} from './encoding.ts'; + +export { decodeVarint, parseProtoFields, parseProtobufValue, parseProtobufStruct, parseProtobufListValue } from './decoding.ts'; +export type { ParsedField } from './decoding.ts'; + +export { + addConnectEnvelope, + readConnectFrame, + isTrailerFrame, + isCompressedFrame, + parseTrailerMetadata, + decompressGzip, + FLAG_COMPRESSED, + FLAG_END_STREAM, + FLAG_TRAILER, +} from './envelope.ts'; +export type { ConnectFrame } from './envelope.ts'; + +export { AgentMode } from './types.ts'; +export type { + OpenAIToolDefinition, + McpExecRequest, + ShellExecRequest, + LsExecRequest, + RequestContextExecRequest, + ReadExecRequest, + GrepExecRequest, + WriteExecRequest, + ExecRequest, + KvServerMessage, + ToolCallInfo, + ParsedToolCall, + ParsedToolCallStarted, + ParsedPartialToolCall, + AgentStreamChunk, + AgentServiceOptions, + AgentChatRequest, + McpResult, + ShellOutcome, + WriteResult, + BlobAnalysis, + ParsedInteractionUpdate, +} from './types.ts'; + +export { + parseExecServerMessage, + buildExecClientMessageWithMcpResult, + buildExecClientMessageWithShellResult, + buildExecClientMessageWithLsResult, + buildExecClientMessageWithRequestContextResult, + buildExecClientMessageWithReadResult, + buildExecClientMessageWithGrepResult, + buildExecClientMessageWithWriteResult, + buildExecClientMessageWithRejectedTool, + buildAgentClientMessageWithExec, + buildExecClientControlMessage, + buildAgentClientMessageWithExecControl, +} from './exec.ts'; + +export { TOOL_FIELD_MAP, TOOL_ARG_SCHEMA, parseToolCall, parseToolCallStartedUpdate, parsePartialToolCallUpdate } from './tool-calls.ts'; + +export { parseKvServerMessage, buildKvClientMessage, buildAgentClientMessageWithKv, analyzeBlobData, extractAssistantContent } from './kv.ts'; +export type { AssistantBlobContent } from './kv.ts'; + +export { encodeBidiRequestId, encodeBidiAppendRequest } from './bidi.ts'; + +export { + encodeMcpToolDefinition, + buildRequestContextEnv, + encodeMcpInstructions, + buildRequestContext, + encodeUserMessage, + encodeUserMessageAction, + encodeConversationAction, + encodeResumeAction, + encodeConversationActionWithResume, + encodeAgentClientMessageWithConversationAction, + encodeModelDetails, + encodeEmptyConversationState, + encodeMcpTools, + encodeMcpDescriptor, + encodeMcpFileSystemOptions, + encodeAgentRunRequest, + encodeAgentClientMessage, +} from './agent-messages.ts'; +export type { McpDescriptorInput, RequestContextEnv } from './agent-messages.ts'; + +export { parseInteractionUpdate } from './interaction.ts'; diff --git a/packages/provider-cursor/src/proto/interaction.ts b/packages/provider-cursor/src/proto/interaction.ts new file mode 100644 index 000000000..9374f1f56 --- /dev/null +++ b/packages/provider-cursor/src/proto/interaction.ts @@ -0,0 +1,87 @@ +import { parseProtoFields } from './decoding.ts'; +import { parseToolCallStartedUpdate, parsePartialToolCallUpdate } from './tool-calls.ts'; +import type { ParsedInteractionUpdate } from './types.ts'; + +/** + * Parse an InteractionUpdate message. + * + * InteractionUpdate fields: + * field 1: text_delta (TextDeltaUpdate) + * field 2: tool_call_started (ToolCallStartedUpdate) + * field 3: tool_call_completed (ToolCallCompletedUpdate) + * field 4: thinking_delta (ThinkingDeltaUpdate) — reasoning/thinking models + * field 7: partial_tool_call (PartialToolCallUpdate) + * field 8: token_delta (TokenDeltaUpdate) + * field 13: heartbeat + * field 14: turn_ended (TurnEndedUpdate) + */ +export function parseInteractionUpdate(data: Uint8Array): ParsedInteractionUpdate { + const fields = parseProtoFields(data); + + let text: string | null = null; + let thinking: string | null = null; + let isComplete = false; + let isHeartbeat = false; + let toolCallStarted: ParsedInteractionUpdate['toolCallStarted'] = null; + let toolCallCompleted: ParsedInteractionUpdate['toolCallCompleted'] = null; + let partialToolCall: ParsedInteractionUpdate['partialToolCall'] = null; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + const innerFields = parseProtoFields(field.value); + for (const innerField of innerFields) { + if (innerField.fieldNumber === 1 && innerField.wireType === 2 && innerField.value instanceof Uint8Array) { + text = new TextDecoder().decode(innerField.value); + } + } + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + const parsed = parseToolCallStartedUpdate(field.value); + if (parsed.toolCall) { + toolCallStarted = { + callId: parsed.callId, + modelCallId: parsed.modelCallId, + toolType: parsed.toolCall.toolType, + name: parsed.toolCall.name, + arguments: JSON.stringify(parsed.toolCall.arguments), + }; + } + } else if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + const parsed = parseToolCallStartedUpdate(field.value); + if (parsed.toolCall) { + toolCallCompleted = { + callId: parsed.callId, + modelCallId: parsed.modelCallId, + toolType: parsed.toolCall.toolType, + name: parsed.toolCall.name, + arguments: JSON.stringify(parsed.toolCall.arguments), + }; + } + } else if (field.fieldNumber === 4 && field.wireType === 2 && field.value instanceof Uint8Array) { + const innerFields = parseProtoFields(field.value); + for (const innerField of innerFields) { + if (innerField.fieldNumber === 1 && innerField.wireType === 2 && innerField.value instanceof Uint8Array) { + thinking = new TextDecoder().decode(innerField.value); + } + } + } else if (field.fieldNumber === 7 && field.wireType === 2 && field.value instanceof Uint8Array) { + const parsed = parsePartialToolCallUpdate(field.value); + partialToolCall = { + callId: parsed.callId, + argsTextDelta: parsed.argsTextDelta, + }; + } else if (field.fieldNumber === 8 && field.wireType === 2 && field.value instanceof Uint8Array) { + const tokenFields = parseProtoFields(field.value); + for (const tField of tokenFields) { + if (tField.fieldNumber === 1 && tField.wireType === 2 && tField.value instanceof Uint8Array) { + text = new TextDecoder().decode(tField.value); + } + } + } else if (field.fieldNumber === 14) { + isComplete = true; + } else if (field.fieldNumber === 13) { + isHeartbeat = true; + } + } + + return { text, thinking, isComplete, isHeartbeat, toolCallStarted, toolCallCompleted, partialToolCall }; +} diff --git a/packages/provider-cursor/src/proto/kv.ts b/packages/provider-cursor/src/proto/kv.ts new file mode 100644 index 000000000..f2ad7db41 --- /dev/null +++ b/packages/provider-cursor/src/proto/kv.ts @@ -0,0 +1,157 @@ +/** + * KV (Key-Value) blob message handling. + * + * The Cursor agent asks us to store/retrieve blobs (set_blob_args / get_blob_args) + * keyed by blob_id. When the backend stores an assistant response in a blob + * instead of streaming it, we extract the text from the blob and surface it. + * + * Proto: + * KvServerMessage: field 1 id (uint32), field 2 get_blob_args, field 3 set_blob_args + * KvClientMessage: field 1 id (uint32), field 2 get_blob_result, field 3 set_blob_result + */ + +import { parseProtoFields } from './decoding.ts'; +import { encodeUint32Field, encodeMessageField, concatBytes } from './encoding.ts'; +import type { KvServerMessage, BlobAnalysis } from './types.ts'; + +export type { KvServerMessage }; + +export function parseKvServerMessage(data: Uint8Array): KvServerMessage { + const fields = parseProtoFields(data); + const result: KvServerMessage = { id: 0, messageType: 'unknown' }; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 0) { + result.id = field.value as number; + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + result.messageType = 'get_blob_args'; + const argsFields = parseProtoFields(field.value); + for (const af of argsFields) { + if (af.fieldNumber === 1 && af.wireType === 2 && af.value instanceof Uint8Array) { + result.blobId = af.value; + } + } + } else if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + result.messageType = 'set_blob_args'; + const argsFields = parseProtoFields(field.value); + for (const af of argsFields) { + if (af.fieldNumber === 1 && af.wireType === 2 && af.value instanceof Uint8Array) { + result.blobId = af.value; + } else if (af.fieldNumber === 2 && af.wireType === 2 && af.value instanceof Uint8Array) { + result.blobData = af.value; + } + } + } + } + + return result; +} + +export function buildKvClientMessage( + id: number, + resultType: 'get_blob_result' | 'set_blob_result', + result: Uint8Array, +): Uint8Array { + const fieldNumber = resultType === 'get_blob_result' ? 2 : 3; + return concatBytes(encodeUint32Field(1, id), encodeMessageField(fieldNumber, result)); +} + +/** + * AgentClientMessage with kv_client_message (field 3). + */ +export function buildAgentClientMessageWithKv(kvClientMessage: Uint8Array): Uint8Array { + return encodeMessageField(3, kvClientMessage); +} + +export function analyzeBlobData(data: Uint8Array): BlobAnalysis { + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(data); + try { + const json = JSON.parse(text) as Record; + return { type: 'json', json, text }; + } catch { + return { type: 'text', text }; + } + } catch { + // not valid utf-8 + } + + try { + const fields = parseProtoFields(data); + if (fields.length > 0 && fields.length < 100) { + const protoFields: BlobAnalysis['protoFields'] = []; + for (const f of fields) { + const entry: { num: number; wire: number; size: number; text?: string } = { + num: f.fieldNumber, + wire: f.wireType, + size: f.value instanceof Uint8Array ? f.value.length : 0, + }; + if (f.wireType === 2 && f.value instanceof Uint8Array) { + try { + entry.text = new TextDecoder('utf-8', { fatal: true }).decode(f.value); + } catch { + // binary field + } + } + protoFields.push(entry); + } + return { type: 'protobuf', protoFields }; + } + } catch { + // not protobuf + } + + return { type: 'binary' }; +} + +export interface AssistantBlobContent { + blobId: string; + content: string; +} + +interface MessageLike { + role?: unknown; + content?: unknown; + type?: unknown; + text?: unknown; + messages?: unknown[]; +} + +export function extractAssistantContent(blobAnalysis: BlobAnalysis, blobKey: string): AssistantBlobContent[] { + const results: AssistantBlobContent[] = []; + + if (blobAnalysis.type === 'json' && blobAnalysis.json) { + const json = blobAnalysis.json as MessageLike; + + if (json.role === 'assistant') { + const content = json.content; + if (typeof content === 'string' && content.length > 0) { + results.push({ blobId: blobKey, content }); + } else if (Array.isArray(content)) { + for (const part of content as MessageLike[]) { + if (typeof part === 'string') { + results.push({ blobId: blobKey, content: part }); + } else if (part?.type === 'text' && typeof part?.text === 'string') { + results.push({ blobId: blobKey, content: part.text }); + } + } + } + } + + if (Array.isArray(json.messages)) { + for (const msg of json.messages as MessageLike[]) { + if (msg?.role === 'assistant' && typeof msg?.content === 'string') { + results.push({ blobId: blobKey, content: msg.content }); + } + } + } + } else if (blobAnalysis.type === 'protobuf' && blobAnalysis.protoFields) { + for (const field of blobAnalysis.protoFields) { + if (field.text && field.text.length > 50 && !field.text.startsWith('{') && !field.text.startsWith('[')) { + results.push({ blobId: `${blobKey}:f${field.num}`, content: field.text }); + } + } + } + + return results; +} diff --git a/packages/provider-cursor/src/proto/proto_test.ts b/packages/provider-cursor/src/proto/proto_test.ts new file mode 100644 index 000000000..e37637693 --- /dev/null +++ b/packages/provider-cursor/src/proto/proto_test.ts @@ -0,0 +1,273 @@ +import { describe, expect, test } from 'vitest'; + +import { + encodeVarint, + decodeVarint, + encodeStringField, + encodeInt64Field, + encodeMessageField, + encodeUint32Field, + encodeProtobufValue, + bytesToHex, + hexToBytes, + concatBytes, + parseProtoFields, + parseProtobufValue, + encodeBidiRequestId, + encodeBidiAppendRequest, + addConnectEnvelope, + readConnectFrame, + isTrailerFrame, + parseInteractionUpdate, + parseExecServerMessage, + parseKvServerMessage, + buildExecClientMessageWithMcpResult, + buildExecClientMessageWithRejectedTool, + buildAgentClientMessageWithExec, + AgentMode, + type ExecRequest, +} from './index.ts'; + +describe('proto encoding primitives', () => { + test('encodeVarint matches canonical byte sequences', () => { + expect(Array.from(encodeVarint(0))).toEqual([0]); + expect(Array.from(encodeVarint(150))).toEqual([0x96, 0x01]); + // 1 << 35 = 2^35, which exceeds the 5-byte varint max (2^35 - 1) → 6 bytes + expect(Array.from(encodeVarint(1n << 35n))).toHaveLength(6); + }); + + test('encodeVarint round-trips through decodeVarint', () => { + for (const v of [0, 1, 127, 128, 150, 16384, 0x0fffffff]) { + const bytes = encodeVarint(v); + const { value, bytesRead } = decodeVarint(bytes, 0); + expect(value).toBe(v); + expect(bytesRead).toBe(bytes.length); + } + }); + + test('encodeStringField round-trips through parseProtoFields', () => { + const field = encodeStringField(1, 'hello-世界'); + const parsed = parseProtoFields(field); + expect(parsed).toHaveLength(1); + expect(parsed[0]!.fieldNumber).toBe(1); + expect(parsed[0]!.wireType).toBe(2); + expect(new TextDecoder().decode(parsed[0]!.value as Uint8Array)).toBe('hello-世界'); + }); + + test('bytesToHex / hexToBytes round-trip', () => { + const bytes = new Uint8Array([0x00, 0xff, 0xab, 0x12, 0x9f]); + const hex = bytesToHex(bytes); + expect(hex).toBe('00ffab129f'); + expect(Array.from(hexToBytes(hex))).toEqual(Array.from(bytes)); + }); + + test('hexToBytes rejects odd-length input', () => { + expect(() => hexToBytes('abc')).toThrow(TypeError); + }); + + test('encodeInt64Field carries bigint seqno', () => { + const field = encodeInt64Field(3, 42n); + const parsed = parseProtoFields(field); + expect(parsed[0]!.fieldNumber).toBe(3); + expect(parsed[0]!.wireType).toBe(0); + // wire type 0 fields surface as a number value + expect(parsed[0]!.value).toBe(42); + }); +}); + +describe('connect envelope', () => { + test('addConnectEnvelope / readConnectFrame round-trip', () => { + const payload = new Uint8Array([1, 2, 3, 4, 5]); + const framed = addConnectEnvelope(payload, 0); + expect(framed.length).toBe(5 + payload.length); + const frame = readConnectFrame(framed, 0); + expect(frame).not.toBeNull(); + expect(frame!.flags).toBe(0); + expect(Array.from(frame!.payload)).toEqual([1, 2, 3, 4, 5]); + expect(frame!.nextOffset).toBe(framed.length); + }); + + test('readConnectFrame returns null for a partial frame', () => { + const payload = new Uint8Array(10); + const framed = addConnectEnvelope(payload, 0); + const partial = framed.slice(0, 7); + expect(readConnectFrame(partial, 0)).toBeNull(); + }); + + test('trailer flag is detected', () => { + const framed = addConnectEnvelope(new TextEncoder().encode('grpc-status: 0'), 0x80); + const frame = readConnectFrame(framed, 0); + expect(isTrailerFrame(frame!.flags)).toBe(true); + }); +}); + +describe('google.protobuf.Value', () => { + test.each([ + ['null', null, null], + ['number', 3.5, 3.5], + ['string', 'hi', 'hi'], + ['true', true, true], + ['false', false, false], + ])('round-trips %s', (_label, input, expected) => { + expect(parseProtobufValue(encodeProtobufValue(input))).toBe(expected); + }); + + test('round-trips a nested struct', () => { + const input = { a: 1, b: 'x', c: [1, 2, { d: true }] }; + expect(parseProtobufValue(encodeProtobufValue(input))).toEqual(input); + }); +}); + +describe('bidi control messages', () => { + test('encodeBidiAppendRequest carries hex data + requestId + seqno', () => { + const hexData = 'deadbeef'; + const requestId = 'req-123'; + const fields = parseProtoFields(encodeBidiAppendRequest(hexData, requestId, 7n)); + expect(fields).toHaveLength(3); + + const data = fields.find(f => f.fieldNumber === 1); + expect(data?.wireType).toBe(2); + expect(new TextDecoder().decode(data!.value as Uint8Array)).toBe(hexData); + + const seqno = fields.find(f => f.fieldNumber === 3); + expect(seqno?.wireType).toBe(0); + expect(seqno!.value).toBe(7); + + // nested requestId message at field 2 + const reqIdMsg = fields.find(f => f.fieldNumber === 2); + const inner = parseProtoFields(reqIdMsg!.value as Uint8Array); + expect(new TextDecoder().decode(inner[0]!.value as Uint8Array)).toBe(requestId); + }); + + test('encodeBidiRequestId is a single string field', () => { + const fields = parseProtoFields(encodeBidiRequestId('r1')); + expect(fields).toHaveLength(1); + expect(fields[0]!.fieldNumber).toBe(1); + expect(new TextDecoder().decode(fields[0]!.value as Uint8Array)).toBe('r1'); + }); +}); + +describe('parseInteractionUpdate', () => { + test('extracts text_delta', () => { + // InteractionUpdate.field1 = TextDeltaUpdate.field1 = text + const update = encodeMessageField(1, encodeStringField(1, 'hello')); + const parsed = parseInteractionUpdate(update); + expect(parsed.text).toBe('hello'); + expect(parsed.isComplete).toBe(false); + }); + + test('extracts thinking_delta', () => { + const update = encodeMessageField(4, encodeStringField(1, 'reasoning...')); + expect(parseInteractionUpdate(update).thinking).toBe('reasoning...'); + }); + + test('flags turn_ended on field 14', () => { + // field 14, wire type 0, value 0 — presence is what matters. Build the + // tag byte explicitly: encodeUint32Field omits zero values. + const update = new Uint8Array([(14 << 3) | 0, 0]); + expect(parseInteractionUpdate(update).isComplete).toBe(true); + }); + + test('flags heartbeat on field 13', () => { + const update = new Uint8Array([(13 << 3) | 0, 0]); + expect(parseInteractionUpdate(update).isHeartbeat).toBe(true); + }); +}); + +describe('parseExecServerMessage', () => { + function buildMcpExec(id: number, mcpArgs: Uint8Array): Uint8Array { + return concatBytes(encodeUint32Field(1, id), encodeMessageField(11, mcpArgs)); + } + + test('parses an mcp exec request', () => { + // McpArgs: field1 name, field3 toolCallId, field4 providerIdentifier, field5 toolName + const mcpArgs = concatBytes( + encodeStringField(1, 'search'), + encodeStringField(3, 'call_1'), + encodeStringField(4, 'cursor-tools'), + encodeStringField(5, 'search'), + ); + const exec = buildMcpExec(42, mcpArgs); + const parsed = parseExecServerMessage(exec) as Extract; + expect(parsed).not.toBeNull(); + expect(parsed.type).toBe('mcp'); + expect(parsed.id).toBe(42); + expect(parsed.name).toBe('search'); + expect(parsed.toolCallId).toBe('call_1'); + expect(parsed.providerIdentifier).toBe('cursor-tools'); + expect(parsed.toolName).toBe('search'); + }); + + test('parses a shell exec request (field 2)', () => { + const shellArgs = concatBytes(encodeStringField(1, 'ls -la'), encodeStringField(2, '/tmp')); + const exec = concatBytes(encodeUint32Field(1, 7), encodeMessageField(2, shellArgs)); + const parsed = parseExecServerMessage(exec) as Extract; + expect(parsed.type).toBe('shell'); + expect(parsed.id).toBe(7); + expect(parsed.command).toBe('ls -la'); + expect(parsed.cwd).toBe('/tmp'); + }); + + test('parses a request_context exec request (field 10)', () => { + const exec = concatBytes(encodeUint32Field(1, 9), encodeMessageField(10, new Uint8Array(0))); + const parsed = parseExecServerMessage(exec) as Extract; + expect(parsed.type).toBe('request_context'); + expect(parsed.id).toBe(9); + }); +}); + +describe('exec result / reject encoders', () => { + test('buildExecClientMessageWithMcpResult encodes id + mcp success', () => { + const msg = buildExecClientMessageWithMcpResult(5, undefined, { success: { content: 'ok' } }); + const fields = parseProtoFields(msg); + expect(fields.find(f => f.fieldNumber === 1)!.value).toBe(5); + expect(fields.find(f => f.fieldNumber === 11)).toBeDefined(); + }); + + test('buildExecClientMessageWithRejectedTool emits a shell failure', () => { + const req: Extract = { + type: 'shell', + id: 3, + execId: 'e1', + command: 'rm -rf /', + cwd: '/', + }; + const msg = buildExecClientMessageWithRejectedTool(req, 'gateway cannot execute shell'); + const fields = parseProtoFields(msg); + expect(fields.find(f => f.fieldNumber === 1)!.value).toBe(3); + // shell result lives at field 2 + expect(fields.find(f => f.fieldNumber === 2)).toBeDefined(); + // execId at field 15 + expect(new TextDecoder().decode(fields.find(f => f.fieldNumber === 15)!.value as Uint8Array)).toBe('e1'); + }); + + test('buildAgentClientMessageWithExec wraps at AgentClientMessage field 2', () => { + const inner = buildExecClientMessageWithMcpResult(1, undefined, { error: 'nope' }); + const wrapped = buildAgentClientMessageWithExec(inner); + const fields = parseProtoFields(wrapped); + expect(fields[0]!.fieldNumber).toBe(2); + expect(fields[0]!.wireType).toBe(2); + }); +}); + +describe('parseKvServerMessage', () => { + test('parses set_blob_args', () => { + const blobId = new Uint8Array([1, 2, 3]); + const blobData = new Uint8Array([0xaa, 0xbb]); + const setBlobArgs = concatBytes(encodeMessageField(1, blobId), encodeMessageField(2, blobData)); + const kv = concatBytes(encodeUint32Field(1, 99), encodeMessageField(3, setBlobArgs)); + const parsed = parseKvServerMessage(kv); + expect(parsed.id).toBe(99); + expect(parsed.messageType).toBe('set_blob_args'); + expect(Array.from(parsed.blobId!)).toEqual([1, 2, 3]); + expect(Array.from(parsed.blobData!)).toEqual([0xaa, 0xbb]); + }); +}); + +describe('AgentMode enum', () => { + test('has the expected modes', () => { + expect(AgentMode.AGENT).toBe(1); + expect(AgentMode.ASK).toBe(2); + expect(AgentMode.UNSPECIFIED).toBe(0); + }); +}); diff --git a/packages/provider-cursor/src/proto/tool-calls.ts b/packages/provider-cursor/src/proto/tool-calls.ts new file mode 100644 index 000000000..95f6eadb4 --- /dev/null +++ b/packages/provider-cursor/src/proto/tool-calls.ts @@ -0,0 +1,152 @@ +import { parseProtoFields } from './decoding.ts'; +import type { ParsedToolCall, ParsedToolCallStarted, ParsedPartialToolCall } from './types.ts'; + +export const TOOL_FIELD_MAP: Record = { + 1: { type: 'shell_tool_call', name: 'bash' }, + 3: { type: 'delete_tool_call', name: 'delete' }, + 4: { type: 'glob_tool_call', name: 'glob' }, + 5: { type: 'grep_tool_call', name: 'grep' }, + 8: { type: 'read_tool_call', name: 'read' }, + 9: { type: 'update_todos_tool_call', name: 'todowrite' }, + 10: { type: 'read_todos_tool_call', name: 'todoread' }, + 12: { type: 'edit_tool_call', name: 'edit' }, + 13: { type: 'ls_tool_call', name: 'list' }, + 14: { type: 'read_lints_tool_call', name: 'read_lints' }, + 15: { type: 'mcp_tool_call', name: 'mcp' }, + 16: { type: 'sem_search_tool_call', name: 'semantic_search' }, + 17: { type: 'create_plan_tool_call', name: 'create_plan' }, + 18: { type: 'web_search_tool_call', name: 'web_search' }, + 19: { type: 'task_tool_call', name: 'task' }, + 20: { type: 'list_mcp_resources_tool_call', name: 'list_mcp_resources' }, + 21: { type: 'read_mcp_resource_tool_call', name: 'read_mcp_resource' }, + 22: { type: 'apply_agent_diff_tool_call', name: 'apply_diff' }, + 23: { type: 'ask_question_tool_call', name: 'ask_question' }, + 24: { type: 'fetch_tool_call', name: 'webfetch' }, + 25: { type: 'switch_mode_tool_call', name: 'switch_mode' }, + 26: { type: 'exa_search_tool_call', name: 'exa_search' }, + 27: { type: 'exa_fetch_tool_call', name: 'exa_fetch' }, + 28: { type: 'generate_image_tool_call', name: 'generate_image' }, + 29: { type: 'record_screen_tool_call', name: 'record_screen' }, + 30: { type: 'computer_use_tool_call', name: 'computer_use' }, +}; + +export const TOOL_ARG_SCHEMA: Record> = { + shell_tool_call: { 1: 'command', 2: 'description', 3: 'working_directory' }, + delete_tool_call: { 1: 'filePath' }, + glob_tool_call: { 1: 'pattern', 2: 'path' }, + grep_tool_call: { 1: 'pattern', 2: 'path', 3: 'include' }, + read_tool_call: { 1: 'filePath', 2: 'offset', 3: 'limit' }, + update_todos_tool_call: { 1: 'todos' }, + read_todos_tool_call: {}, + edit_tool_call: { 1: 'filePath', 2: 'oldString', 3: 'newString', 4: 'replaceAll' }, + ls_tool_call: { 1: 'path', 2: 'ignore' }, + read_lints_tool_call: {}, + mcp_tool_call: { 1: 'provider_identifier', 2: 'tool_name', 3: 'tool_call_id', 4: 'args' }, + sem_search_tool_call: { 1: 'query', 2: 'path' }, + create_plan_tool_call: { 1: 'plan' }, + web_search_tool_call: { 1: 'query' }, + task_tool_call: { 1: 'description', 2: 'prompt', 3: 'subagent_type' }, + list_mcp_resources_tool_call: { 1: 'provider_identifier' }, + read_mcp_resource_tool_call: { 1: 'provider_identifier', 2: 'uri' }, + apply_agent_diff_tool_call: { 1: 'filePath', 2: 'diff' }, + ask_question_tool_call: { 1: 'question' }, + fetch_tool_call: { 1: 'url', 2: 'format' }, + switch_mode_tool_call: { 1: 'mode' }, + exa_search_tool_call: { 1: 'query' }, + exa_fetch_tool_call: { 1: 'url' }, + generate_image_tool_call: { 1: 'prompt' }, + record_screen_tool_call: { 1: 'duration' }, + computer_use_tool_call: { 1: 'action', 2: 'text', 3: 'coordinate' }, +}; + +export function parseToolCall(data: Uint8Array): ParsedToolCall { + const fields = parseProtoFields(data); + let toolType = 'unknown'; + let name = 'unknown'; + const args: Record = {}; + + for (const field of fields) { + const toolInfo = TOOL_FIELD_MAP[field.fieldNumber]; + if (toolInfo && field.wireType === 2 && field.value instanceof Uint8Array) { + toolType = toolInfo.type; + name = toolInfo.name; + + const argSchema = TOOL_ARG_SCHEMA[toolType] || {}; + const toolFields = parseProtoFields(field.value); + + for (const tf of toolFields) { + const argName = argSchema[tf.fieldNumber] || `field_${tf.fieldNumber}`; + + if (tf.wireType === 2 && tf.value instanceof Uint8Array) { + try { + let strValue = new TextDecoder().decode(tf.value); + + if (tf.value.length > 2 && tf.value[0] === 0x0a) { + const nestedFields = parseProtoFields(tf.value); + for (const nf of nestedFields) { + if (nf.fieldNumber === 1 && nf.wireType === 2 && nf.value instanceof Uint8Array) { + strValue = new TextDecoder().decode(nf.value); + break; + } + } + } + + args[argName] = strValue; + } catch { + args[argName] = ``; + } + } else if (tf.wireType === 0) { + if (argName === 'replaceAll') { + args[argName] = tf.value === 1; + } else { + args[argName] = tf.value; + } + } + } + break; + } + } + + return { toolType, name, arguments: args }; +} + +export function parseToolCallStartedUpdate(data: Uint8Array): ParsedToolCallStarted { + const fields = parseProtoFields(data); + let callId = ''; + let modelCallId = ''; + let toolCall: ParsedToolCall | null = null; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + callId = new TextDecoder().decode(field.value); + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + toolCall = parseToolCall(field.value); + } else if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + modelCallId = new TextDecoder().decode(field.value); + } + } + + return { callId, modelCallId, toolCall }; +} + +export function parsePartialToolCallUpdate(data: Uint8Array): ParsedPartialToolCall { + const fields = parseProtoFields(data); + let callId = ''; + let modelCallId = ''; + let argsTextDelta = ''; + let toolCall: ParsedToolCall | null = null; + + for (const field of fields) { + if (field.fieldNumber === 1 && field.wireType === 2 && field.value instanceof Uint8Array) { + callId = new TextDecoder().decode(field.value); + } else if (field.fieldNumber === 2 && field.wireType === 2 && field.value instanceof Uint8Array) { + toolCall = parseToolCall(field.value); + } else if (field.fieldNumber === 3 && field.wireType === 2 && field.value instanceof Uint8Array) { + argsTextDelta = new TextDecoder().decode(field.value); + } else if (field.fieldNumber === 4 && field.wireType === 2 && field.value instanceof Uint8Array) { + modelCallId = new TextDecoder().decode(field.value); + } + } + + return { callId, modelCallId, argsTextDelta, toolCall }; +} diff --git a/packages/provider-cursor/src/proto/types.ts b/packages/provider-cursor/src/proto/types.ts new file mode 100644 index 000000000..26feeabe6 --- /dev/null +++ b/packages/provider-cursor/src/proto/types.ts @@ -0,0 +1,234 @@ +/** + * Cursor Agent protobuf message types. + * + * Field numbers mirror the agent/v1 + aiserver/v1 protos reversed across + * opencode-cursor-proxy, cursor-byok, and OmniRoute. See + * cursor-http11-bidi-protocol memory for the endpoint lineage. + */ + +export enum AgentMode { + UNSPECIFIED = 0, + AGENT = 1, + ASK = 2, + PLAN = 3, + DEBUG = 4, + TRIAGE = 5, +} + +export interface OpenAIToolDefinition { + type: 'function'; + function: { + name: string; + description?: string; + parameters?: Record; + }; +} + +export interface McpExecRequest { + id: number; + execId?: string; + name: string; + args: Record; + toolCallId: string; + providerIdentifier: string; + toolName: string; +} + +export interface ShellExecRequest { + type: 'shell'; + id: number; + execId?: string; + command: string; + cwd?: string; +} + +export interface LsExecRequest { + type: 'ls'; + id: number; + execId?: string; + path: string; +} + +export interface RequestContextExecRequest { + type: 'request_context'; + id: number; + execId?: string; +} + +export interface ReadExecRequest { + type: 'read'; + id: number; + execId?: string; + path: string; +} + +export interface GrepExecRequest { + type: 'grep'; + id: number; + execId?: string; + pattern: string; + path?: string; + glob?: string; +} + +export interface WriteExecRequest { + type: 'write'; + id: number; + execId?: string; + path: string; + fileText: string; + toolCallId?: string; + returnFileContentAfterWrite?: boolean; + fileBytes?: Uint8Array; +} + +export type ExecRequest = + | (McpExecRequest & { type: 'mcp' }) + | ShellExecRequest + | LsExecRequest + | RequestContextExecRequest + | ReadExecRequest + | GrepExecRequest + | WriteExecRequest; + +export interface KvServerMessage { + id: number; + messageType: 'get_blob_args' | 'set_blob_args' | 'unknown'; + blobId?: Uint8Array; + blobData?: Uint8Array; +} + +export interface ToolCallInfo { + callId: string; + modelCallId?: string; + toolType: string; + name: string; + arguments: string; +} + +export interface ParsedToolCall { + toolType: string; + name: string; + arguments: Record; +} + +export interface ParsedToolCallStarted { + callId: string; + modelCallId: string; + toolCall: ParsedToolCall | null; +} + +export interface ParsedPartialToolCall { + callId: string; + modelCallId: string; + argsTextDelta: string; + toolCall: ParsedToolCall | null; +} + +export interface AgentStreamChunk { + type: + | 'text' + | 'thinking' + | 'token' + | 'checkpoint' + | 'done' + | 'error' + | 'tool_call_started' + | 'tool_call_completed' + | 'partial_tool_call' + | 'exec_request' + | 'heartbeat' + | 'exec_server_abort' + | 'interaction_query' + | 'kv_blob_assistant'; + content?: string; + error?: string; + toolCall?: ToolCallInfo; + partialArgs?: string; + execRequest?: ExecRequest; + queryId?: number; + queryType?: string; + blobContent?: string; +} + +export interface AgentServiceOptions { + baseUrl?: string; + privacyMode?: boolean; + workspacePath?: string; + clientVersion?: string; + osVersion?: string; + shell?: string; + timezone?: string; +} + +export interface AgentChatRequest { + message: string; + model?: string; + mode?: AgentMode; + conversationId?: string; + tools?: OpenAIToolDefinition[]; +} + +export interface McpResult { + success?: { content: string; isError?: boolean }; + error?: string; +} + +export interface ShellOutcome { + command: string; + cwd: string; + stdout: string; + stderr: string; + exitCode: number; + executionTimeMs?: number; +} + +export interface WriteResult { + success?: { + path: string; + linesCreated: number; + fileSize: number; + fileContentAfterWrite?: string; + }; + error?: { + path: string; + error: string; + }; +} + +export interface BlobAnalysis { + type: 'json' | 'text' | 'protobuf' | 'binary'; + json?: unknown; + text?: string; + protoFields?: Array<{ + num: number; + wire: number; + size: number; + text?: string; + }>; +} + +export interface ParsedInteractionUpdate { + text: string | null; + thinking: string | null; + isComplete: boolean; + isHeartbeat: boolean; + toolCallStarted: { + callId: string; + modelCallId: string; + toolType: string; + name: string; + arguments: string; + } | null; + toolCallCompleted: { + callId: string; + modelCallId: string; + toolType: string; + name: string; + arguments: string; + } | null; + partialToolCall: { + callId: string; + argsTextDelta: string; + } | null; +} diff --git a/packages/provider-cursor/src/provider.ts b/packages/provider-cursor/src/provider.ts new file mode 100644 index 000000000..3ea44c13c --- /dev/null +++ b/packages/provider-cursor/src/provider.ts @@ -0,0 +1,184 @@ +import { ensureCursorAccessToken, mintCursorAccessToken } from './access-token-cache.ts'; +import { CursorSessionTerminatedError } from './auth/oauth.ts'; +import { assertCursorUpstreamRecord, type CursorUpstreamConfig } from './config.ts'; +import { callCursorChatCompletions, type CursorCallEffects } from './fetch.ts'; +import { cursorChatCompletionsChain } from './interceptors/chat-completions/index.ts'; +import type { ChatCompletionsBoundaryCtx } from './interceptors/chat-completions/types.ts'; +import { cursorRawToUpstreamModel, fetchCursorCatalog } from './models.ts'; +import { pricingForCursorModelKey } from './pricing.ts'; +import { assertCursorUpstreamState, type CursorUpstreamState } from './state.ts'; +import { runInterceptors } from '@floway-dev/interceptor'; +import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; +import { + defaultsForProvider, + getProviderRepo, + resolveEffectiveFlags, + type ModelProvider, + type ModelProviderInstance, + type ProviderCallResult, + type ProviderCompactionResult, + type ProviderStreamResult, + type UpstreamCallOptions, + type UpstreamRecord, +} from '@floway-dev/provider'; + +const gatewayTimezone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + +export const createCursorProvider = async (record: UpstreamRecord): Promise => { + assertCursorUpstreamRecord(record); + assertCursorUpstreamState(record.state); + const config: CursorUpstreamConfig = record.config; + // Always operates on the first account in the pool. The schema carries an + // array so a future fan-out can pick a different active account per call + // without a wire migration. + const accountIdentity = config.accounts[0]; + + const enabledFlags = resolveEffectiveFlags(defaultsForProvider('cursor'), [record.flagOverrides]); + + // Re-read upstream state on every request rather than capturing the record's + // state at construction. Refresh-token rotation, terminal-state transitions, + // and operator re-imports must all be visible to the next in-flight call. + const readActiveAccount = async () => { + const fresh = await getProviderRepo().upstreams.getById(record.id); + if (!fresh) throw new Error(`Cursor upstream ${record.id} disappeared mid-request`); + assertCursorUpstreamState(fresh.state); + const state = fresh.state; + const account = state.accounts.find(a => a.userId === accountIdentity.userId); + if (!account) { + throw new Error(`Cursor upstream ${record.id} state has no credential for account ${accountIdentity.userId}`); + } + return { state, account }; + }; + + const replaceActiveAccount = ( + state: CursorUpstreamState, + next: CursorUpstreamState['accounts'][number], + ): CursorUpstreamState => ({ + accounts: state.accounts.map(a => (a.userId === next.userId ? next : a)), + }); + + const persistRefreshTokenRotation = async (newRefreshToken: string): Promise => { + const { state, account } = await readActiveAccount(); + const next = replaceActiveAccount(state, { + ...account, + refresh_token: newRefreshToken, + state_updated_at: new Date().toISOString(), + }); + await getProviderRepo().upstreams.saveState(record.id, next, { expectedState: state }); + }; + + const persistTerminalState = async ( + newState: 'session_terminated' | 'refresh_failed', + message: string, + ): Promise => { + const { state, account } = await readActiveAccount(); + const next = replaceActiveAccount(state, { + ...account, + state: newState, + state_message: message, + state_updated_at: new Date().toISOString(), + accessToken: null, + }); + await getProviderRepo().upstreams.saveState(record.id, next, { expectedState: state }); + }; + + const effects: CursorCallEffects = { persistRefreshTokenRotation, persistTerminalState }; + + const provider: ModelProvider = { + getProvidedModels: async fetcher => { + // A model-list refresh is the first thing a brand-new Cursor upstream + // does, and it mints an access token. If the refresh_token has been + // revoked, flip the row to refresh_failed and rethrow. + let access; + try { + access = await ensureCursorAccessToken(record.id, accountIdentity.userId, refresh => + mintCursorAccessToken(refresh, fetcher, persistRefreshTokenRotation)); + } catch (err) { + if (err instanceof CursorSessionTerminatedError) { + await persistTerminalState('refresh_failed', err.upstreamMessage); + } + throw err; + } + const raw = await fetchCursorCatalog({ accessToken: access.token, timezone: gatewayTimezone(), fetcher }); + return raw.map(r => cursorRawToUpstreamModel(r, enabledFlags)); + }, + + // Cursor bills as a flat-fee subscription; the dashboard reports notional + // cost per request as if paying the underlying model's public API rates. + getPricingForModelKey: pricingForCursorModelKey, + + callChatCompletions: async (model, body, signal, opts) => { + const ctx: ChatCompletionsBoundaryCtx = { + payload: { ...body, model: model.id }, + headers: new Headers(opts.headers), + model, + }; + return await runInterceptors>( + ctx, + {}, + cursorChatCompletionsChain>(), + async () => { + const { account } = await readActiveAccount(); + const { model: _ignored, ...wireBody } = ctx.payload; + return await callCursorChatCompletions({ + upstreamId: record.id, + account, + model, + body: wireBody, + headers: ctx.headers, + signal, + effects, + call: opts, + }); + }, + ); + }, + + // Cursor upstream only exposes Chat Completions (RunSSE+BidiAppend); + // getProvidedModels advertises that single endpoint. Every other surface + // returns a 405 carrying a proper JSON error rather than a raw stack trace. + // The synthetic response still flows through the per-call latency recorder + // so the gateway's wrap-once contract holds. + callMessages: (_m, _b, _s, opts) => unsupportedStreamResult(opts), + callResponses: (_m, _b, _s, opts) => unsupportedStreamResult(opts), + callMessagesCountTokens: (_m, _b, _s, opts) => unsupportedCallResult(opts), + callCompletions: (_m, _b, _s, opts) => unsupportedCallResult(opts), + callResponsesCompact: (_m, _b, _s, opts) => unsupportedCompactionResult(opts), + callEmbeddings: (_m, _b, _s, opts) => unsupportedCallResult(opts), + callImagesGenerations: (_m, _b, _s, opts) => unsupportedCallResult(opts), + callImagesEdits: (_m, _b, _s, opts) => unsupportedCallResult(opts), + }; + + return { + upstream: record.id, + providerKind: 'cursor', + name: record.name, + disabledPublicModelIds: record.disabledPublicModelIds, + modelPrefix: record.modelPrefix, + provider, + supportsResponsesItemReference: false, + }; +}; + +const synthetic405 = (): Response => + new Response( + JSON.stringify({ error: { type: 'method_not_allowed', message: 'Endpoint not supported by cursor provider' } }), + { status: 405, headers: { 'content-type': 'application/json' } }, + ); + +const unsupportedStreamResult = async (opts: UpstreamCallOptions): Promise> => ({ + ok: false, + modelKey: '', + response: await opts.recordUpstreamLatency(Promise.resolve(synthetic405())), +}); + +const unsupportedCallResult = async (opts: UpstreamCallOptions): Promise => ({ + modelKey: '', + response: await opts.recordUpstreamLatency(Promise.resolve(synthetic405())), +}); + +const unsupportedCompactionResult = async (opts: UpstreamCallOptions): Promise => ({ + ok: false, + modelKey: '', + response: await opts.recordUpstreamLatency(Promise.resolve(synthetic405())), +}); diff --git a/packages/provider-cursor/src/provider_test.ts b/packages/provider-cursor/src/provider_test.ts new file mode 100644 index 000000000..52715f76b --- /dev/null +++ b/packages/provider-cursor/src/provider_test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'vitest'; + +import { createCursorProvider } from './provider.ts'; +import type { UpstreamRecord } from '@floway-dev/provider'; +import { noopUpstreamCallOptions, stubUpstreamModel } from '@floway-dev/test-utils'; + +const record: UpstreamRecord = { + id: 'up', + provider: 'cursor', + name: 'Cursor', + enabled: true, + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + config: { accounts: [{ email: 'a@b.com', userId: 'u1' }] }, + state: { + accounts: [{ + userId: 'u1', + refresh_token: 'rt', + state: 'active', + state_updated_at: '2026-01-01T00:00:00Z', + accessToken: null, + quotaSnapshot: null, + }], + }, + flagOverrides: {}, + disabledPublicModelIds: [], + proxyFallbackList: [], + modelPrefix: null, +}; + +describe('createCursorProvider', () => { + test('returns a cursor provider instance', async () => { + const inst = await createCursorProvider(record); + expect(inst.providerKind).toBe('cursor'); + expect(inst.upstream).toBe('up'); + expect(inst.provider.getPricingForModelKey('composer-2.5')?.input).toBe(3); + }); + + test('unsupported surfaces return 405', async () => { + const inst = await createCursorProvider(record); + const model = stubUpstreamModel({ id: 'gpt-4o' }); + const opts = noopUpstreamCallOptions(); + + const messages = await inst.provider.callMessages(model, {} as never, undefined, opts); + expect(messages.ok).toBe(false); + if (!messages.ok) expect(messages.response.status).toBe(405); + + const embeddings = await inst.provider.callEmbeddings(model, {} as never, undefined, opts); + expect(embeddings.response.status).toBe(405); + + const completions = await inst.provider.callCompletions(model, {} as never, undefined, opts); + expect(completions.response.status).toBe(405); + }); +}); diff --git a/packages/provider-cursor/src/quota.ts b/packages/provider-cursor/src/quota.ts new file mode 100644 index 000000000..6f5287159 --- /dev/null +++ b/packages/provider-cursor/src/quota.ts @@ -0,0 +1,30 @@ +/** + * Cursor rate-limit / quota parsing. + * + * Cursor's rate-limit signal is not yet capture-confirmed (the agent endpoints + * return 429 with TBD headers). This module owns the snapshot shape and the + * 429 detection used by fetch.ts; header parsing is a placeholder to be filled + * from a real capture (see plan risk #4). + */ + +export interface CursorQuotaSnapshot { + // Placeholder fields — populate from real 429/Retry-After capture. + remaining?: number; + limit?: number; + resetAt?: number; +} + +// CursorQuotaSnapshotEntry (the fetchedAt + data wrapper) lives in state.ts, +// alongside the credential that carries it — not here. + +/** True when the upstream response signals rate-limiting (HTTP 429). */ +export const isCursorRateLimited = (status: number): boolean => status === 429; + +/** + * Parse quota hints from upstream response headers. Returns null until real + * header fields are captured; callers treat null as "no quota observation". + */ +export const parseCursorQuotaHeaders = (_headers: Headers): CursorQuotaSnapshot | null => { + // TODO(cursor): populate from real Retry-After / x-cursor-ratelimit-* capture. + return null; +}; diff --git a/packages/provider-cursor/src/session-id.ts b/packages/provider-cursor/src/session-id.ts new file mode 100644 index 000000000..8b600287f --- /dev/null +++ b/packages/provider-cursor/src/session-id.ts @@ -0,0 +1,85 @@ +/** + * Cursor session ID derivation. + * + * Determines a sessionKey for DurableHttpSession.acquire() so consecutive + * tool-call turns reuse the same RunSSE stream. Uses the opencode-cursor-proxy + * pattern: encode the sessionId into tool_call_id itself, so the OpenAI + * protocol's mandatory id-echo becomes the session correlation signal. + * + * sessionKey format: `cursor:${upstreamId}:${apiKeyId}:${form}:${id}` + * - upstreamId: physical isolation (different cursor accounts) + * - apiKeyId: security isolation (same account, different callers) + * - form: 'hdr' (explicit header) | 'auto' (minted/recovered from tool_call_id) + * - id: the session identifier itself + */ + +import type { ChatCompletionsPayload } from '@floway-dev/protocols/chat-completions'; + +const TOOL_CALL_PREFIX_RE = /^sess_([a-zA-Z0-9]+)__/; + +export interface DeriveSessionKeyResult { + /** null = no session correlation found; caller must mint a new key. */ + sessionKey: string | null; + /** true = client is following up on a prior tool_call turn. */ + isFollowUp: boolean; +} + +/** + * Derive a sessionKey from the inbound request. + * + * Priority: + * 1. Explicit `X-Floway-Conversation-Id` header (smart clients) + * 2. `sess___` prefix parsed from tool_call_id in history + * 3. null → caller must mint a new key + */ +export function deriveSessionKey( + upstreamId: string, + apiKeyId: string, + headers: Headers, + messages: ChatCompletionsPayload['messages'], +): DeriveSessionKeyResult { + const scope = `cursor:${upstreamId}:${apiKeyId}`; + + const explicit = headers.get('x-floway-conversation-id'); + if (explicit) { + return { sessionKey: `${scope}:hdr:${explicit}`, isFollowUp: true }; + } + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]!; + if (msg.role === 'tool' && msg.tool_call_id) { + const m = TOOL_CALL_PREFIX_RE.exec(msg.tool_call_id); + if (m) return { sessionKey: `${scope}:auto:${m[1]}`, isFollowUp: true }; + } + if (msg.role === 'assistant' && msg.tool_calls) { + for (const tc of msg.tool_calls) { + const m = TOOL_CALL_PREFIX_RE.exec(tc.id); + if (m) return { sessionKey: `${scope}:auto:${m[1]}`, isFollowUp: true }; + } + } + } + + return { sessionKey: null, isFollowUp: false }; +} + +/** Mint a fresh sessionKey (new conversation). */ +export function mintSessionKey(upstreamId: string, apiKeyId: string): string { + const id = crypto.randomUUID().replace(/-/g, '').slice(0, 12); + return `cursor:${upstreamId}:${apiKeyId}:auto:${id}`; +} + +/** Extract the bare session id from a full sessionKey (last segment). */ +function bareId(sessionKey: string): string { + return sessionKey.split(':').pop()!; +} + +/** Wrap: cursor's tool_call_id → OpenAI-facing id with session prefix. */ +export function wrapToolCallId(sessionKey: string, cursorCallId: string): string { + return `sess_${bareId(sessionKey)}__${cursorCallId}`; +} + +/** Unwrap: OpenAI client's tool_call_id → original cursor id. */ +export function unwrapToolCallId(wrapped: string): string { + const m = TOOL_CALL_PREFIX_RE.exec(wrapped); + return m ? wrapped.slice(m[0].length) : wrapped; +} diff --git a/packages/provider-cursor/src/session-id_test.ts b/packages/provider-cursor/src/session-id_test.ts new file mode 100644 index 000000000..60c450f00 --- /dev/null +++ b/packages/provider-cursor/src/session-id_test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'vitest'; + +import { deriveSessionKey, mintSessionKey, wrapToolCallId, unwrapToolCallId } from './session-id.ts'; + +describe('deriveSessionKey', () => { + test('priority 1: X-Floway-Conversation-Id header wins', () => { + const headers = new Headers({ 'x-floway-conversation-id': 'conv-123' }); + const result = deriveSessionKey('up1', 'ak1', headers, [{ role: 'user', content: 'hi' }]); + expect(result.sessionKey).toBe('cursor:up1:ak1:hdr:conv-123'); + expect(result.isFollowUp).toBe(true); + }); + + test('priority 2: tool_call_id with sess_ prefix in role:tool', () => { + const result = deriveSessionKey('up1', 'ak1', new Headers(), [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: null, tool_calls: [{ id: 'sess_abc123def456__call_xyz', type: 'function', function: { name: 'f', arguments: '{}' } }] }, + { role: 'tool', tool_call_id: 'sess_abc123def456__call_xyz', content: '{"result":1}' }, + ]); + expect(result.sessionKey).toBe('cursor:up1:ak1:auto:abc123def456'); + expect(result.isFollowUp).toBe(true); + }); + + test('priority 2: assistant tool_calls with sess_ prefix (no tool message yet)', () => { + const result = deriveSessionKey('up1', 'ak1', new Headers(), [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: null, tool_calls: [{ id: 'sess_aabbccdd__call_99', type: 'function', function: { name: 'g', arguments: '{}' } }] }, + ]); + expect(result.sessionKey).toBe('cursor:up1:ak1:auto:aabbccdd'); + expect(result.isFollowUp).toBe(true); + }); + + test('priority 3: returns null when no correlation found', () => { + const result = deriveSessionKey('up1', 'ak1', new Headers(), [ + { role: 'user', content: 'What is 2+2?' }, + ]); + expect(result.sessionKey).toBeNull(); + expect(result.isFollowUp).toBe(false); + }); + + test('scans from the end (latest message wins)', () => { + const result = deriveSessionKey('up1', 'ak1', new Headers(), [ + { role: 'tool', tool_call_id: 'sess_old__call_1', content: 'x' }, + { role: 'user', content: 'new turn' }, + { role: 'assistant', content: null, tool_calls: [{ id: 'sess_new__call_2', type: 'function', function: { name: 'f', arguments: '{}' } }] }, + { role: 'tool', tool_call_id: 'sess_new__call_2', content: 'y' }, + ]); + expect(result.sessionKey).toBe('cursor:up1:ak1:auto:new'); + }); + + test('ignores tool_call_ids without the sess_ prefix', () => { + const result = deriveSessionKey('up1', 'ak1', new Headers(), [ + { role: 'assistant', content: null, tool_calls: [{ id: 'call_plain', type: 'function', function: { name: 'f', arguments: '{}' } }] }, + { role: 'tool', tool_call_id: 'call_plain', content: 'z' }, + ]); + expect(result.sessionKey).toBeNull(); + expect(result.isFollowUp).toBe(false); + }); +}); + +describe('mintSessionKey', () => { + test('produces the expected format', () => { + const key = mintSessionKey('up1', 'ak1'); + expect(key).toMatch(/^cursor:up1:ak1:auto:[a-f0-9]{12}$/); + }); + + test('each call produces a unique key', () => { + const a = mintSessionKey('up1', 'ak1'); + const b = mintSessionKey('up1', 'ak1'); + expect(a).not.toBe(b); + }); +}); + +describe('wrapToolCallId / unwrapToolCallId', () => { + test('round-trips correctly', () => { + const sessionKey = 'cursor:up1:ak1:auto:abc123def456'; + const cursorId = 'call_xyz'; + const wrapped = wrapToolCallId(sessionKey, cursorId); + expect(wrapped).toBe('sess_abc123def456__call_xyz'); + expect(unwrapToolCallId(wrapped)).toBe(cursorId); + }); + + test('unwrap passes through ids without the sess_ prefix', () => { + expect(unwrapToolCallId('call_plain')).toBe('call_plain'); + }); + + test('unwrap handles newline-contaminated cursor ids', () => { + const sessionKey = 'cursor:up1:ak1:auto:aabb'; + const cursorId = 'call_x\nfc_y'; + const wrapped = wrapToolCallId(sessionKey, cursorId); + expect(unwrapToolCallId(wrapped)).toBe(cursorId); + }); +}); diff --git a/packages/provider-cursor/src/state.ts b/packages/provider-cursor/src/state.ts new file mode 100644 index 000000000..7a01d3a31 --- /dev/null +++ b/packages/provider-cursor/src/state.ts @@ -0,0 +1,173 @@ +// Gateway-managed Cursor credential state, persisted in upstreams.state_json. +// Writes happen via UpstreamRepo.saveState with optimistic concurrency keyed +// on the prior state JSON. + +import type { CursorQuotaSnapshot } from './quota.ts'; + +export type CursorCredentialHealth = 'active' | 'session_terminated' | 'refresh_failed'; + +// Short-lived OAuth access token minted by exchanging the stored refresh_token +// against /auth/exchange_user_api_key. The refresh_token itself stays on +// CursorAccountCredential so a KV/cache wipe never forces operator re-import. +export interface CursorAccessTokenEntry { + token: string; + expiresAt: number; // unix ms + refreshedAt: string; // ISO 8601 +} + +export interface CursorQuotaSnapshotEntry { + fetchedAt: number; // unix ms + data: CursorQuotaSnapshot; +} + +// One account's autonomous credential state, joined back to its identity in +// CursorUpstreamConfig.accounts via `userId`. +export interface CursorAccountCredential { + userId: string; + // Cursor may rotate refresh_token on /auth/exchange_user_api_key. Stored in + // D1 (not KV) so KV eviction never forces operator re-import. + refresh_token: string; + state: CursorCredentialHealth; + state_message?: string; + // ISO 8601, written on every state transition. + state_updated_at: string; + accessToken: CursorAccessTokenEntry | null; + quotaSnapshot: CursorQuotaSnapshotEntry | null; +} + +export interface CursorUpstreamState { + accounts: CursorAccountCredential[]; +} + +const ALLOWED_CREDENTIAL_KEYS_MAP: Record = { + userId: true, + refresh_token: true, + state: true, + state_message: true, + state_updated_at: true, + accessToken: true, + quotaSnapshot: true, +}; + +const ALLOWED_STATE_KEYS_MAP: Record = { + accounts: true, +}; + +const ALLOWED_ACCESS_TOKEN_KEYS_MAP: Record = { + token: true, + expiresAt: true, + refreshedAt: true, +}; + +const ALLOWED_QUOTA_SNAPSHOT_KEYS_MAP: Record = { + fetchedAt: true, + data: true, +}; + +const assertCursorAccessTokenEntry = (value: unknown, where: string): void => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`${where} must be a plain object`); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (!(key in ALLOWED_ACCESS_TOKEN_KEYS_MAP)) { + throw new TypeError(`${where} has unexpected key '${key}'`); + } + } + if (typeof obj.token !== 'string' || obj.token === '') { + throw new TypeError(`${where}.token must be a non-empty string`); + } + if (typeof obj.expiresAt !== 'number' || !Number.isFinite(obj.expiresAt)) { + throw new TypeError(`${where}.expiresAt must be a finite number`); + } + if (typeof obj.refreshedAt !== 'string' || obj.refreshedAt === '') { + throw new TypeError(`${where}.refreshedAt must be a non-empty string`); + } +}; + +const assertCursorQuotaSnapshotEntry = (value: unknown, where: string): void => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`${where} must be a plain object`); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (!(key in ALLOWED_QUOTA_SNAPSHOT_KEYS_MAP)) { + throw new TypeError(`${where} has unexpected key '${key}'`); + } + } + if (typeof obj.fetchedAt !== 'number' || !Number.isFinite(obj.fetchedAt)) { + throw new TypeError(`${where}.fetchedAt must be a finite number`); + } + if (typeof obj.data !== 'object' || obj.data === null || Array.isArray(obj.data)) { + throw new TypeError(`${where}.data must be a plain object`); + } +}; + +const assertCursorAccountCredential = (value: unknown, where: string): void => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`${where} must be a plain object`); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (!(key in ALLOWED_CREDENTIAL_KEYS_MAP)) { + throw new TypeError(`${where} has unexpected key '${key}'`); + } + } + if (typeof obj.userId !== 'string' || obj.userId === '') { + throw new TypeError(`${where}.userId must be a non-empty string`); + } + if (typeof obj.refresh_token !== 'string' || obj.refresh_token === '') { + throw new TypeError(`${where}.refresh_token must be a non-empty string`); + } + if (obj.state !== 'active' && obj.state !== 'session_terminated' && obj.state !== 'refresh_failed') { + throw new TypeError(`${where}.state must be one of 'active' | 'session_terminated' | 'refresh_failed', got ${String(obj.state)}`); + } + if (obj.state_message !== undefined && typeof obj.state_message !== 'string') { + throw new TypeError(`${where}.state_message must be a string when present`); + } + if (typeof obj.state_updated_at !== 'string' || obj.state_updated_at === '') { + throw new TypeError(`${where}.state_updated_at must be a non-empty ISO string`); + } + if (obj.accessToken !== undefined && obj.accessToken !== null) { + assertCursorAccessTokenEntry(obj.accessToken, `${where}.accessToken`); + } + if (obj.quotaSnapshot !== undefined && obj.quotaSnapshot !== null) { + assertCursorQuotaSnapshotEntry(obj.quotaSnapshot, `${where}.quotaSnapshot`); + } +}; + +export function assertCursorUpstreamState(value: unknown): asserts value is CursorUpstreamState { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('CursorUpstreamState must be a plain object'); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (!(key in ALLOWED_STATE_KEYS_MAP)) { + throw new TypeError(`CursorUpstreamState has unexpected key '${key}'`); + } + } + if (!Array.isArray(obj.accounts)) { + throw new TypeError('CursorUpstreamState.accounts must be an array'); + } + if (obj.accounts.length !== 1) { + throw new TypeError(`CursorUpstreamState.accounts must hold exactly one account (got ${obj.accounts.length})`); + } + for (let i = 0; i < obj.accounts.length; i++) { + assertCursorAccountCredential(obj.accounts[i], `CursorUpstreamState.accounts[${i}]`); + } +} + +// Boundary normalization: legacy rows may carry no accessToken / quotaSnapshot +// key; the typed contract promises null rather than undefined. Shallow copy +// with absent → null; the original raw is left untouched for CAS expectedState. +export const readCursorUpstreamState = (raw: unknown): CursorUpstreamState => { + assertCursorUpstreamState(raw); + return { + ...raw, + accounts: raw.accounts.map(account => ({ + ...account, + accessToken: account.accessToken ?? null, + quotaSnapshot: account.quotaSnapshot ?? null, + })), + }; +}; diff --git a/packages/provider-cursor/src/state_test.ts b/packages/provider-cursor/src/state_test.ts new file mode 100644 index 000000000..2891b7d5d --- /dev/null +++ b/packages/provider-cursor/src/state_test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'vitest'; + +import { assertCursorUpstreamState, readCursorUpstreamState } from './state.ts'; + +const baseCred = { + userId: 'u1', + refresh_token: 'ref', + state: 'active' as const, + state_updated_at: '2026-01-01T00:00:00Z', + accessToken: { token: 'tok', expiresAt: 9999999999999, refreshedAt: '2026-01-01T00:00:00Z' }, + quotaSnapshot: null, +}; + +describe('assertCursorUpstreamState', () => { + test('accepts a complete single-account state', () => { + expect(() => assertCursorUpstreamState({ accounts: [baseCred] })).not.toThrow(); + }); + + test.each([ + ['accounts not an array', { accounts: baseCred }], + ['empty accounts', { accounts: [] }], + ['two accounts', { accounts: [baseCred, { ...baseCred, userId: 'u2' }] }], + ['extra top-level key', { accounts: [baseCred], extra: 1 }], + ['missing userId', { accounts: [{ ...baseCred, userId: '' }] }], + ['missing refresh_token', { accounts: [{ ...baseCred, refresh_token: '' }] }], + ['bad state', { accounts: [{ ...baseCred, state: 'weird' }] }], + ['missing state_updated_at', { accounts: [{ ...baseCred, state_updated_at: '' }] }], + ['bad accessToken', { accounts: [{ ...baseCred, accessToken: { token: '', expiresAt: 1, refreshedAt: 'x' } }] }], + ])('rejects %s', (_label, value) => { + expect(() => assertCursorUpstreamState(value)).toThrow(); + }); +}); + +describe('readCursorUpstreamState', () => { + test('normalizes absent accessToken/quotaSnapshot to null', () => { + const legacy = { + accounts: [ + { + userId: 'u1', + refresh_token: 'ref', + state: 'active', + state_updated_at: '2026-01-01T00:00:00Z', + }, + ], + }; + const read = readCursorUpstreamState(legacy); + expect(read.accounts[0]!.accessToken).toBeNull(); + expect(read.accounts[0]!.quotaSnapshot).toBeNull(); + // original untouched + expect((legacy.accounts[0] as Record)['accessToken']).toBeUndefined(); + }); +}); diff --git a/packages/provider-cursor/tsconfig.json b/packages/provider-cursor/tsconfig.json new file mode 100644 index 000000000..1cea5297e --- /dev/null +++ b/packages/provider-cursor/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +} \ No newline at end of file diff --git a/packages/provider-cursor/vitest.config.ts b/packages/provider-cursor/vitest.config.ts new file mode 100644 index 000000000..a6268ec7f --- /dev/null +++ b/packages/provider-cursor/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*_test.ts'], + environment: 'node', + }, +}); \ No newline at end of file diff --git a/packages/provider-ollama/src/provider_test.ts b/packages/provider-ollama/src/provider_test.ts index 15fb625fd..b362b42ee 100644 --- a/packages/provider-ollama/src/provider_test.ts +++ b/packages/provider-ollama/src/provider_test.ts @@ -158,7 +158,7 @@ test('call* methods POST to /v1/ with the upstream model id and Bearer model, { messages: [{ role: 'user', content: 'hi' }] }, undefined, - { fetcher: directFetcher, recordUpstreamLatency: p => p, waitUntil: () => {}, headers: new Headers() }, + { fetcher: directFetcher, recordUpstreamLatency: p => p, waitUntil: () => {}, headers: new Headers(), apiKeyId: 'test-api-key' }, ); assertEquals(result.modelKey, 'gpt-oss:120b'); }, diff --git a/packages/provider/src/flags.ts b/packages/provider/src/flags.ts index e4fe741de..424ae5d38 100644 --- a/packages/provider/src/flags.ts +++ b/packages/provider/src/flags.ts @@ -50,7 +50,7 @@ export const OPTIONAL_FLAGS = [ }, // The three shim flags default on for every upstream kind that runs // operator-shaped traffic through translation. They are off by default on - // `codex` (no hosted tools at all) and on `claude-code` (the shaped- + // `codex` and `cursor` (no hosted tools at all) and on `claude-code` (the shaped- // passthrough path forwards caller bytes verbatim, so a gateway-side shim // would silently rewrite a request the operator deliberately let through // unchanged). @@ -58,19 +58,19 @@ export const OPTIONAL_FLAGS = [ id: 'messages-web-search-shim', label: 'Messages web search shim', description: "Execute Anthropic native Messages web search through the gateway's configured search provider instead of forwarding it to the upstream. (When a client Messages request is routed to a non-Messages backend, the shim always runs regardless of this flag, because those targets cannot carry Anthropic server tools.)", - defaultFor: ALL_PROVIDER_KINDS.filter(p => !['codex', 'claude-code'].includes(p)), + defaultFor: ALL_PROVIDER_KINDS.filter(p => !['codex', 'claude-code', 'cursor'].includes(p)), }, { id: 'responses-web-search-shim', label: 'Responses web search shim', description: "Execute the Responses `web_search` hosted tool through the gateway's configured search provider instead of forwarding it to a Responses upstream. (When a Responses request is routed to a non-Responses backend, the shim always runs regardless of this flag, because those targets cannot carry hosted web_search.)", - defaultFor: ALL_PROVIDER_KINDS.filter(p => !['codex', 'claude-code'].includes(p)), + defaultFor: ALL_PROVIDER_KINDS.filter(p => !['codex', 'claude-code', 'cursor'].includes(p)), }, { id: 'responses-image-generation-shim', label: 'Responses image generation shim', description: "Execute the Responses `image_generation` hosted tool through the gateway's image-capable upstream (gpt-image-*) instead of forwarding it to a Responses upstream. The orchestrator model calls a generated function tool; the shim drives the standalone /images/{generations,edits} backend and synthesizes the native image_generation_call lifecycle. (When a Responses request is routed to a non-Responses backend, the shim always runs regardless of this flag, because those targets cannot carry the hosted image_generation tool.)", - defaultFor: ALL_PROVIDER_KINDS.filter(p => !['codex', 'claude-code'].includes(p)), + defaultFor: ALL_PROVIDER_KINDS.filter(p => !['codex', 'claude-code', 'cursor'].includes(p)), }, { id: 'disable-reasoning-on-forced-tool-choice', diff --git a/packages/provider/src/model.ts b/packages/provider/src/model.ts index c5e4dbbdd..e7d191069 100644 --- a/packages/provider/src/model.ts +++ b/packages/provider/src/model.ts @@ -2,7 +2,7 @@ import type { UpstreamChatModelConfig } from './model-config.ts'; import type { ModelPrefixConfig } from './model-prefix.ts'; import type { ModelKind, ModelEndpoints, ModelPricing } from '@floway-dev/protocols/common'; -export const ALL_PROVIDER_KINDS = ['copilot', 'custom', 'azure', 'codex', 'claude-code', 'ollama'] as const; +export const ALL_PROVIDER_KINDS = ['copilot', 'custom', 'azure', 'codex', 'claude-code', 'ollama', 'cursor'] as const; export type UpstreamProviderKind = typeof ALL_PROVIDER_KINDS[number]; // One entry in `UpstreamRecord.proxyFallbackList`. `id` is the proxy id from diff --git a/packages/provider/src/provider.ts b/packages/provider/src/provider.ts index e3c5eee30..5856de6b0 100644 --- a/packages/provider/src/provider.ts +++ b/packages/provider/src/provider.ts @@ -104,6 +104,20 @@ export interface UpstreamCallOptions { recordUpstreamLatency: (promise: Promise) => Promise; waitUntil: (promise: Promise) => void; headers: Headers; + /** + * The API key id that authenticated the inbound request. Threaded from the + * gateway's auth middleware (already exposed as `GatewayCtx.apiKeyId`). + * + * Providers that hold cross-request state — currently only cursor's durable + * session — combine this with the upstream id to namespace per-(upstream, + * apiKey) so a session opened by one API key is never reachable from + * another, even when both keys are bound to the same upstream account. + * Mirrors the dump-broker convention of keying observation channels on + * `apiKey.id` (`gateway/src/dump/accumulator.ts:275`); compare also the + * copilot token cache that keys on upstream id alone because OAuth tokens + * are upstream-scoped by nature (`provider-copilot/src/auth.ts:42`). + */ + apiKeyId: string; } export interface ModelProvider { diff --git a/packages/test-utils/src/stubs.ts b/packages/test-utils/src/stubs.ts index 047138d24..19021cb4a 100644 --- a/packages/test-utils/src/stubs.ts +++ b/packages/test-utils/src/stubs.ts @@ -7,11 +7,13 @@ import { directFetcher, type ChatTargetApi, type ModelProvider, type ModelProvid // (the runtime would have absorbed it in production). Each invocation hands // back a fresh `Headers` instance so tests that mutate the bag do not bleed // state across cases. -export const noopUpstreamCallOptions = (): UpstreamCallOptions => ({ +export const noopUpstreamCallOptions = (overrides: Partial = {}): UpstreamCallOptions => ({ fetcher: directFetcher, recordUpstreamLatency: (promise: Promise): Promise => promise, waitUntil: () => {}, headers: new Headers(), + apiKeyId: 'test-api-key', + ...overrides, }); export const stubUpstreamModel = (overrides: Partial = {}): UpstreamModel => ({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf3ef7f58..9cf0fffd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -271,6 +271,9 @@ importers: '@floway-dev/provider-copilot': specifier: workspace:* version: link:../provider-copilot + '@floway-dev/provider-cursor': + specifier: workspace:* + version: link:../provider-cursor '@floway-dev/provider-custom': specifier: workspace:* version: link:../provider-custom @@ -430,6 +433,28 @@ importers: specifier: workspace:* version: link:../test-utils + packages/provider-cursor: + dependencies: + '@floway-dev/http': + specifier: workspace:* + version: link:../http + '@floway-dev/interceptor': + specifier: workspace:* + version: link:../interceptor + '@floway-dev/platform': + specifier: workspace:* + version: link:../platform + '@floway-dev/protocols': + specifier: workspace:* + version: link:../protocols + '@floway-dev/provider': + specifier: workspace:* + version: link:../provider + devDependencies: + '@floway-dev/test-utils': + specifier: workspace:* + version: link:../test-utils + packages/provider-custom: dependencies: '@floway-dev/interceptor': diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc index e59251496..bb65051fd 100644 --- a/wrangler.example.jsonc +++ b/wrangler.example.jsonc @@ -94,11 +94,22 @@ ], // Per-key WebSocket fan-out actor. Content-agnostic — the actor never // inspects the payload; callers layer their own framing via a codec. + // + // DURABLE_HTTP_SESSION is the first actor that owns a live OUTBOUND socket: + // it holds a cross-request upstream HTTP response stream (cursor's RunSSE) + // open so a later inbound request can drive an ExecMcpResult continuation on + // the same conversation. The outbound socket keeps the DO alive (CF + // 2026-06-19 outbound-connections-keep-DOs-alive, 15-minute cap); an idle + // alarm (default 5 min) evicts an abandoned session before then. "durable_objects": { "bindings": [ { "name": "BROADCAST_DO", "class_name": "BroadcastDO" + }, + { + "name": "DURABLE_HTTP_SESSION", + "class_name": "DurableHttpSessionDO" } ] }, @@ -110,12 +121,18 @@ // CF is phasing out and which Workers Free cannot create. // // BroadcastDO has no `ctx.storage.*` calls — the SQLite db is provisioned - // but unused. The name reflects the BACKEND choice, not whether the actor - // touches it. + // but unused. DurableHttpSessionDO uses `ctx.storage.setAlarm` for idle + // eviction. The name reflects the BACKEND choice, not whether the actor + // touches it. Migrations are append-only: v1 created BroadcastDO, v2 adds + // DurableHttpSessionDO. "migrations": [ { "tag": "v1", "new_sqlite_classes": ["BroadcastDO"] + }, + { + "tag": "v2", + "new_sqlite_classes": ["DurableHttpSessionDO"] } ] } From eafd7f8c9365d9f1ae860bce14eeec5495a973b0 Mon Sep 17 00:00:00 2001 From: yyyr Date: Wed, 1 Jul 2026 03:49:20 +0800 Subject: [PATCH 13/79] feat(provider-cursor): cross-instance session reuse via D1 + DurableHttpSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Externalize the cross-turn Cursor session state so a tool-result follow-up can resume on ANY instance (Cloudflare multi-isolate), not just the one that opened the stream. Replaces the in-process cursor-session-state Map with a unified path: the RunSSE read stream lives in the DurableHttpSession (addressable by sessionKey); the scalar write-channel state lives in D1. Why this works (measured): Cursor only ever set_blob's its conversation checkpoints (write-only sink) and never get_blob's them on a follow-up, so the blobStore never needs to travel — only requestId + seqno do. D1 (Phase 1): - migration 0047_cursor_sessions {session_key, request_id, append_seqno, leftover, locked_until, created_at, refreshed_at} + cron sweep (30-min TTL). - CursorSessionsRepo {claim (CAS single-flight), put, delete, deleteOlderThan} on Sql + Memory backends; slim cursorSessions on the provider repo contract. AgentTransport (Phase 2): - split openChatStream into seed() + open/resume + driveReadLoop; the transport reads a provided stream (DHS owns the socket) instead of fetching RunSSE. - getAuthToken injection (replaces a captured token) so a long-session resume re-mints credentials — fixes a latent stale-token 401. - capture leftover (RunSSE bytes past the exec_mcp frame) for cross-instance hand-off; usually empty since cursor pauses after exec_mcp. fetch.ts (Phase 3): - performOpen: acquire DHS (RunSSE), send RunRequest via the proxy-aware fetcher, drive; persist {requestId, seqno, leftover} to D1 at the exec_mcp pause, release the handle. - performResume: claim D1, acquire DHS, sendToolResult (exec id+execId decoded from the echoed tool_call_id — no server-side map), resume; cold-resume fallback on miss/busy/lost-socket. - recordUpstreamLatency wraps the acquire/sendToolResult round-trips (the upstream fetch no longer happens on the provider isolate on CF). Verified: Node 3-turn e2e (tool_calls -> uses tool result -> conversational follow-up) through the new DHS+D1 path. pnpm -w typecheck/lint/test green (311 files, 3553 tests). CF proxy dial + cross-instance e2e are follow-ups. --- .../migrations/0047_cursor_sessions.sql | 30 ++ .../gateway/src/repo/cursor-sessions_test.ts | 95 +++++ packages/gateway/src/repo/memory.ts | 29 ++ packages/gateway/src/repo/sql.ts | 64 +++ packages/gateway/src/repo/types.ts | 20 + packages/gateway/src/scheduled.ts | 7 + .../provider-claude-code/src/fetch_test.ts | 3 +- .../provider-claude-code/src/provider_test.ts | 3 +- .../src/access-token-cache_test.ts | 1 + packages/provider-codex/src/fetch_test.ts | 1 + packages/provider-codex/src/provider_test.ts | 1 + packages/provider-codex/src/quota_test.ts | 1 + packages/provider-copilot/src/auth_test.ts | 1 + .../provider-copilot/src/fetch-models_test.ts | 1 + .../provider-copilot/src/provider_test.ts | 1 + .../provider-cursor/src/agent-translate.ts | 19 +- .../provider-cursor/src/agent-transport.ts | 189 +++++---- .../src/agent-transport_test.ts | 90 ++-- .../src/cursor-session-state.ts | 77 ---- packages/provider-cursor/src/fetch.ts | 400 ++++++++---------- packages/provider-cursor/src/fetch_test.ts | 25 +- packages/provider-cursor/src/session-id.ts | 34 +- .../provider-cursor/src/session-id_test.ts | 30 +- packages/provider/src/index.ts | 2 +- packages/provider/src/repo.ts | 27 ++ packages/test-utils/src/index.ts | 2 +- packages/test-utils/src/stubs.ts | 10 +- 27 files changed, 688 insertions(+), 475 deletions(-) create mode 100644 packages/gateway/migrations/0047_cursor_sessions.sql create mode 100644 packages/gateway/src/repo/cursor-sessions_test.ts delete mode 100644 packages/provider-cursor/src/cursor-session-state.ts diff --git a/packages/gateway/migrations/0047_cursor_sessions.sql b/packages/gateway/migrations/0047_cursor_sessions.sql new file mode 100644 index 000000000..fb3d19be0 --- /dev/null +++ b/packages/gateway/migrations/0047_cursor_sessions.sql @@ -0,0 +1,30 @@ +-- D1/sqlite table backing cross-instance Cursor session reuse. +-- +-- A Cursor agent turn keeps a long-lived RunSSE read stream open (held by the +-- DurableHttpSession) plus a monotonic BidiAppend write `seqno`. When a +-- tool-result follow-up lands on a DIFFERENT isolate, the in-process transport +-- is gone — so the two scalars needed to resume (the upstream `request_id` and +-- the next `append_seqno`) live here in D1 instead, keyed by session. +-- +-- `leftover` carries any RunSSE bytes the prior turn's frame parser read past +-- the exec_mcp frame but did not consume (usually empty — Cursor pauses right +-- after exec_mcp). The blobStore is NOT stored: Cursor only ever set_blob's +-- (write-only sink), so a follow-up instance starts with an empty one. +-- +-- `locked_until` is a single-flight claim lock (unix ms): a follow-up CAS-claims +-- the row before resuming so two concurrent follow-ups can't both drive the +-- same stream and corrupt the seqno; the loser falls back to cold-resume. +-- +-- Rows are short-lived (a live conversation turn, not durable history) and are +-- swept by the maintenance cron against `refreshed_at`. +CREATE TABLE cursor_sessions ( + session_key TEXT PRIMARY KEY, + request_id TEXT NOT NULL, + append_seqno INTEGER NOT NULL, + leftover BLOB, + locked_until INTEGER, -- unix ms; NULL = unclaimed + created_at INTEGER NOT NULL, -- unix ms + refreshed_at INTEGER NOT NULL -- unix ms; cron sweeps by this +); + +CREATE INDEX idx_cursor_sessions_refreshed_at ON cursor_sessions(refreshed_at); diff --git a/packages/gateway/src/repo/cursor-sessions_test.ts b/packages/gateway/src/repo/cursor-sessions_test.ts new file mode 100644 index 000000000..59ffe839e --- /dev/null +++ b/packages/gateway/src/repo/cursor-sessions_test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { InMemoryRepo } from './memory.ts'; +import { SqlRepo } from './sql.ts'; +import { createSqliteTestDb } from './test-sqlite.ts'; +import type { CursorSessionRow, Repo } from './types.ts'; + +// cursor_sessions is the durable half of a cross-instance Cursor turn: the +// CAS claim lock (single-flight) and the BLOB leftover round-trip differ +// subtly between the SQL impl (RETURNING + sqlite BLOB binding) and the JS +// mirror, so run both backends. +const REPO_BACKENDS: Array Promise]> = [ + ['memory', async () => new InMemoryRepo()], + ['sql', async () => new SqlRepo(await createSqliteTestDb())], +]; + +const row = (overrides: Partial = {}): CursorSessionRow => ({ + sessionKey: 'cursor:up:key:auto:abc', + requestId: 'req-1', + appendSeqno: 3, + leftover: null, + ...overrides, +}); + +for (const [backend, makeRepo] of REPO_BACKENDS) { + describe(`[${backend}] cursor_sessions repo`, () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-01T00:00:00Z')); + }); + afterEach(() => vi.useRealTimers()); + + it('claim on a missing session returns null', async () => { + const repo = await makeRepo(); + expect(await repo.cursorSessions.claim('missing', 1000)).toBeNull(); + }); + + it('put then claim returns the scalars and takes the lock', async () => { + const repo = await makeRepo(); + await repo.cursorSessions.put(row({ appendSeqno: 7 })); + const claimed = await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000); + expect(claimed).toEqual({ sessionKey: 'cursor:up:key:auto:abc', requestId: 'req-1', appendSeqno: 7, leftover: null }); + // Second concurrent claim while locked → null (single-flight). + expect(await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000)).toBeNull(); + }); + + it('claim succeeds again once the lock TTL expires', async () => { + const repo = await makeRepo(); + await repo.cursorSessions.put(row()); + await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000); + vi.advanceTimersByTime(1001); + expect(await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000)).not.toBeNull(); + }); + + it('put clears the lock and updates the seqno', async () => { + const repo = await makeRepo(); + await repo.cursorSessions.put(row({ appendSeqno: 3 })); + await repo.cursorSessions.claim('cursor:up:key:auto:abc', 60_000); // lock it + // A suspend persists the advanced seqno and clears the lock. + await repo.cursorSessions.put(row({ appendSeqno: 9 })); + const reclaimed = await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000); + expect(reclaimed?.appendSeqno).toBe(9); + }); + + it('round-trips a non-empty leftover BLOB and a null one', async () => { + const repo = await makeRepo(); + const bytes = new Uint8Array([0, 1, 2, 0xff, 0xfe, 0x80]); + await repo.cursorSessions.put(row({ leftover: bytes })); + const claimed = await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000); + expect(claimed?.leftover ? Array.from(claimed.leftover) : null).toEqual(Array.from(bytes)); + + await repo.cursorSessions.put(row({ leftover: null })); + vi.advanceTimersByTime(2000); + const claimed2 = await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000); + expect(claimed2?.leftover).toBeNull(); + }); + + it('delete removes the row', async () => { + const repo = await makeRepo(); + await repo.cursorSessions.put(row()); + await repo.cursorSessions.delete('cursor:up:key:auto:abc'); + expect(await repo.cursorSessions.claim('cursor:up:key:auto:abc', 1000)).toBeNull(); + }); + + it('deleteOlderThan sweeps rows refreshed before the cutoff', async () => { + const repo = await makeRepo(); + await repo.cursorSessions.put(row({ sessionKey: 'old' })); + vi.advanceTimersByTime(10_000); + await repo.cursorSessions.put(row({ sessionKey: 'fresh' })); + await repo.cursorSessions.deleteOlderThan(Date.now() - 5_000); + expect(await repo.cursorSessions.claim('old', 1000)).toBeNull(); + expect(await repo.cursorSessions.claim('fresh', 1000)).not.toBeNull(); + }); + }); +} diff --git a/packages/gateway/src/repo/memory.ts b/packages/gateway/src/repo/memory.ts index 5a85dba39..9b206db10 100644 --- a/packages/gateway/src/repo/memory.ts +++ b/packages/gateway/src/repo/memory.ts @@ -13,6 +13,8 @@ import type { ApiKeyRepo, BackoffRow, CachedModelsRow, + CursorSessionRow, + CursorSessionsRepo, ModelsCacheRepo, PerformanceDimensions, PerformanceErrorSample, @@ -882,6 +884,31 @@ class MemoryProxyBackoffRepo implements ProxyBackoffRepo { const cloneBackoffRow = (row: BackoffRow): BackoffRow => ({ ...row }); +class MemoryCursorSessionsRepo implements CursorSessionsRepo { + private rows = new Map(); + + async claim(sessionKey: string, ttlMs: number): Promise { + const now = Date.now(); + const row = this.rows.get(sessionKey); + if (!row) return null; + if (row.lockedUntil !== null && row.lockedUntil >= now) return null; + row.lockedUntil = now + ttlMs; + return { sessionKey, requestId: row.requestId, appendSeqno: row.appendSeqno, leftover: row.leftover }; + } + + async put(row: CursorSessionRow): Promise { + this.rows.set(row.sessionKey, { ...row, lockedUntil: null, refreshedAt: Date.now() }); + } + + async delete(sessionKey: string): Promise { + this.rows.delete(sessionKey); + } + + async deleteOlderThan(cutoffMs: number): Promise { + for (const [k, v] of this.rows) if (v.refreshedAt < cutoffMs) this.rows.delete(k); + } +} + export class InMemoryRepo implements Repo { apiKeys: ApiKeyRepo; users: UsersRepo; @@ -896,6 +923,7 @@ export class InMemoryRepo implements Repo { proxyBackoffs: ProxyBackoffRepo; responsesItems: ResponsesItemsRepo; responsesSnapshots: ResponsesSnapshotsRepo; + cursorSessions: CursorSessionsRepo; constructor() { this.users = new MemoryUsersRepo(); @@ -911,5 +939,6 @@ export class InMemoryRepo implements Repo { this.proxyBackoffs = new MemoryProxyBackoffRepo(); this.responsesItems = new MemoryResponsesItemsRepo(); this.responsesSnapshots = new MemoryResponsesSnapshotsRepo(); + this.cursorSessions = new MemoryCursorSessionsRepo(); } } diff --git a/packages/gateway/src/repo/sql.ts b/packages/gateway/src/repo/sql.ts index e3375ca31..4159fb851 100644 --- a/packages/gateway/src/repo/sql.ts +++ b/packages/gateway/src/repo/sql.ts @@ -7,6 +7,8 @@ import type { ApiKeyRepo, BackoffRow, CachedModelsRow, + CursorSessionRow, + CursorSessionsRepo, ModelsCacheRepo, PerformanceDimensions, PerformanceErrorSample, @@ -1585,6 +1587,66 @@ const toBackoffRow = (row: BackoffRowDb): BackoffRow => ({ lastErrorAt: row.last_error_at, }); +interface CursorSessionRowDb { + request_id: string; + append_seqno: number; + leftover: Uint8Array | null; +} + +class SqlCursorSessionsRepo implements CursorSessionsRepo { + constructor(private db: SqlDatabase) {} + + async claim(sessionKey: string, ttlMs: number): Promise { + const now = Date.now(); + // Atomic single-flight: take the lock only if the row exists and is + // unclaimed (or its claim expired), returning the scalars in the same + // statement. RETURNING is supported by both D1 and node:sqlite. + const row = await this.db + .prepare( + `UPDATE cursor_sessions + SET locked_until = ? + WHERE session_key = ? AND (locked_until IS NULL OR locked_until < ?) + RETURNING request_id, append_seqno, leftover`, + ) + .bind(now + ttlMs, sessionKey, now) + .first(); + if (!row) return null; + return { + sessionKey, + requestId: row.request_id, + appendSeqno: row.append_seqno, + leftover: row.leftover ? new Uint8Array(row.leftover) : null, + }; + } + + async put(row: CursorSessionRow): Promise { + const now = Date.now(); + // Upsert the scalars; clear the claim lock and refresh the sweep clock. + await this.db + .prepare( + `INSERT INTO cursor_sessions + (session_key, request_id, append_seqno, leftover, locked_until, created_at, refreshed_at) + VALUES (?, ?, ?, ?, NULL, ?, ?) + ON CONFLICT (session_key) DO UPDATE SET + request_id = excluded.request_id, + append_seqno = excluded.append_seqno, + leftover = excluded.leftover, + locked_until = NULL, + refreshed_at = excluded.refreshed_at`, + ) + .bind(row.sessionKey, row.requestId, row.appendSeqno, row.leftover ?? null, now, now) + .run(); + } + + async delete(sessionKey: string): Promise { + await this.db.prepare('DELETE FROM cursor_sessions WHERE session_key = ?').bind(sessionKey).run(); + } + + async deleteOlderThan(cutoffMs: number): Promise { + await this.db.prepare('DELETE FROM cursor_sessions WHERE refreshed_at < ?').bind(cutoffMs).run(); + } +} + export class SqlRepo implements Repo { users: UsersRepo; sessions: SessionsRepo; @@ -1599,6 +1661,7 @@ export class SqlRepo implements Repo { proxyBackoffs: ProxyBackoffRepo; responsesItems: ResponsesItemsRepo; responsesSnapshots: ResponsesSnapshotsRepo; + cursorSessions: CursorSessionsRepo; constructor(db: SqlDatabase) { this.users = new SqlUsersRepo(db); @@ -1614,5 +1677,6 @@ export class SqlRepo implements Repo { this.proxyBackoffs = new SqlProxyBackoffRepo(db); this.responsesItems = new SqlResponsesItemsRepo(db); this.responsesSnapshots = new SqlResponsesSnapshotsRepo(db); + this.cursorSessions = new SqlCursorSessionsRepo(db); } } diff --git a/packages/gateway/src/repo/types.ts b/packages/gateway/src/repo/types.ts index 0341d41ef..ab0957930 100644 --- a/packages/gateway/src/repo/types.ts +++ b/packages/gateway/src/repo/types.ts @@ -332,4 +332,24 @@ export interface Repo { proxyBackoffs: ProxyBackoffRepo; responsesItems: ResponsesItemsRepo; responsesSnapshots: ResponsesSnapshotsRepo; + cursorSessions: CursorSessionsRepo; +} + +// Cross-instance Cursor session scalars (see migration 0047_cursor_sessions.sql +// and packages/provider/src/repo.ts CursorSessionsRepoSlim). The full repo adds +// the cron sweep; the read/claim/put/delete surface is structurally the slim +// interface the provider package consumes. +export interface CursorSessionRow { + sessionKey: string; + requestId: string; + appendSeqno: number; + leftover: Uint8Array | null; +} + +export interface CursorSessionsRepo { + claim(sessionKey: string, ttlMs: number): Promise; + put(row: CursorSessionRow): Promise; + delete(sessionKey: string): Promise; + /** Cron sweep: drop rows whose refreshed_at is older than the cutoff (unix ms). */ + deleteOlderThan(cutoffMs: number): Promise; } diff --git a/packages/gateway/src/scheduled.ts b/packages/gateway/src/scheduled.ts index 8771aab22..1c3223938 100644 --- a/packages/gateway/src/scheduled.ts +++ b/packages/gateway/src/scheduled.ts @@ -7,6 +7,12 @@ import { getImageCacheStore } from '@floway-dev/platform'; // by it — a row stays referenceable until cleanup removes it. const RESPONSES_ITEM_ROW_TTL_MS = 180 * 24 * 60 * 60 * 1000; +// Cursor session scalars track a LIVE conversation turn (the read stream is +// held for at most the DurableHttpSession idle/cap window), so they expire far +// faster than durable history — a stale row only ever causes a clean +// cold-resume, never corruption. +const CURSOR_SESSION_ROW_TTL_MS = 30 * 60 * 1000; + const runSweep = async (name: string, fn: () => Promise): Promise => { try { await fn(); @@ -21,6 +27,7 @@ export const runScheduledMaintenance = async (): Promise => { await runSweep('responsesItems.sweepPayloadFiles', () => sweepExpiredResponsesItemPayloadFiles(now)); await runSweep('responsesSnapshots.deleteOlderThan', () => getRepo().responsesSnapshots.deleteOlderThan(now - RESPONSES_ITEM_ROW_TTL_MS)); await runSweep('responsesItems.deleteOlderThan', () => getRepo().responsesItems.deleteOlderThan(now - RESPONSES_ITEM_ROW_TTL_MS)); + await runSweep('cursorSessions.deleteOlderThan', () => getRepo().cursorSessions.deleteOlderThan(Date.now() - CURSOR_SESSION_ROW_TTL_MS)); await runSweep('imageCacheStore.sweepExpired', () => getImageCacheStore().sweepExpired(Date.now())); await runSweep('dumps.sweepExpired', () => sweepExpiredDumps()); }; diff --git a/packages/provider-claude-code/src/fetch_test.ts b/packages/provider-claude-code/src/fetch_test.ts index f2c563301..9af6bef18 100644 --- a/packages/provider-claude-code/src/fetch_test.ts +++ b/packages/provider-claude-code/src/fetch_test.ts @@ -9,7 +9,7 @@ import type { ClaudeCodeUpstreamState, } from './state.ts'; import { initProviderRepo, type Fetcher, type UpstreamCallOptions, type UpstreamModel, type UpstreamRecord } from '@floway-dev/provider'; -import { noopUpstreamCallOptions } from '@floway-dev/test-utils'; +import { noopCursorSessionsRepo, noopUpstreamCallOptions } from '@floway-dev/test-utils'; const upstreamId = 'up_cc'; @@ -100,6 +100,7 @@ beforeEach(() => { return { updated: true }; }, }, + cursorSessions: noopCursorSessionsRepo(), })); }); diff --git a/packages/provider-claude-code/src/provider_test.ts b/packages/provider-claude-code/src/provider_test.ts index 1b35625d2..4b186b6fc 100644 --- a/packages/provider-claude-code/src/provider_test.ts +++ b/packages/provider-claude-code/src/provider_test.ts @@ -5,7 +5,7 @@ import { pricingForClaudeCodeModelKey } from './pricing.ts'; import { createClaudeCodeProvider } from './provider.ts'; import type { ClaudeCodeAccessTokenEntry, ClaudeCodeAccountCredential, ClaudeCodeUpstreamState } from './state.ts'; import { initProviderRepo, type UpstreamCallOptions, type UpstreamRecord } from '@floway-dev/provider'; -import { noopUpstreamCallOptions } from '@floway-dev/test-utils'; +import { noopCursorSessionsRepo, noopUpstreamCallOptions } from '@floway-dev/test-utils'; const upstreamId = 'up_cc_provider'; @@ -68,6 +68,7 @@ beforeEach(() => { return { updated: true }; }, }, + cursorSessions: noopCursorSessionsRepo(), })); }); diff --git a/packages/provider-codex/src/access-token-cache_test.ts b/packages/provider-codex/src/access-token-cache_test.ts index f00c3e668..9b84eb240 100644 --- a/packages/provider-codex/src/access-token-cache_test.ts +++ b/packages/provider-codex/src/access-token-cache_test.ts @@ -54,6 +54,7 @@ beforeEach(() => { }); getByIdSpy = vi.fn(async () => current); initProviderRepo(() => ({ + cursorSessions: { claim: async () => null, put: async () => {}, delete: async () => {} }, upstreams: { getById: getByIdSpy, saveState: saveStateSpy }, })); }); diff --git a/packages/provider-codex/src/fetch_test.ts b/packages/provider-codex/src/fetch_test.ts index 69e538a12..8542e94df 100644 --- a/packages/provider-codex/src/fetch_test.ts +++ b/packages/provider-codex/src/fetch_test.ts @@ -62,6 +62,7 @@ beforeEach(() => { vi.useRealTimers(); currentRecord = makeRecord({ accounts: [{ ...activeAccount }] }); initProviderRepo(() => ({ + cursorSessions: { claim: async () => null, put: async () => {}, delete: async () => {} }, upstreams: { getById: async () => currentRecord, saveState: async (_id, newState) => { diff --git a/packages/provider-codex/src/provider_test.ts b/packages/provider-codex/src/provider_test.ts index 39f6f7e0d..7a77bc81b 100644 --- a/packages/provider-codex/src/provider_test.ts +++ b/packages/provider-codex/src/provider_test.ts @@ -37,6 +37,7 @@ beforeEach(() => { saveStateSpy = vi.fn<(id: string, newState: unknown, options: { expectedState: unknown }) => Promise<{ updated: boolean }>>(async () => ({ updated: true })); getByIdSpy = vi.fn<(id: string) => Promise>(async () => recordWithAccessToken()); initProviderRepo(() => ({ + cursorSessions: { claim: async () => null, put: async () => {}, delete: async () => {} }, upstreams: { getById: getByIdSpy, saveState: saveStateSpy }, })); }); diff --git a/packages/provider-codex/src/quota_test.ts b/packages/provider-codex/src/quota_test.ts index 0cc088c2e..c745b5783 100644 --- a/packages/provider-codex/src/quota_test.ts +++ b/packages/provider-codex/src/quota_test.ts @@ -52,6 +52,7 @@ beforeEach(() => { getByIdSpy = vi.fn(async () => current); initProviderRepo(() => ({ upstreams: { getById: getByIdSpy, saveState: saveStateSpy }, + cursorSessions: { claim: async () => null, put: async () => {}, delete: async () => {} }, })); }); diff --git a/packages/provider-copilot/src/auth_test.ts b/packages/provider-copilot/src/auth_test.ts index e8ab6b501..675d5dacd 100644 --- a/packages/provider-copilot/src/auth_test.ts +++ b/packages/provider-copilot/src/auth_test.ts @@ -27,6 +27,7 @@ const installRepoAndClearCache = async () => { config: { githubToken: 'ghu_test', user: { id: 1, login: 't', name: null, avatar_url: '' } }, }; initProviderRepo(() => ({ + cursorSessions: { claim: async () => null, put: async () => {}, delete: async () => {} }, upstreams: { getById: async () => ({ ...stub, state }), saveState: async (_id, newState) => { diff --git a/packages/provider-copilot/src/fetch-models_test.ts b/packages/provider-copilot/src/fetch-models_test.ts index ed2ad66b0..7d5dd692a 100644 --- a/packages/provider-copilot/src/fetch-models_test.ts +++ b/packages/provider-copilot/src/fetch-models_test.ts @@ -24,6 +24,7 @@ const installRepoAndConfig = async () => { config: { githubToken, user: { id: 1, login: 't', name: null, avatar_url: '' } }, }; initProviderRepo(() => ({ + cursorSessions: { claim: async () => null, put: async () => {}, delete: async () => {} }, upstreams: { getById: async () => stub, saveState: async () => ({ updated: true }), diff --git a/packages/provider-copilot/src/provider_test.ts b/packages/provider-copilot/src/provider_test.ts index 937d0f2a2..20eda448a 100644 --- a/packages/provider-copilot/src/provider_test.ts +++ b/packages/provider-copilot/src/provider_test.ts @@ -72,6 +72,7 @@ const setupCopilotTest = async (initial: SetupOptions = {}): Promise ({ + cursorSessions: { claim: async () => null, put: async () => {}, delete: async () => {} }, upstreams: { getById: () => getByIdImpl(), saveState: (id, newState, options) => saveStateImpl(id, newState, options), diff --git a/packages/provider-cursor/src/agent-translate.ts b/packages/provider-cursor/src/agent-translate.ts index abb73426d..6705113fb 100644 --- a/packages/provider-cursor/src/agent-translate.ts +++ b/packages/provider-cursor/src/agent-translate.ts @@ -86,13 +86,10 @@ export function createAgentTranslator(opts: TranslatorOptions): AgentTranslator // Cursor packs two ids into one toolCallId string, separated by a newline: // an OpenAI-style `call_…` token first, then a Responses-API-style `fc_…` // token. OpenAI clients expect a single id with no embedded whitespace, so - // we surface only the leading call_… form. Trim defensively in case the - // backend ever omits the second id. When a sessionKey is present, further - // wrap with the session prefix so the OpenAI client echoes it back. - const cleanCallId = (raw: string): string => { - const cleaned = raw.split('\n')[0]!.trim(); - return opts.sessionKey ? wrapToolCallId(opts.sessionKey, cleaned) : cleaned; - }; + // we surface only the leading call_… form. Used for built-in tool calls, + // which are never resumed; the MCP path (translateExecRequest) instead wraps + // the exec ref via wrapToolCallId so a follow-up can rebuild the result. + const cleanCallId = (raw: string): string => raw.split('\n')[0]!.trim(); let emittedRole = false; let toolCallIndex = 0; @@ -257,7 +254,13 @@ export function createAgentTranslator(opts: TranslatorOptions): AgentTranslator tool_calls: [ { index, - id: cleanCallId(execRequest.toolCallId), + // Encode the session id + cursor exec ref into the tool_call_id so a + // follow-up (possibly cross-instance) rebuilds the ExecMcpResult from + // the client's echoed id. Falls back to the bare call id when no + // session is tracked (shouldn't happen for MCP turns). + id: opts.sessionKey + ? wrapToolCallId(opts.sessionKey, { id: execRequest.id, execId: execRequest.execId }) + : cleanCallId(execRequest.toolCallId), type: 'function', function: { name: execRequest.toolName || execRequest.name, diff --git a/packages/provider-cursor/src/agent-transport.ts b/packages/provider-cursor/src/agent-transport.ts index b03b05938..dd953ad5f 100644 --- a/packages/provider-cursor/src/agent-transport.ts +++ b/packages/provider-cursor/src/agent-transport.ts @@ -77,10 +77,10 @@ const DEFAULT_HEARTBEAT = { idleAfterProgressMs: 30_000, }; -const DEFAULT_REQUEST_TIMEOUT_MS = 300_000; - export interface AgentTransportOptions { - accessToken: string; + /** Supplies the Bearer access token. A getter (not a captured string) so a + * resume on a long-lived session can swap in a freshly-minted token. */ + getAuthToken: () => string; baseUrl: string; env: RequestContextEnv; clientVersion: string; @@ -92,7 +92,6 @@ export interface AgentTransportOptions { fetch?: typeof fetch; /** BidiAppend retry count for transient network errors. Default 1. */ maxRetries?: number; - requestTimeoutMs?: number; heartbeat?: Partial; } @@ -116,7 +115,7 @@ function isRetryableNetworkError(message: string): boolean { * store and seqno are turn-scoped and cleared on openChatStream. */ export class AgentTransport { - private readonly accessToken: string; + private readonly getAuthToken: () => string; private readonly baseUrl: string; private readonly env: RequestContextEnv; private readonly clientVersion: string; @@ -124,19 +123,22 @@ export class AgentTransport { private readonly getChecksum: () => string; private readonly fetchFn: typeof fetch; private readonly maxRetries: number; - private readonly requestTimeoutMs: number; private readonly heartbeat: typeof DEFAULT_HEARTBEAT; private readonly blobStore = new Map(); private readonly blobStoreOrder: string[] = []; - // Turn-scoped state, valid while openChatStream is running. + // Turn-scoped state. seed() sets requestId+seqno before open/resume; the read + // stream + socket lifetime are owned by the DurableHttpSession, so the + // transport no longer holds an AbortController/timeout of its own. private currentRequestId: string | null = null; private currentAppendSeqno = 0n; - private controller: AbortController | null = null; + // RunSSE bytes read past the exec_mcp frame but not yet parsed, captured at + // the pause so a cross-instance resume can prepend them (see driveReadLoop). + private suspendedLeftover: Uint8Array | null = null; constructor(opts: AgentTransportOptions) { - this.accessToken = opts.accessToken; + this.getAuthToken = opts.getAuthToken; this.baseUrl = opts.baseUrl.replace(/\/$/, ''); this.env = opts.env; this.clientVersion = opts.clientVersion; @@ -144,13 +146,22 @@ export class AgentTransport { this.getChecksum = opts.getChecksum; this.fetchFn = opts.fetch ?? globalThis.fetch; this.maxRetries = opts.maxRetries ?? 1; - this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; this.heartbeat = { ...DEFAULT_HEARTBEAT, ...opts.heartbeat }; } + /** The unparsed RunSSE remainder at the last exec_mcp pause (usually empty). */ + get leftover(): Uint8Array | null { + return this.suspendedLeftover; + } + + /** The next BidiAppend seqno (to persist for a cross-instance resume). */ + get seqno(): bigint { + return this.currentAppendSeqno; + } + private getHeaders(requestId?: string): Record { const headers: Record = { - authorization: `Bearer ${this.accessToken}`, + authorization: `Bearer ${this.getAuthToken()}`, 'content-type': GRPC_WEB_PROTO, 'user-agent': USER_AGENT, 'x-cursor-checksum': this.getChecksum(), @@ -314,7 +325,17 @@ export class AgentTransport { * server resumes the model turn automatically (see sendExecResultNoClose). */ async sendMcpResult(execRequest: Extract, result: McpResult): Promise { - const execClientMsg = buildExecClientMessageWithMcpResult(execRequest.id, execRequest.execId, result); + await this.sendMcpResultRaw(execRequest.id, execRequest.execId, result); + } + + /** + * Send an MCP tool result from just the exec identity (id + exec_id) — a + * cross-instance resume reconstructs these from the client's echoed + * tool_call_id (see session-id.ts decodeToolCallId), without the original + * exec_request object. + */ + async sendMcpResultRaw(id: number, execId: string | undefined, result: McpResult): Promise { + const execClientMsg = buildExecClientMessageWithMcpResult(id, execId, result); await this.sendExecResultNoClose(execClientMsg); } @@ -385,29 +406,73 @@ export class AgentTransport { this.currentAppendSeqno += 1n; } - /** Abort the active RunSSE read (e.g. after yielding a tool_calls chunk). */ - abort(): void { - this.controller?.abort(); + /** + * Seed the turn-scoped write-channel state before open/resume. requestId + + * seqno are generated/loaded by the caller (fetch.ts) so they can be shared + * with the DurableHttpSession RunSSE request and persisted to D1 for a + * cross-instance resume. + */ + seed(requestId: string, seqno: bigint): void { + this.currentRequestId = requestId; + this.currentAppendSeqno = seqno; + this.suspendedLeftover = null; + this.clearBlobStore(); } /** - * Drive a full chat turn over the dual channel: open RunSSE, send the - * RunRequest on BidiAppend, then pump AgentServerMessage frames as - * AgentStreamChunk events until turn_ended / idle-timeout / abort / error. - * - * The caller drives exec_request disposition: on an mcp exec it typically - * translates to a downstream tool_calls chunk and breaks (aborting the - * read); on a built-in exec it calls sendRejectedTool / sendRequestContextResult - * and lets the loop continue. + * The RunSSE POST spec the caller hands to DurableHttpSession.acquire — the + * read socket lives in the session, not the transport. The body is just the + * requestId envelope; the actual RunRequest goes out on BidiAppend (open()). */ - async *openChatStream(request: AgentChatRequest): AsyncGenerator { - this.clearBlobStore(); - const requestId = crypto.randomUUID(); - this.currentRequestId = requestId; - this.currentAppendSeqno = 0n; + runSseInit(requestId: string): { method: 'POST'; url: string; headers: Record; body: Uint8Array } { + return { + method: 'POST', + url: `${this.baseUrl}${RUN_SSE_PATH}`, + headers: this.getHeaders(requestId), + body: addConnectEnvelope(encodeBidiRequestId(requestId)), + }; + } + + /** + * Open turn: send the initial RunRequest on BidiAppend (seqno 0, seeded by + * seed()), then pump the provided RunSSE read stream. The stream comes from + * the DurableHttpSession; the transport never fetches RunSSE itself. + */ + async *openChatStream(opts: { readStream: ReadableStream; request: AgentChatRequest }): AsyncGenerator { + const requestId = this.currentRequestId; + if (!requestId) throw new Error('openChatStream called before seed()'); + const messageBody = this.buildChatMessage(opts.request); + try { + await this.bidiAppend(requestId, this.currentAppendSeqno, messageBody); + this.currentAppendSeqno += 1n; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + yield { type: 'error', error: `BidiAppend initial RunRequest failed: ${message}` }; + return; + } + yield* this.driveReadLoop(opts.readStream.getReader(), new Uint8Array(0)); + } - const messageBody = this.buildChatMessage(request); + /** + * Resume turn: the RunRequest was already sent on a prior turn and the + * caller has seeded {requestId, seqno} and sent the ExecMcpResult; we just + * pump the continued RunSSE read stream, prepending any leftover bytes the + * prior turn read past its exec_mcp pause. + */ + async *resumeChatStream(opts: { readStream: ReadableStream; leftover: Uint8Array | null }): AsyncGenerator { + yield* this.driveReadLoop(opts.readStream.getReader(), opts.leftover ?? new Uint8Array(0)); + } + /** + * Pump AgentServerMessage frames off the RunSSE read stream as + * AgentStreamChunk events until turn_ended / idle / done / error. + * + * The caller drives exec_request disposition: on an mcp exec it translates to + * a downstream tool_calls chunk and stops pulling (the pause point — leftover + * is captured here); on a built-in/request_context exec it answers on the + * write channel and keeps pulling. + */ + private async *driveReadLoop(reader: ReadableStreamDefaultReader, initialBuffer: Uint8Array): AsyncGenerator { let lastProgressAt = Date.now(); let heartbeatSinceProgress = 0; let hasProgress = false; @@ -417,59 +482,11 @@ export class AgentTransport { hasProgress = true; }; - const bidiRequestId = encodeBidiRequestId(requestId); - const envelope = addConnectEnvelope(bidiRequestId); - const sseUrl = `${this.baseUrl}${RUN_SSE_PATH}`; - - this.controller = new AbortController(); - const timeout = setTimeout(() => this.controller?.abort(), this.requestTimeoutMs); - const pendingAssistantBlobs: Array<{ blobId: string; content: string }> = []; let hasStreamedText = false; try { - // ssePromise is intentionally kicked off without await so we can fire the - // initial BidiAppend RunRequest concurrently. Without an immediate .catch - // attached, a rejection (DNS failure, connection refused, abort) that - // resolves before the later `await ssePromise` surfaces as an - // unhandledRejection — Node 25 escalates that to a process exit. Attach - // a noop catch so the rejection is observed; the real await below still - // sees the same error. - const ssePromise = this.fetchFn(sseUrl, { - method: 'POST', - headers: this.getHeaders(requestId), - body: envelope as BodyInit, - signal: this.controller.signal, - }); - ssePromise.catch(() => {}); - - // Send the initial RunRequest on the write channel, concurrent with RunSSE. - // BidiAppend can fail for the same reasons RunSSE can (DNS, abort, refused); - // any rejection here is caught by the outer try below, but we surface it - // as an error chunk before falling out so the consumer sees a clean event - // instead of a thrown promise. - try { - await this.bidiAppend(requestId, this.currentAppendSeqno, messageBody); - this.currentAppendSeqno += 1n; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - yield { type: 'error', error: `BidiAppend initial RunRequest failed: ${message}` }; - return; - } - - const sseResponse = await ssePromise; - if (!sseResponse.ok) { - const errorText = await sseResponse.text(); - yield { type: 'error', error: `SSE stream failed: ${sseResponse.status} - ${errorText}` }; - return; - } - if (!sseResponse.body) { - yield { type: 'error', error: 'No response body from SSE stream' }; - return; - } - - const reader = sseResponse.body.getReader(); - let buffer = new Uint8Array(0); + let buffer = initialBuffer; let turnEnded = false; try { @@ -611,6 +628,13 @@ export class AgentTransport { const execRequest = parseExecServerMessage(field.value); if (execRequest) { markProgress(); + // An mcp exec is the pause point: the caller stops pulling + // here. Capture the unparsed remainder so a cross-instance + // resume (fresh transport, fresh DurableHttpSession view) can + // prepend it — these bytes were already dequeued from the + // session and won't be re-served. Usually empty (cursor + // pauses right after exec_mcp). + if (execRequest.type === 'mcp') this.suspendedLeftover = buffer.slice(offset); yield { type: 'exec_request', execRequest }; } } else if (field.fieldNumber === 4 && field.wireType === 2 && field.value instanceof Uint8Array) { @@ -639,7 +663,7 @@ export class AgentTransport { // blobs) all replied at the same seqno — cursor ignores the // duplicates, the blob channel stalls, and the model emits an // empty continuation. - this.currentAppendSeqno = await this.handleKvMessage(kvMsg, requestId); + this.currentAppendSeqno = await this.handleKvMessage(kvMsg, this.currentRequestId!); } catch (kvErr) { const msg = kvErr instanceof Error ? kvErr.message : String(kvErr); if (isRetryableNetworkError(msg)) { @@ -683,7 +707,6 @@ export class AgentTransport { } if (turnEnded) { - this.controller?.abort(); if (!hasStreamedText && pendingAssistantBlobs.length > 0) { for (const blob of pendingAssistantBlobs) { yield { type: 'kv_blob_assistant', blobContent: blob.content }; @@ -696,12 +719,8 @@ export class AgentTransport { } } catch (err: unknown) { const error = err as Error & { name?: string }; - if (error.name === 'AbortError') return; // normal termination + if (error.name === 'AbortError') return; // stream cancelled by the caller yield { type: 'error', error: error.message || String(err) }; - } finally { - clearTimeout(timeout); - this.controller?.abort(); - this.currentRequestId = null; } } } diff --git a/packages/provider-cursor/src/agent-transport_test.ts b/packages/provider-cursor/src/agent-transport_test.ts index 924882c1a..e45df92b0 100644 --- a/packages/provider-cursor/src/agent-transport_test.ts +++ b/packages/provider-cursor/src/agent-transport_test.ts @@ -8,17 +8,34 @@ const ENV: RequestContextEnv = { workspacePath: '/tmp', osVersion: 'darwin 24.0. function makeTransport(fetchMock: unknown): AgentTransport { return new AgentTransport({ - accessToken: 'tok', + getAuthToken: () => 'tok', baseUrl: 'https://api2.cursor.sh', env: ENV, clientVersion: 'cli-test', getChecksum: () => 'checksum-value', fetch: fetchMock as typeof fetch, maxRetries: 1, - requestTimeoutMs: 5000, }); } +// The transport no longer fetches RunSSE itself (the DurableHttpSession owns +// the read socket); it drives a provided read stream. Build one from frames. +function readStreamOf(...frames: Uint8Array[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const f of frames) controller.enqueue(f); + controller.close(); + }, + }); +} + +// Seed + open a turn over a frame stream. fetchMock only serves the BidiAppend +// write channel now. +function openOver(transport: AgentTransport, ...frames: Uint8Array[]): AsyncGenerator { + transport.seed('req-test', 0n); + return transport.openChatStream({ readStream: readStreamOf(...frames), request: { message: 'hi', model: 'gpt-4o' } as AgentChatRequest }); +} + function okEmptyResponse(): Response { return new Response(new Uint8Array(0), { status: 200 }); } @@ -44,16 +61,6 @@ function heartbeatFrame(): Uint8Array { return addConnectEnvelope(serverMsg); } -function streamResponse(...frames: Uint8Array[]): Response { - const stream = new ReadableStream({ - start(controller) { - for (const f of frames) controller.enqueue(f); - controller.close(); - }, - }); - return new Response(stream, { status: 200, headers: { 'content-type': 'application/grpc-web+proto' } }); -} - async function collect(gen: AsyncGenerator): Promise { const out: unknown[] = []; for await (const chunk of gen) out.push(chunk); @@ -106,18 +113,11 @@ describe('AgentTransport.bidiAppend', () => { }); describe('AgentTransport.openChatStream', () => { - function dualChannelMock(runSseResponse: Response): ReturnType { - return vi.fn(async (url: string) => { - if (String(url).includes('RunSSE')) return runSseResponse; - return okEmptyResponse(); // BidiAppend - }); - } + const bidiOnlyMock = (): ReturnType => vi.fn(async (_url: string) => okEmptyResponse()); test('yields text then done on a clean turn', async () => { - const fetchMock = dualChannelMock(streamResponse(textFrame('hello'), turnEndedFrame())); - const transport = makeTransport(fetchMock); - - const chunks = await collect(transport.openChatStream({ message: 'hi', model: 'gpt-4o' } as AgentChatRequest)); + const transport = makeTransport(bidiOnlyMock()); + const chunks = await collect(openOver(transport, textFrame('hello'), turnEndedFrame())); const types = chunks.map(c => (c as { type: string }).type); expect(types).toContain('text'); @@ -130,15 +130,14 @@ describe('AgentTransport.openChatStream', () => { // started — cursor interleaves keep-alive heartbeats and KV checkpoints // between the final text and the turn_ended (IU field 14) marker. Closing // on a beat count truncated short answers whose turn_ended lagged behind. - const fetchMock = dualChannelMock(streamResponse( + const transport = makeTransport(bidiOnlyMock()); + const chunks = await collect(openOver( + transport, textFrame('the answer is 42'), heartbeatFrame(), heartbeatFrame(), heartbeatFrame(), heartbeatFrame(), heartbeatFrame(), heartbeatFrame(), turnEndedFrame(), )); - const transport = makeTransport(fetchMock); - - const chunks = await collect(transport.openChatStream({ message: 'hi', model: 'gpt-4o' } as AgentChatRequest)); const types = chunks.map(c => (c as { type: string }).type); // The full text survives, and the turn ends on the turn_ended frame (done @@ -147,51 +146,28 @@ describe('AgentTransport.openChatStream', () => { expect(types[types.length - 1]).toBe('done'); }); - test('sends the initial RunRequest on BidiAppend concurrently with RunSSE', async () => { - const fetchMock = dualChannelMock(streamResponse(textFrame('hi'), turnEndedFrame())); + test('sends the initial RunRequest on the BidiAppend write channel', async () => { + const fetchMock = bidiOnlyMock(); const transport = makeTransport(fetchMock); - await collect(transport.openChatStream({ message: 'hi', model: 'gpt-4o' } as AgentChatRequest)); + await collect(openOver(transport, textFrame('hi'), turnEndedFrame())); const bidiCalls = fetchMock.mock.calls.filter(c => String(c[0]).includes('BidiAppend')); - const runSseCalls = fetchMock.mock.calls.filter(c => String(c[0]).includes('RunSSE')); - expect(runSseCalls).toHaveLength(1); - // At least the initial RunRequest BidiAppend expect(bidiCalls.length).toBeGreaterThanOrEqual(1); }); - - test('yields an error chunk on non-ok RunSSE', async () => { - const fetchMock = dualChannelMock(new Response('upstream down', { status: 503 })); - const transport = makeTransport(fetchMock); - const chunks = await collect(transport.openChatStream({ message: 'hi', model: 'gpt-4o' } as AgentChatRequest)); - const err = chunks.find(c => (c as { type: string }).type === 'error') as { error?: string }; - expect(err).toBeDefined(); - expect(err!.error).toContain('503'); - }); - - test('yields an error chunk when RunSSE has no body', async () => { - const fetchMock = dualChannelMock(new Response(null, { status: 200 })); - const transport = makeTransport(fetchMock); - const chunks = await collect(transport.openChatStream({ message: 'hi', model: 'gpt-4o' } as AgentChatRequest)); - const err = chunks.find(c => (c as { type: string }).type === 'error') as { error?: string }; - expect(err?.error).toContain('No response body'); - }); }); describe('AgentTransport.sendRejectedTool', () => { test('sends a result frame then a stream-close control (2 BidiAppends)', async () => { // Open a stream first so currentRequestId is set (sendExecAndClose guards it). - const runSseFetch = vi.fn(async (url: string) => { - if (String(url).includes('RunSSE')) return streamResponse(textFrame('hi'), turnEndedFrame()); - return okEmptyResponse(); - }); - const transport2 = makeTransport(runSseFetch); + const bidiFetch = vi.fn(async (_url: string) => okEmptyResponse()); + const transport2 = makeTransport(bidiFetch); // Drive the stream just enough to set currentRequestId, then reject a shell tool. - const gen = transport2.openChatStream({ message: 'hi', model: 'gpt-4o' } as AgentChatRequest); + const gen = openOver(transport2, textFrame('hi'), turnEndedFrame()); await gen.next(); // text chunk, currentRequestId now set - const bidiBefore = runSseFetch.mock.calls.filter(c => String(c[0]).includes('BidiAppend')).length; + const bidiBefore = bidiFetch.mock.calls.filter(c => String(c[0]).includes('BidiAppend')).length; await transport2.sendRejectedTool({ type: 'shell', id: 9, execId: 'e1', command: 'rm', cwd: '/' }, 'no shell in gateway'); - const bidiAfter = runSseFetch.mock.calls.filter(c => String(c[0]).includes('BidiAppend')).length; + const bidiAfter = bidiFetch.mock.calls.filter(c => String(c[0]).includes('BidiAppend')).length; expect(bidiAfter - bidiBefore).toBe(2); // exec result + control close await gen.return(undefined); }); diff --git a/packages/provider-cursor/src/cursor-session-state.ts b/packages/provider-cursor/src/cursor-session-state.ts deleted file mode 100644 index 1269df44c..000000000 --- a/packages/provider-cursor/src/cursor-session-state.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Cursor session protocol state — per-session generator + transport + seqno. - * - * Separate from DurableHttpSession because what we actually persist is the - * **live async generator** (the transport's openChatStream iterator) — not - * raw HTTP bytes. On Node this is a plain Map; on CF (future) the session - * will be reconstructed from the DurableHttpSession body bytes. - * - * Follows the same "in-process Map" pattern as provider-copilot/src/auth.ts:42 - * (inProcessTokenCache) and opencode-cursor-proxy/src/lib/session-reuse.ts - * (sessionMap holding session.iterator). - */ - -import type { AgentStreamChunk } from './proto/index.ts'; - -export interface CursorSessionEntry { - /** The live transport generator — continue pulling for follow-up chunks. */ - gen: AsyncGenerator; - /** Send tool result via BidiAppend on the same session. */ - sendMcpResult: (execId: number, mcpExecId: string | undefined, result: { success?: { content: string; isError?: boolean }; error?: string }) => Promise; - /** Tell cursor to resume streaming after tool results. */ - sendResumeAction: () => Promise; - /** Pending exec requests (tool_call_id → exec info) for matching results. */ - pendingExecs: Map; - /** session key for back-reference. */ - sessionKey: string; - /** Timestamp of last activity for idle eviction. */ - lastActivityAt: number; -} - -const sessions = new Map(); - -const IDLE_TTL_MS = 5 * 60 * 1000; - -export function getCursorSession(sessionKey: string): CursorSessionEntry | null { - const entry = sessions.get(sessionKey); - if (!entry) return null; - entry.lastActivityAt = Date.now(); - return entry; -} - -export function putCursorSession(entry: CursorSessionEntry): void { - entry.lastActivityAt = Date.now(); - sessions.set(entry.sessionKey, entry); -} - -export function deleteCursorSession(sessionKey: string): void { - const entry = sessions.get(sessionKey); - if (entry) { - // Close the generator to release the transport's RunSSE read. - void entry.gen.return(undefined); - sessions.delete(sessionKey); - } -} - -// Lazy idle sweep — runs on every get/put; evicts stale entries. -function sweepIdle(): void { - const now = Date.now(); - for (const [key, entry] of sessions) { - if (now - entry.lastActivityAt > IDLE_TTL_MS) { - void entry.gen.return(undefined); - sessions.delete(key); - } - } -} - -// Run sweep periodically (piggyback on get/put). -let lastSweep = 0; -function maybeSweep(): void { - const now = Date.now(); - if (now - lastSweep > 30_000) { - lastSweep = now; - sweepIdle(); - } -} - -export { maybeSweep as _sweepHook }; diff --git a/packages/provider-cursor/src/fetch.ts b/packages/provider-cursor/src/fetch.ts index 3ebc59dbb..54c09e070 100644 --- a/packages/provider-cursor/src/fetch.ts +++ b/packages/provider-cursor/src/fetch.ts @@ -14,15 +14,14 @@ import { AgentTransport } from './agent-transport.ts'; import { CursorSessionTerminatedError } from './auth/oauth.ts'; import { generateCursorChecksum } from './checksum.ts'; import { CURSOR_BACKEND_BASE, CURSOR_CLIENT_VERSION } from './constants.ts'; -import { getCursorSession, putCursorSession, deleteCursorSession } from './cursor-session-state.ts'; -import { AgentMode, type RequestContextEnv, type OpenAIToolDefinition } from './proto/index.ts'; +import { AgentMode, type AgentStreamChunk, type RequestContextEnv, type OpenAIToolDefinition } from './proto/index.ts'; import { isCursorRateLimited } from './quota.ts'; -import { deriveSessionKey, mintSessionKey, unwrapToolCallId } from './session-id.ts'; +import { deriveSessionKey, mintSessionKey, decodeToolCallId } from './session-id.ts'; import type { CursorAccountCredential } from './state.ts'; import { getDurableHttpSession, type DurableHttpSessionHandle } from '@floway-dev/platform'; import type { ChatCompletionsStreamEvent, ChatCompletionsPayload, ChatCompletionsMessage, ChatCompletionsTool } from '@floway-dev/protocols/chat-completions'; import { eventFrame, doneFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; -import { type ProviderStreamResult, type UpstreamCallOptions, type UpstreamModel } from '@floway-dev/provider'; +import { getProviderRepo, type ProviderStreamResult, type UpstreamCallOptions, type UpstreamModel } from '@floway-dev/provider'; export interface CursorCallEffects { persistRefreshTokenRotation(newRefreshToken: string): Promise; @@ -163,287 +162,244 @@ export const callCursorChatCompletions = async ( const ready = await prepareCursorCall(opts); if (!ready.ok) return { ok: false, modelKey: opts.model.id, response: ready.response }; - // Session correlation: derive sessionKey from inbound tool_call_id or header. + // Session correlation: a tool-result follow-up carries the session id in the + // echoed tool_call_id (see session-id.ts). const { sessionKey: derived, isFollowUp } = deriveSessionKey( opts.upstreamId, opts.call.apiKeyId, opts.headers, opts.body.messages, ); - const sessionKey = derived ?? mintSessionKey(opts.upstreamId, opts.call.apiKeyId); - - // Try to reuse an in-process session (tool-call follow-up continuation). - // Priority: in-process generator cache (Node fast path) > DurableHttpSession - // broker (CF path, future). If neither hits, fall through to new-session. - if (isFollowUp) { - const inProcess = getCursorSession(sessionKey); - if (inProcess) { - return await performCursorSessionFollowUp(opts, ready.accessToken, ready.checksum, null, sessionKey); - } - try { - const broker = getDurableHttpSession(); - const session = await broker.acquire(sessionKey, null, { signal: opts.signal }); - if (session) { - return await performCursorSessionFollowUp(opts, ready.accessToken, ready.checksum, session, sessionKey); - } - } catch { - // broker uninitialized or acquire error → fall through to new-session path - } + + // Follow-up: resume the live RunSSE stream (held by the DurableHttpSession), + // seeded by the {requestId, seqno} persisted in D1. A miss / busy claim / + // lost socket returns null → fall through to a fresh open (cold-resume: + // cursor re-runs the agent loop with the full transcript). + if (isFollowUp && derived) { + const resumed = await performResume(opts, ready.accessToken, ready.checksum, derived); + if (resumed) return resumed; } - return await performCursorChatCall(opts, ready.accessToken, ready.checksum, sessionKey); + // Open always mints a fresh session key so it never collides with a still-live + // session for the derived key (e.g. a racing concurrent follow-up). + return await performOpen(opts, ready.accessToken, ready.checksum); }; -// Build the AgentTransport + self-construct the event stream. The generator: -// - text/thinking/tool_call_* → translator → eventFrame -// - mcp exec_request → translator emits tool_calls, then BREAK (stateless -// passthru: the downstream client returns the tool result in the next -// turn, which Floway inlines into a fresh RunSSE) -// - request_context exec → answer with the gateway env, keep streaming -// - other built-in exec → reject on the write channel, keep streaming -// - done → finalize (finish_reason) + doneFrame -// - error → throw (the gateway surfaces a 502) -// -// 401 retry: prepareCursorCall already mints a fresh token, so a 401 here is -// rare. Mid-stream 401 retry is deferred (TODO cursor): the lazy generator -// can't cleanly retry once events have flowed. Step 9 will add a peek-then- -// stream retry once the real 401 shape is captured. -const performCursorChatCall = async ( +// Claim-lock TTL for the D1 single-flight: long enough to cover a turn's +// upstream round-trips, short enough that a crashed turn frees the session. +const CLAIM_TTL_MS = 60_000; + +const makeTransport = ( opts: CallCursorChatCompletionsOptions, - accessToken: string, + getAuthToken: () => string, checksum: string, - sessionKey: string, -): Promise> => { - const message = flattenMessages(opts.body.messages); - // Always advertise the tools. On a cold-resume (a tool-result follow-up that - // missed the live session) this lets cursor re-run the agent loop natively - // and re-issue the tool call, rather than degrading to a prompt-folded - // result — see flattenMessages. The native follow-up path - // (performCursorSessionFollowUp) handles the happy case on the live session. - const tools = toAgentTools(opts.body.tools); - - // Wrap the proxy-aware Fetcher so per-call latency recording still wraps each - // outbound RunSSE/BidiAppend, even though transport owns the fetch calls. - const fetchWrapper = (url: string, init: RequestInit): Promise => - opts.call.fetcher(url, init, opts.call.recordUpstreamLatency); - - const transport = new AgentTransport({ - accessToken, +): AgentTransport => + new AgentTransport({ + getAuthToken, baseUrl: CURSOR_BACKEND_BASE, env: gatewayEnv, clientVersion: CURSOR_CLIENT_VERSION, getChecksum: () => checksum, - fetch: fetchWrapper as unknown as typeof fetch, - }); - - const id = `chatcmpl-cursor-${crypto.randomUUID()}`; - const created = Math.floor(Date.now() / 1000); - - const translator = createAgentTranslator({ - id, - model: opts.model.id, - created, - composer: isComposerModel(opts.model.id), - sessionKey, + // The BidiAppend write channel goes through the proxy-aware Fetcher (which + // records upstream latency); the RunSSE read is owned by the + // DurableHttpSession, not fetched here. + fetch: ((url: string, init: RequestInit) => + opts.call.fetcher(url, init, opts.call.recordUpstreamLatency)) as unknown as typeof fetch, }); - // AGENT mode keeps the turn open (the backend expects an agent loop to - // continue — tool calls, checkpoints — and does not emit turn_ended on a - // simple reply). ASK mode is the direct Q&A turn shape: the backend emits - // text then turn_ended, which is what a Chat Completions caller expects. - // Tool-calling turns stay in AGENT mode so the model can drive its loop. - const mode = tools && tools.length > 0 ? AgentMode.AGENT : AgentMode.ASK; - const gen = transport.openChatStream({ message, model: opts.model.id, tools, mode }); - - // Kick off the generator before returning ok=true: the first .next() awaits - // the RunSSE fetch + the initial BidiAppend RunRequest, so the gateway's - // recordUpstreamLatency wraps a real upstream round-trip. The recorder is - // asserted at the ok=true boundary in providerStreamResultToExecuteResult, - // so a fully-lazy generator (fetch deferred until the consumer pulls) would - // fail that check. The first chunk is held back and re-yielded so streaming - // stays intact. - const first = await gen.next(); - - const events = async function* (): AsyncGenerator> { - let sessionSaved = false; +// Shared turn driver: pump the transport gen → translator → SSE frames, and +// dispose of the DurableHttpSession + D1 row at the right boundary: +// - mcp exec → tool_calls + pause: persist {requestId, seqno, leftover} +// to D1, release the handle (keep the socket), stop pulling. +// - request_context / built-in → answer/reject on the write channel, continue. +// - done → discard the handle, delete the D1 row. +// - error → discard the handle, delete the D1 row, rethrow (502). +// +// Manual gen pulls (never for-await): for-await calls gen.return() on break, +// which would tear the read loop down mid-pause. +const buildEvents = ( + opts: CallCursorChatCompletionsOptions, + transport: AgentTransport, + gen: AsyncGenerator, + translator: ReturnType, + sessionKey: string, + requestId: string, + handle: DurableHttpSessionHandle, + first: IteratorResult, +): AsyncGenerator> => { + const repo = getProviderRepo().cursorSessions; + return (async function* (): AsyncGenerator> { + let paused = false; try { - // Pull from gen manually instead of for-await — for-await would call - // gen.return() on break, killing the transport's RunSSE read. We need - // the gen to stay alive after a tool_calls break so the next turn can - // continue reading from it. let iterResult = first; while (true) { if (iterResult.done) break; const chunk = iterResult.value; - if (chunk.type === 'error') { - throw new Error(chunk.error ?? 'Cursor agent stream error'); - } + if (chunk.type === 'error') throw new Error(chunk.error ?? 'Cursor agent stream error'); if (chunk.type === 'done') break; if (chunk.type === 'exec_request' && chunk.execRequest) { const exec = chunk.execRequest; if (exec.type === 'mcp') { for (const ev of translator.translate(chunk)) yield eventFrame(ev); - // Save the live session for follow-up tool result continuation. - // The gen stays open (NOT returned) so the RunSSE read stays alive. - // Key pendingExecs by the cleaned tool_call_id (no newline) — - // matches what the translator emits (cleanCallId) and what the - // client will echo back (after unwrap). - const cleanedTcId = exec.toolCallId.split('\n')[0]!.trim(); - const pendingExecs = new Map(); - pendingExecs.set(cleanedTcId, { id: exec.id, execId: exec.execId }); - putCursorSession({ - gen, - sendMcpResult: (id, execId, result) => transport.sendMcpResult({ type: 'mcp', id, execId, name: exec.name, args: exec.args, toolCallId: exec.toolCallId, providerIdentifier: exec.providerIdentifier, toolName: exec.toolName }, result), - sendResumeAction: () => transport.sendResumeAction(), - pendingExecs, + // Pause point: persist the scalars a (possibly cross-instance) + // resume needs. The exec id+execId travel in the tool_call_id, so + // only requestId/seqno/leftover go to D1. + await repo.put({ sessionKey, - lastActivityAt: Date.now(), + requestId, + appendSeqno: Number(transport.seqno), + leftover: transport.leftover, }); - sessionSaved = true; + paused = true; break; } if (exec.type === 'request_context') { await transport.sendRequestContextResult(exec.id, exec.execId); + iterResult = await gen.next(); continue; } await transport.sendRejectedTool(exec, 'Floway gateway cannot execute built-in tools'); + iterResult = await gen.next(); continue; } for (const ev of translator.translate(chunk)) yield eventFrame(ev); - - // Advance to the next chunk manually (no for-await, no auto-return). iterResult = await gen.next(); } - } finally { - // Only abort the transport if we did NOT save the session for follow-up. - // When session is saved, the generator stays open for the next turn. - if (!sessionSaved) { - await gen.return(undefined); - } + } catch (err) { + await gen.return(undefined).catch(() => {}); + await handle.discard('stream error').catch(() => {}); + await repo.delete(sessionKey).catch(() => {}); + throw err; + } + + // gen.return() releases the read lock; leftover was already captured before + // the pause yield, so this is safe. + await gen.return(undefined).catch(() => {}); + if (paused) { + await handle.release().catch(() => {}); // keep the socket; D1 row persisted + } else { + await handle.discard('turn ended').catch(() => {}); + await repo.delete(sessionKey).catch(() => {}); } for (const ev of translator.finalize()) yield eventFrame(ev); yield doneFrame(); - }; + })(); +}; + +// Open a fresh turn: mint a session key, acquire the RunSSE read stream from +// the DurableHttpSession, send the RunRequest on BidiAppend, then drive. +const performOpen = async ( + opts: CallCursorChatCompletionsOptions, + accessToken: string, + checksum: string, +): Promise> => { + const sessionKey = mintSessionKey(opts.upstreamId, opts.call.apiKeyId); + const message = flattenMessages(opts.body.messages); + // Always advertise the tools: a cold-resume (tool-result follow-up that lost + // its session) lets cursor re-run the agent loop natively rather than degrade + // to a prompt-folded result. + const tools = toAgentTools(opts.body.tools); + // AGENT mode keeps the loop open for tool calls; ASK is the direct Q&A shape. + const mode = tools && tools.length > 0 ? AgentMode.AGENT : AgentMode.ASK; + + const transport = makeTransport(opts, () => accessToken, checksum); + const requestId = crypto.randomUUID(); + transport.seed(requestId, 0n); + + const broker = getDurableHttpSession(); + let handle: DurableHttpSessionHandle | null; + try { + handle = await opts.call.recordUpstreamLatency( + broker.acquire(sessionKey, transport.runSseInit(requestId), { signal: opts.signal }), + ); + } catch (err) { + const m = err instanceof Error ? err.message : String(err); + return { ok: false, modelKey: opts.model.id, response: await opts.call.recordUpstreamLatency(Promise.resolve(synthetic503(`Cursor RunSSE dial failed: ${m}`))) }; + } + if (!handle) { + return { ok: false, modelKey: opts.model.id, response: await opts.call.recordUpstreamLatency(Promise.resolve(synthetic503('Cursor session broker unavailable'))) }; + } + + // The RunSSE read is owned by the DurableHttpSession, so a non-200 upstream + // status surfaces here (not in the transport). Surface it as a thrown stream + // error so the gateway returns a 502. + if (handle.status !== 200) { + const bodyText = await new Response(handle.body).text().catch(() => ''); + await handle.discard(`RunSSE status ${handle.status}`).catch(() => {}); + const events = (async function* (): AsyncGenerator> { + throw new Error(`SSE stream failed: ${handle.status} - ${bodyText}`); + })(); + return { ok: true, events, modelKey: opts.model.id }; + } - return { ok: true, events: events(), modelKey: opts.model.id }; + const id = `chatcmpl-cursor-${crypto.randomUUID()}`; + const created = Math.floor(Date.now() / 1000); + const translator = createAgentTranslator({ id, model: opts.model.id, created, composer: isComposerModel(opts.model.id), sessionKey }); + + const gen = transport.openChatStream({ readStream: handle.body, request: { message, model: opts.model.id, tools, mode } }); + // First pull sends the RunRequest (BidiAppend, via the latency-recording + // fetcher) + reads the first chunk, satisfying the ok=true latency assertion. + const first = await gen.next(); + return { ok: true, events: buildEvents(opts, transport, gen, translator, sessionKey, requestId, handle, first), modelKey: opts.model.id }; }; /** - * Follow-up on an existing in-process session (tool result continuation). - * The transport's gen is still alive in cursor-session-state; we send - * ExecMcpResult via the saved transport methods, then sendResumeAction - * so cursor continues streaming, and pump the gen for the follow-up reply. + * Resume a tool-result follow-up on the live RunSSE stream held by the + * DurableHttpSession, seeded by the {requestId, seqno, leftover} persisted in + * D1. Returns null when the session can't be resumed (no row / busy claim / + * lost socket) so the caller cold-resumes via a fresh open(). + * + * Token freshness: the access token is re-minted on this request's isolate and + * injected via getAuthToken, so a follow-up arriving after the prior turn's + * token expired still authenticates. */ -const performCursorSessionFollowUp = async ( +const performResume = async ( opts: CallCursorChatCompletionsOptions, - _accessToken: string, - _checksum: string, - session: DurableHttpSessionHandle | null, + accessToken: string, + checksum: string, sessionKey: string, -): Promise> => { - // Release the DurableHttpSession handle if present — we use the in-process gen. - if (session) await session.release(); - - const entry = getCursorSession(sessionKey); - if (!entry) { - // Session evicted between acquire and here — fallback to new session. - return await performCursorChatCall(opts, _accessToken, _checksum, sessionKey); +): Promise | null> => { + const repo = getProviderRepo().cursorSessions; + // Single-flight: claim atomically locks the row (or returns null if missing / + // already claimed by a racing follow-up → cold-resume). + const row = await repo.claim(sessionKey, CLAIM_TTL_MS); + if (!row) return null; + + const broker = getDurableHttpSession(); + let handle: DurableHttpSessionHandle | null; + try { + handle = await opts.call.recordUpstreamLatency(broker.acquire(sessionKey, null, { signal: opts.signal })); + } catch { + handle = null; + } + if (!handle) { + // Read socket gone (idle-evicted / 15-min cap / cross-instance miss) → drop + // the stale row and cold-resume. + await repo.delete(sessionKey).catch(() => {}); + return null; } - // Match incoming role:'tool' messages to pending exec requests. - // Wrap the BidiAppend calls in this request's recordUpstreamLatency so the - // gateway's recorder sees a real upstream round-trip on this request (not - // the first request's recorder, which belongs to a different UpstreamCallOptions). - const toolMessages = opts.body.messages.filter(m => m.role === 'tool' && typeof m.tool_call_id === 'string'); + const transport = makeTransport(opts, () => accessToken, checksum); + transport.seed(row.requestId, BigInt(row.appendSeqno)); + // Send each tool result on the same stream: the cursor exec ref (id + execId) + // is decoded from the client's echoed tool_call_id — no server-side map. + const toolMessages = opts.body.messages.filter(m => m.role === 'tool' && typeof m.tool_call_id === 'string'); const sendToolResults = async (): Promise => { for (const toolMsg of toolMessages) { - const wrappedId = toolMsg.tool_call_id!; - const unwrapped = unwrapToolCallId(wrappedId); - const pending = entry.pendingExecs.get(unwrapped) ?? entry.pendingExecs.get(wrappedId); - if (!pending) continue; - + const ref = decodeToolCallId(toolMsg.tool_call_id!); + if (!ref) continue; const content = typeof toolMsg.content === 'string' ? toolMsg.content : JSON.stringify(toolMsg.content); - await entry.sendMcpResult(pending.id, pending.execId, { success: { content } }); - entry.pendingExecs.delete(unwrapped); - entry.pendingExecs.delete(wrappedId); + await transport.sendMcpResultRaw(ref.id, ref.execId, { success: { content } }); } - // NO ResumeAction: cursor auto-resumes the model turn on the live RunSSE - // read channel once it receives the mcp_result (matches the validated - // HTTP/2 reference — see AgentTransport.sendExecResultNoClose). Sending a - // ResumeAction here makes the server finalize the turn instead, producing - // an empty follow-up reply. }; - - // Record the BidiAppend round-trips against THIS request's latency recorder. await opts.call.recordUpstreamLatency(sendToolResults()); - // Now pump the gen for the continuation. const id = `chatcmpl-cursor-${crypto.randomUUID()}`; const created = Math.floor(Date.now() / 1000); - const translator = createAgentTranslator({ - id, - model: opts.model.id, - created, - composer: isComposerModel(opts.model.id), - sessionKey, - }); - - // Kick off the first read to satisfy recordUpstreamLatency requirement. - // The bidiAppend calls above already went through fetcher (latency recorded). - const first = await entry.gen.next(); + const translator = createAgentTranslator({ id, model: opts.model.id, created, composer: isComposerModel(opts.model.id), sessionKey }); - const events = async function* (): AsyncGenerator> { - let sessionSavedAgain = false; - try { - // Manual pull (NOT for-await): for-await calls entry.gen.return() on - // break, which kills the transport's RunSSE read and prevents any - // further tool-result continuation on this session. - let iterResult = first; - while (true) { - if (iterResult.done) break; - const chunk = iterResult.value; - if (chunk.type === 'error') { - deleteCursorSession(sessionKey); - throw new Error(chunk.error ?? 'Cursor agent stream error'); - } - if (chunk.type === 'done') break; - - if (chunk.type === 'exec_request' && chunk.execRequest) { - const exec = chunk.execRequest; - if (exec.type === 'mcp') { - for (const ev of translator.translate(chunk)) yield eventFrame(ev); - // Save again for the next follow-up. Key by the cleaned id (no - // embedded newline) to match the translator's emitted tool_call_id - // and what the client echoes back after unwrap. - const cleanedTcId = exec.toolCallId.split('\n')[0]!.trim(); - entry.pendingExecs.set(cleanedTcId, { id: exec.id, execId: exec.execId }); - entry.lastActivityAt = Date.now(); - sessionSavedAgain = true; - break; - } - if (exec.type === 'request_context') { - // request_context only appears on the first turn (the agent loop is - // already bootstrapped on a follow-up). Skip — rare in practice. - iterResult = await entry.gen.next(); - continue; - } - iterResult = await entry.gen.next(); - continue; - } - - for (const ev of translator.translate(chunk)) yield eventFrame(ev); - iterResult = await entry.gen.next(); - } - } finally { - if (!sessionSavedAgain) { - deleteCursorSession(sessionKey); - } - } - - for (const ev of translator.finalize()) yield eventFrame(ev); - yield doneFrame(); - }; - - return { ok: true, events: events(), modelKey: opts.model.id }; + const gen = transport.resumeChatStream({ readStream: handle.body, leftover: row.leftover }); + const first = await gen.next(); + return { ok: true, events: buildEvents(opts, transport, gen, translator, sessionKey, row.requestId, handle, first), modelKey: opts.model.id }; }; diff --git a/packages/provider-cursor/src/fetch_test.ts b/packages/provider-cursor/src/fetch_test.ts index 1824a975b..66a215100 100644 --- a/packages/provider-cursor/src/fetch_test.ts +++ b/packages/provider-cursor/src/fetch_test.ts @@ -3,11 +3,29 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { callCursorChatCompletions, type CursorCallEffects } from './fetch.ts'; import { addConnectEnvelope, encodeMessageField, encodeStringField } from './proto/index.ts'; import type { CursorAccessTokenEntry, CursorAccountCredential, CursorUpstreamState } from './state.ts'; +import { initDurableHttpSession, resetDurableHttpSessionForTesting, type DurableHttpSession } from '@floway-dev/platform'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { initProviderRepo, type UpstreamModel, type UpstreamRecord } from '@floway-dev/provider'; import { noopUpstreamCallOptions, stubUpstreamModel } from '@floway-dev/test-utils'; +// Minimal DurableHttpSession that does the RunSSE fetch via globalThis.fetch +// (which the tests mock), so the read stream flows through the same channel the +// suite already controls. Mirrors the in-process impl's open() shape. +const testDurableHttpSession: DurableHttpSession = { + async acquire(_sessionKey, init) { + if (!init) return null; // resume miss → cold-resume (claim returns null first anyway) + const resp = await globalThis.fetch(init.url, { method: init.method, headers: init.headers, body: init.body as BodyInit }); + return { + status: resp.status, + headers: resp.headers, + body: resp.body ?? new ReadableStream({ start: c => c.close() }), + release: async () => {}, + discard: async () => {}, + }; + }, +}; + const makeEffects = (): CursorCallEffects => ({ persistRefreshTokenRotation: vi.fn(async () => {}), persistTerminalState: vi.fn(async () => {}), @@ -51,8 +69,10 @@ let currentRecord: UpstreamRecord; beforeEach(() => { vi.useRealTimers(); + initDurableHttpSession(testDurableHttpSession); currentRecord = makeRecord({ accounts: [{ ...activeAccount }] }); initProviderRepo(() => ({ + cursorSessions: { claim: async () => null, put: async () => {}, delete: async () => {} }, upstreams: { getById: async () => currentRecord, saveState: async (_id, newState) => { @@ -63,7 +83,10 @@ beforeEach(() => { })); }); -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + resetDurableHttpSessionForTesting(); + vi.restoreAllMocks(); +}); // AgentServerMessage { field 1: InteractionUpdate { field 1: TextDeltaUpdate { field 1: text } } } function textFrame(text: string): Uint8Array { diff --git a/packages/provider-cursor/src/session-id.ts b/packages/provider-cursor/src/session-id.ts index 8b600287f..4657568d0 100644 --- a/packages/provider-cursor/src/session-id.ts +++ b/packages/provider-cursor/src/session-id.ts @@ -73,13 +73,35 @@ function bareId(sessionKey: string): string { return sessionKey.split(':').pop()!; } -/** Wrap: cursor's tool_call_id → OpenAI-facing id with session prefix. */ -export function wrapToolCallId(sessionKey: string, cursorCallId: string): string { - return `sess_${bareId(sessionKey)}__${cursorCallId}`; +/** The cursor exec identity needed to build an ExecMcpResult on a follow-up. */ +export interface ExecRef { + /** ExecClientMessage.id (field 1). */ + id: number; + /** exec_id (field 15); may be absent. */ + execId: string | undefined; } -/** Unwrap: OpenAI client's tool_call_id → original cursor id. */ -export function unwrapToolCallId(wrapped: string): string { +/** + * Wrap: encode the session id AND the cursor exec identity (id + exec_id) into + * the OpenAI-facing tool_call_id. The OpenAI protocol echoes tool_call_id back + * verbatim on the follow-up tool message, so the follow-up — possibly on a + * different instance — can rebuild the ExecMcpResult straight from the client's + * id, with no server-side pending-exec map. `__` is the delimiter; cursor ids + * use single underscores only (`call_…`, `fc_…`). + */ +export function wrapToolCallId(sessionKey: string, exec: ExecRef): string { + return `sess_${bareId(sessionKey)}__${exec.id}__${exec.execId ?? ''}`; +} + +/** Decode the cursor exec identity from a wrapped tool_call_id, or null. */ +export function decodeToolCallId(wrapped: string): ExecRef | null { const m = TOOL_CALL_PREFIX_RE.exec(wrapped); - return m ? wrapped.slice(m[0].length) : wrapped; + if (!m) return null; + const rest = wrapped.slice(m[0].length); // `${id}__${execId}` + const sep = rest.indexOf('__'); + const idStr = sep === -1 ? rest : rest.slice(0, sep); + const execIdStr = sep === -1 ? '' : rest.slice(sep + 2); + const id = Number(idStr); + if (!Number.isInteger(id)) return null; + return { id, execId: execIdStr === '' ? undefined : execIdStr }; } diff --git a/packages/provider-cursor/src/session-id_test.ts b/packages/provider-cursor/src/session-id_test.ts index 60c450f00..d8b2f1f37 100644 --- a/packages/provider-cursor/src/session-id_test.ts +++ b/packages/provider-cursor/src/session-id_test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import { deriveSessionKey, mintSessionKey, wrapToolCallId, unwrapToolCallId } from './session-id.ts'; +import { deriveSessionKey, mintSessionKey, wrapToolCallId, decodeToolCallId } from './session-id.ts'; describe('deriveSessionKey', () => { test('priority 1: X-Floway-Conversation-Id header wins', () => { @@ -70,23 +70,25 @@ describe('mintSessionKey', () => { }); }); -describe('wrapToolCallId / unwrapToolCallId', () => { - test('round-trips correctly', () => { +describe('wrapToolCallId / decodeToolCallId', () => { + test('encodes the session id + exec ref and round-trips', () => { const sessionKey = 'cursor:up1:ak1:auto:abc123def456'; - const cursorId = 'call_xyz'; - const wrapped = wrapToolCallId(sessionKey, cursorId); - expect(wrapped).toBe('sess_abc123def456__call_xyz'); - expect(unwrapToolCallId(wrapped)).toBe(cursorId); + const wrapped = wrapToolCallId(sessionKey, { id: 7, execId: 'fc_01c10a8' }); + expect(wrapped).toBe('sess_abc123def456__7__fc_01c10a8'); + // deriveSessionKey still recovers the session id from the prefix. + expect(deriveSessionKey('up1', 'ak1', new Headers(), [{ role: 'tool', tool_call_id: wrapped, content: 'x' }]).sessionKey) + .toBe('cursor:up1:ak1:auto:abc123def456'); + expect(decodeToolCallId(wrapped)).toEqual({ id: 7, execId: 'fc_01c10a8' }); }); - test('unwrap passes through ids without the sess_ prefix', () => { - expect(unwrapToolCallId('call_plain')).toBe('call_plain'); + test('round-trips an absent execId', () => { + const wrapped = wrapToolCallId('cursor:up1:ak1:auto:aabb', { id: 3, execId: undefined }); + expect(wrapped).toBe('sess_aabb__3__'); + expect(decodeToolCallId(wrapped)).toEqual({ id: 3, execId: undefined }); }); - test('unwrap handles newline-contaminated cursor ids', () => { - const sessionKey = 'cursor:up1:ak1:auto:aabb'; - const cursorId = 'call_x\nfc_y'; - const wrapped = wrapToolCallId(sessionKey, cursorId); - expect(unwrapToolCallId(wrapped)).toBe(cursorId); + test('decode returns null for ids without the sess_ prefix or a non-numeric id', () => { + expect(decodeToolCallId('call_plain')).toBeNull(); + expect(decodeToolCallId('sess_aabb__notanumber__fc_1')).toBeNull(); }); }); diff --git a/packages/provider/src/index.ts b/packages/provider/src/index.ts index f7b6c2a04..c02f4a678 100644 --- a/packages/provider/src/index.ts +++ b/packages/provider/src/index.ts @@ -53,7 +53,7 @@ export type { } from './provider.ts'; export { streamingProviderCall, type ProviderStreamParser } from './streaming.ts'; -export type { ProviderRepo, UpstreamsRepoSlim } from './repo.ts'; +export type { CursorSessionRow, CursorSessionsRepoSlim, ProviderRepo, UpstreamsRepoSlim } from './repo.ts'; export { getProviderRepo, initProviderRepo } from './repo.ts'; export { diff --git a/packages/provider/src/repo.ts b/packages/provider/src/repo.ts index c6800bfd0..1c7b207f6 100644 --- a/packages/provider/src/repo.ts +++ b/packages/provider/src/repo.ts @@ -8,8 +8,35 @@ export interface UpstreamsRepoSlim { saveState(id: string, newState: unknown, options: { expectedState: unknown }): Promise<{ updated: boolean }>; } +// Cross-instance Cursor session scalars (the durable half of a live agent +// turn — the read stream lives in the DurableHttpSession). Persisted in D1 so +// a tool-result follow-up landing on a different isolate can resume the +// upstream RunSSE without the in-process transport. See migration +// 0047_cursor_sessions.sql. Structurally matched by the gateway SqlRepo. +export interface CursorSessionRow { + sessionKey: string; + requestId: string; + /** Next BidiAppend seqno. bigint in the transport; a safe JS number here. */ + appendSeqno: number; + /** RunSSE bytes read past the exec_mcp frame but unconsumed (usually empty). */ + leftover: Uint8Array | null; +} + +export interface CursorSessionsRepoSlim { + /** + * Atomically claim (lock for ttlMs) and return the row — single-flight so two + * concurrent follow-ups can't both drive the same stream. Returns null if the + * session is missing or already claimed (caller cold-resumes). + */ + claim(sessionKey: string, ttlMs: number): Promise; + /** Upsert the scalars, clear the claim lock, and refresh the sweep timestamp. */ + put(row: CursorSessionRow): Promise; + delete(sessionKey: string): Promise; +} + export interface ProviderRepo { upstreams: UpstreamsRepoSlim; + cursorSessions: CursorSessionsRepoSlim; } let _accessor: (() => ProviderRepo) | null = null; diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index 18ecd9692..7113212be 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -9,4 +9,4 @@ export { assertThrows, } from './assert.ts'; export { jsonResponse, sseResponse, withMockedFetch } from './mock-fetch.ts'; -export { noopUpstreamCallOptions, stubProvider, stubProviderCandidate, stubUpstreamModel, testTelemetryModelIdentity } from './stubs.ts'; +export { noopCursorSessionsRepo, noopUpstreamCallOptions, stubProvider, stubProviderCandidate, stubUpstreamModel, testTelemetryModelIdentity } from './stubs.ts'; diff --git a/packages/test-utils/src/stubs.ts b/packages/test-utils/src/stubs.ts index 19021cb4a..1d0aacee7 100644 --- a/packages/test-utils/src/stubs.ts +++ b/packages/test-utils/src/stubs.ts @@ -1,4 +1,12 @@ -import { directFetcher, type ChatTargetApi, type ModelProvider, type ModelProviderInstance, type ProviderCandidate, type ProviderModelRecord, type TelemetryModelIdentity, type UpstreamCallOptions, type UpstreamModel } from '@floway-dev/provider'; +import { directFetcher, type ChatTargetApi, type CursorSessionsRepoSlim, type ModelProvider, type ModelProviderInstance, type ProviderCandidate, type ProviderModelRecord, type TelemetryModelIdentity, type UpstreamCallOptions, type UpstreamModel } from '@floway-dev/provider'; + +// No-op cursor-sessions slim repo for provider tests that wire initProviderRepo +// but don't exercise cross-instance session reuse. Always misses on claim. +export const noopCursorSessionsRepo = (): CursorSessionsRepoSlim => ({ + claim: async () => null, + put: async () => {}, + delete: async () => {}, +}); // No-op UpstreamCallOptions factory for tests calling provider methods // directly: identity recordUpstreamLatency satisfies the contract without From 7fb4fe6314b03200470361c61f4711425a617624 Mon Sep 17 00:00:00 2001 From: yyyr Date: Wed, 1 Jul 2026 05:17:40 +0800 Subject: [PATCH 14/79] fix(provider-cursor): fix cross-instance resume frame loss + Phase 4 proxy dial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-instance resume produced an intermittent empty turn-2 (cursor received a byte-perfect ExecMcpResult but only heartbeated, never generating). Root cause was frame loss in the in-process DurableHttpSession view handoff between turns — a dropped KV(set_blob) frame went un-acked, wedging cursor's blob channel into an empty continuation. Two races: 1. release() left the stale per-view dequeue waiter set, so the pump delivered turn-2's first chunk to the dead view A. Now release() resolves+clears it so the chunk buffers for the next acquirer. 2. the per-view ReadableStream's default highWaterMark of 1 pulled ahead speculatively. Set highWaterMark 0 so it pulls 1:1 with reads (mirrors the old direct-socket read). CF DurableHttpSessionDO gets the parity fix: release() nulls the consumer synchronously instead of waiting for the WS 'close' event (safeSend drops, not buffers, a chunk whose send throws). [CF path pending Phase 5 wrangler e2e.] Verified: old in-process flow 6/6 reliable (cursor not flaky); new flow 8/8 + 3-turn 4/4 after fix. Includes Phase 4: DHS dials RunSSE through @floway-dev/proxy (streaming) on Node + CF, provider resolves the proxy list per upstream. typecheck/lint green; cursor 138, platform-node 50, platform-cloudflare 43, gateway 1461 tests pass. --- apps/platform-cloudflare/package.json | 1 + .../src/durable-http-session-do.ts | 45 ++++++++++++--- apps/platform-node/package.json | 1 + .../src/in-process-durable-http-session.ts | 56 +++++++++++++++++-- packages/gateway/src/repo/index.ts | 35 +++++++++++- packages/platform/src/durable-http-session.ts | 9 +++ packages/provider-cursor/src/fetch.ts | 7 ++- packages/provider/src/repo.ts | 12 ++++ pnpm-lock.yaml | 6 ++ 9 files changed, 153 insertions(+), 19 deletions(-) diff --git a/apps/platform-cloudflare/package.json b/apps/platform-cloudflare/package.json index 6648752f9..b54fa6660 100644 --- a/apps/platform-cloudflare/package.json +++ b/apps/platform-cloudflare/package.json @@ -10,6 +10,7 @@ "@floway-dev/gateway": "workspace:*", "@floway-dev/http": "workspace:*", "@floway-dev/platform": "workspace:*", + "@floway-dev/proxy": "workspace:*", "@floway-dev/protocols": "workspace:*", "hono": "^4" }, diff --git a/apps/platform-cloudflare/src/durable-http-session-do.ts b/apps/platform-cloudflare/src/durable-http-session-do.ts index 953625622..328a950fb 100644 --- a/apps/platform-cloudflare/src/durable-http-session-do.ts +++ b/apps/platform-cloudflare/src/durable-http-session-do.ts @@ -3,6 +3,8 @@ import { DurableObject } from 'cloudflare:workers'; import { HeldSocket } from './_held-socket.ts'; import { cloudflareSocketDial } from './socket-dial.ts'; import { fetchOnStream } from '@floway-dev/http'; +import { normalizeDialHost } from '@floway-dev/platform'; +import { runProxiedRequest, type ProxyConfig } from '@floway-dev/proxy'; // DurableHttpSessionDO — one instance per sessionKey. It is the first actor in // the workspace that owns a live OUTBOUND socket: it dials the upstream with @@ -26,6 +28,8 @@ export interface DurableHttpSessionStartInit { url: string; headers: Record; body?: Uint8Array; + /** Opaque ProxyConfig[] to dial through (see DurableHttpSessionInit.proxies). */ + proxies?: unknown[]; } export interface DurableHttpSessionStartResult { @@ -84,21 +88,37 @@ export class DurableHttpSessionDO extends DurableObject { const tls = url.protocol === 'https:'; const port = url.port ? Number(url.port) : (tls ? 443 : 80); - const dialed = await cloudflareSocketDial.connect(url.hostname, port, { tls }); - this.held = new HeldSocket(dialed); - const path = `${url.pathname}${url.search}`; // Host is mandatory on HTTP/1.1; derive it from the URL rather than // trusting the caller to pass it (fetchOnStream validates/forwards the rest). const headers: Record = { Host: url.host, ...init.headers }; let response: Response; try { - response = await fetchOnStream(this.held.asDuplex(), { - method: init.method, - path, - headers, - body: init.body, - }); + const proxies = (init.proxies ?? []) as ProxyConfig[]; + if (proxies.length > 0) { + // Proxied dial (streaming): runProxiedRequest composes dial → TLS → + // fetch-on-stream over the CF socket dial. Try each in order. No + // HeldSocket: discard cancels the response body, which tears down the + // proxied transport. + const target = { host: normalizeDialHost(url.hostname), port, tls }; + const request = { method: init.method, path, headers, body: init.body }; + let lastErr: unknown; + let proxied: Response | null = null; + for (const config of proxies) { + try { proxied = await runProxiedRequest(config, target, request, { socketDial: cloudflareSocketDial }); break; } catch (err) { lastErr = err; } + } + if (!proxied) throw lastErr ?? new Error('all proxies failed for DurableHttpSessionDO dial'); + response = proxied; + } else { + const dialed = await cloudflareSocketDial.connect(url.hostname, port, { tls }); + this.held = new HeldSocket(dialed); + response = await fetchOnStream(this.held.asDuplex(), { + method: init.method, + path, + headers, + body: init.body, + }); + } } catch (err) { await this.discard(`upstream dial/request failed: ${err instanceof Error ? err.message : String(err)}`); throw err; @@ -203,6 +223,13 @@ export class DurableHttpSessionDO extends DurableObject { * consumer reference also clears on its own WS 'close' event. */ async release(): Promise { + // Detach synchronously rather than waiting for the WS 'close' event. If we + // left this.consumer set, a chunk the pump reads in the gap between release + // and the close event would be safeSent to the detaching socket — and + // safeSend drops (does not buffer) a chunk whose send throws, losing it + // across the turn handoff. Nulling now makes the pump buffer for the next + // acquire instead. The provider side closes its WS end independently. + this.consumer = null; this.touch(); } diff --git a/apps/platform-node/package.json b/apps/platform-node/package.json index 8ba71c664..26e185017 100644 --- a/apps/platform-node/package.json +++ b/apps/platform-node/package.json @@ -14,6 +14,7 @@ "@floway-dev/gateway": "workspace:*", "@floway-dev/http": "workspace:*", "@floway-dev/platform": "workspace:*", + "@floway-dev/proxy": "workspace:*", "@floway-dev/protocols": "workspace:*", "@hono/node-server": "^2.0.4", "hono": "^4", diff --git a/apps/platform-node/src/in-process-durable-http-session.ts b/apps/platform-node/src/in-process-durable-http-session.ts index b42c49bc0..e3eefee47 100644 --- a/apps/platform-node/src/in-process-durable-http-session.ts +++ b/apps/platform-node/src/in-process-durable-http-session.ts @@ -1,12 +1,43 @@ import { + getSocketDial, + normalizeDialHost, type DurableHttpSession, type DurableHttpSessionAcquireOptions, type DurableHttpSessionHandle, type DurableHttpSessionInit, } from '@floway-dev/platform'; +import { runProxiedRequest, type ProxyConfig } from '@floway-dev/proxy'; const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; +// Dial the upstream: direct via globalThis.fetch (undici), or — when the init +// carries proxies — through them in order via runProxiedRequest (which streams, +// unlike the gateway's buffered proxy Fetcher), falling back to the next on a +// dial failure. +const dialUpstream = async (init: DurableHttpSessionInit): Promise => { + const proxies = (init.proxies ?? []) as ProxyConfig[]; + if (proxies.length === 0) { + return await globalThis.fetch(init.url, { + method: init.method, + headers: init.headers, + body: init.body ? (init.body as BodyInit) : null, + }); + } + const u = new URL(init.url); + const target = { host: normalizeDialHost(u.hostname), port: u.port ? Number(u.port) : (u.protocol === 'https:' ? 443 : 80), tls: u.protocol === 'https:' }; + const request = { method: init.method, path: `${u.pathname}${u.search}`, headers: init.headers, body: init.body }; + const socketDial = getSocketDial(); + let lastErr: unknown; + for (const config of proxies) { + try { + return await runProxiedRequest(config, target, request, { socketDial }); + } catch (err) { + lastErr = err; + } + } + throw lastErr ?? new Error('all proxies failed for DurableHttpSession dial'); +}; + interface Entry { response: Response; reader: ReadableStreamDefaultReader; @@ -39,11 +70,7 @@ export class InProcessDurableHttpSession implements DurableHttpSession { if (!entry) { if (init === null) return null; const lock = (async (): Promise => { - const response = await globalThis.fetch(init.url, { - method: init.method, - headers: init.headers, - body: init.body ? (init.body as BodyInit) : null, - }); + const response = await dialUpstream(init); if (!response.body) { throw new Error(`InProcessDurableHttpSession: ${init.method} ${init.url} returned no body`); } @@ -160,6 +187,13 @@ export class InProcessDurableHttpSession implements DurableHttpSession { else signal.addEventListener('abort', onAbort, { once: true }); } + // highWaterMark 0: the stream only pulls to satisfy an active read() — it + // never speculatively reads ahead. Without this, the default hWM of 1 makes + // the stream pull a chunk into its own internal queue (or park a waiter) + // right after the consumer's last read, so at the exec_mcp pause a chunk the + // upstream sends next can land in the about-to-be-discarded view instead of + // the shared entry buffer — lost across the turn handoff, wedging the + // upstream. Pulling 1:1 with reads mirrors reading the socket directly. const body = new ReadableStream({ async pull(controller): Promise { if (released) { controller.close(); return; } @@ -171,7 +205,7 @@ export class InProcessDurableHttpSession implements DurableHttpSession { released = true; if (signal) signal.removeEventListener('abort', onAbort); }, - }); + }, { highWaterMark: 0 }); return { status: entry.response.status, @@ -180,6 +214,16 @@ export class InProcessDurableHttpSession implements DurableHttpSession { async release(): Promise { released = true; if (signal) signal.removeEventListener('abort', onAbort); + // Abandon any dequeue this view left pending. The stream pulls ahead, so + // at the pause point view A is usually parked on an empty-buffer waiter. + // If we leave it, the pump delivers the next chunk (the first byte the + // upstream sends after the tool result) to this dead stream — it is lost + // and never acked, wedging the upstream. Close view A's pull and null the + // slot so the next chunk buffers for the next acquirer instead. + if (entry.waiter) { + entry.waiter.resolve({ done: true }); + entry.waiter = null; + } }, async discard(reason: string): Promise { released = true; diff --git a/packages/gateway/src/repo/index.ts b/packages/gateway/src/repo/index.ts index 5c4f51720..cbe69e433 100644 --- a/packages/gateway/src/repo/index.ts +++ b/packages/gateway/src/repo/index.ts @@ -1,13 +1,42 @@ +import { DIRECT_PROXY_ID } from './proxy-fallback-list.ts'; import type { Repo } from './types.ts'; import { initProviderRepo } from '@floway-dev/provider'; +import { parseProxyUri } from '@floway-dev/proxy'; let _repo: Repo | null = null; +// Resolve an upstream's proxy fallback list into ordered ProxyConfig objects +// (direct entries + malformed/missing rows dropped) for a provider that must +// dial a streaming upstream through the proxy itself (cursor's RunSSE — the +// buffered proxy Fetcher rejects streaming bodies). Returned opaque (unknown[]) +// across the slim provider-repo boundary. +const resolveDirectDialProxies = async (repo: Repo, upstreamId: string): Promise => { + const upstream = await repo.upstreams.getById(upstreamId); + if (!upstream) return []; + const proxyIds = upstream.proxyFallbackList.filter(e => e.id !== DIRECT_PROXY_ID).map(e => e.id); + if (proxyIds.length === 0) return []; + const byId = new Map((await repo.proxies.list()).map(p => [p.id, p] as const)); + const out: unknown[] = []; + for (const id of proxyIds) { + const row = byId.get(id); + if (!row) continue; + try { out.push(parseProxyUri(row.url)); } catch { /* skip malformed row */ } + } + return out; +}; + export function initRepo(repo: Repo): void { _repo = repo; - // Hand provider-package helpers (models-store, etc.) a lazy accessor for the - // same singleton so they read the cache through the live repo. - initProviderRepo(() => getRepo()); + // Hand provider-package helpers (models-store, cursor session reuse, etc.) a + // lazy accessor for the same singleton so they read through the live repo. + initProviderRepo(() => { + const live = getRepo(); + return { + upstreams: live.upstreams, + cursorSessions: live.cursorSessions, + proxies: { resolveForUpstream: (upstreamId: string) => resolveDirectDialProxies(live, upstreamId) }, + }; + }); } export function getRepo(): Repo { diff --git a/packages/platform/src/durable-http-session.ts b/packages/platform/src/durable-http-session.ts index 88620e40c..742018dcc 100644 --- a/packages/platform/src/durable-http-session.ts +++ b/packages/platform/src/durable-http-session.ts @@ -20,6 +20,15 @@ export interface DurableHttpSessionInit { url: string; headers: Record; body?: Uint8Array; + /** + * Ordered proxy configs (opaque `@floway-dev/proxy` ProxyConfig) to dial the + * upstream through, tried in order; empty/absent = direct. The contract keeps + * this opaque so the platform package doesn't depend on @floway-dev/proxy — + * the concrete impls cast and call runProxiedRequest. Streaming responses + * (RunSSE) can't go through the gateway's buffered proxy Fetcher, so the + * session does its own proxied dial. + */ + proxies?: unknown[]; } export interface DurableHttpSessionHandle { diff --git a/packages/provider-cursor/src/fetch.ts b/packages/provider-cursor/src/fetch.ts index 54c09e070..63689bff8 100644 --- a/packages/provider-cursor/src/fetch.ts +++ b/packages/provider-cursor/src/fetch.ts @@ -306,11 +306,16 @@ const performOpen = async ( const requestId = crypto.randomUUID(); transport.seed(requestId, 0n); + // Resolve the upstream's proxies so the DurableHttpSession dials RunSSE + // through them (the buffered Fetcher can't stream through a proxy). Empty = + // direct. Absent resolver (test mocks) = direct. + const proxies = (await getProviderRepo().proxies?.resolveForUpstream(opts.upstreamId)) ?? []; + const broker = getDurableHttpSession(); let handle: DurableHttpSessionHandle | null; try { handle = await opts.call.recordUpstreamLatency( - broker.acquire(sessionKey, transport.runSseInit(requestId), { signal: opts.signal }), + broker.acquire(sessionKey, { ...transport.runSseInit(requestId), proxies }, { signal: opts.signal }), ); } catch (err) { const m = err instanceof Error ? err.message : String(err); diff --git a/packages/provider/src/repo.ts b/packages/provider/src/repo.ts index 1c7b207f6..406d0d7ce 100644 --- a/packages/provider/src/repo.ts +++ b/packages/provider/src/repo.ts @@ -37,6 +37,18 @@ export interface CursorSessionsRepoSlim { export interface ProviderRepo { upstreams: UpstreamsRepoSlim; cursorSessions: CursorSessionsRepoSlim; + /** + * Resolve an upstream's proxy fallback list into ordered, opaque + * `@floway-dev/proxy` ProxyConfig objects (direct entries dropped) for a + * provider that must dial a streaming upstream through the proxy itself + * (cursor's RunSSE — see DurableHttpSessionInit.proxies). Optional so test + * mocks that don't exercise proxying can omit it (callers fall back to direct). + */ + proxies?: ProxyResolverSlim; +} + +export interface ProxyResolverSlim { + resolveForUpstream(upstreamId: string): Promise; } let _accessor: (() => ProviderRepo) | null = null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cf0fffd3..93283db33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,9 @@ importers: '@floway-dev/protocols': specifier: workspace:* version: link:../../packages/protocols + '@floway-dev/proxy': + specifier: workspace:* + version: link:../../packages/proxy hono: specifier: ^4 version: 4.12.22 @@ -107,6 +110,9 @@ importers: '@floway-dev/protocols': specifier: workspace:* version: link:../../packages/protocols + '@floway-dev/proxy': + specifier: workspace:* + version: link:../../packages/proxy '@hono/node-server': specifier: ^2.0.4 version: 2.0.4(hono@4.12.22) From 1f09b8ac4fb3f03ce3c9cb4234271411bac8469f Mon Sep 17 00:00:00 2001 From: yyyr Date: Wed, 1 Jul 2026 19:27:12 +0800 Subject: [PATCH 15/79] fix(platform-cloudflare): credit-based DurableHttpSessionDO body channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DO pushed every upstream chunk to the attached consumer WebSocket as it arrived. Cursor emits its post-exec KV frames immediately after exec_mcp (before the ExecMcpResult), while turn-1's transport has already paused at exec_mcp and stopped reading — so those frames were forwarded to turn-1's WS, sat unread, and were lost when it closed on release. Turn-2 re-attached to an empty buffer and cursor stalled (~20% empty turn-2 in local wrangler e2e). Switch the WS body channel to credit-based (pull) flow, mirroring the Node in-process DHS (shared buffer + highWaterMark 0): - DO: deliver a buffered chunk only when the consumer credits one (one inbound WS message = one credit). A paused consumer stops crediting, so post-exec chunks stay in the DO buffer for the next acquire instead of being pushed at a closing WS. flushToConsumer leaves a chunk in the buffer if send throws (never drops); safeSend is retired. - Broker wsToByteStream: highWaterMark 0 + send one CREDIT_BYTE per pull, giving strict 1:1 pull→credit→chunk backpressure. Contract, Node impl, and FakeDurableHttpSession unchanged. Unit tests updated for the credit protocol (platform-cloudflare 44 pass); typecheck + lint green. --- .../src/do-durable-http-session.ts | 12 ++- .../src/do-durable-http-session_test.ts | 26 +++++- .../src/durable-http-session-do.ts | 87 ++++++++++++------- .../src/durable-http-session-do_test.ts | 19 +++- 4 files changed, 109 insertions(+), 35 deletions(-) diff --git a/apps/platform-cloudflare/src/do-durable-http-session.ts b/apps/platform-cloudflare/src/do-durable-http-session.ts index e5fce4352..e508d08f9 100644 --- a/apps/platform-cloudflare/src/do-durable-http-session.ts +++ b/apps/platform-cloudflare/src/do-durable-http-session.ts @@ -16,6 +16,11 @@ import type { const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; +// One credit = "send me the next chunk". The broker sends exactly one per +// ReadableStream pull; the DO treats any inbound message as a credit and ignores +// the content, so a 1-byte sentinel is enough. +const CREDIT_BYTE = new Uint8Array([1]); + // Minimal namespace/stub surface — declared locally so this file stays off // `@cloudflare/workers-types` (same convention as do-channel-broker.ts). export interface DurableHttpSessionNamespace { @@ -139,6 +144,11 @@ const wsToByteStream = ( controller.close(); return; } + // Credit-based backpressure: request exactly one chunk, then wait for it. + // With highWaterMark 0 the stream only pulls on an active read, so a + // consumer that pauses (transport parked at exec_mcp) sends no credit and + // the DO holds subsequent chunks in its buffer for the next acquire. + try { socket.send(CREDIT_BYTE); } catch { controller.close(); return; } const result = await new Promise<{ value?: Uint8Array; done: boolean }>(resolve => { pull = resolve; }); if (result.done) { if (pendingError) controller.error(pendingError); @@ -148,5 +158,5 @@ const wsToByteStream = ( if (result.value) controller.enqueue(result.value); }, cancel(): void { end(); }, - }); + }, { highWaterMark: 0 }); }; diff --git a/apps/platform-cloudflare/src/do-durable-http-session_test.ts b/apps/platform-cloudflare/src/do-durable-http-session_test.ts index 96a51fdb7..68c7ef99f 100644 --- a/apps/platform-cloudflare/src/do-durable-http-session_test.ts +++ b/apps/platform-cloudflare/src/do-durable-http-session_test.ts @@ -3,12 +3,17 @@ import { describe, expect, test, vi } from 'vitest'; import { DurableObjectDurableHttpSession, type DurableHttpSessionNamespace } from './do-durable-http-session.ts'; // Fake WebSocket the broker treats as the DO body channel. Tests drive it by -// calling emitMessage / emitClose after the broker has wired its listeners. +// calling emitMessage / emitClose after the broker has wired its listeners. The +// broker sends one credit (send) per ReadableStream pull; `credits` records them +// and `onCredit` lets a test script a frame per credit (the real DO's behavior). class FakeWebSocket { readonly listeners = new Map void>>(); + readonly credits: Uint8Array[] = []; + onCredit: (() => void) | null = null; closed: { code: number; reason: string } | null = null; accepted = false; accept(): void { this.accepted = true; } + send(data: Uint8Array): void { this.credits.push(data); this.onCredit?.(); } close(code = 1000, reason = ''): void { if (this.closed) return; this.closed = { code, reason }; @@ -94,6 +99,25 @@ describe('DurableObjectDurableHttpSession.acquire', () => { expect(chunks.flatMap(c => Array.from(c))).toEqual([1, 2, 3, 4]); }); + test('issues exactly one credit per pull (strict backpressure)', async () => { + const socket = new FakeWebSocket(); + // Respond to each credit with the next frame, then the close — modelling the + // DO's one-chunk-per-credit delivery. + const frames = [new Uint8Array([1]), new Uint8Array([2])]; + let i = 0; + socket.onCredit = () => { + queueMicrotask(() => { if (i < frames.length) socket.emitMessage(frames[i++]); else socket.emitClose(); }); + }; + const { ns } = makeNamespace({ meta: { status: 200, headers: [] }, socket }); + const broker = new DurableObjectDurableHttpSession(ns); + const handle = await broker.acquire('k', { method: 'POST', url: 'https://x', headers: {} }); + + const chunks = await drain(handle!.body); + expect(chunks.flatMap(c => Array.from(c))).toEqual([1, 2]); + // Two data chunks + the final pull that received the close = three credits. + expect(socket.credits).toHaveLength(3); + }); + test('release closes the body socket and calls stub.release', async () => { const socket = new FakeWebSocket(); const { ns, calls } = makeNamespace({ meta: { status: 200, headers: [] }, socket }); diff --git a/apps/platform-cloudflare/src/durable-http-session-do.ts b/apps/platform-cloudflare/src/durable-http-session-do.ts index 328a950fb..e4dd9a547 100644 --- a/apps/platform-cloudflare/src/durable-http-session-do.ts +++ b/apps/platform-cloudflare/src/durable-http-session-do.ts @@ -55,6 +55,9 @@ export class DurableHttpSessionDO extends DurableObject { private buffer: Uint8Array[] = []; private bufferedBytes = 0; private consumer: WebSocket | null = null; + // Chunks the current consumer has requested (one credit per broker-side + // ReadableStream pull) but not yet been sent. Reset on attach/release/discard. + private credits = 0; private idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS; private lastActivityAt = 0; @@ -138,9 +141,10 @@ export class DurableHttpSessionDO extends DurableObject { return { status: this.statusCode, headers: this.headerList }; } - // Continuously drain the upstream reader. With a consumer attached, frames - // are forwarded live; otherwise they accumulate in the buffer (bounded by - // MAX_BUFFERED_BYTES) for the next consumer to pick up mid-stream. + // Continuously drain the upstream reader into `buffer`, then hand chunks to + // the consumer only as fast as it credits them (see flushToConsumer). A + // consumer that pauses reading stops crediting, so subsequent chunks stay + // buffered for the next acquire instead of being pushed at a WS about to close. private startPump(): void { const pump = async (): Promise => { try { @@ -148,20 +152,17 @@ export class DurableHttpSessionDO extends DurableObject { const { done, value } = await this.reader!.read(); if (done) { this.done = true; - this.consumer?.close(1000, 'upstream ended'); + this.flushToConsumer(); return; } if (!value || value.byteLength === 0) continue; - if (this.consumer) { - this.safeSend(this.consumer, value); - } else { - this.buffer.push(value); - this.bufferedBytes += value.byteLength; - if (this.bufferedBytes > MAX_BUFFERED_BYTES) { - await this.discard('buffer overflow with no consumer'); - return; - } + this.buffer.push(value); + this.bufferedBytes += value.byteLength; + if (this.bufferedBytes > MAX_BUFFERED_BYTES) { + await this.discard('buffer overflow with no consumer'); + return; } + this.flushToConsumer(); } } catch (err) { this.errored = true; @@ -172,13 +173,29 @@ export class DurableHttpSessionDO extends DurableObject { void pump(); } - private safeSend(ws: WebSocket, bytes: Uint8Array): void { - try { - ws.send(bytes); - } catch { - // Consumer socket went away between the open check and the send; drop it - // so the pump falls back to buffering for the next acquire. - if (this.consumer === ws) this.consumer = null; + // Deliver buffered chunks to the consumer up to the credits it has requested + // (the broker sends one credit per ReadableStream pull). A send that throws + // leaves the chunk at the head of `buffer` — never dropped — and detaches the + // dead consumer so the next acquire resumes from the same point. Once the + // upstream is done and the buffer is fully drained, a waiting credit gets the + // end-of-stream close. + private flushToConsumer(): void { + while (this.consumer && this.credits > 0 && this.buffer.length > 0) { + const chunk = this.buffer[0]; + try { + this.consumer.send(chunk); + } catch { + this.consumer = null; + return; + } + this.buffer.shift(); + this.bufferedBytes -= chunk.byteLength; + this.credits -= 1; + } + if (this.consumer && this.credits > 0 && this.done && this.buffer.length === 0) { + try { + this.consumer.close(1000, this.errored ? 'upstream error' : 'upstream ended'); + } catch { /* already closing */ } } } @@ -200,7 +217,16 @@ export class DurableHttpSessionDO extends DurableObject { private attachConsumer(ws: WebSocket): void { this.consumer = ws; + this.credits = 0; this.touch(); + // Each inbound message is one credit — "send me one more chunk". The broker + // sends exactly one per ReadableStream pull, giving strict 1:1 backpressure. + ws.addEventListener('message', () => { + if (this.consumer !== ws) return; + this.credits += 1; + this.touch(); + this.flushToConsumer(); + }); ws.addEventListener('close', () => { if (this.consumer === ws) { this.consumer = null; @@ -210,11 +236,9 @@ export class DurableHttpSessionDO extends DurableObject { ws.addEventListener('error', () => { if (this.consumer === ws) this.consumer = null; }); - // Flush whatever accumulated since the last consumer detached. - for (const chunk of this.buffer) this.safeSend(ws, chunk); - this.buffer = []; - this.bufferedBytes = 0; - if (this.done) ws.close(1000, this.errored ? 'upstream error' : 'upstream ended'); + // No eager flush and no done-close here: delivery is credit-driven, so a + // fresh consumer starts receiving only once it pulls (credits arrive). + this.flushToConsumer(); } /** @@ -223,13 +247,13 @@ export class DurableHttpSessionDO extends DurableObject { * consumer reference also clears on its own WS 'close' event. */ async release(): Promise { - // Detach synchronously rather than waiting for the WS 'close' event. If we - // left this.consumer set, a chunk the pump reads in the gap between release - // and the close event would be safeSent to the detaching socket — and - // safeSend drops (does not buffer) a chunk whose send throws, losing it - // across the turn handoff. Nulling now makes the pump buffer for the next - // acquire instead. The provider side closes its WS end independently. + // Detach the consumer and drop its outstanding credits; the buffer is + // retained so the next acquire continues mid-stream. Because delivery is + // credit-driven, any chunk the pump reads after this point simply stays + // buffered (the paused turn never credited it), so nothing is pushed at the + // detaching socket. The provider side closes its WS end independently. this.consumer = null; + this.credits = 0; this.touch(); } @@ -238,6 +262,7 @@ export class DurableHttpSessionDO extends DurableObject { this.done = true; try { this.consumer?.close(1000, reason); } catch { /* already closing */ } this.consumer = null; + this.credits = 0; const reader = this.reader; this.reader = null; if (reader) { try { await reader.cancel(reason); } catch { /* already done */ } } diff --git a/apps/platform-cloudflare/src/durable-http-session-do_test.ts b/apps/platform-cloudflare/src/durable-http-session-do_test.ts index 4f6345997..2bd274454 100644 --- a/apps/platform-cloudflare/src/durable-http-session-do_test.ts +++ b/apps/platform-cloudflare/src/durable-http-session-do_test.ts @@ -51,6 +51,11 @@ class FakeWebSocket { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type)!.add(fn); } + // Test helper: simulate the broker sending one credit ("send me one chunk"). + // The DO's message listener ignores the payload, so any data works. + credit(): void { + for (const fn of this.listeners.get('message') ?? []) fn({ data: new Uint8Array([1]) }); + } } const createdPairs: [FakeWebSocket, FakeWebSocket][] = []; @@ -154,7 +159,7 @@ describe('DurableHttpSessionDO body channel + lifecycle', () => { expect(resp.status).toBe(409); }); - test('fetch after start upgrades to 101 and flushes buffered + live bytes', async () => { + test('fetch after start upgrades to 101 and delivers one chunk per credit', async () => { const { actor } = makeDO(); await actor.queryOrStart(POST_INIT, 1000); @@ -164,20 +169,30 @@ describe('DurableHttpSessionDO body channel + lifecycle', () => { const resp = await actor.fetch(new Request('https://durable-http.do/body')); expect(resp.status).toBe(101); const server = createdPairs[0]![1]; + // Credit-based backpressure: nothing is sent until the consumer requests it. + expect(server.sent).toEqual([]); + + server.credit(); expect(server.sent.flatMap(c => Array.from(c))).toEqual([1, 2]); + // A live chunk stays buffered until the next credit. h.ctl.bodyController!.enqueue(new Uint8Array([3])); await flushMicrotasks(); + expect(server.sent.flatMap(c => Array.from(c))).toEqual([1, 2]); + server.credit(); expect(server.sent.flatMap(c => Array.from(c))).toEqual([1, 2, 3]); }); - test('upstream end closes the consumer socket', async () => { + test('upstream end closes the consumer socket once a credit is waiting', async () => { const { actor } = makeDO(); await actor.queryOrStart(POST_INIT, 1000); await actor.fetch(new Request('https://durable-http.do/body')); const server = createdPairs[0]![1]; h.ctl.bodyController!.close(); await flushMicrotasks(); + // End-of-stream is delivered on a pull, not pushed: no credit → no close. + expect(server.closed).toBeNull(); + server.credit(); expect(server.closed).not.toBeNull(); }); From b42ee58d195091fd917cd7de80923875896f4b81 Mon Sep 17 00:00:00 2001 From: yyyr Date: Wed, 1 Jul 2026 21:28:15 +0800 Subject: [PATCH 16/79] feat(provider-cursor): record TTFT as upstream_success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor's upstream_success latency measured only the BidiAppend write round-trip (~100ms), hiding the model's queue/think/first-token time — because the RunSSE read of the first chunk was in no recordUpstreamLatency wrap. Add pullToFirstMeaningful to drain cursor's pre-output control frames (checkpoint/heartbeat/token/...) to the model's first real output frame, and wrap that span (plus tool-result send on resume) so upstream_success reflects TTFT. Skipping control frames is output-neutral; the first meaningful frame is handed to buildEvents unchanged, so exec-pause and translation stay there. Contained to provider-cursor; no gateway/platform/frontend/migration change. --- packages/provider-cursor/src/fetch.ts | 60 ++++++++++++++-- packages/provider-cursor/src/fetch_test.ts | 80 +++++++++++++++++++++- 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/packages/provider-cursor/src/fetch.ts b/packages/provider-cursor/src/fetch.ts index 63689bff8..b5e16bf76 100644 --- a/packages/provider-cursor/src/fetch.ts +++ b/packages/provider-cursor/src/fetch.ts @@ -286,6 +286,46 @@ const buildEvents = ( })(); }; +// A cursor turn opens with pre-output control frames — conversation +// checkpoints and keep-alive heartbeats cursor emits while the model is still +// queued/thinking — that arrive before the first real token. Classify the +// frame the model's first *output* rides on: real text/thinking content, any +// tool call (incl. the exec_request pause), a kv-blob content frame, or a +// terminal done/error. +const isFirstTokenFrame = (chunk: AgentStreamChunk): boolean => { + switch (chunk.type) { + // Pure control / keep-alive frames: the model has not produced output yet. + case 'checkpoint': + case 'heartbeat': + case 'interaction_query': + case 'exec_server_abort': + case 'token': + return false; + // Empty text/thinking deltas carry no token — keep waiting for real content. + case 'text': + case 'thinking': + return Boolean(chunk.content); + default: + return true; + } +}; + +// Pull the transport generator to the model's first output frame (or a terminal +// result), discarding the pre-output control frames. Skipping is output-neutral: +// those frames translate to zero downstream events, and the returned result is +// handed to buildEvents unchanged, so exec-pause / seqno persistence / all +// translation stay there. Wrapping this call in recordUpstreamLatency makes +// `upstream_success` measure TTFT (request submitted → first token) instead of +// the BidiAppend write round-trip the earlier wraps saw. +export const pullToFirstMeaningful = async ( + gen: AsyncGenerator, +): Promise> => { + for (;;) { + const result = await gen.next(); + if (result.done || isFirstTokenFrame(result.value)) return result; + } +}; + // Open a fresh turn: mint a session key, acquire the RunSSE read stream from // the DurableHttpSession, send the RunRequest on BidiAppend, then drive. const performOpen = async ( @@ -342,9 +382,12 @@ const performOpen = async ( const translator = createAgentTranslator({ id, model: opts.model.id, created, composer: isComposerModel(opts.model.id), sessionKey }); const gen = transport.openChatStream({ readStream: handle.body, request: { message, model: opts.model.id, tools, mode } }); - // First pull sends the RunRequest (BidiAppend, via the latency-recording - // fetcher) + reads the first chunk, satisfying the ok=true latency assertion. - const first = await gen.next(); + // The first pull sends the RunRequest (BidiAppend) then reads; pull past + // cursor's pre-output control frames to the model's first token so the + // recorded `upstream_success` latency is TTFT, and satisfy the ok=true + // latency assertion. broker.acquire above stays wrapped too so the non-200 + // early-return path still records a duration; on success this later wrap wins. + const first = await opts.call.recordUpstreamLatency(pullToFirstMeaningful(gen)); return { ok: true, events: buildEvents(opts, transport, gen, translator, sessionKey, requestId, handle, first), modelKey: opts.model.id }; }; @@ -398,13 +441,20 @@ const performResume = async ( await transport.sendMcpResultRaw(ref.id, ref.execId, { success: { content } }); } }; - await opts.call.recordUpstreamLatency(sendToolResults()); const id = `chatcmpl-cursor-${crypto.randomUUID()}`; const created = Math.floor(Date.now() / 1000); const translator = createAgentTranslator({ id, model: opts.model.id, created, composer: isComposerModel(opts.model.id), sessionKey }); const gen = transport.resumeChatStream({ readStream: handle.body, leftover: row.leftover }); - const first = await gen.next(); + // TTFT on a resume spans sending the tool results → the model's first new + // token; wrap both so `upstream_success` reflects the model's turnaround, not + // just the tool-result write round-trip. + const first = await opts.call.recordUpstreamLatency( + (async () => { + await sendToolResults(); + return await pullToFirstMeaningful(gen); + })(), + ); return { ok: true, events: buildEvents(opts, transport, gen, translator, sessionKey, row.requestId, handle, first), modelKey: opts.model.id }; }; diff --git a/packages/provider-cursor/src/fetch_test.ts b/packages/provider-cursor/src/fetch_test.ts index 66a215100..1f5a89a8c 100644 --- a/packages/provider-cursor/src/fetch_test.ts +++ b/packages/provider-cursor/src/fetch_test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { callCursorChatCompletions, type CursorCallEffects } from './fetch.ts'; -import { addConnectEnvelope, encodeMessageField, encodeStringField } from './proto/index.ts'; +import { callCursorChatCompletions, pullToFirstMeaningful, type CursorCallEffects } from './fetch.ts'; +import { addConnectEnvelope, encodeMessageField, encodeStringField, type AgentStreamChunk } from './proto/index.ts'; import type { CursorAccessTokenEntry, CursorAccountCredential, CursorUpstreamState } from './state.ts'; import { initDurableHttpSession, resetDurableHttpSessionForTesting, type DurableHttpSession } from '@floway-dev/platform'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; @@ -101,6 +101,14 @@ function turnEndedFrame(): Uint8Array { return addConnectEnvelope(serverMsg); } +// AgentServerMessage { field 3: conversation_checkpoint_update } — a pre-output +// control frame cursor emits before the first token; driveReadLoop yields it as +// { type: 'checkpoint' }. +function checkpointFrame(): Uint8Array { + const serverMsg = encodeMessageField(3, new Uint8Array([1, 2, 3])); + return addConnectEnvelope(serverMsg); +} + function streamResponse(...frames: Uint8Array[]): Response { const stream = new ReadableStream({ start(controller) { @@ -182,4 +190,72 @@ describe('callCursorChatCompletions', () => { if (!result.ok) return; await expect(collectEvents(result)).rejects.toThrow(/SSE stream failed: 503/); }); + + test('records TTFT: the latency wrap resolves to the first token frame, past control frames', async () => { + mockCursorFetch(streamResponse(checkpointFrame(), textFrame('hello'), turnEndedFrame())); + // Mirror the real recorder: last-settled promise wins. The acquire/BidiAppend + // wraps settle first; the pull-to-first-token wrap settles last, so its value + // (the first meaningful IteratorResult) is what upstream_success would record. + const settled: unknown[] = []; + const call = noopUpstreamCallOptions({ + recordUpstreamLatency: (promise: Promise): Promise => { + void promise.then(v => settled.push(v)).catch(() => {}); + return promise; + }, + }); + const result = await callCursorChatCompletions({ + upstreamId, account: activeAccount, model, + body: { messages: [{ role: 'user', content: 'hi' }] }, + headers: new Headers(), effects: makeEffects(), call, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const events = await collectEvents(result); + expect(events.map(e => e.choices[0]?.delta?.content ?? '').filter(Boolean)).toContain('hello'); + + // Only the pull wrap resolves to an IteratorResult (acquire→handle, + // BidiAppend→Response). It must carry the text token, not the checkpoint. + const pullResults = settled.filter( + (v): v is IteratorResult => !!v && typeof v === 'object' && 'done' in v, + ); + expect(pullResults.at(-1)).toEqual({ done: false, value: { type: 'text', content: 'hello' } }); + }); +}); + +describe('pullToFirstMeaningful', () => { + async function* chunkGen(chunks: AgentStreamChunk[]): AsyncGenerator { + for (const c of chunks) yield c; + } + + test('skips pre-output control frames and stops at the first text token', async () => { + const r = await pullToFirstMeaningful(chunkGen([ + { type: 'checkpoint' }, { type: 'heartbeat' }, { type: 'text', content: 'hi' }, + ])); + expect(r).toEqual({ done: false, value: { type: 'text', content: 'hi' } }); + }); + + test('skips empty text/thinking deltas, stops at the first non-empty one', async () => { + const r = await pullToFirstMeaningful(chunkGen([ + { type: 'text', content: '' }, { type: 'thinking', content: '' }, { type: 'thinking', content: 'reasoning' }, + ])); + expect(r).toEqual({ done: false, value: { type: 'thinking', content: 'reasoning' } }); + }); + + test('stops at exec_request when the model goes straight to a tool call', async () => { + const r = await pullToFirstMeaningful(chunkGen([{ type: 'checkpoint' }, { type: 'exec_request' }])); + expect(r.done).toBe(false); + expect((r.value as AgentStreamChunk).type).toBe('exec_request'); + }); + + test('stops at a terminal done frame on an empty (all-heartbeat) turn', async () => { + const r = await pullToFirstMeaningful(chunkGen([ + { type: 'heartbeat' }, { type: 'heartbeat' }, { type: 'done' }, + ])); + expect(r).toEqual({ done: false, value: { type: 'done' } }); + }); + + test('returns the done result when the generator ends with only control frames', async () => { + const r = await pullToFirstMeaningful(chunkGen([{ type: 'checkpoint' }])); + expect(r.done).toBe(true); + }); }); From bb8b493f2cf64786876e3745f3dedb260942a810 Mon Sep 17 00:00:00 2001 From: yyyr Date: Wed, 1 Jul 2026 23:24:10 +0800 Subject: [PATCH 17/79] feat(provider-cursor): fold tool-call history into cold-open transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flattenMessages dropped every tool-related message: role:'tool' results were skipped and tool_calls-only assistant turns contributed nothing. On a cold-open (live session lost / fresh RunSSE with zero server-side memory) that erased what the model had already called and learned, so it re-ran tools and lost context. Reconstruct the full transcript instead: render each assistant turn's text plus its tool calls (→ called name(args)) and each tool result as [Tool result: ], with the name recovered from the matching assistant tool_call id. Framing is explicit (which tool, args, result) — the degradation the old code avoided came from folding an unaddressed JSON blob, not from tool history itself. Past AND trailing pending rounds fold, since cold-open has no live channel to protect. live-resume (performResume, tool result over BidiAppend) is unchanged. Contained to provider-cursor. Verified: forced cold-open answers from the folded result without re-running the tool (3/3). --- packages/provider-cursor/src/fetch.ts | 63 ++++++++++++++-------- packages/provider-cursor/src/fetch_test.ts | 57 +++++++++++++++++++- 2 files changed, 98 insertions(+), 22 deletions(-) diff --git a/packages/provider-cursor/src/fetch.ts b/packages/provider-cursor/src/fetch.ts index b5e16bf76..86b7416f5 100644 --- a/packages/provider-cursor/src/fetch.ts +++ b/packages/provider-cursor/src/fetch.ts @@ -90,45 +90,66 @@ const prepareCursorCall = async ( // an empty conversation state, so the full transcript is inlined into the // single user message. System → prefix, history → role-tagged turns. // -// Tool calling does NOT go through this transcript. Cursor's MCP protocol -// carries tool outputs over the BidiAppend ExecMcpResult write channel on a -// live session (see performCursorSessionFollowUp) — folding a tool result into -// the prompt as "[Tool result] {...}" severely degrades the model (it reads an -// unaddressed JSON blob and either ignores it or re-runs the tool). So we never -// flatten tool results here: role:'tool' messages are dropped, and an assistant -// turn that only carried tool_calls contributes just its own text (if any). -// // This path serves (a) genuine new conversations and (b) the cold-resume // fallback when a live session was lost (cross-instance / evicted / CF cold -// start). In case (b) the tools list stays advertised so cursor re-runs the -// agent loop natively — an honest "fresh turn" rather than a degraded prompt -// fold. -const flattenMessages = (messages: ChatCompletionsMessage[]): string => { +// start). Only live-resume (performResume) carries a tool result over the +// BidiAppend ExecMcpResult write channel; here on cold-open cursor has zero +// server-side memory, so past tool rounds are reconstructed into the transcript +// too — otherwise the model loses what it already called and re-runs tools. They +// are framed explicitly (which tool, its arguments, its result) so the model +// reads them as history: the degradation the earlier version avoided came from +// folding an *unaddressed* JSON blob, not from tool history per se. Tools stay +// advertised so cursor can still continue the agent loop natively. +export const flattenMessages = (messages: ChatCompletionsMessage[]): string => { + // A tool message carries only tool_call_id + content; recover the tool name + // from the assistant tool_calls so its result can be labelled with what it + // answers. + const toolNameById = new Map(); + for (const m of messages) { + if (m.role === 'assistant' && m.tool_calls) { + for (const tc of m.tool_calls) toolNameById.set(tc.id, tc.function.name); + } + } + const parts: string[] = []; for (const m of messages) { - // Tool results live on the MCP write channel, never in the transcript. - if (m.role === 'tool') continue; + if (m.role === 'tool') { + const mapped = m.tool_call_id ? toolNameById.get(m.tool_call_id) : undefined; + const name = mapped ?? m.tool_call_id ?? 'tool'; + parts.push(`[Tool result: ${name}]\n${messageText(m)}`); + continue; + } + + if (m.role === 'assistant') { + // Text (if any) then one line per tool call it made — an assistant turn + // that only carried tool_calls still contributes its calls, not nothing. + const lines: string[] = []; + const text = messageText(m); + if (text) lines.push(text); + for (const tc of m.tool_calls ?? []) { + lines.push(`→ called ${tc.function.name}(${tc.function.arguments})`); + } + if (lines.length === 0) continue; + parts.push(`[Assistant]\n${lines.join('\n')}`); + continue; + } const text = messageText(m); if (!text) continue; - const tag = m.role === 'system' || m.role === 'developer' ? 'System' - : m.role === 'user' ? 'User' - : m.role === 'assistant' ? 'Assistant' - : m.role; + const tag = m.role === 'system' || m.role === 'developer' ? 'System' : m.role === 'user' ? 'User' : m.role; parts.push(`[${tag}]\n${text}`); } return parts.join('\n\n'); }; +// Plain-text extraction: string content verbatim, array content's text parts +// joined, everything else empty (assistant tool_calls are rendered separately). const messageText = (m: ChatCompletionsMessage): string => { if (typeof m.content === 'string') return m.content; if (Array.isArray(m.content)) { return m.content.filter(p => p.type === 'text').map(p => p.text).join('\n'); } - // An assistant turn carrying only tool_calls (no text) contributes nothing to - // the transcript — the call itself is replayed natively via the tools list, - // not narrated into the prompt. return ''; }; diff --git a/packages/provider-cursor/src/fetch_test.ts b/packages/provider-cursor/src/fetch_test.ts index 1f5a89a8c..0b69d97d4 100644 --- a/packages/provider-cursor/src/fetch_test.ts +++ b/packages/provider-cursor/src/fetch_test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { callCursorChatCompletions, pullToFirstMeaningful, type CursorCallEffects } from './fetch.ts'; +import { callCursorChatCompletions, flattenMessages, pullToFirstMeaningful, type CursorCallEffects } from './fetch.ts'; import { addConnectEnvelope, encodeMessageField, encodeStringField, type AgentStreamChunk } from './proto/index.ts'; import type { CursorAccessTokenEntry, CursorAccountCredential, CursorUpstreamState } from './state.ts'; import { initDurableHttpSession, resetDurableHttpSessionForTesting, type DurableHttpSession } from '@floway-dev/platform'; @@ -259,3 +259,58 @@ describe('pullToFirstMeaningful', () => { expect(r.done).toBe(true); }); }); + +describe('flattenMessages', () => { + test('folds a completed tool round with framing and tool-name labels', () => { + const out = flattenMessages([ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'weather in Tokyo?' }, + { role: 'assistant', content: 'Plan: check the weather.', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' } }] }, + { role: 'tool', tool_call_id: 'call_1', content: '{"temperature":18,"condition":"cloudy"}' }, + { role: 'assistant', content: 'Tokyo is cloudy, 18C.' }, + { role: 'user', content: 'thanks' }, + ]); + expect(out).toContain('[System]\nYou are helpful.'); + expect(out).toContain('[User]\nweather in Tokyo?'); + expect(out).toContain('[Assistant]\nPlan: check the weather.\n→ called get_weather({"city":"Tokyo"})'); + expect(out).toContain('[Tool result: get_weather]\n{"temperature":18,"condition":"cloudy"}'); + expect(out).toContain('[Assistant]\nTokyo is cloudy, 18C.'); + }); + + test('folds a trailing pending tool round instead of dropping it (full reconstruction)', () => { + const out = flattenMessages([ + { role: 'user', content: 'weather?' }, + { role: 'assistant', content: null, tool_calls: [{ id: 'c', type: 'function', function: { name: 'get_weather', arguments: '{"city":"NYC"}' } }] }, + { role: 'tool', tool_call_id: 'c', content: '{"t":20}' }, + ]); + expect(out).toContain('→ called get_weather({"city":"NYC"})'); + expect(out.endsWith('[Tool result: get_weather]\n{"t":20}')).toBe(true); + }); + + test('renders an assistant turn that carried only tool_calls (no text)', () => { + const out = flattenMessages([ + { role: 'assistant', content: null, tool_calls: [{ id: 'x', type: 'function', function: { name: 'search', arguments: '{}' } }] }, + ]); + expect(out).toBe('[Assistant]\n→ called search({})'); + }); + + test('plain-text conversation keeps the role-tagged shape', () => { + const out = flattenMessages([ + { role: 'system', content: 'sys' }, + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + ]); + expect(out).toBe('[System]\nsys\n\n[User]\nhi\n\n[Assistant]\nhello'); + }); + + test('normalizes tool-result content (array parts) and keeps the frame on empty/unknown', () => { + const arr = flattenMessages([ + { role: 'assistant', content: null, tool_calls: [{ id: 'a', type: 'function', function: { name: 't', arguments: '{}' } }] }, + { role: 'tool', tool_call_id: 'a', content: [{ type: 'text', text: 'part1' }, { type: 'text', text: 'part2' }] }, + ]); + expect(arr).toContain('[Tool result: t]\npart1\npart2'); + // Unknown id (no matching assistant tool_call) → labelled by the id itself. + const nul = flattenMessages([{ role: 'tool', tool_call_id: 'z', content: null }]); + expect(nul).toBe('[Tool result: z]\n'); + }); +}); From 3672d81be657fa227131976b6717518f06ecfe60 Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 2 Jul 2026 00:40:04 +0800 Subject: [PATCH 18/79] feat(provider-cursor): add privacyMode to CursorUpstreamConfig schema --- packages/provider-cursor/src/config.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/provider-cursor/src/config.ts b/packages/provider-cursor/src/config.ts index f5477df42..238aef027 100644 --- a/packages/provider-cursor/src/config.ts +++ b/packages/provider-cursor/src/config.ts @@ -14,6 +14,11 @@ export interface CursorAccountIdentity { // round-robin pool feature can widen the tuple without a schema migration. export interface CursorUpstreamConfig { accounts: [CursorAccountIdentity]; + // Privacy / ghost mode toggle sent to Cursor as the x-ghost-mode data-plane + // header. Absent = default on (privacy preserved) — see provider.ts, which + // resolves `privacyMode ?? true`. Only the chat data plane honors this; the + // model-catalog fetch stays always-private (no user content flows there). + privacyMode?: boolean; } export type CursorUpstreamRecord = UpstreamRecord & { @@ -27,10 +32,13 @@ function assertCursorUpstreamConfig(value: unknown): asserts value is CursorUpst } const obj = value as Record; for (const key of Object.keys(obj)) { - if (key !== 'accounts') { + if (key !== 'accounts' && key !== 'privacyMode') { throw new TypeError(`CursorUpstreamConfig has unexpected key '${key}'`); } } + if (obj.privacyMode !== undefined && typeof obj.privacyMode !== 'boolean') { + throw new TypeError('CursorUpstreamConfig.privacyMode must be a boolean when present'); + } if (!Array.isArray(obj.accounts)) { throw new TypeError('CursorUpstreamConfig.accounts must be an array'); } From c7144627e11175a809f512080b9601b0f8d87ad2 Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 2 Jul 2026 00:40:47 +0800 Subject: [PATCH 19/79] feat(provider-cursor): thread privacyMode config through to transport x-ghost-mode --- packages/provider-cursor/src/fetch.ts | 3 +++ packages/provider-cursor/src/provider.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/provider-cursor/src/fetch.ts b/packages/provider-cursor/src/fetch.ts index 86b7416f5..7c1752714 100644 --- a/packages/provider-cursor/src/fetch.ts +++ b/packages/provider-cursor/src/fetch.ts @@ -36,6 +36,8 @@ interface CursorChatCallBase { signal?: AbortSignal; effects: CursorCallEffects; call: UpstreamCallOptions; + /** Ghost-mode toggle, resolved by provider.ts from config (default on). */ + privacyMode: boolean; } export interface CallCursorChatCompletionsOptions extends CursorChatCallBase { @@ -217,6 +219,7 @@ const makeTransport = ( baseUrl: CURSOR_BACKEND_BASE, env: gatewayEnv, clientVersion: CURSOR_CLIENT_VERSION, + privacyMode: opts.privacyMode, getChecksum: () => checksum, // The BidiAppend write channel goes through the proxy-aware Fetcher (which // records upstream latency); the RunSSE read is owned by the diff --git a/packages/provider-cursor/src/provider.ts b/packages/provider-cursor/src/provider.ts index 3ea44c13c..ff66faa70 100644 --- a/packages/provider-cursor/src/provider.ts +++ b/packages/provider-cursor/src/provider.ts @@ -129,6 +129,9 @@ export const createCursorProvider = async (record: UpstreamRecord): Promise Date: Thu, 2 Jul 2026 00:41:53 +0800 Subject: [PATCH 20/79] feat(provider-cursor): encode conversation_state root_prompt blob ref --- .../provider-cursor/src/proto/agent-messages.ts | 16 +++++++++++++++- packages/provider-cursor/src/proto/index.ts | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/provider-cursor/src/proto/agent-messages.ts b/packages/provider-cursor/src/proto/agent-messages.ts index 0351ccd6a..598c2ec52 100644 --- a/packages/provider-cursor/src/proto/agent-messages.ts +++ b/packages/provider-cursor/src/proto/agent-messages.ts @@ -122,6 +122,17 @@ export function encodeEmptyConversationState(): Uint8Array { return new Uint8Array(0); } +// ConversationStateStructure with only root_prompt_messages_json (field 1, +// repeated bytes) set to a single content-addressed blob reference. blobId is +// the raw 32-byte SHA-256 digest of the system-prompt JSON; the same bytes are +// seeded into the transport blob store under hex(blobId) so cursor's follow-up +// get_blob_args resolves to the real prompt. encodeMessageField gives the +// correct `bytes` wire encoding (length-delimited, contents uninterpreted) — +// encodeStringField would UTF-8-mangle the binary digest. +export function encodeConversationStateWithRootPrompt(blobId: Uint8Array): Uint8Array { + return encodeMessageField(1, blobId); +} + export function encodeMcpTools(tools: OpenAIToolDefinition[]): Uint8Array { const parts: Uint8Array[] = []; for (const tool of tools) { @@ -191,8 +202,11 @@ export function encodeAgentRunRequest( conversationId: string | undefined, tools: OpenAIToolDefinition[] | undefined, workspacePath: string | undefined, + rootPromptBlobId?: Uint8Array, ): Uint8Array { - const conversationState = encodeEmptyConversationState(); + const conversationState = rootPromptBlobId + ? encodeConversationStateWithRootPrompt(rootPromptBlobId) + : encodeEmptyConversationState(); const parts: Uint8Array[] = [ encodeMessageField(1, conversationState), diff --git a/packages/provider-cursor/src/proto/index.ts b/packages/provider-cursor/src/proto/index.ts index 5a40575b8..bb4868c6a 100644 --- a/packages/provider-cursor/src/proto/index.ts +++ b/packages/provider-cursor/src/proto/index.ts @@ -97,6 +97,7 @@ export { encodeAgentClientMessageWithConversationAction, encodeModelDetails, encodeEmptyConversationState, + encodeConversationStateWithRootPrompt, encodeMcpTools, encodeMcpDescriptor, encodeMcpFileSystemOptions, From 785899d5dd7360c856cb0037423820c4b035f0df Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 2 Jul 2026 00:42:46 +0800 Subject: [PATCH 21/79] feat(provider-cursor): seed root-prompt blob and reference it from run request --- packages/provider-cursor/src/agent-transport.ts | 14 ++++++++++++++ packages/provider-cursor/src/proto/types.ts | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/packages/provider-cursor/src/agent-transport.ts b/packages/provider-cursor/src/agent-transport.ts index dd953ad5f..0ae58eaea 100644 --- a/packages/provider-cursor/src/agent-transport.ts +++ b/packages/provider-cursor/src/agent-transport.ts @@ -250,12 +250,26 @@ export class AgentTransport { const userMessageAction = encodeUserMessageAction(userMessage, requestContext); const conversationAction = encodeConversationAction(userMessageAction); const modelDetails = encodeModelDetails(model); + + // Native system-prompt injection: seed the turn-scoped blob store with the + // prompt JSON keyed by hex(blobId) so cursor's later get_blob_args resolves + // it (handleKvMessage serves from the same store), then reference blobId + // from conversation_state.root_prompt_messages_json. seed() cleared the + // store just before openChatStream, so this entry lives for the whole turn. + let rootPromptBlobId: Uint8Array | undefined; + if (request.rootPromptBlob) { + const { blobId, jsonBytes } = request.rootPromptBlob; + this.storeBlob(this.blobIdToKey(blobId), jsonBytes); + rootPromptBlobId = blobId; + } + const agentRunRequest = encodeAgentRunRequest( conversationAction, modelDetails, conversationId, request.tools, this.env.workspacePath, + rootPromptBlobId, ); return encodeAgentClientMessage(agentRunRequest); } diff --git a/packages/provider-cursor/src/proto/types.ts b/packages/provider-cursor/src/proto/types.ts index 26feeabe6..8a3141820 100644 --- a/packages/provider-cursor/src/proto/types.ts +++ b/packages/provider-cursor/src/proto/types.ts @@ -167,6 +167,12 @@ export interface AgentChatRequest { mode?: AgentMode; conversationId?: string; tools?: OpenAIToolDefinition[]; + // Native system-prompt injection. blobId is the raw 32-byte SHA-256 digest of + // jsonBytes (the `{"role":"system","content":...}` UTF-8 bytes); the transport + // seeds the blob store with hex(blobId)→jsonBytes and references blobId from + // conversation_state.root_prompt_messages_json so cursor fetches it via + // get_blob_args. Absent = empty conversation_state (prompt stays inlined). + rootPromptBlob?: { blobId: Uint8Array; jsonBytes: Uint8Array }; } export interface McpResult { From 656d5fc66ae62595e51870844db6ff8acb3e5ee1 Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 2 Jul 2026 00:44:34 +0800 Subject: [PATCH 22/79] feat(provider-cursor): route system prompt to native root_prompt blob on open --- packages/provider-cursor/src/fetch.ts | 45 +++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/provider-cursor/src/fetch.ts b/packages/provider-cursor/src/fetch.ts index 7c1752714..65e14eee5 100644 --- a/packages/provider-cursor/src/fetch.ts +++ b/packages/provider-cursor/src/fetch.ts @@ -14,11 +14,11 @@ import { AgentTransport } from './agent-transport.ts'; import { CursorSessionTerminatedError } from './auth/oauth.ts'; import { generateCursorChecksum } from './checksum.ts'; import { CURSOR_BACKEND_BASE, CURSOR_CLIENT_VERSION } from './constants.ts'; -import { AgentMode, type AgentStreamChunk, type RequestContextEnv, type OpenAIToolDefinition } from './proto/index.ts'; +import { AgentMode, hexToBytes, type AgentStreamChunk, type RequestContextEnv, type OpenAIToolDefinition } from './proto/index.ts'; import { isCursorRateLimited } from './quota.ts'; import { deriveSessionKey, mintSessionKey, decodeToolCallId } from './session-id.ts'; import type { CursorAccountCredential } from './state.ts'; -import { getDurableHttpSession, type DurableHttpSessionHandle } from '@floway-dev/platform'; +import { getDurableHttpSession, sha256Hex, type DurableHttpSessionHandle } from '@floway-dev/platform'; import type { ChatCompletionsStreamEvent, ChatCompletionsPayload, ChatCompletionsMessage, ChatCompletionsTool } from '@floway-dev/protocols/chat-completions'; import { eventFrame, doneFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { getProviderRepo, type ProviderStreamResult, type UpstreamCallOptions, type UpstreamModel } from '@floway-dev/provider'; @@ -89,8 +89,10 @@ const prepareCursorCall = async ( }; // Cursor agent is stateless from the gateway's view: each fresh RunSSE carries -// an empty conversation state, so the full transcript is inlined into the -// single user message. System → prefix, history → role-tagged turns. +// the transcript inlined into the single user message. System/developer +// messages are the exception — they go native via +// conversation_state.root_prompt_messages_json (see extractSystemPrompt / +// performOpen), so flattenMessages carries only user/assistant/tool history. // // This path serves (a) genuine new conversations and (b) the cold-resume // fallback when a live session was lost (cross-instance / evicted / CF cold @@ -115,6 +117,9 @@ export const flattenMessages = (messages: ChatCompletionsMessage[]): string => { const parts: string[] = []; for (const m of messages) { + // System/developer prompts go native (root_prompt blob), not inlined here. + if (m.role === 'system' || m.role === 'developer') continue; + if (m.role === 'tool') { const mapped = m.tool_call_id ? toolNameById.get(m.tool_call_id) : undefined; const name = mapped ?? m.tool_call_id ?? 'tool'; @@ -138,13 +143,37 @@ export const flattenMessages = (messages: ChatCompletionsMessage[]): string => { const text = messageText(m); if (!text) continue; - const tag = m.role === 'system' || m.role === 'developer' ? 'System' : m.role === 'user' ? 'User' : m.role; + const tag = m.role === 'user' ? 'User' : m.role; parts.push(`[${tag}]\n${text}`); } return parts.join('\n\n'); }; +// Concatenate all system/developer message contents (in order, blank-line +// separated) into the single system prompt cursor's root_prompt blob carries. +// Empty when the caller supplied none (injectDefaultInstructions normally +// prepends one upstream, so this is rare). +export const extractSystemPrompt = (messages: ChatCompletionsMessage[]): string => + messages + .filter(m => m.role === 'system' || m.role === 'developer') + .map(messageText) + .filter(Boolean) + .join('\n\n'); + +// Content-addressed root-prompt blob: SHA-256 of the UTF-8 `{"role":"system", +// "content":...}` JSON, matching the reference clients byte-for-byte so cursor's +// get_blob_args (keyed on this digest) resolves to the seeded bytes. Returns the +// raw 32-byte digest as blobId plus the exact hashed bytes as jsonBytes. +export const computeRootPromptBlob = async ( + systemPrompt: string, +): Promise<{ blobId: Uint8Array; jsonBytes: Uint8Array }> => { + const systemJson = JSON.stringify({ role: 'system', content: systemPrompt }); + const jsonBytes = new TextEncoder().encode(systemJson); + const blobId = hexToBytes(await sha256Hex(jsonBytes)); + return { blobId, jsonBytes }; +}; + // Plain-text extraction: string content verbatim, array content's text parts // joined, everything else empty (assistant tool_calls are rendered separately). const messageText = (m: ChatCompletionsMessage): string => { @@ -359,6 +388,10 @@ const performOpen = async ( ): Promise> => { const sessionKey = mintSessionKey(opts.upstreamId, opts.call.apiKeyId); const message = flattenMessages(opts.body.messages); + // System prompt goes native via conversation_state.root_prompt_messages_json + // rather than folded into the user text. Absent → empty conversation_state. + const systemPrompt = extractSystemPrompt(opts.body.messages); + const rootPromptBlob = systemPrompt ? await computeRootPromptBlob(systemPrompt) : undefined; // Always advertise the tools: a cold-resume (tool-result follow-up that lost // its session) lets cursor re-run the agent loop natively rather than degrade // to a prompt-folded result. @@ -405,7 +438,7 @@ const performOpen = async ( const created = Math.floor(Date.now() / 1000); const translator = createAgentTranslator({ id, model: opts.model.id, created, composer: isComposerModel(opts.model.id), sessionKey }); - const gen = transport.openChatStream({ readStream: handle.body, request: { message, model: opts.model.id, tools, mode } }); + const gen = transport.openChatStream({ readStream: handle.body, request: { message, model: opts.model.id, tools, mode, rootPromptBlob } }); // The first pull sends the RunRequest (BidiAppend) then reads; pull past // cursor's pre-output control frames to the model's first token so the // recorded `upstream_success` latency is TTFT, and satisfy the ok=true From 029805bdc220b7bbceeed018d63607896f465bb5 Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 2 Jul 2026 00:50:10 +0800 Subject: [PATCH 23/79] test(provider-cursor): cover privacyMode header/config + root_prompt blob injection --- .../src/agent-transport_test.ts | 50 ++++++++++++++++++- packages/provider-cursor/src/config_test.ts | 9 ++++ packages/provider-cursor/src/fetch_test.ts | 46 ++++++++++++++--- 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/packages/provider-cursor/src/agent-transport_test.ts b/packages/provider-cursor/src/agent-transport_test.ts index e45df92b0..0072dd118 100644 --- a/packages/provider-cursor/src/agent-transport_test.ts +++ b/packages/provider-cursor/src/agent-transport_test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from 'vitest'; import { AgentTransport } from './agent-transport.ts'; -import { addConnectEnvelope, encodeMessageField, encodeStringField } from './proto/index.ts'; +import { addConnectEnvelope, bytesToHex, encodeMessageField, encodeStringField } from './proto/index.ts'; import type { AgentChatRequest, RequestContextEnv } from './proto/index.ts'; const ENV: RequestContextEnv = { workspacePath: '/tmp', osVersion: 'darwin 24.0.0', shell: '/bin/zsh', timezone: 'UTC' }; @@ -179,3 +179,51 @@ describe('AgentTransport.sendRejectedTool', () => { ).rejects.toThrow('No active chat stream'); }); }); + +describe('AgentTransport privacy + root-prompt injection', () => { + function makeTransportPrivacy(fetchMock: unknown, privacyMode: boolean | undefined): AgentTransport { + return new AgentTransport({ + getAuthToken: () => 'tok', + baseUrl: 'https://api2.cursor.sh', + env: ENV, + clientVersion: 'cli-test', + privacyMode, + getChecksum: () => 'checksum-value', + fetch: fetchMock as typeof fetch, + }); + } + + test('x-ghost-mode reflects privacyMode on the RunSSE init headers', () => { + const off = makeTransportPrivacy(vi.fn(), false).runSseInit('req-1').headers; + expect(off['x-ghost-mode']).toBe('false'); + + const on = makeTransportPrivacy(vi.fn(), true).runSseInit('req-1').headers; + expect(on['x-ghost-mode']).toBe('true'); + + // Absent option defaults to privacy on. + const dflt = makeTransportPrivacy(vi.fn(), undefined).runSseInit('req-1').headers; + expect(dflt['x-ghost-mode']).toBe('true'); + }); + + test('open with rootPromptBlob embeds the blob id in the RunRequest conversation_state', async () => { + const bodies: Uint8Array[] = []; + const fetchMock = vi.fn(async (_url: unknown, init: RequestInit) => { + bodies.push(init.body as Uint8Array); + return okEmptyResponse(); + }); + const transport = makeTransportPrivacy(fetchMock, true); + transport.seed('req-x', 0n); + + const blobId = new Uint8Array(32).map((_, i) => (i * 7 + 3) & 0xff); + const jsonBytes = new TextEncoder().encode('{"role":"system","content":"hi"}'); + const request = { message: 'hi', model: 'gpt-4o', rootPromptBlob: { blobId, jsonBytes } } as AgentChatRequest; + + await collect(transport.openChatStream({ readStream: readStreamOf(turnEndedFrame()), request })); + + // The first BidiAppend write is the RunRequest; its data field is the hex of + // the AgentClientMessage, so the raw blob id shows up as its hex substring — + // proving conversation_state.root_prompt_messages_json carries the reference. + const runRequestText = new TextDecoder().decode(bodies[0]); + expect(runRequestText).toContain(bytesToHex(blobId)); + }); +}); diff --git a/packages/provider-cursor/src/config_test.ts b/packages/provider-cursor/src/config_test.ts index feb6bc09a..8488035de 100644 --- a/packages/provider-cursor/src/config_test.ts +++ b/packages/provider-cursor/src/config_test.ts @@ -43,4 +43,13 @@ describe('assertCursorUpstreamRecord (config validation)', () => { ])('rejects %s', (_label, value) => { expect(() => assertCursorUpstreamRecord(wrap(value))).toThrow(); }); + + test('accepts an explicit privacyMode boolean', () => { + expect(() => assertCursorUpstreamRecord(wrap({ ...good, privacyMode: true }))).not.toThrow(); + expect(() => assertCursorUpstreamRecord(wrap({ ...good, privacyMode: false }))).not.toThrow(); + }); + + test('rejects a non-boolean privacyMode', () => { + expect(() => assertCursorUpstreamRecord(wrap({ ...good, privacyMode: 'true' }))).toThrow(/privacyMode must be a boolean/); + }); }); diff --git a/packages/provider-cursor/src/fetch_test.ts b/packages/provider-cursor/src/fetch_test.ts index 0b69d97d4..a1ace3cef 100644 --- a/packages/provider-cursor/src/fetch_test.ts +++ b/packages/provider-cursor/src/fetch_test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { callCursorChatCompletions, flattenMessages, pullToFirstMeaningful, type CursorCallEffects } from './fetch.ts'; -import { addConnectEnvelope, encodeMessageField, encodeStringField, type AgentStreamChunk } from './proto/index.ts'; +import { callCursorChatCompletions, computeRootPromptBlob, extractSystemPrompt, flattenMessages, pullToFirstMeaningful, type CursorCallEffects } from './fetch.ts'; +import { addConnectEnvelope, bytesToHex, encodeMessageField, encodeStringField, type AgentStreamChunk } from './proto/index.ts'; import type { CursorAccessTokenEntry, CursorAccountCredential, CursorUpstreamState } from './state.ts'; import { initDurableHttpSession, resetDurableHttpSessionForTesting, type DurableHttpSession } from '@floway-dev/platform'; import type { ChatCompletionsStreamEvent } from '@floway-dev/protocols/chat-completions'; @@ -145,6 +145,7 @@ describe('callCursorChatCompletions', () => { body: { messages: [{ role: 'user', content: 'hi' }] }, headers: new Headers(), effects: makeEffects(), + privacyMode: true, call: noopUpstreamCallOptions(), }); expect(result.ok).toBe(false); @@ -163,6 +164,7 @@ describe('callCursorChatCompletions', () => { body: { messages: [{ role: 'user', content: 'hi' }] }, headers: new Headers(), effects: makeEffects(), + privacyMode: true, call: noopUpstreamCallOptions(), }); expect(result.ok).toBe(true); @@ -184,6 +186,7 @@ describe('callCursorChatCompletions', () => { body: { messages: [{ role: 'user', content: 'hi' }] }, headers: new Headers(), effects: makeEffects(), + privacyMode: true, call: noopUpstreamCallOptions(), }); expect(result.ok).toBe(true); @@ -206,7 +209,7 @@ describe('callCursorChatCompletions', () => { const result = await callCursorChatCompletions({ upstreamId, account: activeAccount, model, body: { messages: [{ role: 'user', content: 'hi' }] }, - headers: new Headers(), effects: makeEffects(), call, + headers: new Headers(), effects: makeEffects(), privacyMode: true, call, }); expect(result.ok).toBe(true); if (!result.ok) return; @@ -270,7 +273,8 @@ describe('flattenMessages', () => { { role: 'assistant', content: 'Tokyo is cloudy, 18C.' }, { role: 'user', content: 'thanks' }, ]); - expect(out).toContain('[System]\nYou are helpful.'); + expect(out).not.toContain('[System]'); + expect(out).not.toContain('You are helpful.'); expect(out).toContain('[User]\nweather in Tokyo?'); expect(out).toContain('[Assistant]\nPlan: check the weather.\n→ called get_weather({"city":"Tokyo"})'); expect(out).toContain('[Tool result: get_weather]\n{"temperature":18,"condition":"cloudy"}'); @@ -294,13 +298,14 @@ describe('flattenMessages', () => { expect(out).toBe('[Assistant]\n→ called search({})'); }); - test('plain-text conversation keeps the role-tagged shape', () => { + test('drops system/developer messages (they go native via root_prompt) and keeps the rest role-tagged', () => { const out = flattenMessages([ { role: 'system', content: 'sys' }, + { role: 'developer', content: 'dev' }, { role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }, ]); - expect(out).toBe('[System]\nsys\n\n[User]\nhi\n\n[Assistant]\nhello'); + expect(out).toBe('[User]\nhi\n\n[Assistant]\nhello'); }); test('normalizes tool-result content (array parts) and keeps the frame on empty/unknown', () => { @@ -314,3 +319,32 @@ describe('flattenMessages', () => { expect(nul).toBe('[Tool result: z]\n'); }); }); + +describe('extractSystemPrompt', () => { + test('joins system + developer contents blank-line separated, in order', () => { + expect( + extractSystemPrompt([ + { role: 'system', content: 'a' }, + { role: 'user', content: 'x' }, + { role: 'developer', content: 'b' }, + ]), + ).toBe('a\n\nb'); + }); + + test('empty string when no system/developer message present', () => { + expect(extractSystemPrompt([{ role: 'user', content: 'x' }])).toBe(''); + }); +}); + +describe('computeRootPromptBlob', () => { + test('blobId is the 32-byte SHA-256 of the {role,content} JSON; jsonBytes are exactly those hashed bytes', async () => { + const { blobId, jsonBytes } = await computeRootPromptBlob('be terse'); + // The hashed bytes are the literal reference-client JSON, byte-for-byte. + expect(new TextDecoder().decode(jsonBytes)).toBe('{"role":"system","content":"be terse"}'); + expect(blobId.length).toBe(32); + // Independent oracle: hashing jsonBytes must reproduce blobId exactly, so + // cursor's get_blob_args (keyed on this digest) resolves to the seeded bytes. + const oracle = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(jsonBytes))); + expect(bytesToHex(blobId)).toBe(bytesToHex(oracle)); + }); +}); From 5bede6a7211712c5bec7cb1e3545d868818bf41b Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 2 Jul 2026 00:59:30 +0800 Subject: [PATCH 24/79] feat(gateway): allow cursor privacyMode config PATCH (reject only credential edits) --- .../gateway/src/control-plane/upstreams/routes.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/gateway/src/control-plane/upstreams/routes.ts b/packages/gateway/src/control-plane/upstreams/routes.ts index 07d8b72e0..b4a0ada17 100644 --- a/packages/gateway/src/control-plane/upstreams/routes.ts +++ b/packages/gateway/src/control-plane/upstreams/routes.ts @@ -71,6 +71,7 @@ import { refreshCursorAccessToken, CursorSessionTerminatedError, assertCursorUpstreamState, + assertCursorUpstreamRecord, type CursorUpstreamConfig, type CursorUpstreamState, } from '@floway-dev/provider-cursor'; @@ -140,6 +141,10 @@ const normalizeConfig = (record: UpstreamRecord): ValidationResult => { assertClaudeCodeUpstreamRecord(record); return { ok: true, value: record.config }; } + if (record.provider === 'cursor') { + assertCursorUpstreamRecord(record); + return { ok: true, value: record.config }; + } return { ok: true, value: copilotConfigField( @@ -324,7 +329,11 @@ export const updateUpstream = async (c: CtxWithJson Date: Thu, 2 Jul 2026 01:02:03 +0800 Subject: [PATCH 25/79] test(gateway): cursor privacyMode PATCH accepted, credential edits still rejected --- .../control-plane/upstreams/routes_test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/gateway/src/control-plane/upstreams/routes_test.ts b/packages/gateway/src/control-plane/upstreams/routes_test.ts index cafa95685..ea071fb9d 100644 --- a/packages/gateway/src/control-plane/upstreams/routes_test.ts +++ b/packages/gateway/src/control-plane/upstreams/routes_test.ts @@ -1043,6 +1043,62 @@ test('PATCH /api/upstreams rejects config edits on a claude-code row', async () assertEquals(body.error.includes('claude-code-reimport'), true); }); +test('PATCH /api/upstreams accepts a privacyMode edit on a cursor row but rejects credential edits', async () => { + const { repo, adminSession } = await setupAppTest(); + await repo.upstreams.deleteAll(); + await repo.upstreams.save({ + id: 'up_cursor_privacy', + provider: 'cursor', + name: 'Cursor', + enabled: true, + sortOrder: 0, + createdAt: '2026-05-22T00:00:00.000Z', + updatedAt: '2026-05-22T00:00:00.000Z', + flagOverrides: {}, + disabledPublicModelIds: [], + proxyFallbackList: [], + modelPrefix: null, + config: { accounts: [{ email: 'a@b.com', userId: 'u1' }] }, + state: { + accounts: [{ + userId: 'u1', + refresh_token: 'rt', + state: 'active', + state_updated_at: '2026-01-01T00:00:00.000Z', + // Far-future token so warmModelsCache never mints (network-free); its + // catalog fetch is mocked below and swallowed on failure. + accessToken: { token: 'at.cursor.test', expiresAt: 4102444800000, refreshedAt: '2026-01-01T00:00:00.000Z' }, + quotaSnapshot: null, + }], + }, + }); + + // Credential (accounts) edits still belong to cursor-reimport → 400. + const rejected = await requestApp('/api/upstreams/up_cursor_privacy', { + method: 'PATCH', + headers: { 'content-type': 'application/json', 'x-floway-session': adminSession }, + body: JSON.stringify({ config: { accounts: [] } }), + }); + assertEquals(rejected.status, 400); + assertEquals(((await rejected.json()) as { error: string }).error.includes('cursor-reimport'), true); + + // A privacyMode-only edit is accepted and merged over the existing config, so + // the account credentials survive. + const accepted = await withMockedFetch( + () => new Response('', { status: 500 }), + () => requestApp('/api/upstreams/up_cursor_privacy', { + method: 'PATCH', + headers: { 'content-type': 'application/json', 'x-floway-session': adminSession }, + body: JSON.stringify({ config: { privacyMode: false } }), + }), + ); + assertEquals(accepted.status, 200); + const stored = await repo.upstreams.getById('up_cursor_privacy'); + const cfg = stored?.config as { privacyMode?: boolean; accounts?: unknown[] }; + assertEquals(cfg.privacyMode, false); + assertEquals(cfg.accounts?.length, 1); +}); + test('PATCH /api/upstreams accepts metadata edits on a claude-code row', async () => { const { repo, adminSession } = await setupAppTest(); await repo.upstreams.deleteAll(); From f13cef86a1d439df2a8d830702779f493906ab20 Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 2 Jul 2026 01:04:43 +0800 Subject: [PATCH 26/79] feat(web): cursor privacy-mode toggle in config panel, persisted via config PATCH --- apps/web/src/api/types.ts | 3 +++ .../components/upstream-edit/CursorConfigPanel.vue | 14 +++++++++++++- .../upstream-edit/UpstreamConfigPanel.vue | 2 ++ .../components/upstream-edit/UpstreamEditPage.vue | 6 ++++++ 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index e07afa95c..6b472f5cc 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -144,6 +144,9 @@ export interface CursorAccountIdentity { export interface CursorUpstreamConfig { accounts: [CursorAccountIdentity]; + // Ghost/privacy mode toggle sent as the x-ghost-mode data-plane header. + // Absent = privacy on (default). Editable via the config panel. + privacyMode?: boolean; } export interface CursorAccessTokenState { diff --git a/apps/web/src/components/upstream-edit/CursorConfigPanel.vue b/apps/web/src/components/upstream-edit/CursorConfigPanel.vue index ac8ae05c2..3c371dc59 100644 --- a/apps/web/src/components/upstream-edit/CursorConfigPanel.vue +++ b/apps/web/src/components/upstream-edit/CursorConfigPanel.vue @@ -3,7 +3,7 @@ import { ref } from 'vue'; import { callApi, useApi } from '../../api/client.ts'; import type { ProxyFallbackEntry, UpstreamRecord } from '../../api/types.ts'; -import { Button, Spinner } from '@floway-dev/ui'; +import { Button, Spinner, Switch } from '@floway-dev/ui'; type CursorUpstreamRecord = Extract; @@ -26,6 +26,10 @@ const emit = defineEmits<{ error: [message: string]; }>(); +// Ghost/privacy mode toggle. Persisted by the page's Save button via a +// config PATCH (see UpstreamEditPage.save). Only meaningful in edit mode. +const privacyMode = defineModel('privacyMode', { required: true }); + const api = useApi(); interface CursorAuthorizeResult { @@ -98,6 +102,14 @@ const refreshTokenNow = async () => {

{{ record.config.accounts[0].email }}

Cursor · {{ record.state?.accounts[0].state ?? 'unknown' }}

+ +
+
+

Privacy mode

+

Send the ghost-mode header so Cursor does not retain request data. On by default.

+
+ +
+
+ +

+ Token usage for Cursor is an account-level approximation. Cursor exposes no + per-request usage to a gateway, so real usage is pulled hourly from your Cursor account dashboard and split + across API keys by request count. It includes usage from this Cursor account that did not go through Floway + (e.g. your own Cursor IDE or other clients), and lags a few minutes behind. +

+