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/durable-turn-input-cancellation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Preserve queued prompts and unfinished turn context across interruptions and restarts, and stop in-flight model streams promptly when a turn is cancelled.
5 changes: 5 additions & 0 deletions .changeset/preserve-interrupted-turn-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Tell the model when a previous turn failed or was interrupted so follow-up prompts retain the unfinished request context.
20 changes: 18 additions & 2 deletions apps/kimi-code/src/tui/components/dialogs/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* "Compaction complete (X → Y tokens)"
* - `markCanceled()` on `compaction.cancelled` → solid warning bullet +
* "Compaction cancelled"
* - `markFailed()` when the compaction error event arrives without a
* terminal event → solid error bullet + "Compaction failed"
*
* Bullet animation mirrors `ToolCallComponent` (500ms blink) so the user
* reads the same "work in progress" signal across the UI.
Expand All @@ -31,6 +33,7 @@ export class CompactionComponent extends Container {
private blinkTimer: ReturnType<typeof setInterval> | null = null;
private done = false;
private canceled = false;
private failed = false;
private tokensBefore: number | undefined;
private tokensAfter: number | undefined;
private summary: string | undefined;
Expand Down Expand Up @@ -87,7 +90,7 @@ export class CompactionComponent extends Container {
}

markDone(tokensBefore?: number, tokensAfter?: number, summary?: string): void {
if (this.done || this.canceled) return;
if (this.done || this.canceled || this.failed) return;
this.done = true;
this.tokensBefore = tokensBefore;
this.tokensAfter = tokensAfter;
Expand All @@ -101,13 +104,21 @@ export class CompactionComponent extends Container {
}

markCanceled(): void {
if (this.done || this.canceled) return;
if (this.done || this.canceled || this.failed) return;
this.canceled = true;
this.stopBlink();
this.headerText.setText(this.buildHeader());
this.ui?.requestRender();
}

markFailed(): void {
if (this.done || this.canceled || this.failed) return;
this.failed = true;
this.stopBlink();
this.headerText.setText(this.buildHeader());
this.ui?.requestRender();
}

setExpanded(expanded: boolean): void {
if (this.expanded === expanded) return;
this.expanded = expanded;
Expand Down Expand Up @@ -164,6 +175,11 @@ export class CompactionComponent extends Container {
const label = currentTheme.boldFg('warning', 'Compaction cancelled');
return `${bullet}${label}`;
}
if (this.failed) {
const bullet = currentTheme.fg('error', STATUS_BULLET);
const label = currentTheme.boldFg('error', 'Compaction failed');
return `${bullet}${label}`;
}
const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' ';
const label = currentTheme.boldFg('primary', 'Compacting context...');
const tip = this.tip ? currentTheme.fg('textDim', ` · Tip: ${this.tip}`) : '';
Expand Down
26 changes: 23 additions & 3 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ export class SessionEventHandler {
case 'goal.updated': this.handleGoalUpdated(event); break;
case 'skill.activated': this.handleSkillActivated(event); break;
case 'plugin_command.activated': this.handlePluginCommandActivated(event); break;
case 'error': this.handleSessionError(event); break;
case 'error': this.handleSessionError(event, sendQueued); break;
case 'warning': this.handleSessionWarning(event); break;
case 'compaction.started': this.handleCompactionBegin(event); break;
case 'compaction.completed': this.handleCompactionEnd(event, sendQueued); break;
Expand Down Expand Up @@ -851,10 +851,24 @@ export class SessionEventHandler {
}
}

private handleSessionError(event: ErrorEvent): void {
private handleSessionError(
event: ErrorEvent,
sendQueued: (item: QueuedMessage) => void,
): void {
this.host.streamingUI.flushNow();
this.host.streamingUI.resetToolUi();
this.host.streamingUI.finalizeLiveTextBuffers('idle');
if (
this.host.state.appState.isCompacting &&
(event.code.startsWith('compaction.') ||
event.code === 'auth.login_required' ||
event.code === 'provider.auth_error')
) {
// Pre-commit compaction failures have no completed/cancelled terminal:
// the error event itself is the terminal signal for the UI.
this.host.streamingUI.failCompaction();
this.finishCompaction(sendQueued);
}
if (event.code === OAUTH_LOGIN_REQUIRED_CODE) {
this.host.showError(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE);
return;
Expand Down Expand Up @@ -1012,7 +1026,13 @@ export class SessionEventHandler {
private finishCompaction(sendQueued: (item: QueuedMessage) => void): void {
const hasActiveTurn = this.host.streamingUI.hasActiveTurn();
if (!hasActiveTurn) {
const next = this.host.shiftQueuedMessage();
// Auto-compaction failures terminate the owning turn first and emit the
// compaction error second. finalizeTurn may therefore have already
// removed and scheduled one queued message; never drain a second item for
// the same terminal sequence.
const next = this.host.state.queuedMessageDispatchPending
? undefined
: this.host.shiftQueuedMessage();
if (next !== undefined) {
this.host.state.queuedMessageDispatchPending = true;
}
Expand Down
11 changes: 10 additions & 1 deletion apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
Session,
ToolCall,
} from '@moonshot-ai/kimi-code-sdk';
import { extractImageCompressionCaptions } from '@moonshot-ai/kimi-code-sdk';

import { ToolCallComponent } from '../components/messages/tool-call';
import { currentTheme } from '../theme';
Expand Down Expand Up @@ -334,8 +335,16 @@ export class SessionReplayRenderer {
}

this.advanceTurn(context);
const content =
message.origin?.kind === 'user'
? message.content.map((part) =>
part.type === 'text'
? { ...part, text: extractImageCompressionCaptions(part.text).text }
: part,
)
: message.content;
this.host.appendTranscriptEntry(
replayEntry(context, 'user', contentPartsToText(message.content), 'plain'),
replayEntry(context, 'user', contentPartsToText(content), 'plain'),
);
}

Expand Down
8 changes: 8 additions & 0 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,14 @@ export class StreamingUIController {
this.host.state.ui.requestRender();
}

failCompaction(): void {
const block = this._activeCompactionBlock;
if (block === undefined) return;
block.markFailed();
this._activeCompactionBlock = undefined;
this.host.state.ui.requestRender();
}

// ---------------------------------------------------------------------------
// Tool call grouping
// ---------------------------------------------------------------------------
Expand Down
14 changes: 14 additions & 0 deletions apps/kimi-code/test/tui/components/dialogs/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,20 @@ describe('CompactionComponent', () => {
}
});

it('stops the progress animation and renders a failed terminal', () => {
const component = new CompactionComponent();

try {
component.markFailed();

const output = component.render(80).map(strip).join('\n');
expect(output).toContain('Compaction failed');
expect(output).not.toContain('Compacting context');
} finally {
component.dispose();
}
});

it('renders a cancelled terminal state', () => {
const component = new CompactionComponent();

Expand Down
95 changes: 95 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3235,6 +3235,101 @@ command = "vim"
expect(transcript).not.toContain('kimi export');
});

it('treats a compaction error as a failed terminal and leaves compacting state', async () => {
const { driver } = await makeDriver();
const sendQueued = vi.fn();

driver.sessionEventHandler.handleEvent(
{
type: 'compaction.started',
agentId: 'main',
sessionId: 'ses-1',
} as Event,
sendQueued,
);
expect(driver.state.appState.isCompacting).toBe(true);

driver.sessionEventHandler.handleEvent(
{
type: 'error',
agentId: 'main',
sessionId: 'ses-1',
code: 'compaction.failed',
message: 'provider aborted without a cancellation signal',
retryable: false,
} as Event,
sendQueued,
);

expect(driver.state.appState.isCompacting).toBe(false);
expect(driver.state.appState.streamingPhase).toBe('idle');
const transcript = stripSgr(renderTranscript(driver));
expect(transcript).toContain('Compaction failed');
expect(transcript).not.toContain('Compacting context');
});

it('does not drain a second queued message when turn failure precedes compaction error', async () => {
vi.useFakeTimers();
try {
const { driver } = await makeDriver();
const sendQueued = vi.fn();
driver.sessionEventHandler.handleEvent(
{
type: 'turn.started',
agentId: 'main',
sessionId: 'ses-1',
turnId: 1,
origin: { kind: 'user' },
} as Event,
sendQueued,
);
driver.sessionEventHandler.handleEvent(
{
type: 'compaction.started',
agentId: 'main',
sessionId: 'ses-1',
turnId: 1,
trigger: 'auto',
} as Event,
sendQueued,
);
driver.state.queuedMessages = [{ text: 'first' }, { text: 'second' }];

driver.sessionEventHandler.handleEvent(
{
type: 'turn.ended',
agentId: 'main',
sessionId: 'ses-1',
turnId: 1,
reason: 'failed',
durationMs: 1,
error: { code: 'compaction.failed', message: 'summary failed', retryable: false },
} as Event,
sendQueued,
);
driver.sessionEventHandler.handleEvent(
{
type: 'error',
agentId: 'main',
sessionId: 'ses-1',
code: 'compaction.failed',
message: 'summary failed',
retryable: false,
} as Event,
sendQueued,
);

expect(driver.state.queuedMessages).toEqual([{ text: 'second' }]);
expect(driver.state.queuedMessageDispatchPending).toBe(true);
await vi.runAllTimersAsync();
expect(sendQueued).toHaveBeenCalledTimes(1);
expect(sendQueued).toHaveBeenCalledWith({ text: 'first' });
expect(driver.state.queuedMessages).toEqual([{ text: 'second' }]);
} finally {
vi.useRealTimers();
}
});

it('shows concise provider filter text for filtered session errors', async () => {
const { driver } = await makeDriver();
const verboseMessage =
Expand Down
Loading