Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/vscode-context-overflow-compact-retry.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions apps/vscode/shared/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const Methods = {
ResetSession: "resetSession",
SetPlanMode: "setPlanMode",
SteerChat: "steerChat",
CompactContext: "compactContext",
RespondApproval: "respondApproval",

GetKimiSessions: "getKimiSessions",
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions apps/vscode/shared/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export const ERROR_MESSAGES: Record<string, string> = {
"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.",
};

Expand All @@ -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";
}
11 changes: 11 additions & 0 deletions apps/vscode/src/handlers/chat.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void, { ok: boolean }> = async (_, ctx) => {
const runtime = ctx.getSession();
if (runtime === undefined || runtime.isBusy) return { ok: false };
await runtime.session.compact();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wait for compaction completion before retrying

When Compact & Retry is used with the default v2 runtime, Session.compact() only starts the background compaction worker and returns immediately (packages/node-sdk/src/sdk-rpc-client-v2.ts:1816-1829). This handler therefore reports { ok: true } before any compaction.completed event, causing compactAndRetry() to resend while compaction is still active or before the context has shrunk; the retry can be rejected or overflow again, and asynchronous compaction failure is also reported as success. Wait for the completed/cancelled/error event before resolving, as the existing SessionRuntime.compactHostAction() path does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 215cc1a: compactContext now goes through a new SessionRuntime.runCompaction(), which registers a pending compaction and resolves on the compaction.completed / compaction.cancelled engine events (the same pattern as compactHostAction()), so the webview only retries after the context has actually shrunk. Cancelled compactions report { ok: false }. Added tests covering the wait, the cancelled path, and the busy rejection.

return { ok: true };
};

const resetSession: Handler<void, { ok: boolean }> = async (_, ctx) => {
const runtime = ctx.getSession();
if (runtime !== undefined) injectedEditorContextSessions.delete(runtime.id);
Expand All @@ -191,6 +201,7 @@ export const chatHandlers: Record<string, Handler<any, any>> = {
[Methods.RespondQuestion]: respondQuestion,
[Methods.SetPlanMode]: setPlanMode,
[Methods.SteerChat]: steerChat,
[Methods.CompactContext]: compactContext,
[Methods.ResetSession]: resetSession,
};

Expand Down
44 changes: 44 additions & 0 deletions apps/vscode/test/bridge-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
60 changes: 60 additions & 0 deletions apps/vscode/test/settings-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -22,6 +23,7 @@ vi.mock("@/services", () => ({
saveConfig: boundary.saveConfig,
streamChat: boundary.streamChat,
abortChat: boundary.abortChat,
compactContext: boundary.compactContext,
trackFiles: boundary.trackFiles,
},
}));
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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", () => {
Expand Down
23 changes: 20 additions & 3 deletions apps/vscode/webview-ui/src/components/InlineError.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,47 @@
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 {
error: InlineErrorType;
}

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 (
<div className={cn("flex flex-col gap-1 px-3 py-2 mt-2 rounded-md", "bg-red-50 dark:bg-red-950/30", "border border-red-200 dark:border-red-900/50")}>
<div className="flex items-center gap-2">
<IconAlertCircle className="size-4 text-red-500 shrink-0" />
<span className="text-xs text-red-600 dark:text-red-400 flex-1">{error.message}</span>
{canCompact && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30"
onClick={() => { void compactAndRetry(); }}
disabled={busy}
>
<IconArrowsMinimize className="size-3.5 mr-1" />
{isCompacting ? "Compacting..." : "Compact & Retry"}
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30"
onClick={retryLastMessage}
disabled={isStreaming}
disabled={busy}
>
<IconRefresh className="size-3.5 mr-1" />
Retry
Expand Down
4 changes: 4 additions & 0 deletions apps/vscode/webview-ui/src/services/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ class Bridge {
return this.call<{ aborted: boolean }>(Methods.AbortChat);
}

compactContext() {
return this.call<{ ok: boolean }>(Methods.CompactContext);
}

resetSession() {
return this.call<{ ok: boolean }>(Methods.ResetSession);
}
Expand Down
23 changes: 23 additions & 0 deletions apps/vscode/webview-ui/src/stores/chat.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export interface ChatState {

sendMessage: (text: string) => void;
retryLastMessage: () => void;
compactAndRetry: () => Promise<void>;
processEvent: (event: UIStreamEvent) => void;
loadSession: (sessionId: string, events: UIStreamEvent[]) => Promise<void>;
startNewConversation: () => Promise<void>;
Expand Down Expand Up @@ -248,6 +249,28 @@ export const useChatStore = create<ChatState>((set, get) => ({
doSend(get(), pendingInput.content, pendingInput.model);
},

compactAndRetry: async () => {
const { pendingInput, isStreaming, isCompacting } = get();
if (isStreaming || isCompacting || !pendingInput) {
return;
}

// Compact first; only resend once the context has actually shrunk.
// retryLastMessage pops the failed turn right before resending, so the
// compaction card stays attached to the failed message until then.
try {
const result = await bridge.compactContext();
if (!result.ok) {
toast.error("Failed to compact the conversation context.");
return;
}
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
return;
}
get().retryLastMessage();
},

processEvent: (event) => {
// Mid-turn warnings (terminal === false) leave the turn, the composer, and
// the queued messages untouched — the engine is still streaming, so they
Expand Down