Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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";
}
14 changes: 14 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,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<void, { ok: boolean }> = 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<void, { ok: boolean }> = async (_, ctx) => {
const runtime = ctx.getSession();
if (runtime !== undefined) injectedEditorContextSessions.delete(runtime.id);
Expand All @@ -191,6 +204,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
52 changes: 37 additions & 15 deletions apps/vscode/src/runtime/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -82,7 +83,7 @@ export class SessionRuntime {
private hostActionSequence = 0;
private activeHostActionId: number | undefined;
private readonly cancelledHostActions = new Set<number>();
private pendingHostCompaction: PendingHostCompaction | undefined;
private pendingCompaction: PendingCompaction | undefined;
private readonly activeWorkSettledWaiters = new Set<() => void>();
private exclusiveActionActive = false;
private readonly terminalKeys = new Set<string>();
Expand Down Expand Up @@ -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.");
}

Expand All @@ -305,7 +330,7 @@ export class SessionRuntime {
resolveCompletion = resolve;
rejectCompletion = reject;
});
this.pendingHostCompaction = {
this.pendingCompaction = {
actionId,
resolve: resolveCompletion,
reject: rejectCompletion,
Expand All @@ -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<void> {
Expand Down Expand Up @@ -395,8 +417,8 @@ export class SessionRuntime {
async close(): Promise<void> {
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);
Expand Down Expand Up @@ -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");
}
}
Expand Down
55 changes: 55 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,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) => {
Expand Down
52 changes: 52 additions & 0 deletions apps/vscode/test/session-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";
Expand Down Expand Up @@ -112,6 +114,9 @@ function createFakeSession(): FakeSessionBoundary {
async cancel() {
cancellations += 1;
},
async compact() {
compactions += 1;
},
async cancelCompaction() {
compactionCancellations += 1;
},
Expand Down Expand Up @@ -151,6 +156,7 @@ function createFakeSession(): FakeSessionBoundary {
setPermissions,
subscriptionCount: () => subscriptions,
cancelCount: () => cancellations,
compactionCount: () => compactions,
cancelCompactionCount: () => compactionCancellations,
closeCount: () => closes,
emit(event) {
Expand Down Expand Up @@ -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" });
});
});
Loading