Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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/goal-steer-queued-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix messages sent while a goal is running being rejected with a "Cannot launch a new turn while another turn is active" error; they are now steered into the active goal turn instead of being dropped.
16 changes: 16 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,22 @@ export class KimiTUI {
this.beginSessionRequest();

const sdkInput = options?.parts ?? input;
// While a goal is being pursued the engine holds its active turn across the
// whole continuation loop, so a fresh prompt races the goal driver at every
// continuation boundary and is rejected with `turn.agent_busy`, dropping
// the message. Steer instead: the engine buffers it into the running goal
// turn, or launches a turn of its own if the loop just ended.
if (this.state.appState.goal?.status === 'active') {
void session.steer(sdkInput).catch((error: unknown) => {
Comment thread
chengluyu marked this conversation as resolved.
const message = formatErrorMessage(error);
// Same reset as the prompt path: beginSessionRequest already moved the
// TUI to the waiting phase, and no turn events may follow a failed
// steer (e.g. the session is gone), which would leave the UI stuck
// queueing input behind a request that never completes.
this.failSessionRequest(`Failed to steer: ${message}`);
});
Comment thread
chengluyu marked this conversation as resolved.
return;
}
void session.prompt(sdkInput).catch((error: unknown) => {
const message = formatErrorMessage(error);
this.failSessionRequest(`Failed to send: ${message}`);
Expand Down
115 changes: 114 additions & 1 deletion apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
resetCapabilitiesCache,
setCapabilities,
} from '@moonshot-ai/pi-tui';
import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi-code-sdk';
import type {
ApprovalRequest,
ApprovalResponse,
Event,
GoalSnapshot,
} from '@moonshot-ai/kimi-code-sdk';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel';
Expand Down Expand Up @@ -311,6 +316,29 @@ async function makeDriver(
return { driver, session, harness };
}

function makeActiveGoalSnapshot(): GoalSnapshot {
return {
goalId: 'g1',
objective: 'Ship the feature',
status: 'active',
turnsUsed: 3,
tokensUsed: 100,
wallClockMs: 1000,
budget: {
tokenBudget: null,
turnBudget: null,
wallClockBudgetMs: null,
remainingTokens: null,
remainingTurns: null,
remainingWallClockMs: null,
tokenBudgetReached: false,
turnBudgetReached: false,
wallClockBudgetReached: false,
overBudget: false,
},
};
}

function renderTranscript(driver: MessageDriver): string {
return driver.state.transcriptContainer.render(120).join('\n');
}
Expand Down Expand Up @@ -1645,6 +1673,91 @@ command = "vim"
expect(harness.track).toHaveBeenCalledWith('input_queue', undefined);
});

it('steers fresh input while a goal is active even when the streaming phase is idle', async () => {
const { driver, session } = await makeDriver();
driver.state.appState.goal = makeActiveGoalSnapshot();

driver.handleUserInput('hello mid-goal');

expect(session.steer).toHaveBeenCalledWith('hello mid-goal');
expect(session.prompt).not.toHaveBeenCalled();
expect(driver.state.transcriptEntries).toEqual([
expect.objectContaining({
kind: 'user',
content: 'hello mid-goal',
}),
]);
});

it('resets the streaming phase when steering mid-goal input fails', async () => {
const session = makeSession({
steer: vi.fn(async () => {
throw new Error('session closed');
}),
});
const { driver } = await makeDriver(session);
driver.state.appState.goal = makeActiveGoalSnapshot();

driver.handleUserInput('hello mid-goal');

expect(driver.state.appState.streamingPhase).toBe('waiting');
await vi.waitFor(() => {
expect(driver.state.appState.streamingPhase).toBe('idle');
});
expect(stripSgr(renderTranscript(driver))).toContain('Failed to steer: session closed');
});

it('steers a queued message at a turn boundary while a goal is active', async () => {
const { driver, session } = await makeDriver();
driver.state.appState.goal = makeActiveGoalSnapshot();
driver.state.appState.streamingPhase = 'waiting';
driver.handleUserInput('mid-goal note');
expect(driver.state.queuedMessages).toEqual([{ text: 'mid-goal note', agentId: 'main' }]);

driver.sessionEventHandler.handleEvent(
{
type: 'turn.ended',
agentId: 'main',
sessionId: 'ses-1',
turnId: 0,
reason: 'completed',
} as Event,
(item) => {
driver.sendQueuedMessage(session, item);
},
);

await vi.waitFor(() => {
expect(session.steer).toHaveBeenCalledWith('mid-goal note');
});
expect(session.prompt).not.toHaveBeenCalled();
expect(driver.state.queuedMessages).toEqual([]);
});

it('prompts the queued message as a new turn when no goal is active', async () => {
const { driver, session } = await makeDriver();
driver.state.appState.streamingPhase = 'waiting';
driver.handleUserInput('after the turn');

driver.sessionEventHandler.handleEvent(
{
type: 'turn.ended',
agentId: 'main',
sessionId: 'ses-1',
turnId: 0,
reason: 'completed',
} as Event,
(item) => {
driver.sendQueuedMessage(session, item);
},
);

await vi.waitFor(() => {
expect(session.prompt).toHaveBeenCalledWith('after the turn');
});
expect(session.steer).not.toHaveBeenCalled();
});

it('cancels active streaming from Escape and Ctrl-C editor shortcuts', async () => {
const { driver, session } = await makeDriver();

Expand Down
Loading