diff --git a/.changeset/exclude-chatgpt-from-prompt-cache-breakpoint.md b/.changeset/exclude-chatgpt-from-prompt-cache-breakpoint.md new file mode 100644 index 00000000000..6362bdd7ff1 --- /dev/null +++ b/.changeset/exclude-chatgpt-from-prompt-cache-breakpoint.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Exclude ChatGPT subscriptions from explicit prompt cache breakpoints. diff --git a/.changeset/show-vscode-session-errors.md b/.changeset/show-vscode-session-errors.md new file mode 100644 index 00000000000..5302a13a707 --- /dev/null +++ b/.changeset/show-vscode-session-errors.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep model and provider errors visible in VS Code when chat history refreshes. diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index c4bd2f9f74e..c1284b1d481 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -450,7 +450,7 @@ export type WebviewMessage = | { type: "sessionUpdated"; session: ReturnType } | { type: "sessionDeleted"; sessionID: string } | { type: "messageRemoved"; sessionID: string; messageID: string } - | { type: "sessionError"; sessionID?: string; error?: unknown } + | { type: "sessionError"; eventID: string; sessionID?: string; error?: unknown } | { type: "sandboxStatus" sessionID: string @@ -620,6 +620,7 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin case "session.error": { return { type: "sessionError", + eventID: event.id, sessionID: event.properties.sessionID, error: event.properties.error, } diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 3a9213d887f..75ed68671e5 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -21,6 +21,7 @@ import type { Event, EventSessionStatus, EventSessionTurnClose, + EventSessionError, EventSandboxStatusChanged, EventPermissionAsked, EventPermissionReplied, @@ -375,6 +376,30 @@ describe("mapSSEEventToWebviewMessage", () => { expect(msg).toEqual({ type: "sessionTurnClosed", sessionID: "sess-1", reason: "interrupted" }) }) + it("maps session errors with their event identity and message", () => { + const event: EventSessionError = { + id: "evt-error", + type: "session.error", + properties: { + sessionID: "sess-1", + error: { + name: "APIError", + data: { + message: "prompt_cache_breakpoint is not supported on this model", + isRetryable: false, + }, + }, + }, + } + + expect(mapSSEEventToWebviewMessage(event, "sess-1")).toEqual({ + type: "sessionError", + eventID: "evt-error", + sessionID: "sess-1", + error: event.properties.error, + }) + }) + it("maps permission.asked to permissionRequest", () => { const event: EventPermissionAsked = { type: "permission.asked", diff --git a/packages/kilo-vscode/tests/unit/session-errors.test.ts b/packages/kilo-vscode/tests/unit/session-errors.test.ts index c24ed0297a8..53eb01d8152 100644 --- a/packages/kilo-vscode/tests/unit/session-errors.test.ts +++ b/packages/kilo-vscode/tests/unit/session-errors.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test" -import { errorIDs, visibleError } from "../../webview-ui/src/context/session-errors" +import { + errorIDs, + preserveSessionErrors, + visibleError, + withoutResolvedSessionErrors, +} from "../../webview-ui/src/context/session-errors" import type { Message } from "../../webview-ui/src/types/messages" const base = { @@ -45,3 +50,32 @@ describe("visibleError", () => { expect(visibleError(messages, () => false)).toBeUndefined() }) }) + +describe("session error reconciliation", () => { + const error = { + name: "APIError", + data: { message: "prompt_cache_breakpoint is not supported on this model", isRetryable: false }, + } + + it("preserves transient errors across stale history replacements", () => { + const user = { ...base, id: "message_1", role: "user" as const } + const transient = { ...assistant("message_2", error), parentID: user.id, sessionErrorID: "event_1" } + + expect(preserveSessionErrors([user, transient], [user])).toEqual([user, transient]) + }) + + it("replaces a transient error with its persisted assistant message", () => { + const transient = { ...assistant("message_2", error), parentID: "message_1", sessionErrorID: "event_1" } + const persisted = { ...assistant("message_3", error), parentID: "message_1" } + + expect(preserveSessionErrors([transient], [persisted])).toEqual([persisted]) + expect(withoutResolvedSessionErrors([transient], [persisted])).toEqual([]) + }) + + it("deduplicates repeated delivery of the same session error event", () => { + const first = { ...assistant("message_2", error), parentID: "message_1", sessionErrorID: "event_1" } + const repeat = { ...assistant("message_3", error), parentID: "message_1", sessionErrorID: "event_1" } + + expect(withoutResolvedSessionErrors([first], [repeat])).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-errors.ts b/packages/kilo-vscode/webview-ui/src/context/session-errors.ts index a5fd388726f..7d7f56aa072 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-errors.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-errors.ts @@ -3,6 +3,27 @@ import type { Message } from "../types/messages" type Entry = { id: string; error?: Message["error"] } type Error = NonNullable +function sameError(a: Message, b: Message) { + if (!a.error || !b.error || a.error.name !== b.error.name) return false + if (a.parentID !== b.parentID) return false + return JSON.stringify(a.error.data) === JSON.stringify(b.error.data) +} + +export function withoutResolvedSessionErrors(current: Message[], incoming: Message[]) { + const events = new Set(incoming.map((msg) => msg.sessionErrorID).filter((id): id is string => !!id)) + return current.filter((msg) => { + if (!msg.sessionErrorID) return true + if (events.has(msg.sessionErrorID)) return false + return !incoming.some((next) => !next.sessionErrorID && sameError(msg, next)) + }) +} + +export function preserveSessionErrors(current: Message[], incoming: Message[]) { + const ids = new Set(incoming.map((msg) => msg.id)) + const errors = withoutResolvedSessionErrors(current, incoming).filter((msg) => msg.sessionErrorID && !ids.has(msg.id)) + return [...incoming, ...errors] +} + export function errorIDs(messages: Entry[]) { return messages.filter((msg) => !!msg.error).map((msg) => msg.id) } diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 8831d7edfb0..0451e708583 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -70,7 +70,7 @@ import { Identifier } from "../utils/id" import { resolveModelSelection } from "./model-selection" import { getAgentModel } from "./session-model-store" import { resolveMessagePrefs } from "./session-preferences" -import { errorIDs } from "./session-errors" +import { errorIDs, preserveSessionErrors, withoutResolvedSessionErrors } from "./session-errors" import { PartStash } from "./part-stash" import { mergeParts, sameParts } from "./session-parts" import { state as todoState } from "./todo-revert" @@ -1175,7 +1175,7 @@ export const SessionProvider: ParentComponent = (props) => { break case "sessionError": { - if (message.error?.name === "MessageAbortedError") break + if (!message.error || message.error.name === "MessageAbortedError") break const sid = message.sessionID ?? currentSessionID() if (!sid) break // Find the last user message in this session to use as parentID @@ -1188,6 +1188,7 @@ export const SessionProvider: ParentComponent = (props) => { createdAt: new Date().toISOString(), parentID: parent?.id, error: message.error, + sessionErrorID: message.eventID, } handleMessageCreated(errorMsg) break @@ -1381,20 +1382,17 @@ export const SessionProvider: ParentComponent = (props) => { } function mergeMessages(current: Message[], incoming: Message[], mode: Exclude) { + const kept = withoutResolvedSessionErrors(current, incoming) if (mode === "reconcile") { - // Tail reconcile: incoming is the authoritative newest-N snapshot. - // Local state may already hold some of those IDs and may also hold - // newer optimistic entries created after the fetch was taken. Merge - // by id (server wins on collision) then sort by createdAt so new - // server messages land in the right position and optimistic tail - // entries stay at the end. + // Merge the authoritative newest-N snapshot by ID, then sort so newer + // optimistic entries stay at the end. const byId = new Map() - for (const msg of current) byId.set(msg.id, msg) + for (const msg of kept) byId.set(msg.id, msg) for (const msg of incoming) byId.set(msg.id, msg) return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) } const seen = new Set() - const source = mode === "prepend" ? [...incoming, ...current] : incoming + const source = mode === "prepend" ? [...incoming, ...kept] : incoming return source.filter((msg) => { if (seen.has(msg.id)) return false seen.add(msg.id) @@ -1418,12 +1416,13 @@ export const SessionProvider: ParentComponent = (props) => { } function withPending(sessionID: string, messages: Message[]) { - const pending = pendingOptimistic.get(sessionID) - if (!pending || pending.size === 0) return messages - const ids = new Set(messages.map((msg) => msg.id)) const current = store.messages[sessionID] ?? [] + const merged = preserveSessionErrors(current, messages) + const pending = pendingOptimistic.get(sessionID) + if (!pending || pending.size === 0) return merged + const ids = new Set(merged.map((msg) => msg.id)) const orphans = current.filter((msg) => pending.has(msg.id) && !ids.has(msg.id)) - return [...messages, ...orphans] + return [...merged, ...orphans] } // Cheap tail check: same ids in the same order and no visible streamed-part @@ -1601,16 +1600,18 @@ export const SessionProvider: ParentComponent = (props) => { const exists = (store.messages[message.sessionID] ?? []).some((msg) => msg.id === message.id) setStore("messages", message.sessionID, (msgs = []) => { + if (message.sessionErrorID && msgs.some((msg) => msg.sessionErrorID === message.sessionErrorID)) return msgs + const current = withoutResolvedSessionErrors(msgs, [message]) // Check if message already exists (optimistic or update case). // Since we now use the same messageID for optimistic and server messages, // this naturally handles the optimistic→real transition. - const idx = msgs.findIndex((m) => m.id === message.id) + const idx = current.findIndex((m) => m.id === message.id) if (idx >= 0) { - const updated = [...msgs] - updated[idx] = { ...msgs[idx], ...message } + const updated = [...current] + updated[idx] = { ...current[idx], ...message } return updated } - return [...msgs, message] + return [...current, message] }) patchPage(message.sessionID, { lastMutation: exists ? "update" : "append" }) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index ed8aa570f7f..222b45fee6a 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -148,6 +148,7 @@ export interface SessionTurnClosedMessage { export interface SessionErrorMessage { type: "sessionError" + eventID: string sessionID?: string error?: { name: string; data?: Record } } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts index 05477b126a8..eec557fe1e7 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts @@ -22,6 +22,7 @@ export interface Message { parentID?: string path?: { cwd: string; root: string } error?: { name: string; data?: Record } + sessionErrorID?: string summary?: { title?: string; body?: string; diffs?: unknown[] } | boolean cost?: number tokens?: TokenUsage diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index ff534bf107f..310c9d0c83c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -328,15 +328,20 @@ function normalizeMessages( return msgs } -// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ -function supportsPromptCacheBreakpoint(modelId: string): boolean { - const match = modelId.match(/gpt-(\d+)\.(\d+)/) +// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ (excluding ChatGPT subscriptions) +function isLikelyChatGPTSubscription(model: Provider.Model): boolean { + return model.providerID === "openai" && model.cost?.input === 0 && model.cost?.output === 0 +} + +function supportsPromptCacheBreakpoint(model: Provider.Model): boolean { + if (isLikelyChatGPTSubscription(model)) return false + const match = model.api.id.match(/gpt-(\d+)\.(\d+)/) if (match) { const major = Number(match[1]) const minor = Number(match[2]) if (major > 5 || (major === 5 && minor >= 6)) return true } - const majorMatch = modelId.match(/gpt-(\d+)/) + const majorMatch = model.api.id.match(/gpt-(\d+)/) if (majorMatch && Number(majorMatch[1]) >= 6) return true return false } @@ -366,7 +371,7 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage cacheControl: { type: "ephemeral" }, }, // kilocode_change start - ...(supportsPromptCacheBreakpoint(model.api.id) + ...(supportsPromptCacheBreakpoint(model) ? { openai: { promptCacheBreakpoint: { mode: "explicit" }, @@ -494,7 +499,7 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re ((model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure" || model.api.npm === "@kilocode/kilo-gateway") && - supportsPromptCacheBreakpoint(model.api.id))) && + supportsPromptCacheBreakpoint(model))) && model.api.npm !== "@ai-sdk/gateway" ) { msgs = applyCaching(msgs, model) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 74ef174ade8..e834ec326eb 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3162,6 +3162,38 @@ describe("ProviderTransform.message - cache control on gateway", () => { }, }) }) + + test("openai gpt-5.6 with ChatGPT subscription (zero cost heuristic) does not apply promptCacheBreakpoint", () => { + const model = createModel({ + providerID: "openai", + api: { + id: "gpt-5.6", + url: "https://api.openai.com/v1", + npm: "@ai-sdk/openai", + }, + id: "gpt-5.6", + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + }) + const msgs = [ + { + role: "system", + content: "You are a helpful assistant", + }, + { + role: "user", + content: "Hello", + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) as any[] + + expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined() + }) // kilocode_change end })