diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 14ccbea6f..cf8103b84 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 754c892d5..c55f24431 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -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 }; + +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, 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 { + 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 { + const encoded: Record = {}; + 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(); + const pairedToolCallIds = new Set(); + 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(); - 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.) diff --git a/packages/ai/test/cursor-exec-handlers.test.ts b/packages/ai/test/cursor-exec-handlers.test.ts index bfdc4fad3..76fb799ae 100644 --- a/packages/ai/test/cursor-exec-handlers.test.ts +++ b/packages/ai/test/cursor-exec-handlers.test.ts @@ -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 { const { promise, resolve, reject } = Promise.withResolvers(); @@ -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" } }, + }), + }), + }), + ], ]); }); }); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 6104a2571..7e4946048 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -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 diff --git a/packages/catalog/src/discovery/cursor.ts b/packages/catalog/src/discovery/cursor.ts index 7f4c1efc6..dce510286 100644 --- a/packages/catalog/src/discovery/cursor.ts +++ b/packages/catalog/src/discovery/cursor.ts @@ -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 { diff --git a/packages/catalog/test/cursor-discovery.test.ts b/packages/catalog/test/cursor-discovery.test.ts index b3d99613d..de6d8aeb7 100644 --- a/packages/catalog/test/cursor-discovery.test.ts +++ b/packages/catalog/test/cursor-discovery.test.ts @@ -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