From 95e12f843d429549ccbe25e0a024c067e2a298a5 Mon Sep 17 00:00:00 2001 From: skymecode <46559677@qq.com> Date: Wed, 19 Aug 2026 15:11:58 +0800 Subject: [PATCH 1/2] fix(vscode): offer compact-and-retry when the context window overflows When the engine's auto-compaction gives up, the session surfaces context.overflow and the Webview only offered a Retry button that resent the same prompt into the same wall. Add a compactContext bridge method, a friendly error message for context.overflow / compaction.failed, and a Compact & Retry action on the inline error that compacts first and then resends the pending input. --- .../vscode-context-overflow-compact-retry.md | 5 ++ apps/vscode/shared/bridge.ts | 2 + apps/vscode/shared/errors.ts | 11 ++++ apps/vscode/src/handlers/chat.handler.ts | 11 ++++ apps/vscode/test/bridge-handler.test.ts | 44 ++++++++++++++ apps/vscode/test/settings-store.test.ts | 60 +++++++++++++++++++ .../webview-ui/src/components/InlineError.tsx | 23 ++++++- apps/vscode/webview-ui/src/services/bridge.ts | 4 ++ .../webview-ui/src/stores/chat.store.ts | 23 +++++++ 9 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 .changeset/vscode-context-overflow-compact-retry.md 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..d3e2446e23 100644 --- a/apps/vscode/src/handlers/chat.handler.ts +++ b/apps/vscode/src/handlers/chat.handler.ts @@ -176,6 +176,16 @@ 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 }; + await runtime.session.compact(); + return { ok: true }; +}; + const resetSession: Handler = async (_, ctx) => { const runtime = ctx.getSession(); if (runtime !== undefined) injectedEditorContextSessions.delete(runtime.id); @@ -191,6 +201,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/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index 9a6023e373..2515b05612 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -190,6 +190,50 @@ 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 compact = vi.fn(async () => undefined); + vi.spyOn(bridge.runtime, "getSessionForView").mockReturnValue({ + isBusy: false, + session: { compact }, + } 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(compact).toHaveBeenCalledOnce(); + }); + + it("refuses to compact while the session is busy", async () => { + const compact = vi.fn(async () => undefined); + vi.spyOn(bridge.runtime, "getSessionForView").mockReturnValue({ + isBusy: true, + session: { compact }, + } 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(compact).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/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 && ( + + )}