diff --git a/frontend/__tests__/services/notification.test.tsx b/frontend/__tests__/services/notification.test.tsx new file mode 100644 index 000000000000..1471c6be59b4 --- /dev/null +++ b/frontend/__tests__/services/notification.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { sendNotification } from '../../src/services/notification'; + +interface NotificationConstructor { + new (title: string, options?: NotificationOptions): Notification; + readonly permission: NotificationPermission; + readonly maxActions: number; + requestPermission(): Promise; +} + +describe('sendNotification', () => { + let mockNotification: Mock; + + beforeEach(() => { + // Mock localStorage + Storage.prototype.getItem = vi.fn(); + Storage.prototype.setItem = vi.fn(); + + // Mock Notification API + mockNotification = vi.fn((title: string, options?: NotificationOptions) => ({ + title, + ...options, + })); + Object.defineProperty(mockNotification, 'permission', { + get: () => 'granted', + configurable: true + }); + + // Set up the window.Notification mock + const NotificationMock = mockNotification as unknown as NotificationConstructor; + Object.defineProperty(window, 'Notification', { + value: NotificationMock, + writable: true, + }); + }); + + it('should send notification when notifications are enabled', () => { + // Mock notifications being enabled + vi.mocked(Storage.prototype.getItem).mockReturnValue('true'); + + const title = 'Test Title'; + const options = { + body: 'Test Body', + icon: '/test-icon.png' + }; + + sendNotification(title, options); + + expect(mockNotification).toHaveBeenCalledWith(title, options); + }); + + it('should not send notification when notifications are disabled', () => { + // Mock notifications being disabled + vi.mocked(Storage.prototype.getItem).mockReturnValue('false'); + + sendNotification('Test Title', { body: 'Test Body' }); + + expect(mockNotification).not.toHaveBeenCalled(); + }); + + it('should not send notification when permission is not granted', () => { + // Mock notifications being enabled but permission not granted + vi.mocked(Storage.prototype.getItem).mockReturnValue('true'); + + // Change permission to denied + Object.defineProperty(mockNotification, 'permission', { + get: () => 'denied', + configurable: true + }); + + sendNotification('Test Title', { body: 'Test Body' }); + + expect(mockNotification).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/services/observations.test.tsx b/frontend/__tests__/services/observations.test.tsx new file mode 100644 index 000000000000..136d7974ffbf --- /dev/null +++ b/frontend/__tests__/services/observations.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleObservationMessage } from '../../src/services/observations'; +import { AgentState } from '../../src/types/agent-state'; +import { sendNotification } from '../../src/services/notification'; +import store from '../../src/store'; + +vi.mock('../../src/services/notification'); +vi.mock('../../src/store', () => ({ + default: { + dispatch: vi.fn(), + }, +})); + +describe('handleObservationMessage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should trigger notification when agent state changes to AWAITING_USER_INPUT', () => { + const message = { + id: 1, + source: 'agent' as const, + observation: 'agent_state_changed', + extras: { + agent_state: AgentState.AWAITING_USER_INPUT, + metadata: {}, + error_id: '', + }, + message: 'Agent state changed', + content: '', + cause: 0, + timestamp: new Date().toISOString(), + }; + + handleObservationMessage(message); + + expect(sendNotification).toHaveBeenCalledWith('OpenHands', { + body: 'Agent is awaiting user input...', + icon: '/favicon.ico', + }); + }); + + it('should trigger notification when agent state changes to FINISHED', () => { + const message = { + id: 2, + source: 'agent' as const, + observation: 'agent_state_changed', + extras: { + agent_state: AgentState.FINISHED, + metadata: {}, + error_id: '', + }, + message: 'Agent state changed', + content: '', + cause: 0, + timestamp: new Date().toISOString(), + }; + + handleObservationMessage(message); + + expect(sendNotification).toHaveBeenCalledWith('OpenHands', { + body: 'Task completed successfully!', + icon: '/favicon.ico', + }); + }); + + it('should not trigger notification for other agent states', () => { + const message = { + id: 3, + source: 'agent' as const, + observation: 'agent_state_changed', + extras: { + agent_state: AgentState.RUNNING, + metadata: {}, + error_id: '', + }, + message: 'Agent state changed', + content: '', + cause: 0, + timestamp: new Date().toISOString(), + }; + + handleObservationMessage(message); + + expect(sendNotification).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/frontend/src/services/notification.ts b/frontend/src/services/notification.ts new file mode 100644 index 000000000000..784fc26653e0 --- /dev/null +++ b/frontend/src/services/notification.ts @@ -0,0 +1,21 @@ +export const sendNotification = ( + title: string, + options?: NotificationOptions, +) => { + if (!("Notification" in window)) { + // eslint-disable-next-line no-console + console.warn("This browser does not support desktop notifications"); + return; + } + + const notificationsEnabled = + localStorage.getItem("notifications-enabled") === "true"; + if (!notificationsEnabled) { + return; + } + + if (Notification.permission === "granted") { + // eslint-disable-next-line no-new + new Notification(title, options); + } +}; diff --git a/frontend/src/services/observations.ts b/frontend/src/services/observations.ts index 1ec6a784d910..443a9f43a941 100644 --- a/frontend/src/services/observations.ts +++ b/frontend/src/services/observations.ts @@ -10,6 +10,7 @@ import { addAssistantMessage, addAssistantObservation, } from "#/state/chat-slice"; +import { sendNotification } from "./notification"; export function handleObservationMessage(message: ObservationMessage) { switch (message.observation) { @@ -39,6 +40,17 @@ export function handleObservationMessage(message: ObservationMessage) { break; case ObservationType.AGENT_STATE_CHANGED: store.dispatch(setCurrentAgentState(message.extras.agent_state)); + if (message.extras.agent_state === AgentState.AWAITING_USER_INPUT) { + sendNotification("OpenHands", { + body: "Agent is awaiting user input...", + icon: "/favicon.ico", + }); + } else if (message.extras.agent_state === AgentState.FINISHED) { + sendNotification("OpenHands", { + body: "Task completed successfully!", + icon: "/favicon.ico", + }); + } break; case ObservationType.DELEGATE: // TODO: better UI for delegation result (#2309)