From 467437a47ee4ffcc67e3d5d1900a00a72682acbf Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 3 Aug 2026 15:19:17 +0000 Subject: [PATCH] fix(cursor): degrade K3 same-model history missing thinking assertCursorKimiK3HistoryReplayable threw for any assistant turn lacking a non-empty thinking block, including same-model kimi-k3 turns whose stream carried no thinkingDelta events. Such a turn is persisted with only text/toolCall blocks, so every subsequent turn failed locally before any request, permanently bricking the session. Split the two failure modes: foreign history still hard-errors (another model's turns cannot replay K3 reasoning), but a same-model turn missing thinking now degrades to a one-time warning and replays without the reasoning part via buildCursorAssistantContent, which already omits it. Fixes #7516 --- packages/ai/CHANGELOG.md | 4 ++ packages/ai/src/providers/cursor.ts | 25 ++++++++++-- packages/ai/test/cursor-exec-handlers.test.ts | 40 ++++++++++++++++++- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index abfe680ff..957e612bd 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Cursor `kimi-k3` sessions bricking permanently when a same-model assistant turn was persisted without thinking blocks (a turn whose stream carried no `thinkingDelta` events). `assertCursorKimiK3HistoryReplayable` now only hard-errors on genuinely foreign history; same-model turns missing thinking degrade to a one-time warning and replay without the reasoning part ([#7516](https://github.com/can1357/oh-my-pi/issues/7516)). + ## [17.2.6] - 2026-08-03 ### Added diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 119e50869..97b7113b1 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -144,6 +144,7 @@ import { isKimiK3ModelId } from "@oh-my-pi/pi-catalog/identity"; import { calculateCost } from "@oh-my-pi/pi-catalog/models"; import { $env, + logger, parseJsonWithRepair, parseStreamingJson, parseStreamingJsonThrottled, @@ -4113,16 +4114,34 @@ function assertCursorKimiK3HistoryReplayable( ): void { if (!targetModelId || !isKimiK3ModelId(targetModelId)) return; const historyEnd = activeUserMessageIndex >= 0 ? activeUserMessageIndex : messages.length; + let sawSameModelMissingThinking = false; 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) { + if (!isSameCursorModel) { + // Foreign history genuinely cannot replay K3 thinking: another model's + // turns carry no K3-signed reasoning to reconstruct, so continuation + // is unsafe and must fail hard. 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}.`, + `Cursor ${targetModelId} cannot continue history from a different model (${msg.provider}/${msg.model}); start a new session.`, ); } + const hasThinking = msg.content.some(item => item.type === "thinking" && item.thinking.length > 0); + if (!hasThinking) sawSameModelMissingThinking = true; + } + if (sawSameModelMissingThinking) { + // A same-model turn whose stream carried no `thinkingDelta` events is + // persisted without thinking blocks. `buildCursorAssistantContent` still + // replays its text/tool-call structure and simply omits the reasoning + // part, so the history remains usable — degrade with a one-time warning + // rather than permanently bricking the session. Per Kimi's + // preserved-thinking caveat, K3 generation may be less stable across the + // affected span. + logger.warn( + "Cursor kimi-k3 history contains a same-model assistant turn without thinking blocks; replaying without reasoning for that span (generation may be less stable)", + { model: targetModelId }, + ); } } diff --git a/packages/ai/test/cursor-exec-handlers.test.ts b/packages/ai/test/cursor-exec-handlers.test.ts index a0b737a5c..14ce2a562 100644 --- a/packages/ai/test/cursor-exec-handlers.test.ts +++ b/packages/ai/test/cursor-exec-handlers.test.ts @@ -757,10 +757,48 @@ describe("Cursor history encoding", () => { ]; expect(() => buildCursorHistoryForTest(messages, undefined, "kimi-k3-high")).toThrow( - "start a new session instead of continuing history from anthropic/claude-4.6-opus-high", + "cannot continue history from a different model (anthropic/claude-4.6-opus-high)", ); }); + it("replays a same-model K3 turn missing thinking instead of bricking the session", () => { + const messages: Context["messages"] = [ + { role: "user", content: "Do a big multi-tool task", timestamp: 1 }, + cursorAssistant( + "kimi-k3-high", + [{ 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: "text", text: "Done." }], 4), + { role: "user", content: "Continue the same session", timestamp: 5 }, + ]; + + const history = buildCursorHistoryForTest(messages, undefined, "kimi-k3-high"); + + expect(history.rootPromptMessagesJson).toEqual([ + { role: "user", content: [{ type: "text", text: "Do a big multi-tool task" }] }, + { + 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" }], + }, + { role: "assistant", content: [{ type: "text", text: "Done." }] }, + ]); + }); + it("keeps non-K3 Cursor thinking out of model-facing history", () => { const messages: Context["messages"] = [ { role: "user", content: "Inspect package.json", timestamp: 1 },