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

Prevent the terminal completion bell from firing during goal continuation turns before the goal is actually finished.
9 changes: 8 additions & 1 deletion apps/kimi-code/src/tui/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi
import { isAbortError } from '../utils/errors';
import { formatErrorMessage } from '../utils/event-payload';
import { buildExportMarkdown } from '../utils/export-markdown';
import { notifyTerminalOnce } from '../utils/terminal-notification';
import type { SlashCommandHost } from './dispatch';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -169,9 +170,15 @@ export async function handleInitCommand(host: SlashCommandHost): Promise<void> {
try {
await session.init();
host.track('init_complete');
host.streamingUI.finalizeTurn((item) => {
const queued = host.streamingUI.finalizeTurn((item) => {
host.sendQueuedMessage(session, item);
});
if (!queued && host.state.appState.goal?.status !== 'active') {
notifyTerminalOnce(host.state, `init-complete:${host.state.appState.sessionId}`, {
title: 'Kimi Code task complete',
body: host.state.appState.sessionTitle ?? undefined,
});
}
} catch (error) {
if (isAbortError(error)) {
host.setAppState({ streamingPhase: 'idle' });
Expand Down
64 changes: 63 additions & 1 deletion apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
import { formatBackgroundTaskTranscript } from '../utils/background-task-status';
import { formatHookResultMarkdown } from '../utils/hook-result-format';
import { McpOAuthAuthorizationUrlOpener } from '../utils/mcp-oauth';
import { notifyTerminalOnce } from '../utils/terminal-notification';
import {
formatMcpStartupStatusSummary,
mcpServerStatusKey,
Expand Down Expand Up @@ -144,6 +145,10 @@ export class SessionEventHandler {
private queuedGoalPromotionPending = false;
private queuedGoalPromotionInFlight = false;
private queuedGoalPromotionTimer: ReturnType<typeof setTimeout> | undefined;
/** True when the active goal has left the `active` state and we are waiting
* for the current turn to end before emitting the terminal notification. */
private goalStoppedAwaitingNotification = false;
private goalStoppedNotificationTurnId: string | undefined = undefined;

resetRuntimeState(): void {
this.backgroundTasks.clear();
Expand All @@ -160,6 +165,8 @@ export class SessionEventHandler {
this.queuedGoalPromotionInFlight = false;
this.clearQueuedGoalPromotionTimer();
this.stopAllMcpServerStatusSpinners();
this.goalStoppedAwaitingNotification = false;
this.goalStoppedNotificationTurnId = undefined;
}

clearAgentSwarmProgress(): void {
Expand Down Expand Up @@ -295,6 +302,8 @@ export class SessionEventHandler {
this.clearAgentSwarmProgress();
this.host.streamingUI.resetToolUi();
this.host.streamingUI.setStep(0);
this.goalStoppedAwaitingNotification = false;
this.goalStoppedNotificationTurnId = undefined;
this.host.patchLivePane({
mode: 'waiting',
pendingApproval: null,
Expand Down Expand Up @@ -337,13 +346,43 @@ export class SessionEventHandler {
this.host.streamingUI.setTodoList([]);
}
this.host.streamingUI.resetToolUi();
this.host.streamingUI.finalizeTurn(sendQueued);
const queued = this.host.streamingUI.finalizeTurn(sendQueued);
if (!queued) {
this.maybeNotifyTurnComplete(event.turnId);
}
this.renderPendingModelBlockedFallback();
this.currentTurnHasAssistantText = false;
this.goalCompletionTurnEnded = true;
this.scheduleQueuedGoalPromotion();
}

private maybeNotifyTurnComplete(turnId: number): void {
const { state } = this.host;
const goal = state.appState.goal;

// Goal mode: the goal may stop (complete/paused/blocked) before or after
// turn.ended is emitted. If it stopped earlier in this turn and we have
// been waiting for the turn to end, notify now.
if (this.goalStoppedAwaitingNotification) {
this.goalStoppedAwaitingNotification = false;
this.goalStoppedNotificationTurnId = undefined;
notifyTerminalOnce(state, `turn-complete:${turnId}`, {
title: 'Kimi Code task complete',
body: state.appState.sessionTitle ?? undefined,
});
return;
}

// No active goal at turn end: either there never was one, or it was already
// paused/blocked/cleared by the time the turn finished. Notify normally.
if (goal === null || goal === undefined || goal.status !== 'active') {
notifyTerminalOnce(state, `turn-complete:${turnId}`, {
title: 'Kimi Code task complete',
body: state.appState.sessionTitle ?? undefined,
});
}
}

private handleStepBegin(event: TurnStepStartedEvent): void {
this.host.streamingUI.flushNow();
this.host.streamingUI.setStep(event.step);
Expand Down Expand Up @@ -606,7 +645,30 @@ export class SessionEventHandler {
}

private handleGoalUpdated(event: GoalUpdatedEvent): void {
const previousGoal = this.host.state.appState.goal;
this.host.setAppState({ goal: event.snapshot });

// Detect when the active goal leaves the `active` state (complete / paused /
// blocked / cancelled). If the turn has already ended, notify immediately;
// otherwise wait for turn.ended to fire the notification.
if (
previousGoal?.status === 'active' &&
(event.snapshot === null || event.snapshot.status !== 'active')
) {
if (
this.host.state.appState.streamingPhase === 'idle' &&
this.host.state.queuedMessages.length === 0
) {
notifyTerminalOnce(this.host.state, `goal-stopped:${previousGoal.goalId}`, {
title: 'Kimi Code task complete',
body: this.host.state.appState.sessionTitle ?? undefined,
});
} else {
this.goalStoppedAwaitingNotification = true;
this.goalStoppedNotificationTurnId = previousGoal.goalId;
}
}

if (event.snapshot === null && this.goalCompletionAwaitingClear) {
this.goalCompletionAwaitingClear = false;
this.queuedGoalPromotionPending = true;
Expand Down
21 changes: 11 additions & 10 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { ToolCallComponent } from '../components/messages/tool-call';
import { STREAMING_UI_FLUSH_MS } from '../constant/streaming';
import { hasDispose } from '../utils/component-capabilities';
import { appendStreamingArgsPreview, parseStreamingArgs } from '../utils/event-payload';
import { notifyTerminalOnce } from '../utils/terminal-notification';
import { nextTranscriptId } from '../utils/transcript-id';
import type { TodoItem } from '../components/chrome/todo-panel';
import type {
Expand Down Expand Up @@ -547,12 +546,17 @@ export class StreamingUIController {
this.finalizeAssistantStream();
}

finalizeTurn(sendQueued: (item: QueuedMessage) => void): void {


/**
* Flushes live text/tool state, drains any queued message, and resets the
* turn UI. Returns `true` when a queued message was scheduled (so the caller
* should skip the terminal-completion notification), and `false` otherwise.
*/
finalizeTurn(sendQueued: (item: QueuedMessage) => void): boolean {
const { state } = this.host;
if (state.appState.streamingPhase === 'idle') return;
if (state.appState.streamingPhase === 'idle') return false;
this.host.deferUserMessages = false;
const completedTurnKey =
this._currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`;
this.finalizeLiveTextBuffers('idle');
this.resetToolCallState();
this._currentTurnId = undefined;
Expand All @@ -564,15 +568,12 @@ export class StreamingUIController {
setTimeout(() => {
sendQueued(next);
}, 0);
return;
return true;
}

this.host.setAppState({ streamingPhase: 'idle' });
this.host.resetLivePane();
notifyTerminalOnce(state, `turn-complete:${completedTurnKey}`, {
title: 'Kimi Code task complete',
body: state.appState.sessionTitle ?? undefined,
});
return false;
}

// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,27 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) {
streamingPhase: 'waiting',
model: 'kimi-model',
permissionMode: 'auto',
sessionTitle: 'Test session',
notifications: {
enabled: true,
condition: 'unfocused',
},
},
queuedMessages: [],
theme: { palette: getBuiltInPalette('dark') },
toolOutputExpanded: false,
todoPanel: { getTodos: vi.fn(() => []) },
transcriptContainer: { addChild: vi.fn() },
ui: { requestRender: vi.fn() },
terminalState: {
notificationKeys: new Set<string>(),
focused: false,
supportsOsc9: true,
insideTmux: false,
},
terminal: {
write: vi.fn(),
},
},
session,
aborted: false,
Expand All @@ -67,7 +81,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) {
setTurnId: vi.fn(),
flushNow: vi.fn(),
resetToolUi: vi.fn(),
finalizeTurn: vi.fn(),
finalizeTurn: vi.fn(() => false),
hasThinkingDraft: vi.fn(() => false),
flushThinkingToTranscript: vi.fn(),
appendAssistantDelta: vi.fn(),
Expand Down Expand Up @@ -96,6 +110,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) {
});
host.streamingUI.finalizeTurn.mockImplementation(() => {
host.setAppState({ streamingPhase: 'idle' });
return false;
});
return { host: host as any, session };
}
Expand Down Expand Up @@ -201,9 +216,10 @@ describe('SessionEventHandler goal queue promotion', () => {
setTimeout(() => {
sendQueued(next);
}, 0);
return;
return true;
}
host.setAppState({ streamingPhase: 'idle' });
return false;
});
host.sendQueuedMessage.mockImplementation((_session: unknown, item: { text: string }) => {
if (item.text === 'queued user turn') {
Expand Down
Loading
Loading