diff --git a/.changeset/vscode-context-overflow-compact-retry.md b/.changeset/vscode-context-overflow-compact-retry.md new file mode 100644 index 0000000000..ec2e149658 --- /dev/null +++ b/.changeset/vscode-context-overflow-compact-retry.md @@ -0,0 +1,5 @@ +--- +"kimi-code": patch +--- + +Offer a Compact & Retry action when a conversation exceeds the model's context window in Kimi Code for VS Code, so the failed message is resent after compacting instead of failing again. diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts index d7f9bd88ae..71e37d9384 100644 --- a/apps/vscode/shared/bridge.ts +++ b/apps/vscode/shared/bridge.ts @@ -37,6 +37,7 @@ export const Methods = { ResetSession: "resetSession", SetPlanMode: "setPlanMode", SteerChat: "steerChat", + CompactContext: "compactContext", RespondApproval: "respondApproval", GetKimiSessions: "getKimiSessions", @@ -141,6 +142,7 @@ function validateParams(method: RpcMethod, params: unknown): boolean { case Methods.GetMCPServers: case Methods.AbortChat: case Methods.ResetSession: + case Methods.CompactContext: case Methods.GetKimiSessions: case Methods.GetAllKimiSessions: case Methods.GetRegisteredWorkDirs: diff --git a/apps/vscode/shared/errors.ts b/apps/vscode/shared/errors.ts index 4640b54ff4..7cb05f27d5 100644 --- a/apps/vscode/shared/errors.ts +++ b/apps/vscode/shared/errors.ts @@ -86,6 +86,8 @@ export const ERROR_MESSAGES: Record = { "provider.auth_error": "Authentication failed. Please sign in again.", "provider.connection_error": "Could not connect to the model provider.", "request.prompt_input_empty": "Prompt cannot be empty.", + "context.overflow": "The conversation is too long for the model's context window.", + "compaction.failed": "Failed to compact the conversation context.", internal: "Internal error occurred.", }; @@ -104,3 +106,12 @@ export function isPreflightError(code: string): boolean { export function isUserInterrupt(code: string): boolean { return code === LEGACY.TURN_INTERRUPTED || code === "turn.cancelled"; } + +/** + * The engine's auto-compaction has already retried by the time this surfaces, + * so the only way forward is a user-driven compact. The Webview offers a + * "Compact & Retry" action for this code. + */ +export function isContextOverflowError(code: string): boolean { + return code === "context.overflow"; +} diff --git a/apps/vscode/src/handlers/chat.handler.ts b/apps/vscode/src/handlers/chat.handler.ts index c5b21f89ad..ed79834634 100644 --- a/apps/vscode/src/handlers/chat.handler.ts +++ b/apps/vscode/src/handlers/chat.handler.ts @@ -176,6 +176,19 @@ const steerChat: Handler<{ content: string | ContentPart[] }, { ok: boolean }> = return { ok: true }; }; +// User-driven recovery from context.overflow: by the time that error surfaces +// the engine's auto-compaction has already exhausted its retries, so the +// Webview's "Compact & Retry" action compacts here and then resends. +const compactContext: Handler = async (_, ctx) => { + const runtime = ctx.getSession(); + if (runtime === undefined || runtime.isBusy) return { ok: false }; + // Session.compact() only launches the background compaction worker on the + // v2 runtime; wait for the completed/cancelled event so a retry never + // resends into a still-full context. + const result = await runtime.runCompaction(); + return { ok: result === "completed" }; +}; + const resetSession: Handler = async (_, ctx) => { const runtime = ctx.getSession(); if (runtime !== undefined) injectedEditorContextSessions.delete(runtime.id); @@ -191,6 +204,7 @@ export const chatHandlers: Record> = { [Methods.RespondQuestion]: respondQuestion, [Methods.SetPlanMode]: setPlanMode, [Methods.SteerChat]: steerChat, + [Methods.CompactContext]: compactContext, [Methods.ResetSession]: resetSession, }; diff --git a/apps/vscode/src/runtime/session-runtime.ts b/apps/vscode/src/runtime/session-runtime.ts index a3bd2b4615..c75486357f 100644 --- a/apps/vscode/src/runtime/session-runtime.ts +++ b/apps/vscode/src/runtime/session-runtime.ts @@ -56,8 +56,9 @@ interface SuppressedError { readonly message: string; } -interface PendingHostCompaction { - readonly actionId: number; +interface PendingCompaction { + /** Set when the compaction is driven by a host action (e.g. `/compact`). */ + readonly actionId?: number; readonly resolve: (result: "completed" | "cancelled") => void; readonly reject: (error: unknown) => void; } @@ -82,7 +83,7 @@ export class SessionRuntime { private hostActionSequence = 0; private activeHostActionId: number | undefined; private readonly cancelledHostActions = new Set(); - private pendingHostCompaction: PendingHostCompaction | undefined; + private pendingCompaction: PendingCompaction | undefined; private readonly activeWorkSettledWaiters = new Set<() => void>(); private exclusiveActionActive = false; private readonly terminalKeys = new Set(); @@ -295,7 +296,31 @@ export class SessionRuntime { if (!this.hostActionActive || actionId !== this.activeHostActionId) { throw new Error("The host action is no longer active."); } - if (this.pendingHostCompaction !== undefined) { + const result = await this.startCompaction(actionId, instruction); + if (result === "cancelled") { + throw new Error("Context compaction was cancelled."); + } + } + + /** + * Compact outside a host action (the Webview's "Compact & Retry" recovery). + * On the v2 runtime `Session.compact()` only launches the background + * summarizer and returns immediately, so this resolves only when the + * engine reports the compaction completed or was cancelled. + */ + async runCompaction(): Promise<"completed" | "cancelled"> { + this.ensureOpen(); + if (this.isBusy) { + throw new Error(ALREADY_GENERATING_MESSAGE); + } + return this.startCompaction(undefined); + } + + private async startCompaction( + actionId: number | undefined, + instruction?: string, + ): Promise<"completed" | "cancelled"> { + if (this.pendingCompaction !== undefined) { throw new Error("A context compaction is already running."); } @@ -305,7 +330,7 @@ export class SessionRuntime { resolveCompletion = resolve; rejectCompletion = reject; }); - this.pendingHostCompaction = { + this.pendingCompaction = { actionId, resolve: resolveCompletion, reject: rejectCompletion, @@ -314,16 +339,13 @@ export class SessionRuntime { try { await this.session.compact(instruction === undefined ? {} : { instruction }); } catch (error) { - if (this.pendingHostCompaction?.actionId === actionId) { - this.pendingHostCompaction = undefined; + if (this.pendingCompaction?.actionId === actionId) { + this.pendingCompaction = undefined; rejectCompletion(error); } } - const result = await completion; - if (result === "cancelled") { - throw new Error("Context compaction was cancelled."); - } + return completion; } async cancel(): Promise { @@ -395,8 +417,8 @@ export class SessionRuntime { async close(): Promise { if (this.closed) return; this.closed = true; - this.pendingHostCompaction?.reject(new Error("Session closed during context compaction.")); - this.pendingHostCompaction = undefined; + this.pendingCompaction?.reject(new Error("Session closed during context compaction.")); + this.pendingCompaction = undefined; this.reverseRpc.cancelAll("Session closed"); this.unsubscribe(); this.session.setApprovalHandler(undefined); @@ -440,9 +462,9 @@ export class SessionRuntime { if (this.closed) return; if (event.type === "compaction.completed" || event.type === "compaction.cancelled") { - const pending = this.pendingHostCompaction; + const pending = this.pendingCompaction; if (pending !== undefined) { - this.pendingHostCompaction = undefined; + this.pendingCompaction = undefined; pending.resolve(event.type === "compaction.completed" ? "completed" : "cancelled"); } } diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index 9a6023e373..858fd4a603 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -190,6 +190,61 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () => expect(cancel).toHaveBeenCalledOnce(); }); + it("does not execute the compact handler when a payload is supplied", async () => { + const result = await bridge.handle( + { id: "rpc-1", method: Methods.CompactContext, params: {} }, + "view-1", + ); + + expect(result).toEqual({ + id: "rpc-1", + error: "Invalid bridge params for method: compactContext", + }); + }); + + it("reports not-ok when compacting without an active session", async () => { + const result = await bridge.handle({ id: "rpc-1", method: Methods.CompactContext }, "view-1"); + + expect(result).toEqual({ id: "rpc-1", result: { ok: false } }); + }); + + it("compacts the view's session on request", async () => { + const runCompaction = vi.fn(async () => "completed" as const); + vi.spyOn(bridge.runtime, "getSessionForView").mockReturnValue({ + isBusy: false, + runCompaction, + } as never); + + const result = await bridge.handle({ id: "rpc-1", method: Methods.CompactContext }, "view-1"); + + expect(result).toEqual({ id: "rpc-1", result: { ok: true } }); + expect(runCompaction).toHaveBeenCalledOnce(); + }); + + it("reports not-ok when the compaction is cancelled", async () => { + vi.spyOn(bridge.runtime, "getSessionForView").mockReturnValue({ + isBusy: false, + runCompaction: vi.fn(async () => "cancelled" as const), + } as never); + + const result = await bridge.handle({ id: "rpc-1", method: Methods.CompactContext }, "view-1"); + + expect(result).toEqual({ id: "rpc-1", result: { ok: false } }); + }); + + it("refuses to compact while the session is busy", async () => { + const runCompaction = vi.fn(async () => "completed" as const); + vi.spyOn(bridge.runtime, "getSessionForView").mockReturnValue({ + isBusy: true, + runCompaction, + } as never); + + const result = await bridge.handle({ id: "rpc-1", method: Methods.CompactContext }, "view-1"); + + expect(result).toEqual({ id: "rpc-1", result: { ok: false } }); + expect(runCompaction).not.toHaveBeenCalled(); + }); + it.each(["missingMethod", "toString", "constructor", "__proto__"])( "does not dispatch the unknown or prototype method %s", async (method) => { diff --git a/apps/vscode/test/session-runtime.test.ts b/apps/vscode/test/session-runtime.test.ts index 640277273a..7ca6454141 100644 --- a/apps/vscode/test/session-runtime.test.ts +++ b/apps/vscode/test/session-runtime.test.ts @@ -44,6 +44,7 @@ interface FakeSessionBoundary { readonly setPermissions: PermissionMode[]; readonly subscriptionCount: () => number; readonly cancelCount: () => number; + readonly compactionCount: () => number; readonly cancelCompactionCount: () => number; readonly closeCount: () => number; emit(event: Event): void; @@ -68,6 +69,7 @@ function createFakeSession(): FakeSessionBoundary { let nextMetadataError: Error | undefined; let subscriptions = 0; let cancellations = 0; + let compactions = 0; let compactionCancellations = 0; let closes = 0; let permission: PermissionMode = "manual"; @@ -112,6 +114,9 @@ function createFakeSession(): FakeSessionBoundary { async cancel() { cancellations += 1; }, + async compact() { + compactions += 1; + }, async cancelCompaction() { compactionCancellations += 1; }, @@ -151,6 +156,7 @@ function createFakeSession(): FakeSessionBoundary { setPermissions, subscriptionCount: () => subscriptions, cancelCount: () => cancellations, + compactionCount: () => compactions, cancelCompactionCount: () => compactionCancellations, closeCount: () => closes, emit(event) { @@ -744,4 +750,50 @@ describe("session runtime (adapts one SDK session for subscribed Webviews)", () expect(baselines).toEqual([]); }); + + it("waits for the compaction completion event before resolving runCompaction", async () => { + const { runtime, sdk } = createRuntime(); + + let settled = false; + const pending = runtime.runCompaction().then((result) => { + settled = true; + return result; + }); + // The pending marker is registered synchronously, but the SDK call itself + // resolves immediately for the v2 runtime — the wait must outlive it. + await new Promise((resolve) => setImmediate(resolve)); + expect(sdk.compactionCount()).toBe(1); + expect(settled).toBe(false); + + sdk.emit({ + type: "compaction.completed", + sessionId: "session-1", + agentId: "main", + result: { summary: "s", compactedCount: 2, tokensBefore: 100, tokensAfter: 40 }, + }); + await expect(pending).resolves.toBe("completed"); + }); + + it("resolves runCompaction as cancelled when the engine cancels the compaction", async () => { + const { runtime, sdk } = createRuntime(); + + const pending = runtime.runCompaction(); + sdk.emit({ type: "compaction.cancelled", sessionId: "session-1", agentId: "main" }); + + await expect(pending).resolves.toBe("cancelled"); + }); + + it("rejects runCompaction while a turn is active", async () => { + const { runtime, sdk } = createRuntime(); + + const prompt = runtime.prompt("hello"); + await expect(runtime.runCompaction()).rejects.toThrow( + "A response is already being generated for this session.", + ); + expect(sdk.compactionCount()).toBe(0); + + sdk.emit(turnStarted()); + sdk.emit(turnEnded("completed")); + await expect(prompt).resolves.toEqual({ status: "finished" }); + }); }); diff --git a/apps/vscode/test/settings-store.test.ts b/apps/vscode/test/settings-store.test.ts index 93d0decad8..86926ba37b 100644 --- a/apps/vscode/test/settings-store.test.ts +++ b/apps/vscode/test/settings-store.test.ts @@ -12,6 +12,7 @@ const boundary = vi.hoisted(() => ({ saveConfig: vi.fn(), streamChat: vi.fn(), abortChat: vi.fn(), + compactContext: vi.fn(), trackFiles: vi.fn(), toastError: vi.fn(), toastWarning: vi.fn(), @@ -22,6 +23,7 @@ vi.mock("@/services", () => ({ saveConfig: boundary.saveConfig, streamChat: boundary.streamChat, abortChat: boundary.abortChat, + compactContext: boundary.compactContext, trackFiles: boundary.trackFiles, }, })); @@ -57,6 +59,8 @@ beforeEach(() => { boundary.streamChat.mockResolvedValue({ done: false }); boundary.abortChat.mockReset(); boundary.abortChat.mockResolvedValue({ aborted: true }); + boundary.compactContext.mockReset(); + boundary.compactContext.mockResolvedValue({ ok: true }); boundary.trackFiles.mockReset(); boundary.toastError.mockReset(); boundary.toastWarning.mockReset(); @@ -289,6 +293,62 @@ describe("Webview chat error recovery", () => { detail: "HTTP 400: function name is invalid", }); }); + + it("compacts and then resends the pending input after a context overflow", async () => { + useChatStore.getState().sendMessage("too long request"); + useChatStore.getState().processEvent({ + type: "TurnBegin", + payload: { user_input: "too long request" }, + }); + useChatStore.getState().processEvent({ + type: "error", + code: "context.overflow", + message: "The conversation is too long for the model's context window.", + detail: "Compaction failed to bring the context under the model window after 3 attempts.", + phase: "runtime", + }); + boundary.streamChat.mockClear(); + + await useChatStore.getState().compactAndRetry(); + + expect(boundary.compactContext).toHaveBeenCalledOnce(); + // retryLastMessage cleared the inline error and resent the same input. + expect(boundary.streamChat).toHaveBeenCalledTimes(1); + expect(boundary.streamChat).toHaveBeenCalledWith("too long request", "plain", "off", false, undefined); + expect(useChatStore.getState().messages.at(-1)?.inlineError).toBeUndefined(); + expect(useChatStore.getState().isStreaming).toBe(true); + }); + + it("keeps the failed turn and does not resend when compaction fails", async () => { + boundary.compactContext.mockRejectedValue(new Error("No messages to compact in current history.")); + useChatStore.getState().sendMessage("too long request"); + useChatStore.getState().processEvent({ + type: "TurnBegin", + payload: { user_input: "too long request" }, + }); + useChatStore.getState().processEvent({ + type: "error", + code: "context.overflow", + message: "The conversation is too long for the model's context window.", + phase: "runtime", + }); + boundary.streamChat.mockClear(); + + await useChatStore.getState().compactAndRetry(); + + expect(boundary.streamChat).not.toHaveBeenCalled(); + expect(boundary.toastError).toHaveBeenCalledWith("No messages to compact in current history."); + expect(useChatStore.getState().messages).toHaveLength(2); + expect(useChatStore.getState().isStreaming).toBe(false); + }); + + it("does not compact while a response is streaming", async () => { + useChatStore.getState().sendMessage("start a turn"); + + await useChatStore.getState().compactAndRetry(); + + expect(boundary.compactContext).not.toHaveBeenCalled(); + }); }); describe("Webview thinking mode parity with the TUI", () => { diff --git a/apps/vscode/webview-ui/src/components/InlineError.tsx b/apps/vscode/webview-ui/src/components/InlineError.tsx index 7ccbe75024..1866003f9e 100644 --- a/apps/vscode/webview-ui/src/components/InlineError.tsx +++ b/apps/vscode/webview-ui/src/components/InlineError.tsx @@ -1,7 +1,8 @@ -import { IconAlertCircle, IconRefresh } from "@tabler/icons-react"; +import { IconAlertCircle, IconArrowsMinimize, IconRefresh } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { useChatStore } from "@/stores"; import { cn } from "@/lib/utils"; +import { isContextOverflowError } from "shared/errors"; import type { InlineError as InlineErrorType } from "../stores/chat.store"; interface InlineErrorProps { @@ -9,22 +10,38 @@ interface InlineErrorProps { } export function InlineError({ error }: InlineErrorProps) { - const { retryLastMessage, isStreaming } = useChatStore(); + const { retryLastMessage, compactAndRetry, isStreaming, isCompacting } = useChatStore(); // 如果 detail 和 message 不同,则显示详细错误信息 const showDetail = error.detail && error.detail !== error.message; + // context.overflow means the engine's auto-compaction already gave up; a + // plain Retry would resend into the same wall, so offer compact-then-retry. + const canCompact = isContextOverflowError(error.code); + const busy = isStreaming || isCompacting; return (
{error.message} + {canCompact && ( + + )}