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:
@@ -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
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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" } },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user