mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
Merge branch 'main' into resolute-cattle
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Exclude ChatGPT subscriptions from explicit prompt cache breakpoints.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user