diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6f6c1cbda..f3317c93d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Fixed + +- Fixed Cursor provider requests failing with `Cannot send empty user message to Cursor API` after tool-result history by selecting the latest user/developer turn instead of assuming the final context message is the active user turn. ## [15.7.5] - 2026-06-01 diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 73d41b3d2..2bc805931 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -2247,7 +2247,7 @@ function findLastUserMessageIndex(messages: Message[]): number { * actual model prompt. `turns[]` is UI/display metadata. Without populating * this field, multi-turn conversations lose prior context — the model sees * only an empty placeholder where historical user turns should be. - * The last user message is excluded because it is sent in the action. + * The active user message is excluded because it is sent in the action. */ /** * Build one Cursor system-message JSON blob per ordered system prompt. Emitting separate blobs @@ -2270,17 +2270,16 @@ function buildRootPromptMessagesJson( messages: Message[], systemPromptIds: Uint8Array[], blobStore: Map, + activeUserMessageIndex = findLastUserMessageIndex(messages), ): Uint8Array[] { const entries: Uint8Array[] = [...systemPromptIds]; - const lastUserIdx = findLastUserMessageIndex(messages); - const pushJson = (obj: unknown) => { const bytes = new TextEncoder().encode(JSON.stringify(obj)); entries.push(storeCursorBlob(blobStore, bytes)); }; for (let i = 0; i < messages.length; i++) { - if (i === lastUserIdx) break; + if (i === activeUserMessageIndex) break; const msg = messages[i]; if (msg.role === "user" || msg.role === "developer") { const content = buildCursorRootPromptContent(msg.content); @@ -2306,12 +2305,16 @@ function buildRootPromptMessagesJson( /** * Convert context.messages to Cursor's ConversationTurnStructure blob IDs. * Groups messages into turns: each turn is a user message followed by the assistant's response. - * Excludes the last user message (which goes in the action). + * Excludes the active user message (which goes in the action). * * Each `AgentConversationTurnStructure.user_message`, `steps[]`, and the outer * `ConversationStateStructure.turns[]` entry is a blob ID into `blobStore`. */ -function buildConversationTurns(messages: Message[], blobStore: Map): Uint8Array[] { +function buildConversationTurns( + messages: Message[], + blobStore: Map, + activeUserMessageIndex = findLastUserMessageIndex(messages), +): Uint8Array[] { const turns: Uint8Array[] = []; // Find turn boundaries - each turn starts with a user message @@ -2325,15 +2328,10 @@ function buildConversationTurns(messages: Message[], blobStore: Map(); - const rootPromptMessagesJson = buildRootPromptMessagesJson(messages, [], blobStore).map(blobId => - JSON.parse(new TextDecoder().decode(readCursorBlob(blobStore, blobId))), + const rootPromptMessagesJson = buildRootPromptMessagesJson(messages, [], blobStore, activeUserMessageIndex).map( + blobId => JSON.parse(new TextDecoder().decode(readCursorBlob(blobStore, blobId))), ); const turnUserMessagesJson: JsonValue[] = []; - for (const turnBlobId of buildConversationTurns(messages, blobStore)) { + const turnStepMessagesJson: JsonValue[][] = []; + for (const turnBlobId of buildConversationTurns(messages, blobStore, activeUserMessageIndex)) { const turn = fromBinary(ConversationTurnStructureSchema, readCursorBlob(blobStore, turnBlobId)); if (turn.turn.case !== "agentConversationTurn") { continue; } const userMessage = fromBinary(UserMessageSchema, readCursorBlob(blobStore, turn.turn.value.userMessage)); turnUserMessagesJson.push(toJson(UserMessageSchema, userMessage)); + turnStepMessagesJson.push( + turn.turn.value.steps.map(stepBlobId => { + const step = fromBinary(ConversationStepSchema, readCursorBlob(blobStore, stepBlobId)); + return toJson(ConversationStepSchema, step); + }), + ); } - return { rootPromptMessagesJson, turnUserMessagesJson }; + return { rootPromptMessagesJson, turnUserMessagesJson, turnStepMessagesJson }; } function createCursorUserMessage( content: string | (TextContent | ImageContent)[], @@ -2479,12 +2488,15 @@ function buildGrpcRequest( storeCursorBlob(blobStore, new TextEncoder().encode(json)), ); - const lastMessage = context.messages[context.messages.length - 1]; + const activeUserMessageIndex = context.messages.length - 1; + const activeMessage = context.messages[activeUserMessageIndex]; + const activeUserMessage = + activeMessage?.role === "user" || activeMessage?.role === "developer" ? activeMessage : undefined; let userContent: string | (TextContent | ImageContent)[] | undefined; let userText = ""; let hasUserImages = false; - if (lastMessage?.role === "user" || lastMessage?.role === "developer") { - userContent = lastMessage.content; + if (activeUserMessage?.role === "user" || activeUserMessage?.role === "developer") { + userContent = activeUserMessage.content; if (typeof userContent === "string") { userText = userContent.trim(); } else { @@ -2508,15 +2520,20 @@ function buildGrpcRequest( }, }); - // Build conversation turns from prior messages (excluding the last user message). - // This populates the UI-side history view (`turns[]`). - const turns = buildConversationTurns(context.messages, blobStore); + // Build conversation turns from prior messages, excluding only the active user message + // when the request is sending one. Resume actions must preserve trailing tool results. + const turns = buildConversationTurns(context.messages, blobStore, activeUserMessage ? activeUserMessageIndex : -1); // Build `rootPromptMessagesJson` from prior messages. Cursor's server uses this // field (not `turns[]`) to construct the actual model prompt; if we only send the // system prompt here, multi-turn conversations lose prior context and the model // sees only the current user message. - const rootPromptMessagesJson = buildRootPromptMessagesJson(context.messages, systemPromptIds, blobStore); + const rootPromptMessagesJson = buildRootPromptMessagesJson( + context.messages, + systemPromptIds, + blobStore, + activeUserMessage ? activeUserMessageIndex : -1, + ); // Preserve cached non-history state fields (todos, file states, summaries, etc.) // when the system prompt is unchanged; otherwise start fresh. diff --git a/packages/ai/test/cursor-exec-handlers.test.ts b/packages/ai/test/cursor-exec-handlers.test.ts index 75d0ad2a2..b0ec0af0b 100644 --- a/packages/ai/test/cursor-exec-handlers.test.ts +++ b/packages/ai/test/cursor-exec-handlers.test.ts @@ -41,6 +41,46 @@ function isAgentRunRequest(payload: unknown): payload is AgentRunRequest { return !!payload && typeof payload === "object" && "$typeName" in payload; } +function toolResultContext(): Context { + return { + messages: [ + { role: "user", content: "Use the read tool.", timestamp: 1 }, + { + role: "assistant", + api: "cursor-agent", + provider: "cursor", + model: "cursor-composer-2.5", + content: [ + { + type: "toolCall", + id: "call-read", + name: "read", + arguments: { path: "package.json" }, + }, + ], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: 2, + }, + { + role: "toolResult", + toolCallId: "call-read", + toolName: "read", + content: [{ type: "text", text: "package contents" }], + isError: false, + timestamp: 3, + }, + ], + }; +} + describe("Cursor resolveExecHandler execHandlers binding", () => { it("invokes handler with correct this when passed as bound method", async () => { const sentinel = { tag: "bound-correctly" }; @@ -105,6 +145,7 @@ describe("Cursor system prompt encoding", () => { expect(JSON.parse(jsons[0])).toEqual({ role: "system", content: "You are a helpful assistant." }); }); }); + describe("Cursor request action encoding", () => { it("uses a resume action for empty user turns", async () => { const payload = await captureCursorPayload({ @@ -122,6 +163,12 @@ describe("Cursor request action encoding", () => { expect(payload.action?.action.case).toBe("userMessageAction"); }); + it("uses a resume action when a tool result is the final context message", async () => { + const payload = await captureCursorPayload(toolResultContext()); + + expect(payload.action?.action.case).toBe("resumeAction"); + }); + it("uses a user message action with selected context for image-only user turns", async () => { const imageData = "aW1hZ2U="; const payload = await captureCursorPayload({ @@ -201,4 +248,23 @@ describe("Cursor history encoding", () => { }), ]); }); + + it("preserves trailing tool result history for resume actions", () => { + const history = buildCursorHistoryForTest(toolResultContext().messages, -1); + + expect(history.rootPromptMessagesJson).toEqual([ + { + role: "user", + content: [{ type: "text", text: "Use the read tool." }], + }, + { + role: "user", + content: [{ type: "text", text: "[Tool Result]\npackage contents" }], + }, + ]); + expect(history.turnUserMessagesJson).toEqual([expect.objectContaining({ text: "Use the read tool." })]); + expect(history.turnStepMessagesJson).toEqual([ + [expect.objectContaining({ assistantMessage: { text: "[Tool Result]\npackage contents" } })], + ]); + }); });