feat(kimi-web): add status-aware browser notifications (#1479)

* feat(kimi-web): add approval notification storage key and i18n copy

* feat(kimi-web): add approval notification helpers and tests

* feat(kimi-web): wire approval notifications and guard completion alerts

* fix(kimi-web): extract shouldNotifyCompletion helper and add tests

* feat(kimi-web): add approval notification settings toggle

* chore(kimi-web): add changeset and tidy notification module comment

- Align approval notification tag with spec (kimi-approval-${approvalId})

- Update module header to describe all three notification kinds

* fix(kimi-web): make notifications fire reliably

- Key completion notification tags by turn (sid + promptId) and question
  tags by request id, so a stale notification left in the notification
  center no longer swallows every follow-up alert in the same session
- Suppress notifications only while the window is actually focused, not
  merely visible (document.hasFocus() on top of visibilityState)
- Play the attention sound when a tool needs approval, matching the
  completion and question sounds

* chore(kimi-web): simplify changeset
This commit is contained in:
liruifengv 2026-07-09 12:30:15 +08:00 committed by GitHub
parent b89fc1a4fb
commit 735922c291
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 320 additions and 48 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
web: Add notifications when a tool needs approval, and improve notification reliability.

View file

@ -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)"

View file

@ -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)"
/>
</div>
<div class="row">
<span class="rlabel">
{{ t('settings.notifyOnApproval') }}
<span v-if="notifyPermission === 'denied'" class="hint">{{ t('settings.notifyDenied') }}</span>
</span>
<Switch
:model-value="notifyApproval"
:disabled="notifyPermission === 'denied'"
:label="t('settings.notifyOnApproval')"
@update:model-value="emit('setNotifyApproval', $event)"
/>
</div>
<div class="row">
<span class="rlabel">{{ t('settings.soundOnComplete') }}</span>
<Switch

View file

@ -1,20 +1,29 @@
// apps/kimi-web/src/composables/client/useNotification.ts
// Browser notifications for when the agent needs attention: a turn finished or
// a question is waiting for an answer. Each kind has its own on/off preference
// (persisted) plus the shared OS permission + Notification API. Pure UI action
// module — it never reads rawState or calls the API. The rawState-dependent
// bits (is the session active & visible, its title, the click-to-select action)
// are passed in by the caller via the ctx objects.
// Browser notifications for when the agent needs attention: a turn finished, a
// question waiting for an answer, or a tool needing approval. Each kind has its
// own on/off preference (persisted) plus the shared OS permission + Notification
// API. Pure UI action module — it never reads rawState or calls the API. The
// rawState-dependent bits (is the user watching the session, its title, the
// click-to-select action) are passed in by the caller via the ctx objects.
//
// Why two preferences: completion notifications default on (existing behavior),
// but question notifications surface question text and default OFF, so an
// existing user who only opted into completion alerts doesn't start receiving
// question content on their desktop without explicitly opting in.
// Why three preferences: completion notifications default on (existing
// behavior), but question and approval notifications surface request text/tool
// names and default OFF, so an existing user who only opted into completion
// alerts doesn't start receiving sensitive content on their desktop without
// explicitly opting in.
import { ref, type Ref } from 'vue';
import { i18n } from '../../i18n';
import { safeGetString, safeSetString, STORAGE_KEYS } from '../../lib/storage';
export function shouldNotifyCompletion(
status: 'idle' | 'aborted',
hasPendingApproval: boolean,
hasPendingQuestion: boolean,
): boolean {
return status === 'idle' && !hasPendingApproval && !hasPendingQuestion;
}
function loadNotify(key: string, defaultOn: boolean): boolean {
const v = safeGetString(key);
return v === null ? defaultOn : v === '1';
@ -22,6 +31,7 @@ function loadNotify(key: string, defaultOn: boolean): boolean {
const notifyOnComplete = ref(loadNotify(STORAGE_KEYS.notifyOnComplete, true));
const notifyOnQuestion = ref(loadNotify(STORAGE_KEYS.notifyOnQuestion, false));
const notifyOnApproval = ref(loadNotify(STORAGE_KEYS.notifyOnApproval, false));
const notifyPermission = ref<string>(
typeof Notification !== 'undefined' ? Notification.permission : 'denied',
);
@ -61,20 +71,42 @@ function setNotifyOnQuestion(on: boolean): Promise<void> {
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<void> {
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,
};
}

View file

@ -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,
};
}

View file

@ -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<PendingEvent>(
@ -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.
// 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, {
isActiveAndVisible:
sid === rawState.activeSessionId &&
typeof document !== 'undefined' &&
document.visibilityState === 'visible',
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,

View file

@ -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',

View file

@ -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 默认值',

View file

@ -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

View file

@ -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<string, unknown>).Notification = FakeNotification;
notifyOnComplete.value = true;
notifyOnQuestion.value = true;
notifyOnApproval.value = true;
});
afterEach(() => {
delete (globalThis as Record<string, unknown>).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);
});
});