fix(cursor): preserved structured K3 history replay

- Rebuilt assistant thinking, tool calls, and paired tool results in Cursor-native history shapes.
- Rejected K3 continuation when prior same-model thinking cannot be replayed safely.
- Marked dynamically discovered Cursor K3 variants as reasoning models.

Fixes #7184
This commit is contained in:
roboomp
2026-07-31 15:23:52 +00:00
parent 4df68d6043
commit 53eae701bd
6 changed files with 476 additions and 59 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Cursor history replay flattening assistant tool calls/results and dropping same-model Kimi K3 thinking, preserving Cursor's structured message order and rejecting unsafe mid-session switches to K3 ([#7184](https://github.com/can1357/oh-my-pi/issues/7184)).
## [17.2.1] - 2026-07-30
### Added
+226 -51
View File
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
import http2 from "node:http2";
import { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from "@bufbuild/protobuf";
import { ValueSchema } from "@bufbuild/protobuf/wkt";
import type { McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
import type { ConversationStep, McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
import {
AgentClientMessageSchema,
AgentConversationTurnStructureSchema,
@@ -76,15 +76,19 @@ import {
LsSuccessSchema,
McpAllowlistPrecheckResultSchema,
McpApprovedSchema,
McpArgsSchema,
McpErrorSchema,
McpImageContentSchema,
McpRejectedSchema,
McpResultSchema,
McpSuccessSchema,
McpTextContentSchema,
McpToolCallSchema,
McpToolDefinitionSchema,
McpToolErrorSchema,
McpToolNotFoundSchema,
McpToolResultContentItemSchema,
McpToolResultSchema,
ModelDetailsSchema,
ReadErrorSchema,
ReadMcpResourceErrorSchema,
@@ -124,6 +128,8 @@ import {
SubagentAwaitResultSchema,
SubagentErrorSchema,
SubagentResultSchema,
ThinkingMessageSchema,
ToolCallSchema,
UserMessageActionSchema,
UserMessageSchema,
WebFetchAllowlistPrecheckResultSchema,
@@ -134,6 +140,7 @@ import {
WriteShellStdinResultSchema,
WriteSuccessSchema,
} from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
import { isKimiK3ModelId } from "@oh-my-pi/pi-catalog/identity";
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
import {
$env,
@@ -4047,16 +4054,74 @@ function cursorUserContentKey(content: string | (TextContent | ImageContent)[]):
return hash.digest("hex");
}
/**
* Extract text content from an assistant message.
*/
function extractAssistantMessageText(msg: Message): string {
if (msg.role !== "assistant") return "";
if (!Array.isArray(msg.content)) return "";
return msg.content
.filter((c): c is TextContent => c.type === "text")
.map(c => c.text)
.join("\n");
type CursorRootPromptAssistantContentPart =
| { type: "text"; text: string }
| {
type: "reasoning";
text: string;
providerOptions: { cursor: { modelName: string } };
signature?: string;
}
| { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown> };
function canReplayCursorThinking(msg: AssistantMessage, targetModelId: string | undefined): boolean {
return (
targetModelId !== undefined &&
isKimiK3ModelId(targetModelId) &&
msg.api === "cursor-agent" &&
msg.provider === "cursor" &&
msg.model === targetModelId
);
}
function buildCursorAssistantContent(
msg: AssistantMessage,
targetModelId: string | undefined,
): CursorRootPromptAssistantContentPart[] {
const content: CursorRootPromptAssistantContentPart[] = [];
const replayThinking = canReplayCursorThinking(msg, targetModelId);
for (const item of msg.content) {
if (item.type === "text") {
if (item.text) content.push({ type: "text", text: item.text });
} else if (item.type === "thinking") {
if (replayThinking && item.thinking) {
content.push({
type: "reasoning",
text: item.thinking,
providerOptions: { cursor: { modelName: msg.model } },
...(item.thinkingSignature ? { signature: item.thinkingSignature } : {}),
});
}
} else if (item.type === "toolCall") {
content.push({
type: "tool-call",
toolCallId: item.id,
toolName: item.name,
args: item.arguments,
});
}
}
return content;
}
function assertCursorKimiK3HistoryReplayable(
messages: Message[],
activeUserMessageIndex: number,
targetModelId: string | undefined,
): void {
if (!targetModelId || !isKimiK3ModelId(targetModelId)) return;
const historyEnd = activeUserMessageIndex >= 0 ? activeUserMessageIndex : messages.length;
for (let i = 0; i < historyEnd; i++) {
const msg = messages[i];
if (msg.role !== "assistant") continue;
const isSameCursorModel = msg.api === "cursor-agent" && msg.provider === "cursor" && msg.model === targetModelId;
const hasThinking = msg.content.some(item => item.type === "thinking" && item.thinking.length > 0);
if (!isSameCursorModel || !hasThinking) {
throw new AIError.ValidationError(
`Cursor ${targetModelId} requires complete same-model thinking history; start a new session instead of continuing history from ${msg.provider}/${msg.model}.`,
);
}
}
}
/**
@@ -4107,7 +4172,9 @@ function buildRootPromptMessagesJson(
systemPromptIds: Uint8Array[],
blobStore: Map<string, Uint8Array>,
activeUserMessageIndex = findLastUserMessageIndex(messages),
targetModelId?: string,
): Uint8Array[] {
assertCursorKimiK3HistoryReplayable(messages, activeUserMessageIndex, targetModelId);
const entries: Uint8Array[] = [...systemPromptIds];
const pushJson = (obj: unknown) => {
const bytes = new TextEncoder().encode(JSON.stringify(obj));
@@ -4122,16 +4189,24 @@ function buildRootPromptMessagesJson(
if (content.length === 0) continue;
pushJson({ role: "user", content });
} else if (msg.role === "assistant") {
const text = extractAssistantMessageText(msg);
if (!text) continue;
pushJson({ role: "assistant", content: [{ type: "text", text }] });
const content = buildCursorAssistantContent(msg, targetModelId);
if (content.length === 0) continue;
pushJson({ role: "assistant", content });
} else if (msg.role === "toolResult") {
const text = toolResultToText(msg);
if (!text) continue;
const prefix = msg.isError ? "[Tool Error]" : "[Tool Result]";
const result = toolResultToText(msg);
if (!result) continue;
pushJson({
role: "user",
content: [{ type: "text", text: `${prefix}\n${text}` }],
role: "tool",
id: msg.toolCallId,
content: [
{
type: "tool-result",
toolName: msg.toolName,
toolCallId: msg.toolCallId,
result,
...(msg.isError ? { isError: true } : {}),
},
],
});
}
}
@@ -4139,6 +4214,90 @@ function buildRootPromptMessagesJson(
return entries;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function isJsonValue(value: unknown): value is JsonValue {
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
if (!isPlainRecord(value)) return false;
for (const key in value) {
if (!isJsonValue(value[key])) return false;
}
return true;
}
function encodeCursorMcpArguments(toolCall: ToolCall): Record<string, Uint8Array> {
const encoded: Record<string, Uint8Array> = {};
for (const name in toolCall.arguments) {
const value = toolCall.arguments[name];
if (!isJsonValue(value)) {
throw new AIError.ValidationError(`Cursor tool argument ${toolCall.name}.${name} is not JSON-serializable`);
}
encoded[name] = toBinary(ValueSchema, fromJson(ValueSchema, value));
}
return encoded;
}
function createCursorMcpResult(result: ToolResultMessage) {
if (result.isError) {
return create(McpToolResultSchema, {
result: {
case: "error",
value: create(McpToolErrorSchema, { error: toolResultToText(result) }),
},
});
}
return create(McpToolResultSchema, {
result: {
case: "success",
value: create(McpSuccessSchema, {
content: result.content.map(item =>
item.type === "text"
? create(McpToolResultContentItemSchema, {
content: { case: "text", value: create(McpTextContentSchema, { text: item.text }) },
})
: create(McpToolResultContentItemSchema, {
content: {
case: "image",
value: create(McpImageContentSchema, {
data: Uint8Array.from(Buffer.from(item.data, "base64")),
mimeType: item.mimeType,
}),
},
}),
),
}),
},
});
}
function createCursorToolCallStep(toolCall: ToolCall, result: ToolResultMessage | undefined) {
const mcpCall = create(McpToolCallSchema, {
args: create(McpArgsSchema, {
name: toolCall.name,
args: encodeCursorMcpArguments(toolCall),
toolCallId: toolCall.id,
providerIdentifier: "pi-agent",
toolName: toolCall.name,
}),
...(result ? { result: createCursorMcpResult(result) } : {}),
});
return create(ConversationStepSchema, {
message: {
case: "toolCall",
value: create(ToolCallSchema, {
tool: { case: "mcpToolCall", value: mcpCall },
toolCallId: toolCall.id,
}),
},
});
}
/**
* 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.
@@ -4153,26 +4312,29 @@ function buildConversationTurns(
activeUserMessageIndex = findLastUserMessageIndex(messages),
): Uint8Array[] {
const turns: Uint8Array[] = [];
const historyEnd = activeUserMessageIndex >= 0 ? activeUserMessageIndex : messages.length;
const toolResults = new Map<string, ToolResultMessage>();
const pairedToolCallIds = new Set<string>();
for (let index = 0; index < historyEnd; index++) {
const message = messages[index];
if (message.role === "toolResult") {
toolResults.set(message.toolCallId, message);
} else if (message.role === "assistant") {
for (const item of message.content) {
if (item.type === "toolCall") pairedToolCallIds.add(item.id);
}
}
}
// Find turn boundaries - each turn starts with a user message
let i = 0;
while (i < messages.length) {
const msg = messages[i];
// Skip non-user messages at the start
if (msg.role !== "user" && msg.role !== "developer") {
i++;
continue;
}
if (i === activeUserMessageIndex) break;
// 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;
}
// Create and serialize user message
const userText = extractUserMessageText(msg);
if (userText.length === 0 && !hasUserMessageImages(msg)) {
i++;
@@ -4184,29 +4346,39 @@ function buildConversationTurns(
userText,
deterministicUuid(`u:${turns.length}:${cursorUserContentKey(msg.content)}`),
);
const userMessageBytes = toBinary(UserMessageSchema, userMessage);
const userMessageBlobId = storeCursorBlob(blobStore, userMessageBytes);
// Collect and serialize steps until next user message
const userMessageBlobId = storeCursorBlob(blobStore, toBinary(UserMessageSchema, userMessage));
const stepBlobIds: Uint8Array[] = [];
i++;
while (i < messages.length && messages[i].role !== "user" && messages[i].role !== "developer") {
const stepMsg = messages[i];
if (stepMsg.role === "assistant") {
const text = extractAssistantMessageText(stepMsg);
if (text) {
const step = create(ConversationStepSchema, {
message: {
case: "assistantMessage",
value: create(AssistantMessageSchema, { text }),
},
});
for (const item of stepMsg.content) {
let step: ConversationStep;
if (item.type === "text") {
if (!item.text) continue;
step = create(ConversationStepSchema, {
message: {
case: "assistantMessage",
value: create(AssistantMessageSchema, { text: item.text }),
},
});
} else if (item.type === "thinking") {
if (!item.thinking) continue;
step = create(ConversationStepSchema, {
message: {
case: "thinkingMessage",
value: create(ThinkingMessageSchema, { text: item.thinking }),
},
});
} else if (item.type === "toolCall") {
step = createCursorToolCallStep(item, toolResults.get(item.id));
} else {
continue;
}
stepBlobIds.push(storeCursorBlob(blobStore, toBinary(ConversationStepSchema, step)));
}
} else if (stepMsg.role === "toolResult") {
// Include tool results as assistant text for context
} else if (stepMsg.role === "toolResult" && !pairedToolCallIds.has(stepMsg.toolCallId)) {
const text = toolResultToText(stepMsg);
if (text) {
const prefix = stepMsg.isError ? "[Tool Error]" : "[Tool Result]";
@@ -4219,12 +4391,9 @@ function buildConversationTurns(
stepBlobIds.push(storeCursorBlob(blobStore, toBinary(ConversationStepSchema, step)));
}
}
i++;
}
// Create the serialized turn using Structure types. The bytes fields
// (user_message, steps) are blob IDs resolved through the KV store.
const agentTurn = create(AgentConversationTurnStructureSchema, {
userMessage: userMessageBlobId,
steps: stepBlobIds,
@@ -4245,15 +4414,20 @@ function buildConversationTurns(
export function buildCursorHistoryForTest(
messages: Message[],
activeUserMessageIndex = findLastUserMessageIndex(messages),
targetModelId?: string,
): {
rootPromptMessagesJson: unknown[];
turnUserMessagesJson: JsonValue[];
turnStepMessagesJson: JsonValue[][];
} {
const blobStore = new Map<string, Uint8Array>();
const rootPromptMessagesJson = buildRootPromptMessagesJson(messages, [], blobStore, activeUserMessageIndex).map(
blobId => JSON.parse(new TextDecoder().decode(readCursorBlob(blobStore, blobId))),
);
const rootPromptMessagesJson = buildRootPromptMessagesJson(
messages,
[],
blobStore,
activeUserMessageIndex,
targetModelId,
).map(blobId => JSON.parse(new TextDecoder().decode(readCursorBlob(blobStore, blobId))));
const turnUserMessagesJson: JsonValue[] = [];
const turnStepMessagesJson: JsonValue[][] = [];
for (const turnBlobId of buildConversationTurns(messages, blobStore, activeUserMessageIndex)) {
@@ -4371,6 +4545,7 @@ function buildGrpcRequest(
systemPromptIds,
blobStore,
activeUserMessage ? activeUserMessageIndex : -1,
model.id,
);
// Preserve cached non-history state fields (todos, file states, summaries, etc.)
+229 -7
View File
@@ -59,6 +59,30 @@ const cursorMaxModeModel: Model<"cursor-agent"> = buildModel({
maxTokens: 1,
cursorMaxMode: true,
});
function cursorAssistant(
model: string,
content: AssistantMessage["content"],
timestamp: number,
stopReason: AssistantMessage["stopReason"] = "stop",
): AssistantMessage {
return {
role: "assistant",
api: "cursor-agent",
provider: "cursor",
model,
content,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason,
timestamp,
};
}
function captureCursorPayload(context: Context, model: Model<"cursor-agent"> = cursorModel): Promise<AgentRunRequest> {
const { promise, resolve, reject } = Promise.withResolvers<AgentRunRequest>();
@@ -492,6 +516,147 @@ describe("Cursor request action encoding", () => {
});
describe("Cursor history encoding", () => {
it("preserves same-model K3 thinking and paired tool structure in request history", () => {
const messages: Context["messages"] = [
{ role: "user", content: "Inspect package.json", timestamp: 1 },
cursorAssistant(
"kimi-k3-high",
[
{ type: "thinking", thinking: "I should inspect the package." },
{ type: "toolCall", id: "call-read", name: "read", arguments: { path: "package.json" } },
],
2,
"toolUse",
),
{
role: "toolResult",
toolCallId: "call-read",
toolName: "read",
content: [{ type: "text", text: "package contents" }],
isError: false,
timestamp: 3,
},
cursorAssistant(
"kimi-k3-high",
[
{ type: "thinking", thinking: "The package is valid." },
{ type: "text", text: "Verified." },
],
4,
),
{ role: "user", content: "What did you verify?", timestamp: 5 },
];
const history = buildCursorHistoryForTest(messages, undefined, "kimi-k3-high");
expect(history.rootPromptMessagesJson).toEqual([
{ role: "user", content: [{ type: "text", text: "Inspect package.json" }] },
{
role: "assistant",
content: [
{
type: "reasoning",
text: "I should inspect the package.",
providerOptions: { cursor: { modelName: "kimi-k3-high" } },
},
{
type: "tool-call",
toolCallId: "call-read",
toolName: "read",
args: { path: "package.json" },
},
],
},
{
role: "tool",
id: "call-read",
content: [
{
type: "tool-result",
toolName: "read",
toolCallId: "call-read",
result: "package contents",
},
],
},
{
role: "assistant",
content: [
{
type: "reasoning",
text: "The package is valid.",
providerOptions: { cursor: { modelName: "kimi-k3-high" } },
},
{ type: "text", text: "Verified." },
],
},
]);
expect(history.turnStepMessagesJson).toEqual([
[
expect.objectContaining({ thinkingMessage: { text: "I should inspect the package." } }),
expect.objectContaining({
toolCall: expect.objectContaining({
toolCallId: "call-read",
mcpToolCall: expect.objectContaining({
args: expect.objectContaining({ toolCallId: "call-read", toolName: "read" }),
result: { success: { content: [{ text: { text: "package contents" } }] } },
}),
}),
}),
expect.objectContaining({ thinkingMessage: { text: "The package is valid." } }),
expect.objectContaining({ assistantMessage: { text: "Verified." } }),
],
]);
expect(buildCursorHistoryForTest(messages, undefined, "kimi-k3-high")).toEqual(history);
});
it("rejects switching existing foreign history to K3", () => {
const messages: Context["messages"] = [
{ role: "user", content: "Plan this change.", timestamp: 1 },
{
...cursorAssistant(
"claude-4.6-opus-high",
[{ type: "thinking", thinking: "Foreign signed reasoning.", thinkingSignature: "signature" }],
2,
),
api: "anthropic-messages",
provider: "anthropic",
},
{ role: "user", content: "Continue with K3.", timestamp: 3 },
];
expect(() => buildCursorHistoryForTest(messages, undefined, "kimi-k3-high")).toThrow(
"start a new session instead of continuing history from anthropic/claude-4.6-opus-high",
);
});
it("keeps non-K3 Cursor thinking out of model-facing history", () => {
const messages: Context["messages"] = [
{ role: "user", content: "Inspect package.json", timestamp: 1 },
cursorAssistant(
"cursor-composer-2.5",
[
{ type: "thinking", thinking: "Internal reasoning." },
{ type: "text", text: "Visible answer." },
],
2,
),
{ role: "user", content: "Continue.", timestamp: 3 },
];
const history = buildCursorHistoryForTest(messages, undefined, "cursor-composer-2.5");
expect(history.rootPromptMessagesJson).toEqual([
{ role: "user", content: [{ type: "text", text: "Inspect package.json" }] },
{ role: "assistant", content: [{ type: "text", text: "Visible answer." }] },
]);
expect(history.turnStepMessagesJson).toEqual([
[
expect.objectContaining({ thinkingMessage: { text: "Internal reasoning." } }),
expect.objectContaining({ assistantMessage: { text: "Visible answer." } }),
],
]);
});
it("preserves image-only user turns in root prompt history and conversation turns", () => {
const imageData = "aW1hZ2U=";
const history = buildCursorHistoryForTest([
@@ -553,17 +718,45 @@ describe("Cursor history encoding", () => {
content: [{ type: "text", text: "Use the read tool." }],
},
{
role: "user",
content: [{ type: "text", text: "[Tool Result]\npackage contents" }],
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-read",
toolName: "read",
args: { path: "package.json" },
},
],
},
{
role: "tool",
id: "call-read",
content: [
{
type: "tool-result",
toolName: "read",
toolCallId: "call-read",
result: "package contents",
},
],
},
]);
expect(history.turnUserMessagesJson).toEqual([expect.objectContaining({ text: "Use the read tool." })]);
expect(history.turnStepMessagesJson).toEqual([
[expect.objectContaining({ assistantMessage: { text: "[Tool Result]\npackage contents" } })],
[
expect.objectContaining({
toolCall: expect.objectContaining({
toolCallId: "call-read",
mcpToolCall: expect.objectContaining({
result: { success: { content: [{ text: { text: "package contents" } }] } },
}),
}),
}),
],
]);
});
it("formats tool errors with [Tool Error] prefix", () => {
it("preserves structured tool errors", () => {
const errorContext: Context = {
messages: [
{
@@ -614,12 +807,41 @@ describe("Cursor history encoding", () => {
content: [{ type: "text", text: "Search for nothing." }],
},
{
role: "user",
content: [{ type: "text", text: "[Tool Error]\nPattern must not be empty" }],
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-search",
toolName: "search",
args: { pattern: "" },
},
],
},
{
role: "tool",
id: "call-search",
content: [
{
type: "tool-result",
toolName: "search",
toolCallId: "call-search",
result: "Pattern must not be empty",
isError: true,
},
],
},
]);
expect(history.turnStepMessagesJson).toEqual([
[expect.objectContaining({ assistantMessage: { text: "[Tool Error]\nPattern must not be empty" } })],
[
expect.objectContaining({
toolCall: expect.objectContaining({
toolCallId: "call-search",
mcpToolCall: expect.objectContaining({
result: { error: { error: "Pattern must not be empty" } },
}),
}),
}),
],
]);
});
});
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed dynamically discovered Cursor Kimi K3 effort variants being classified as non-reasoning models when Cursor omits `thinkingDetails` ([#7184](https://github.com/can1357/oh-my-pi/issues/7184)).
## [17.2.1] - 2026-07-30
### Fixed
+2 -1
View File
@@ -1,6 +1,7 @@
import * as http2 from "node:http2";
import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
import { type } from "arktype";
import { isKimiK3ModelId } from "../identity";
import { getBundledModels } from "../models";
import { toModelSpec } from "../provider-models/bundled-references";
import type { Model, ModelSpec } from "../types";
@@ -282,7 +283,7 @@ function normalizeCursorModel(
const name = pickModelDisplayName(details, id);
const reference = references.get(id);
const reasoning = Boolean(details.thinkingDetails) || reference?.reasoning === true;
const reasoning = isKimiK3ModelId(id) || Boolean(details.thinkingDetails) || reference?.reasoning === true;
if (reference) {
return {
@@ -20,6 +20,10 @@ const FIXTURE_MODEL_IDS = [
"gemini-4-pro-exp",
// Reference-less ids from text-only families.
"composer-3",
// Reference-less K3 effort variants omit Cursor thinkingDetails.
"kimi-k3-high",
"kimi-k3-low",
"kimi-k3-max",
"grok-code-fast-2",
// Bundled-reference ids: the reference stays authoritative.
"claude-4.5-opus-high",
@@ -81,6 +85,13 @@ describe("cursor discovery input modalities (issue #4726)", () => {
expect(byId.get("grok-code-fast-2")?.input).toEqual(["text"]);
});
it("recognizes reference-less Kimi K3 effort variants as reasoning models", async () => {
const byId = await discover();
expect(byId.get("kimi-k3-high")?.reasoning).toBe(true);
expect(byId.get("kimi-k3-low")?.reasoning).toBe(true);
expect(byId.get("kimi-k3-max")?.reasoning).toBe(true);
});
it("keeps bundled references authoritative for input modalities", async () => {
const byId = await discover();
// Bundled cursor references carry their own input classification; the