From 7a9d43f14129d61d14abf7f45ad69a886e9b5d65 Mon Sep 17 00:00:00 2001 From: basedcorp99 Date: Sat, 23 May 2026 16:52:00 +0200 Subject: [PATCH 1/2] fix cursor request user message selection --- packages/ai/CHANGELOG.md | 3 + packages/ai/src/providers/cursor.ts | 3 +- packages/ai/test/cursor-exec-handlers.test.ts | 77 ++++++++++++++++++- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b98e0c032..723f22988 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.2.4] - 2026-05-22 diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index da34d0c4f..42fa15f70 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -2322,7 +2322,8 @@ function buildGrpcRequest( storeCursorBlob(blobStore, new TextEncoder().encode(json)), ); - const lastMessage = context.messages[context.messages.length - 1]; + const lastUserIdx = findLastUserMessageIndex(context.messages); + const lastMessage = lastUserIdx >= 0 ? context.messages[lastUserIdx] : undefined; const userText = lastMessage?.role === "user" || lastMessage?.role === "developer" ? typeof lastMessage.content === "string" diff --git a/packages/ai/test/cursor-exec-handlers.test.ts b/packages/ai/test/cursor-exec-handlers.test.ts index 00a2e9f5b..0b7f4a721 100644 --- a/packages/ai/test/cursor-exec-handlers.test.ts +++ b/packages/ai/test/cursor-exec-handlers.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from "bun:test"; -import { buildCursorSystemPromptJsons, resolveExecHandler } from "../src/providers/cursor"; +import { afterEach, describe, expect, it, vi } from "bun:test"; +import http2 from "node:http2"; +import { buildCursorSystemPromptJsons, resolveExecHandler, streamCursor } from "../src/providers/cursor"; +import type { Context, Model } from "../src/types"; + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("Cursor resolveExecHandler execHandlers binding", () => { it("invokes handler with correct this when passed as bound method", async () => { @@ -65,3 +71,70 @@ describe("Cursor system prompt encoding", () => { expect(JSON.parse(jsons[0])).toEqual({ role: "system", content: "You are a helpful assistant." }); }); }); +describe("Cursor stream request assembly", () => { + it("uses the latest user message when a tool result is the final context message", async () => { + const model: Model<"cursor-agent"> = { + id: "cursor-test", + name: "Cursor Test", + api: "cursor-agent", + provider: "cursor", + baseUrl: "https://cursor.invalid", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_000, + }; + const context: Context = { + messages: [ + { role: "user", content: "Use the read tool.", timestamp: 1 }, + { + role: "assistant", + api: "cursor-agent", + provider: "cursor", + model: "cursor-test", + 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, + }, + ], + }; + + const connect = vi.spyOn(http2, "connect").mockImplementation(() => { + throw new Error("request built"); + }); + + const result = await streamCursor(model, context, { + apiKey: "cursor-test-token", + sessionId: "cursor-last-user-regression", + }).result(); + + expect(connect).toHaveBeenCalledTimes(1); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("request built"); + expect(result.errorMessage).not.toContain("Cannot send empty user message"); + }); +}); From 417336b59c28f232937eab90e0785ef6f4b05e04 Mon Sep 17 00:00:00 2001 From: basedcorp99 Date: Mon, 1 Jun 2026 21:29:44 +0200 Subject: [PATCH 2/2] Address Cursor tool-result resume history --- packages/ai/src/providers/cursor.ts | 72 +++++++----- packages/ai/test/cursor-exec-handlers.test.ts | 104 +++++++++++------- 2 files changed, 106 insertions(+), 70 deletions(-) diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index af9eac265..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,13 +2488,15 @@ function buildGrpcRequest( storeCursorBlob(blobStore, new TextEncoder().encode(json)), ); - const lastUserIdx = findLastUserMessageIndex(context.messages); - const lastMessage = lastUserIdx >= 0 ? context.messages[lastUserIdx] : undefined; + 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 { @@ -2509,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 cdfb7143f..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" }; @@ -123,49 +163,10 @@ describe("Cursor request action encoding", () => { expect(payload.action?.action.case).toBe("userMessageAction"); }); - it("uses the latest user message when a tool result is the final context message", async () => { - const payload = await captureCursorPayload({ - 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, - }, - ], - }); + it("uses a resume action when a tool result is the final context message", async () => { + const payload = await captureCursorPayload(toolResultContext()); - if (payload.action?.action.case !== "userMessageAction") { - throw new Error("Expected Cursor userMessageAction"); - } - expect(payload.action.action.value.userMessage?.text).toBe("Use the read tool."); + expect(payload.action?.action.case).toBe("resumeAction"); }); it("uses a user message action with selected context for image-only user turns", async () => { @@ -247,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" } })], + ]); + }); });