Merge branch 'main' into resolute-cattle

This commit is contained in:
Christiaan Arnoldus
2026-08-10 15:38:25 +02:00
committed by GitHub
11 changed files with 157 additions and 26 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Exclude ChatGPT subscriptions from explicit prompt cache breakpoints.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep model and provider errors visible in VS Code when chat history refreshes.
@@ -450,7 +450,7 @@ export type WebviewMessage =
| { type: "sessionUpdated"; session: ReturnType<typeof sessionToWebview> }
| { 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,
}
@@ -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",
@@ -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([])
})
})
@@ -3,6 +3,27 @@ import type { Message } from "../types/messages"
type Entry = { id: string; error?: Message["error"] }
type Error = NonNullable<Message["error"]>
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)
}
@@ -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<MessageLoadMode, "focus">) {
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<string, Message>()
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<string>()
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" })
@@ -148,6 +148,7 @@ export interface SessionTurnClosedMessage {
export interface SessionErrorMessage {
type: "sessionError"
eventID: string
sessionID?: string
error?: { name: string; data?: Record<string, unknown> }
}
@@ -22,6 +22,7 @@ export interface Message {
parentID?: string
path?: { cwd: string; root: string }
error?: { name: string; data?: Record<string, unknown> }
sessionErrorID?: string
summary?: { title?: string; body?: string; diffs?: unknown[] } | boolean
cost?: number
tokens?: TokenUsage
+11 -6
View File
@@ -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)
@@ -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
})