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-busy-bounce-queue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kimi-code": patch
---

Fix prompts vanishing when sent while the session is busy with a turn the chat lost track of (e.g. a live turn after a window reload). The send used to be rejected with "A message is being sent." and the text was parked invisibly until the other turn ended; a busy bounce now moves the message into the queue so it sends when the running turn finishes, and any other send that fails before its turn starts immediately restores the text into the composer.
5 changes: 5 additions & 0 deletions .changeset/vscode-busy-error-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kimi-code": patch
---

Stop reporting a busy chat session as "Internal error occurred." A prompt sent while a response is still being generated now surfaces as "A message is being sent. Please wait." (code `turn.agent_busy`) with the original detail attached, instead of the generic internal-error message that the plain `Error` was mapped to.
6 changes: 4 additions & 2 deletions apps/vscode/src/handlers/chat.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function prependSystemContext(content: string | ContentPart[], context: string):
return copy;
}

const streamChat: Handler<StreamChatParams, { done: boolean }> = async (params, ctx) => {
const streamChat: Handler<StreamChatParams, { done: boolean; bounced?: boolean }> = async (params, ctx) => {
if (!ctx.workDir) {
emitPreflightError(ctx, "NO_WORKSPACE", "Please open a folder to start.");
void vscode.window.showWarningMessage("Kimi: Please open a folder first.", "Open Folder").then((action) => {
Expand Down Expand Up @@ -137,7 +137,9 @@ const streamChat: Handler<StreamChatParams, { done: boolean }> = async (params,
const systemContext = await buildSystemContext(runtime.id, ctx);
try {
const result = await runtime.prompt(prependSystemContext(params.content, systemContext));
return { done: result.status === "finished" };
// A busy bounce tells the webview the message never started a turn and is
// safe to queue for when the live turn ends.
return { done: result.status === "finished", bounced: result.reason === "busy" ? true : undefined };
} catch (error) {
emitCaughtError(ctx, error, "runtime", runtime.id);
return { done: false };
Expand Down
12 changes: 9 additions & 3 deletions apps/vscode/src/runtime/session-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
isKimiError,
KimiError,
type ContentPart as SdkContentPart,
type Event,
type PromptInput,
Expand Down Expand Up @@ -49,6 +50,11 @@ const ALREADY_GENERATING_MESSAGE = "A response is already being generated for th

export interface PromptResult {
readonly status: "finished" | "cancelled" | "failed";
/**
* Present when the call was rejected because the session was already busy —
* the message never started a turn and can safely be queued for later.
*/
readonly reason?: "busy";
}

interface SuppressedError {
Expand Down Expand Up @@ -188,11 +194,11 @@ export class SessionRuntime {
// such terminal event, so reject terminally: the caller's composer must
// unlock rather than hang until the handshake timeout.
this.emitError(
new Error(ALREADY_GENERATING_MESSAGE),
new KimiError("turn.agent_busy", ALREADY_GENERATING_MESSAGE),
"runtime",
{ terminal: this.hasActiveWork ? false : undefined },
);
return { status: "failed" };
return { status: "failed", reason: "busy" };
}

let resolveCompletion!: (result: PromptResult) => void;
Expand Down Expand Up @@ -225,7 +231,7 @@ export class SessionRuntime {
beginHostAction(input: string | LegacyContentPart[], forkable = false): number {
this.ensureOpen();
if (this.isBusy) {
throw new Error(ALREADY_GENERATING_MESSAGE);
throw new KimiError("turn.agent_busy", ALREADY_GENERATING_MESSAGE);
}
const actionId = ++this.hostActionSequence;
this.hostActionActive = true;
Expand Down
2 changes: 1 addition & 1 deletion apps/vscode/test/kimi-harness.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1122,7 +1122,7 @@ describe("VS Code Kimi harness integration (shares one in-process SDK home)", ()
const first = runtime.prompt("first message");
await blocked.started;

await expect(runtime.prompt("concurrent message")).resolves.toEqual({ status: "failed" });
await expect(runtime.prompt("concurrent message")).resolves.toEqual({ status: "failed", reason: "busy" });

// The rejection surfaces as a mid-turn warning; the active turn is untouched.
expect(runtime.isBusy).toBe(true);
Expand Down
6 changes: 4 additions & 2 deletions apps/vscode/test/kimi-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,13 +592,15 @@ describe("Kimi runtime (owns shared SDK sessions for Webviews)", () => {
boundary.emit({ type: "turn.started", agentId: "main", sessionId: opened.id, turnId: "t1" } as unknown as Event);
expect(opened.isBusy).toBe(true);

await expect(opened.prompt("concurrent message")).resolves.toEqual({ status: "failed" });
await expect(opened.prompt("concurrent message")).resolves.toEqual({ status: "failed", reason: "busy" });

// The rejection surfaces as a mid-turn warning; the active turn is untouched.
expect(opened.isBusy).toBe(true);
const busyWarning = broadcasts.find(({ data }) => (data as { type?: string }).type === "error");
expect(busyWarning?.data).toMatchObject({
type: "error",
code: "turn.agent_busy",
message: "A message is being sent. Please wait.",
phase: "runtime",
detail: "A response is already being generated for this session.",
terminal: false,
Expand All @@ -624,7 +626,7 @@ describe("Kimi runtime (owns shared SDK sessions for Webviews)", () => {
// terminal so the caller's composer can unlock.
expect(opened.isBusy).toBe(true);

await expect(opened.prompt("during fork")).resolves.toEqual({ status: "failed" });
await expect(opened.prompt("during fork")).resolves.toEqual({ status: "failed", reason: "busy" });
const rejection = broadcasts.find(({ data }) => (data as { type?: string }).type === "error");
expect(rejection?.data).toMatchObject({ type: "error", phase: "runtime" });
expect((rejection?.data as Record<string, unknown>)["terminal"]).toBeUndefined();
Expand Down
147 changes: 144 additions & 3 deletions apps/vscode/test/settings-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ beforeEach(() => {
isStreaming: false,
isCompacting: false,
handshakeReceived: false,
awaitingTurnBegin: false,
draftMedia: [],
lastStatus: null,
tokenUsage: { input_other: 0, output: 0, input_cache_read: 0, input_cache_creation: 0 },
Expand Down Expand Up @@ -419,22 +420,24 @@ describe("Webview thinking effort parity with the TUI", () => {

describe("Webview mid-turn warnings", () => {
it("shows a non-terminal error as a toast without unlocking the composer", async () => {
// A genuinely started turn keeps its bridge call open until the turn ends.
boundary.streamChat.mockImplementation(() => new Promise(() => {}));
useChatStore.getState().sendMessage("first message");
useChatStore.getState().sendMessage("queued follow-up");
expect(useChatStore.getState().isStreaming).toBe(true);
expect(useChatStore.getState().queue).toHaveLength(1);

useChatStore.getState().processEvent({
type: "error",
code: "internal",
message: "Internal error occurred.",
code: "turn.agent_busy",
message: "A message is being sent. Please wait.",
detail: "A response is already being generated for this session.",
phase: "runtime",
terminal: false,
});

// The turn is still running: nothing unlocks, nothing flushes, nothing is retried.
expect(boundary.toastWarning).toHaveBeenCalledWith("Internal error occurred.");
expect(boundary.toastWarning).toHaveBeenCalledWith("A message is being sent. Please wait.");
const state = useChatStore.getState();
expect(state.isStreaming).toBe(true);
expect(state.queue).toHaveLength(1);
Expand All @@ -449,3 +452,141 @@ describe("Webview mid-turn warnings", () => {
});
});
});

describe("Webview send bounce handling", () => {
it("enqueues the message when the session bounced the send as busy", async () => {
boundary.streamChat.mockResolvedValue({ done: false, bounced: true });

useChatStore.getState().sendMessage("hold this");

await vi.waitFor(() => {
expect(useChatStore.getState().queue).toHaveLength(1);
});
const state = useChatStore.getState();
expect(state.queue[0]?.content).toBe("hold this");
// The live (untracked) turn still owns the composer state: stays
// streaming so further input enqueues, and nothing is parked in
// pendingInput.
expect(state.isStreaming).toBe(true);
expect(state.pendingInput).toBeNull();
expect(state.awaitingTurnBegin).toBe(false);
});

it("rolls the composer back when the send never started a turn", async () => {
boundary.streamChat.mockResolvedValue({ done: false });

useChatStore.getState().sendMessage("give it back");

await vi.waitFor(() => {
expect(useChatStore.getState().isStreaming).toBe(false);
});
const state = useChatStore.getState();
// pendingInput keeps the text so the composer restores it.
expect(state.pendingInput).toEqual({ content: "give it back", model: "plain" });
expect(state.queue).toHaveLength(0);
expect(state.awaitingTurnBegin).toBe(false);
});

it("does not roll back a send whose turn already started", async () => {
let resolveSend!: (result: { done: boolean }) => void;
boundary.streamChat.mockImplementation(
() => new Promise<{ done: boolean }>((resolve) => { resolveSend = resolve; }),
);

useChatStore.getState().sendMessage("in flight");
useChatStore.getState().processEvent({
type: "TurnBegin",
payload: { user_input: "in flight" },
});
resolveSend({ done: true });

await vi.waitFor(() => {
expect(boundary.streamChat).toHaveBeenCalledTimes(1);
});
const state = useChatStore.getState();
expect(state.isStreaming).toBe(true);
expect(state.pendingInput).toEqual({ content: "in flight", model: "plain" });
expect(state.queue).toHaveLength(0);
});
});

describe("Webview send bounce ordering", () => {
it("resends the prompt when the busy turn completes before the bounce reply arrives", async () => {
let resolveFirst!: (result: { done: boolean; bounced: boolean }) => void;
boundary.streamChat
.mockImplementationOnce(
() =>
new Promise<{ done: boolean; bounced: boolean }>((resolve) => {
resolveFirst = resolve;
}),
)
.mockResolvedValue({ done: true });

useChatStore.getState().sendMessage("later");
// The live turn ends before the bounce reply lands: its terminal event
// clears the parked input and the streaming state.
useChatStore.getState().processEvent({ type: "stream_complete", result: { status: "finished" } });
resolveFirst({ done: false, bounced: true });

await vi.waitFor(() => {
expect(boundary.streamChat).toHaveBeenCalledTimes(2);
});
const state = useChatStore.getState();
expect(state.isStreaming).toBe(true);
expect(state.pendingInput).toEqual({ content: "later", model: "plain" });
expect(state.queue).toHaveLength(0);
});
});

describe("Webview send reply correlation", () => {
it("enqueues a bounced send even when another view's TurnBegin arrived first", async () => {
boundary.streamChat.mockResolvedValue({ done: false, bounced: true });

useChatStore.getState().sendMessage("from the losing view");
// The winning view's TurnBegin is broadcast to every subscriber and
// clears our awaitingTurnBegin before our bounce reply arrives.
useChatStore.getState().processEvent({
type: "TurnBegin",
payload: { user_input: "the winning view" },
});

await vi.waitFor(() => {
expect(useChatStore.getState().queue).toHaveLength(1);
});
expect(useChatStore.getState().queue[0]?.content).toBe("from the losing view");
});

it("ignores a stale bridge reply once a newer send owns the composer", async () => {
let resolveFirst!: (result: { done: boolean }) => void;
boundary.streamChat
.mockImplementationOnce(
() => new Promise<{ done: boolean }>((resolve) => { resolveFirst = resolve; }),
)
// The second send stays in flight for the whole test.
.mockImplementation(() => new Promise(() => {}));

useChatStore.getState().sendMessage("first");
useChatStore.getState().processEvent({
type: "TurnBegin",
payload: { user_input: "first" },
});
useChatStore.getState().processEvent({
type: "error",
code: "provider.api_error",
message: "Service temporarily unavailable.",
phase: "runtime",
});
expect(useChatStore.getState().isStreaming).toBe(false);

useChatStore.getState().sendMessage("second");
// The first send's bridge reply only arrives now — it must not touch the
// second send's state.
resolveFirst({ done: false });
await new Promise((resolve) => setTimeout(resolve, 0));

const state = useChatStore.getState();
expect(state.isStreaming).toBe(true);
expect(state.pendingInput).toEqual({ content: "second", model: "plain" });
expect(state.queue).toHaveLength(0);
});
});
2 changes: 1 addition & 1 deletion apps/vscode/webview-ui/src/services/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ class Bridge {
}

streamChat(content: string | ContentPart[], model: string, effort: string, planMode: boolean, sessionId?: string) {
return this.call<{ done: boolean }>(Methods.StreamChat, { content, model, effort, planMode, sessionId });
return this.call<{ done: boolean; bounced?: boolean }>(Methods.StreamChat, { content, model, effort, planMode, sessionId });
}

abortChat() {
Expand Down
Loading