Address Cursor tool-result resume history
This commit is contained in:
@@ -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,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.
|
||||
|
||||
@@ -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" } })],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user