Merge pull request #1312 from basedcorp99/fix/cursor-last-user-message

Fix Cursor request user message selection
This commit is contained in:
Can Bölük
2026-06-02 08:55:35 +03:00
committed by GitHub
3 changed files with 113 additions and 27 deletions
+3
View File
@@ -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
+44 -27
View File
@@ -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<string, Uint8Array>,
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<string, Uint8Array>): Uint8Array[] {
function buildConversationTurns(
messages: Message[],
blobStore: Map<string, Uint8Array>,
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<string, Uint
continue;
}
// Check if this is the last user message (which goes in the action, not turns)
let isLastUserMessage = true;
for (let j = i + 1; j < messages.length; j++) {
if (messages[j].role === "user" || messages[j].role === "developer") {
isLastUserMessage = false;
break;
}
}
if (isLastUserMessage) {
// The active user message goes in the action, not turns. A prior user
// followed by assistant/tool-result messages is complete history and
// must remain serialized for resume actions.
if (i === activeUserMessageIndex) {
break;
}
@@ -2406,24 +2404,35 @@ function buildConversationTurns(messages: Message[], blobStore: Map<string, Uint
}
/** Exported for tests: decodes Cursor history blobs built from conversation messages. */
export function buildCursorHistoryForTest(messages: Message[]): {
export function buildCursorHistoryForTest(
messages: Message[],
activeUserMessageIndex = findLastUserMessageIndex(messages),
): {
rootPromptMessagesJson: unknown[];
turnUserMessagesJson: JsonValue[];
turnStepMessagesJson: JsonValue[][];
} {
const blobStore = new Map<string, Uint8Array>();
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.
@@ -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" } })],
]);
});
});