diff --git a/.changeset/suppress-turn-notification-during-goal.md b/.changeset/suppress-turn-notification-during-goal.md new file mode 100644 index 000000000..405dd304b --- /dev/null +++ b/.changeset/suppress-turn-notification-during-goal.md @@ -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. diff --git a/apps/kimi-code/src/tui/commands/session.ts b/apps/kimi-code/src/tui/commands/session.ts index 2f0870db0..5c66d2f6f 100644 --- a/apps/kimi-code/src/tui/commands/session.ts +++ b/apps/kimi-code/src/tui/commands/session.ts @@ -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'; // --------------------------------------------------------------------------- @@ -169,9 +170,15 @@ export async function handleInitCommand(host: SlashCommandHost): Promise { 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' }); diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index a00dcb559..3d29bc75d 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -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, @@ -144,6 +145,10 @@ export class SessionEventHandler { private queuedGoalPromotionPending = false; private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | 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(); @@ -160,6 +165,8 @@ export class SessionEventHandler { this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); this.stopAllMcpServerStatusSpinners(); + this.goalStoppedAwaitingNotification = false; + this.goalStoppedNotificationTurnId = undefined; } clearAgentSwarmProgress(): void { @@ -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, @@ -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); @@ -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; diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index cb620801e..8044d27a9 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -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 { @@ -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; @@ -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; } // --------------------------------------------------------------------------- diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index d02578f30..fb4f7fcd9 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -52,6 +52,11 @@ 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') }, @@ -59,6 +64,15 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { todoPanel: { getTodos: vi.fn(() => []) }, transcriptContainer: { addChild: vi.fn() }, ui: { requestRender: vi.fn() }, + terminalState: { + notificationKeys: new Set(), + focused: false, + supportsOsc9: true, + insideTmux: false, + }, + terminal: { + write: vi.fn(), + }, }, session, aborted: false, @@ -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(), @@ -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 }; } @@ -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') { diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-notification.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-notification.test.ts new file mode 100644 index 000000000..0f54c720e --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-notification.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +function fakeGoalSnapshot(objective: string, status: 'active' | 'blocked' | 'paused' | 'complete') { + return { + goalId: 'g1', + objective, + status, + turnsUsed: 1, + tokensUsed: 10, + wallClockMs: 100, + budget: { + tokenBudget: null, + turnBudget: 20, + wallClockBudgetMs: null, + remainingTokens: null, + remainingTurns: 19, + remainingWallClockMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + overBudget: false, + }, + }; +} + +function makeHost(options: { goal?: ReturnType } = {}) { + const terminalWrites: string[] = []; + const appState: Record = { + sessionId: 's1', + streamingPhase: 'waiting', + streamingStartTime: 1, + model: 'kimi-model', + permissionMode: 'auto', + sessionTitle: 'Test session', + goal: options.goal ?? null, + notifications: { + enabled: true, + condition: 'unfocused', + }, + }; + const state = { + appState, + queuedMessages: [], + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + terminalState: { + notificationKeys: new Set(), + focused: false, + supportsOsc9: true, + insideTmux: false, + }, + terminal: { + write: (text: string) => { + terminalWrites.push(text); + }, + }, + }; + const session = { + createGoal: vi.fn(), + cancelGoal: vi.fn(), + }; + const host = { + state, + session, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: { + setTurnId: vi.fn(), + setStep: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + finalizeTurn: vi.fn(() => { + appState['streamingPhase'] = 'idle'; + return false; + }), + hasThinkingDraft: vi.fn(() => false), + flushThinkingToTranscript: vi.fn(), + appendAssistantDelta: vi.fn(), + scheduleFlush: vi.fn(), + getTurnContext: vi.fn(() => ({ turnId: 't1', step: 0 })), + }, + requireSession: vi.fn(() => session), + setAppState: vi.fn((patch: Record) => { + Object.assign(appState, patch); + }), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + updateActivityPane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(() => undefined), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + return { host: host as any, session, terminalWrites }; +} + +function turnStartedEvent() { + return { type: 'turn.started', sessionId: 's1', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as const; +} + +function turnEndedEvent(reason: 'completed' | 'cancelled' | 'failed' | 'filtered' = 'completed') { + return { type: 'turn.ended', sessionId: 's1', agentId: 'main', turnId: 1, reason } as const; +} + +function goalUpdatedEvent( + snapshot: ReturnType | null, + change?: { kind: 'lifecycle' | 'completion'; status: 'active' | 'paused' | 'blocked' | 'complete' }, +) { + return { + type: 'goal.updated', + sessionId: 's1', + agentId: 'main', + snapshot, + change, + } as const; +} + +describe('SessionEventHandler terminal notifications', () => { + it('notifies when a normal turn ends without a goal', () => { + const { host, terminalWrites } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnStartedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(terminalWrites.length).toBe(1); + expect(terminalWrites[0]).toContain('Kimi Code task complete'); + }); + + it('does not notify during an active goal continuation turn', () => { + const { host, terminalWrites } = makeHost({ goal: fakeGoalSnapshot('Ship feature X', 'active') }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnStartedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(terminalWrites.length).toBe(0); + }); + + it('notifies when a goal completes inside a turn', () => { + const { host, terminalWrites } = makeHost({ goal: fakeGoalSnapshot('Ship feature X', 'active') }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnStartedEvent(), vi.fn()); + // Model-driven: goal.complete arrives before turn.ended while the turn is still running. + handler.handleEvent( + goalUpdatedEvent(fakeGoalSnapshot('Ship feature X', 'complete'), { + kind: 'completion', + status: 'complete', + }), + vi.fn(), + ); + handler.handleEvent(goalUpdatedEvent(null), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(terminalWrites.length).toBe(1); + expect(terminalWrites[0]).toContain('Kimi Code task complete'); + }); + + it('notifies when a goal is paused after the turn ends', () => { + const { host, terminalWrites } = makeHost({ goal: fakeGoalSnapshot('Ship feature X', 'active') }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnStartedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent('failed'), vi.fn()); + // Runtime-driven pause arrives after turn.ended. + handler.handleEvent( + goalUpdatedEvent(fakeGoalSnapshot('Ship feature X', 'paused'), { + kind: 'lifecycle', + status: 'paused', + }), + vi.fn(), + ); + + expect(terminalWrites.length).toBe(1); + expect(terminalWrites[0]).toContain('Kimi Code task complete'); + }); + + it('notifies when a goal is blocked after the turn ends', () => { + const { host, terminalWrites } = makeHost({ goal: fakeGoalSnapshot('Ship feature X', 'active') }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnStartedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.handleEvent( + goalUpdatedEvent(fakeGoalSnapshot('Ship feature X', 'blocked'), { + kind: 'lifecycle', + status: 'blocked', + }), + vi.fn(), + ); + + expect(terminalWrites.length).toBe(1); + expect(terminalWrites[0]).toContain('Kimi Code task complete'); + }); + + it('does not notify when a queued message will be sent next', () => { + const { host, terminalWrites } = makeHost(); + host.state.queuedMessages = [{ text: 'continue' }] as never[]; + host.streamingUI.finalizeTurn.mockImplementation((sendQueued: (item: unknown) => void) => { + host.setAppState({ streamingPhase: 'idle' }); + setTimeout(() => { + sendQueued(host.state.queuedMessages.shift()); + }, 0); + return true; + }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnStartedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(terminalWrites.length).toBe(0); + }); + + it('does not double-notify for the same turn', () => { + const { host, terminalWrites } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnStartedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), vi.fn()); + + expect(terminalWrites.length).toBe(1); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/streaming-ui.test.ts b/apps/kimi-code/test/tui/controllers/streaming-ui.test.ts new file mode 100644 index 000000000..d58487af5 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/streaming-ui.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { StreamingUIController } from '#/tui/controllers/streaming-ui'; +import type { StreamingUIHost } from '#/tui/controllers/streaming-ui'; +import type { TUIState } from '#/tui/kimi-tui'; +import type { AppState, QueuedMessage } from '#/tui/types'; + +function makeHost(args: { goal?: AppState['goal'] } = {}): { + host: StreamingUIHost; +} { + const state = { + appState: { + streamingPhase: 'waiting', + streamingStartTime: 1, + sessionTitle: 'Test session', + goal: args.goal, + notifications: { + enabled: true, + condition: 'unfocused' as const, + }, + }, + terminalState: { + notificationKeys: new Set(), + focused: false, + supportsOsc9: true, + insideTmux: false, + }, + terminal: { + write: vi.fn(), + }, + ui: { requestRender: vi.fn() }, + } as unknown as TUIState; + + const host: StreamingUIHost = { + state, + session: undefined, + setAppState: (patch) => { + Object.assign(state.appState, patch); + }, + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + updateActivityPane: vi.fn(), + updateQueueDisplay: vi.fn(), + requireSession: vi.fn(() => { + throw new Error('no session'); + }), + deferUserMessages: false, + shiftQueuedMessage: vi.fn(() => undefined), + pushTranscriptEntry: vi.fn(), + mergeCurrentTurnSteps: vi.fn(), + }; + + return { host }; +} + +describe('StreamingUIController.finalizeTurn', () => { + it('returns false and idles the UI when no queued message exists', () => { + const { host } = makeHost({ goal: null }); + const controller = new StreamingUIController(host); + controller.setTurnId('t1'); + + const result = controller.finalizeTurn(vi.fn()); + + expect(result).toBe(false); + expect(host.state.appState.streamingPhase).toBe('idle'); + expect(host.resetLivePane).toHaveBeenCalled(); + }); + + it('returns true and schedules a queued message instead of idling', async () => { + const { host } = makeHost({ goal: null }); + const next: QueuedMessage = { text: 'continue' }; + host.shiftQueuedMessage = vi.fn(() => next); + const sendQueued = vi.fn(); + const controller = new StreamingUIController(host); + controller.setTurnId('t1'); + + const result = controller.finalizeTurn(sendQueued); + + expect(result).toBe(true); + expect(host.state.appState.streamingPhase).toBe('idle'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(sendQueued).toHaveBeenCalledWith(next); + }); + + it('does nothing when the streaming phase is already idle', () => { + const { host } = makeHost({ goal: null }); + host.state.appState.streamingPhase = 'idle'; + const controller = new StreamingUIController(host); + + const result = controller.finalizeTurn(vi.fn()); + + expect(result).toBe(false); + expect(host.resetLivePane).not.toHaveBeenCalled(); + }); +});