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.
5 changes: 5 additions & 0 deletions .changeset/vscode-reattach-streaming-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kimi-code": patch
---

Keep the composer in streaming state when a chat is opened while its session has a live turn (e.g. a turn started in another view, or one still running when the window reloaded). The session history reply now carries a `turn_active` marker when the runtime is busy, and the store honors it — new input enqueues and is sent when the running turn finishes, instead of taking the send path and bouncing off the busy runtime.
3 changes: 3 additions & 0 deletions apps/vscode/shared/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const Methods = {

GetKimiSessions: "getKimiSessions",
GetAllKimiSessions: "getAllKimiSessions",
IsSessionBusy: "isSessionBusy",
GetRegisteredWorkDirs: "getRegisteredWorkDirs",
SetWorkDir: "setWorkDir",
BrowseWorkDir: "browseWorkDir",
Expand Down Expand Up @@ -194,6 +195,8 @@ function validateParams(method: RpcMethod, params: unknown): boolean {
return isPlainObject(params) && (params["workDir"] === null || typeof params["workDir"] === "string");
case Methods.LoadKimiSessionHistory:
return hasNonEmptyString(params, "kimiSessionId");
case Methods.IsSessionBusy:
return hasNonEmptyString(params, "sessionId");
case Methods.DeleteKimiSession:
return hasNonEmptyString(params, "sessionId");
case Methods.ForkKimiSession:
Expand Down
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
10 changes: 10 additions & 0 deletions apps/vscode/src/handlers/session.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export const sessionHandlers: Record<string, Handler<any, any>> = {
.map(toSessionInfo);
},

[Methods.IsSessionBusy]: async (params: { sessionId: string }, ctx): Promise<{ busy: boolean }> => {
return { busy: ctx.runtime.getSession(params.sessionId)?.isBusy ?? false };
},

[Methods.GetRegisteredWorkDirs]: async (_, ctx): Promise<string[]> => {
if (!ctx.workspaceRoot) return [];
const sessions = await ctx.harness.listSessions();
Expand Down Expand Up @@ -172,6 +176,12 @@ export const sessionHandlers: Record<string, Handler<any, any>> = {
ctx.logError("Unable to show the file change warning", noticeError);
});
}
if (runtime.isBusy) {
// The session has an in-flight turn (e.g. started by another view, or
// still running while this webview reloaded): tell the store, so the
// composer queues new input instead of bouncing off the busy runtime.
history.push({ type: "turn_active", payload: {}, _sessionId: runtime.id });
Comment on lines +179 to +183

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reconcile terminal events after emitting the active marker

If the live turn finishes after this isBusy check but before the history RPC is applied by the webview, stream_complete is either processed against the pre-load store or filtered by App.tsx, and then loadSession() replays this stale marker and sets isStreaming back to true. No later terminal event exists to clear it, so the composer remains locked and subsequent prompts queue indefinitely; buffer/reconcile events during loading or make the active-state snapshot versioned rather than returning a bare marker.

Useful? React with 👍 / 👎.

}
return history;
},

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
66 changes: 66 additions & 0 deletions apps/vscode/test/bridge-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,72 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () =>
expect(deleteBaseline).toHaveBeenCalledWith("session-2");
});

it("appends a turn_active marker when the resumed session has a live turn", async () => {
const session = createResumedSession("session-1", root);
host.harness.resumeSession.mockResolvedValue(session as never);

const first = await bridge.handle(
{
id: "rpc-1",
method: Methods.LoadKimiSessionHistory,
params: { kimiSessionId: "session-1" },
},
"view-1",
);
expect(first).toEqual({
id: "rpc-1",
result: expect.not.arrayContaining([
expect.objectContaining({ type: "turn_active" }),
]),
});

// A second attach while the runtime reports a live turn must say so.
const runtime = bridge.runtime.getSession("session-1")!;
vi.spyOn(runtime, "isBusy", "get").mockReturnValue(true);

const second = await bridge.handle(
{
id: "rpc-2",
method: Methods.LoadKimiSessionHistory,
params: { kimiSessionId: "session-1" },
},
"view-1",
);
expect(second).toEqual({
id: "rpc-2",
result: expect.arrayContaining([
expect.objectContaining({ type: "turn_active", _sessionId: "session-1" }),
]),
});
});

it("reports whether a session has live work", async () => {
const session = createResumedSession("session-1", root);
host.harness.resumeSession.mockResolvedValue(session as never);
await bridge.handle(
{
id: "rpc-1",
method: Methods.LoadKimiSessionHistory,
params: { kimiSessionId: "session-1" },
},
"view-1",
);

const idle = await bridge.handle(
{ id: "rpc-2", method: Methods.IsSessionBusy, params: { sessionId: "session-1" } },
"view-1",
);
expect(idle).toEqual({ id: "rpc-2", result: { busy: false } });

const runtime = bridge.runtime.getSession("session-1")!;
vi.spyOn(runtime, "isBusy", "get").mockReturnValue(true);
const busy = await bridge.handle(
{ id: "rpc-3", method: Methods.IsSessionBusy, params: { sessionId: "session-1" } },
"view-1",
);
expect(busy).toEqual({ id: "rpc-3", result: { busy: true } });
});

it("keeps conversation history available when its baseline snapshot disappears", async () => {
const session = createResumedSession("session-1", root);
host.harness.resumeSession.mockResolvedValueOnce(session as never);
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
Loading