diff --git a/.changeset/web-approval-notifications.md b/.changeset/web-approval-notifications.md
new file mode 100644
index 000000000..4ffab64dc
--- /dev/null
+++ b/.changeset/web-approval-notifications.md
@@ -0,0 +1,5 @@
+---
+"@moonshot-ai/kimi-code": patch
+---
+
+web: Add notifications when a tool needs approval, and improve notification reliability.
diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue
index eb9131b48..f41127fb7 100644
--- a/apps/kimi-web/src/App.vue
+++ b/apps/kimi-web/src/App.vue
@@ -898,6 +898,7 @@ function openPr(url: string): void {
:account-model="client.defaultModel.value"
:notify="client.notifyOnComplete.value"
:notify-question="client.notifyOnQuestion.value"
+ :notify-approval="client.notifyOnApproval.value"
:notify-permission="client.notifyPermission.value"
:sound="client.soundOnComplete.value"
:conversation-toc="client.conversationToc.value"
@@ -910,6 +911,7 @@ function openPr(url: string): void {
@set-ui-font-size="client.setUiFontSize($event)"
@set-notify="client.setNotifyOnComplete($event)"
@set-notify-question="client.setNotifyOnQuestion($event)"
+ @set-notify-approval="client.setNotifyOnApproval($event)"
@set-sound="client.setSoundOnComplete($event)"
@set-conversation-toc="client.setConversationToc($event)"
@update-config="handleUpdateConfig($event)"
diff --git a/apps/kimi-web/src/components/settings/SettingsDialog.vue b/apps/kimi-web/src/components/settings/SettingsDialog.vue
index 44d7cf3ca..8f3493a6f 100644
--- a/apps/kimi-web/src/components/settings/SettingsDialog.vue
+++ b/apps/kimi-web/src/components/settings/SettingsDialog.vue
@@ -32,6 +32,8 @@ const props = defineProps<{
notify: boolean;
/** Browser-notification-on-question (needs answer) preference. */
notifyQuestion: boolean;
+ /** Browser-notification-on-approval preference. */
+ notifyApproval: boolean;
/** OS permission state ('default' | 'granted' | 'denied') for the hint. */
notifyPermission?: string;
/** Play-a-sound-on-completion preference. */
@@ -54,6 +56,7 @@ const emit = defineEmits<{
setUiFontSize: [size: number];
setNotify: [on: boolean];
setNotifyQuestion: [on: boolean];
+ setNotifyApproval: [on: boolean];
setSound: [on: boolean];
setConversationToc: [on: boolean];
login: [];
@@ -417,6 +420,18 @@ function archiveTime(iso: string): string {
@update:model-value="emit('setNotifyQuestion', $event)"
/>
+
+
+ {{ t('settings.notifyOnApproval') }}
+ {{ t('settings.notifyDenied') }}
+
+
+
{{ t('settings.soundOnComplete') }}
(
typeof Notification !== 'undefined' ? Notification.permission : 'denied',
);
@@ -61,20 +71,42 @@ function setNotifyOnQuestion(on: boolean): Promise {
return setNotifyPref(notifyOnQuestion, STORAGE_KEYS.notifyOnQuestion, on);
}
-export interface NotifyCompletionCtx {
- /** True when the target session is the active one and the page is visible —
- in which case we suppress the notification. */
- isActiveAndVisible: boolean;
+/** Enable/disable approval notifications. Off by default. */
+function setNotifyOnApproval(on: boolean): Promise {
+ return setNotifyPref(notifyOnApproval, STORAGE_KEYS.notifyOnApproval, on);
+}
+
+export interface NotifyBaseCtx {
+ /** True when the user is actually watching the target session: it is the
+ active session, the page is visible, and the window has focus — in which
+ case we suppress the notification. */
+ isUserWatching: boolean;
/** Session title used as the completion notification body and a question-body fallback. */
sessionTitle: string;
/** Called when the user clicks the notification (e.g. select the session). */
onClick: () => void;
}
-export interface NotifyQuestionCtx extends NotifyCompletionCtx {
+export interface NotifyCompletionCtx extends NotifyBaseCtx {
+ /** Prompt id of the finished turn; keys the dedup tag so every turn fires its
+ own notification while a replayed idle event for the same turn stays
+ collapsed. Falls back to a per-call unique tag when absent. */
+ promptId?: string;
+}
+
+export interface NotifyQuestionCtx extends NotifyBaseCtx {
/** Short preview of the question, used as the notification body. Falls back
to the session title, then to a generic line when empty. */
questionPreview: string;
+ /** Unique question request id; used to deduplicate notifications per request. */
+ questionId: string;
+}
+
+export interface NotifyApprovalCtx extends NotifyBaseCtx {
+ /** Tool call name needing approval, used as the notification body. */
+ toolName: string;
+ /** Unique approval request id; used to deduplicate notifications per request. */
+ approvalId: string;
}
export interface NotificationCopy {
@@ -111,12 +143,29 @@ export function questionNotificationCopy(
};
}
+export function approvalNotificationCopy(
+ sessionTitle: string,
+ toolName: string,
+): NotificationCopy {
+ return {
+ title: i18n.global.t('settings.notifyApprovalTitle'),
+ body: firstText(
+ toolName,
+ sessionTitle,
+ i18n.global.t('settings.notifyApprovalFallback'),
+ ),
+ };
+}
+
/** Shared permission gate + fire. `enabled` is the caller's per-kind preference;
- `copy` and `tag` let each kind carry its own text and a per-kind dedup tag
- so a completion and a question don't collapse into one notification. */
+ `copy` and `tag` let each kind carry its own text and a per-turn/per-request
+ dedup tag: repeats of the same turn or request collapse into one
+ notification, while distinct ones each fire (same-tag notifications replace
+ silently — renotify is unreliable across platforms — so the tag must change
+ whenever a new alert should pop). */
function maybeNotify(
enabled: boolean,
- ctx: NotifyCompletionCtx,
+ ctx: NotifyBaseCtx,
copy: NotificationCopy,
tag: string,
): void {
@@ -135,8 +184,8 @@ function maybeNotify(
fire(ctx, copy, tag);
}
-function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): void {
- if (ctx.isActiveAndVisible) return;
+function fire(ctx: NotifyBaseCtx, copy: NotificationCopy, tag: string): void {
+ if (ctx.isUserWatching) return;
try {
const n = new Notification(copy.title, { body: copy.body, tag, icon: NOTIFICATION_ICON });
n.onclick = () => {
@@ -154,24 +203,38 @@ function fire(ctx: NotifyCompletionCtx, copy: NotificationCopy, tag: string): vo
}
/** Fire a completion notification for a finished session, but only when the
- caller says the user isn't already looking at it. */
+ caller says the user isn't already looking at it. The tag carries the turn's
+ prompt id: same-tag notifications replace silently, so without it a stale
+ notification left in the notification center would swallow every later
+ turn's alert for that session. */
function maybeNotifyCompletion(sid: string, ctx: NotifyCompletionCtx): void {
maybeNotify(
notifyOnComplete.value,
ctx,
completionNotificationCopy(ctx.sessionTitle),
- `kimi-complete-${sid}`,
+ `kimi-complete-${sid}-${ctx.promptId ?? Date.now()}`,
);
}
/** Fire a notification when a session asks a question, but only when the user
explicitly opted into question notifications and isn't already looking. */
-function maybeNotifyQuestion(sid: string, ctx: NotifyQuestionCtx): void {
+function maybeNotifyQuestion(ctx: NotifyQuestionCtx): void {
maybeNotify(
notifyOnQuestion.value,
ctx,
questionNotificationCopy(ctx.sessionTitle, ctx.questionPreview),
- `kimi-question-${sid}`,
+ `kimi-question-${ctx.questionId}`,
+ );
+}
+
+/** Fire a notification when a tool needs approval, but only when the user
+ explicitly opted into approval notifications and isn't already looking. */
+function maybeNotifyApproval(ctx: NotifyApprovalCtx): void {
+ maybeNotify(
+ notifyOnApproval.value,
+ ctx,
+ approvalNotificationCopy(ctx.sessionTitle, ctx.toolName),
+ `kimi-approval-${ctx.approvalId}`,
);
}
@@ -179,10 +242,13 @@ export function useNotification() {
return {
notifyOnComplete,
notifyOnQuestion,
+ notifyOnApproval,
notifyPermission,
setNotifyOnComplete,
setNotifyOnQuestion,
+ setNotifyOnApproval,
maybeNotifyCompletion,
maybeNotifyQuestion,
+ maybeNotifyApproval,
};
}
diff --git a/apps/kimi-web/src/composables/client/useSoundNotification.ts b/apps/kimi-web/src/composables/client/useSoundNotification.ts
index db58f6b64..3016c0c6d 100644
--- a/apps/kimi-web/src/composables/client/useSoundNotification.ts
+++ b/apps/kimi-web/src/composables/client/useSoundNotification.ts
@@ -1,7 +1,9 @@
// apps/kimi-web/src/composables/client/useSoundNotification.ts
-// Browser "turn completed" sound: a persisted on/off preference plus a short
-// chime synthesized with the WebAudio API (no audio asset, no permission
-// prompt). Pure UI action module — it never reads rawState or calls the API.
+// Browser attention sound: a persisted on/off preference plus a short chime
+// synthesized with the WebAudio API (no audio asset, no permission prompt).
+// One chime covers every "the agent needs you" moment — a finished turn, a
+// question waiting for an answer, a tool needing approval. Pure UI action
+// module — it never reads rawState or calls the API.
//
// Why the eager "unlock": the sound is most useful when the tab is in the
// background (so you hear it while doing something else). But an AudioContext
@@ -161,11 +163,19 @@ function maybePlayQuestionSound(): void {
playChime();
}
+/** Play the attention sound when a tool needs approval, whenever the
+ preference is on. Same chime as completion: it means "the agent needs you". */
+function maybePlayApprovalSound(): void {
+ if (!soundOnComplete.value) return;
+ playChime();
+}
+
export function useSoundNotification() {
return {
soundOnComplete,
setSoundOnComplete,
maybePlayCompletionSound,
maybePlayQuestionSound,
+ maybePlayApprovalSound,
};
}
diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts
index 2f7e822fc..9b61944bb 100644
--- a/apps/kimi-web/src/composables/useKimiWebClient.ts
+++ b/apps/kimi-web/src/composables/useKimiWebClient.ts
@@ -30,7 +30,7 @@ import {
} from '../lib/storage';
import { createEventBatcher, isRenderEvent } from './client/eventBatcher';
import { useAppearance } from './client/useAppearance';
-import { useNotification } from './client/useNotification';
+import { useNotification, shouldNotifyCompletion } from './client/useNotification';
import { useSoundNotification } from './client/useSoundNotification';
import { useTaskPoller } from './client/useTaskPoller';
import { useModelProviderState } from './client/useModelProviderState';
@@ -860,6 +860,11 @@ function processEvent(appEvent: AppEvent, meta: { sessionId: string; seq: number
if (appEvent.type === 'questionRequested') {
onQuestionRequested(appEvent.sessionId, appEvent.question);
}
+
+ // The agent needs approval for a tool call — surface it so the user comes back.
+ if (appEvent.type === 'approvalRequested') {
+ onApprovalRequested(appEvent.sessionId, appEvent.approval);
+ }
}
const enqueueEvent = createEventBatcher(
@@ -2315,10 +2320,27 @@ const workspaceState = useWorkspaceState(rawState, {
fileDiffLoading,
});
+/** True when the user is actually watching this session: it is the active
+ session, the page is visible, and the window has focus. Focus matters on
+ top of visibility: a window that lost focus to another app often stays
+ (partially) visible on screen, but the user is working elsewhere and would
+ miss the moment without a notification. */
+function isUserWatching(sid: string): boolean {
+ return (
+ sid === rawState.activeSessionId &&
+ typeof document !== 'undefined' &&
+ document.visibilityState === 'visible' &&
+ document.hasFocus()
+ );
+}
+
function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void {
// The turn finished — this session no longer has a prompt in flight.
inFlightPromptSessions.delete(sid);
rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false };
+ // Capture before the cleanup below drops it — it keys the completion
+ // notification's dedup tag so each finished turn alerts once.
+ const finishedPromptId = rawState.promptIdBySession[sid];
// Drop any cached prompt_id so a later skill activation (which has no
// prompt_id) doesn't accidentally reuse this stale id for :abort.
if (rawState.promptIdBySession[sid] !== undefined) {
@@ -2343,16 +2365,20 @@ function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void {
}
// Browser notification when the user isn't watching this session.
- notification.maybeNotifyCompletion(sid, {
- isActiveAndVisible:
- sid === rawState.activeSessionId &&
- typeof document !== 'undefined' &&
- document.visibilityState === 'visible',
- sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
- onClick: () => {
- void workspaceState.selectSession(sid);
- },
- });
+ // Only real completions notify; aborted turns and turns that ended up
+ // blocked on approval/question do not fire the generic "Turn finished" alert.
+ const hasPendingApproval = (rawState.approvalsBySession[sid] ?? []).length > 0;
+ const hasPendingQuestion = (rawState.questionsBySession[sid] ?? []).length > 0;
+ if (shouldNotifyCompletion(status, hasPendingApproval, hasPendingQuestion)) {
+ notification.maybeNotifyCompletion(sid, {
+ isUserWatching: isUserWatching(sid),
+ sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
+ promptId: finishedPromptId,
+ onClick: () => {
+ void workspaceState.selectSession(sid);
+ },
+ });
+ }
// Completion sound — only for real completions (aborted/cancelled turns stay
// silent). Plays regardless of visibility so it also reaches a backgrounded tab.
@@ -2391,13 +2417,11 @@ function onQuestionRequested(sid: string, question: AppQuestionRequest): void {
header && questionText ? `${header}: ${questionText}` : questionText || header;
// Browser notification when the user isn't watching this session.
- notification.maybeNotifyQuestion(sid, {
- isActiveAndVisible:
- sid === rawState.activeSessionId &&
- typeof document !== 'undefined' &&
- document.visibilityState === 'visible',
+ notification.maybeNotifyQuestion({
+ isUserWatching: isUserWatching(sid),
sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
questionPreview: preview,
+ questionId: question.questionId,
onClick: () => {
void workspaceState.selectSession(sid);
},
@@ -2408,6 +2432,23 @@ function onQuestionRequested(sid: string, question: AppQuestionRequest): void {
sound.maybePlayQuestionSound();
}
+function onApprovalRequested(sid: string, approval: AppApprovalRequest): void {
+ // Browser notification when the user isn't watching this session.
+ notification.maybeNotifyApproval({
+ isUserWatching: isUserWatching(sid),
+ sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '',
+ toolName: approval.toolName,
+ approvalId: approval.approvalId,
+ onClick: () => {
+ void workspaceState.selectSession(sid);
+ },
+ });
+
+ // Attention sound — plays regardless of visibility so it also reaches a
+ // backgrounded tab (same as the completion sound).
+ sound.maybePlayApprovalSound();
+}
+
// ---------------------------------------------------------------------------
// Composable return
// ---------------------------------------------------------------------------
@@ -2501,9 +2542,11 @@ export function useKimiWebClient() {
setAccent: appearance.setAccent,
notifyOnComplete: notification.notifyOnComplete,
notifyOnQuestion: notification.notifyOnQuestion,
+ notifyOnApproval: notification.notifyOnApproval,
notifyPermission: notification.notifyPermission,
setNotifyOnComplete: notification.setNotifyOnComplete,
setNotifyOnQuestion: notification.setNotifyOnQuestion,
+ setNotifyOnApproval: notification.setNotifyOnApproval,
soundOnComplete: sound.soundOnComplete,
setSoundOnComplete: sound.setSoundOnComplete,
onboarded,
diff --git a/apps/kimi-web/src/i18n/locales/en/settings.ts b/apps/kimi-web/src/i18n/locales/en/settings.ts
index ec2c8d770..c8aa5a56f 100644
--- a/apps/kimi-web/src/i18n/locales/en/settings.ts
+++ b/apps/kimi-web/src/i18n/locales/en/settings.ts
@@ -12,12 +12,15 @@ export default {
notifications: 'Notifications',
notifyOnComplete: 'Notify when a turn completes',
notifyOnQuestion: 'Notify when a question needs an answer',
- soundOnComplete: 'Play a sound when a turn completes or needs an answer',
+ notifyOnApproval: 'Notify when a tool needs approval',
+ soundOnComplete: 'Play a sound when a turn completes, needs an answer, or needs approval',
notifyDenied: 'Blocked in browser settings',
notifyTitle: 'Kimi Code · Turn finished',
notifyQuestionTitle: 'Kimi Code · Needs answer',
+ notifyApprovalTitle: 'Kimi Code · Approval required',
notifyFallback: 'View result',
notifyQuestionFallback: 'A question is waiting for your answer',
+ notifyApprovalFallback: 'A tool needs your approval',
account: 'Account',
uiFontSize: 'Font size',
agentDefaults: 'Agent defaults',
diff --git a/apps/kimi-web/src/i18n/locales/zh/settings.ts b/apps/kimi-web/src/i18n/locales/zh/settings.ts
index a06a14bdd..d20d6bfa6 100644
--- a/apps/kimi-web/src/i18n/locales/zh/settings.ts
+++ b/apps/kimi-web/src/i18n/locales/zh/settings.ts
@@ -12,12 +12,15 @@ export default {
notifications: '通知',
notifyOnComplete: '会话完成时通知',
notifyOnQuestion: '待回答时通知',
- soundOnComplete: '会话完成或待回答时播放提示音',
+ notifyOnApproval: '待审批时通知',
+ soundOnComplete: '会话完成、待回答或待审批时播放提示音',
notifyDenied: '已在浏览器设置中被阻止',
notifyTitle: 'Kimi Code · 回合完成',
notifyQuestionTitle: 'Kimi Code · 待回答',
+ notifyApprovalTitle: 'Kimi Code · 等待审批',
notifyFallback: '点击查看结果',
notifyQuestionFallback: '有提问等待你回答',
+ notifyApprovalFallback: '有工具等待你审批',
account: '账户',
uiFontSize: '字体大小',
agentDefaults: 'Agent 默认值',
diff --git a/apps/kimi-web/src/lib/storage.ts b/apps/kimi-web/src/lib/storage.ts
index a6d0a1f31..36dab4c61 100644
--- a/apps/kimi-web/src/lib/storage.ts
+++ b/apps/kimi-web/src/lib/storage.ts
@@ -32,6 +32,7 @@ export const STORAGE_KEYS = {
conversationToc: 'kimi-web.beta-toc',
notifyOnComplete: 'kimi-web.notify-on-complete',
notifyOnQuestion: 'kimi-web.notify-on-question',
+ notifyOnApproval: 'kimi-web.notify-on-approval',
soundOnComplete: 'kimi-web.sound-on-complete',
inputHistory: 'kimi-web.input-history',
// cross-file
diff --git a/apps/kimi-web/test/notification-logic.test.ts b/apps/kimi-web/test/notification-logic.test.ts
index f10a31d7d..5b13de8fc 100644
--- a/apps/kimi-web/test/notification-logic.test.ts
+++ b/apps/kimi-web/test/notification-logic.test.ts
@@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { i18n } from '../src/i18n';
import { STORAGE_KEYS, safeGetString } from '../src/lib/storage';
import {
+ approvalNotificationCopy,
completionNotificationCopy,
questionNotificationCopy,
+ shouldNotifyCompletion,
useNotification,
} from '../src/composables/client/useNotification';
@@ -41,11 +43,17 @@ function installStorage(storage: Storage): void {
// Singleton — module-level refs + setters. The OS Notification API is absent in
// the test env, so the *enable* path is a no-op; the disable path and the
// load-from-storage defaults are what we exercise here.
-const { notifyOnComplete, notifyOnQuestion, setNotifyOnComplete, setNotifyOnQuestion } = useNotification();
-// Captured at import (before beforeEach touches the refs), so these reflect the
-// load-from-storage defaults when nothing has been stored yet.
+const {
+ notifyOnComplete,
+ notifyOnQuestion,
+ notifyOnApproval,
+ setNotifyOnComplete,
+ setNotifyOnQuestion,
+ setNotifyOnApproval,
+} = useNotification();
const importedCompleteDefault = notifyOnComplete.value;
const importedQuestionDefault = notifyOnQuestion.value;
+const importedApprovalDefault = notifyOnApproval.value;
describe('useNotification preferences', () => {
beforeEach(() => {
@@ -64,6 +72,10 @@ describe('useNotification preferences', () => {
expect(importedQuestionDefault).toBe(false);
});
+ it('approval notifications default to off', () => {
+ expect(importedApprovalDefault).toBe(false);
+ });
+
it('disabling question notifications persists "0" and updates the ref', () => {
void setNotifyOnQuestion(false);
expect(notifyOnQuestion.value).toBe(false);
@@ -75,6 +87,12 @@ describe('useNotification preferences', () => {
expect(notifyOnComplete.value).toBe(false);
expect(safeGetString(STORAGE_KEYS.notifyOnComplete)).toBe('0');
});
+
+ it('disabling approval notifications persists "0" and updates the ref', () => {
+ void setNotifyOnApproval(false);
+ expect(notifyOnApproval.value).toBe(false);
+ expect(safeGetString(STORAGE_KEYS.notifyOnApproval)).toBe('0');
+ });
});
describe('notification copy', () => {
@@ -110,6 +128,32 @@ describe('notification copy', () => {
});
});
+ it('uses tool name in approval notifications', () => {
+ expect(approvalNotificationCopy('Refactor auth flow', 'bash')).toEqual({
+ title: 'Kimi Code · Approval required',
+ body: 'bash',
+ });
+ });
+
+ it('falls back to session title and then generic approval line', () => {
+ expect(approvalNotificationCopy('Refactor auth flow', ' ')).toEqual({
+ title: 'Kimi Code · Approval required',
+ body: 'Refactor auth flow',
+ });
+ expect(approvalNotificationCopy(' ', ' ')).toEqual({
+ title: 'Kimi Code · Approval required',
+ body: 'A tool needs your approval',
+ });
+ });
+
+ it('localizes approval notification copy', () => {
+ i18n.global.locale.value = 'zh';
+ expect(approvalNotificationCopy('', '')).toEqual({
+ title: 'Kimi Code · 等待审批',
+ body: '有工具等待你审批',
+ });
+ });
+
it('localizes the notification copy', () => {
i18n.global.locale.value = 'zh';
@@ -123,3 +167,83 @@ describe('notification copy', () => {
});
});
});
+
+describe('shouldNotifyCompletion', () => {
+ it('returns true only for idle + no pending approval + no pending question', () => {
+ expect(shouldNotifyCompletion('idle', false, false)).toBe(true);
+ });
+
+ it('returns false for aborted', () => {
+ expect(shouldNotifyCompletion('aborted', false, false)).toBe(false);
+ });
+
+ it('returns false when pending approval exists', () => {
+ expect(shouldNotifyCompletion('idle', true, false)).toBe(false);
+ });
+
+ it('returns false when pending question exists', () => {
+ expect(shouldNotifyCompletion('idle', false, true)).toBe(false);
+ });
+});
+
+// Same-tag notifications replace silently (renotify is unreliable), so the tag
+// must be unique per turn/request for follow-up alerts in a session to pop.
+describe('notification tags', () => {
+ class FakeNotification {
+ static permission = 'granted';
+ static fired: Array<{ title: string; tag?: string }> = [];
+ onclick: (() => void) | null = null;
+ constructor(title: string, options?: { body?: string; tag?: string; icon?: string }) {
+ FakeNotification.fired.push({ title, tag: options?.tag });
+ }
+ close(): void {}
+ }
+
+ const { maybeNotifyCompletion, maybeNotifyQuestion, maybeNotifyApproval } = useNotification();
+ const base = { isUserWatching: false, sessionTitle: 'T', onClick: () => {} };
+
+ beforeEach(() => {
+ FakeNotification.fired = [];
+ (globalThis as Record).Notification = FakeNotification;
+ notifyOnComplete.value = true;
+ notifyOnQuestion.value = true;
+ notifyOnApproval.value = true;
+ });
+
+ afterEach(() => {
+ delete (globalThis as Record).Notification;
+ notifyOnComplete.value = true;
+ notifyOnQuestion.value = false;
+ notifyOnApproval.value = false;
+ });
+
+ it('completion tags carry the prompt id so each turn in a session alerts', () => {
+ maybeNotifyCompletion('s1', { ...base, promptId: 'p1' });
+ maybeNotifyCompletion('s1', { ...base, promptId: 'p2' });
+ expect(FakeNotification.fired.map((f) => f.tag)).toEqual([
+ 'kimi-complete-s1-p1',
+ 'kimi-complete-s1-p2',
+ ]);
+ });
+
+ it('a replayed idle event for the same turn keeps the same tag', () => {
+ maybeNotifyCompletion('s1', { ...base, promptId: 'p1' });
+ maybeNotifyCompletion('s1', { ...base, promptId: 'p1' });
+ expect(FakeNotification.fired).toHaveLength(2);
+ expect(FakeNotification.fired[0]?.tag).toBe(FakeNotification.fired[1]?.tag);
+ });
+
+ it('question and approval tags are per-request', () => {
+ maybeNotifyQuestion({ ...base, questionPreview: 'q', questionId: 'q1' });
+ maybeNotifyApproval({ ...base, toolName: 'bash', approvalId: 'a1' });
+ expect(FakeNotification.fired.map((f) => f.tag)).toEqual([
+ 'kimi-question-q1',
+ 'kimi-approval-a1',
+ ]);
+ });
+
+ it('suppresses the notification while the user is watching the session', () => {
+ maybeNotifyCompletion('s1', { ...base, isUserWatching: true, promptId: 'p1' });
+ expect(FakeNotification.fired).toHaveLength(0);
+ });
+});