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); + }); +});