',
+ ].join('');
+ mockFetch.mockResolvedValue({ text: async () => html });
+ const result = await executeToolCall({ id: 'ws_3', name: 'web_search', arguments: { query: 'example' } });
+ expect(result.error).toBeUndefined();
+ expect(result.content).toBeDefined();
+ });
+});
+
+describe('send_email handler', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockOpenURL.mockResolvedValue(undefined);
+ });
+
+ it('opens mail app with to, subject, and body', async () => {
+ const result = await executeToolCall({
+ id: 'se_1',
+ name: 'send_email',
+ arguments: { to: 'test@example.com', subject: 'Hello', body: 'World' },
+ });
+ expect(result.error).toBeUndefined();
+ expect(result.content).toContain('test@example.com');
+ expect(result.content).toContain('Hello');
+ expect(mockOpenURL).toHaveBeenCalledWith(expect.stringContaining('mailto:'));
+ });
+
+ it('opens mail app with only the to address when no subject or body', async () => {
+ const result = await executeToolCall({
+ id: 'se_2',
+ name: 'send_email',
+ arguments: { to: 'user@example.com' },
+ });
+ expect(result.error).toBeUndefined();
+ expect(result.content).toContain('user@example.com');
+ expect(mockOpenURL).toHaveBeenCalledWith(expect.stringContaining('mailto:'));
+ });
+
+ it('returns error when mail app cannot be opened', async () => {
+ mockOpenURL.mockRejectedValue(new Error('No mail app'));
+ const result = await executeToolCall({
+ id: 'se_3',
+ name: 'send_email',
+ arguments: { to: 'fail@example.com' },
+ });
+ expect(result.error).toContain('mail app');
+ });
+
+ it('returns error for missing to parameter', async () => {
+ const result = await executeToolCall({ id: 'se_4', name: 'send_email', arguments: {} });
+ expect(result.error).toContain('Missing required parameter: to');
+ });
+});
diff --git a/__tests__/unit/services/tools/extensions.test.ts b/__tests__/unit/services/tools/extensions.test.ts
new file mode 100644
index 00000000..fea829d4
--- /dev/null
+++ b/__tests__/unit/services/tools/extensions.test.ts
@@ -0,0 +1,58 @@
+import {
+ registerToolExtension,
+ getToolExtensions,
+ _clearExtensionsForTesting,
+ ToolExtension,
+} from '../../../../src/services/tools/extensions';
+
+const makeExt = (id: string, toolCount = 0): ToolExtension => ({
+ id,
+ getSystemPromptHint: () => `[hint:${id}]`,
+ parseToolCalls: () => [],
+ stripFromVisibleText: (text: string) => text,
+ canHandle: () => false,
+ execute: () => Promise.resolve({ name: id, content: '', durationMs: 0 }),
+ enabledToolCount: () => toolCount,
+});
+
+describe('tool extension registry', () => {
+ beforeEach(() => {
+ _clearExtensionsForTesting();
+ });
+
+ it('returns empty array when no extensions registered', () => {
+ expect(getToolExtensions()).toEqual([]);
+ });
+
+ it('registers a single extension', () => {
+ const ext = makeExt('mcp');
+ registerToolExtension(ext);
+ expect(getToolExtensions()).toHaveLength(1);
+ expect(getToolExtensions()[0].id).toBe('mcp');
+ });
+
+ it('ignores duplicate registrations by id', () => {
+ const ext1 = makeExt('mcp');
+ const ext2 = makeExt('mcp');
+ registerToolExtension(ext1);
+ registerToolExtension(ext2);
+ expect(getToolExtensions()).toHaveLength(1);
+ expect(getToolExtensions()[0]).toBe(ext1);
+ });
+
+ it('allows multiple extensions with different ids', () => {
+ registerToolExtension(makeExt('mcp'));
+ registerToolExtension(makeExt('calendar'));
+ expect(getToolExtensions()).toHaveLength(2);
+ });
+
+ it('returns extension with correct interface', () => {
+ registerToolExtension(makeExt('mcp', 3));
+ const [ext] = getToolExtensions();
+ expect(ext.getSystemPromptHint()).toBe('[hint:mcp]');
+ expect(ext.enabledToolCount()).toBe(3);
+ expect(ext.parseToolCalls('anything')).toEqual([]);
+ expect(ext.stripFromVisibleText('hello')).toBe('hello');
+ expect(ext.canHandle('any_tool')).toBe(false);
+ });
+});
diff --git a/__tests__/unit/services/tools/handlers.test.ts b/__tests__/unit/services/tools/handlers.test.ts
index 74e14143..0385ea2b 100644
--- a/__tests__/unit/services/tools/handlers.test.ts
+++ b/__tests__/unit/services/tools/handlers.test.ts
@@ -27,6 +27,18 @@ jest.mock('../../../../src/services/rag', () => ({
},
}));
+const mockSaveEvent = jest.fn();
+const mockRequestPermissions = jest.fn();
+const mockFetchAllEvents = jest.fn();
+jest.mock('react-native-calendar-events', () => ({
+ __esModule: true,
+ default: {
+ saveEvent: (...args: any[]) => mockSaveEvent(...args),
+ requestPermissions: (...args: any[]) => mockRequestPermissions(...args),
+ fetchAllEvents: (...args: any[]) => mockFetchAllEvents(...args),
+ },
+}));
+
// ============================================================================
// Helpers
// ============================================================================
@@ -561,4 +573,143 @@ describe('Tool Handlers', () => {
expect(result.error).toBeUndefined();
});
});
+
+ // ==========================================================================
+ // Calendar handlers
+ // ==========================================================================
+ describe('calendar handlers', () => {
+ beforeEach(() => {
+ mockSaveEvent.mockReset();
+ mockRequestPermissions.mockReset();
+ mockFetchAllEvents.mockReset();
+ });
+
+ describe('create_calendar_event', () => {
+ const validArgs = {
+ title: 'Dentist',
+ start_date: '2026-07-01T09:00:00.000Z',
+ end_date: '2026-07-01T10:00:00.000Z',
+ location: 'Clinic',
+ notes: 'bring insurance card',
+ };
+
+ it('requests write permission and saves the event', async () => {
+ mockRequestPermissions.mockResolvedValue('authorized');
+ mockSaveEvent.mockResolvedValue('event-id-1');
+
+ const result = await runTool('create_calendar_event', validArgs);
+
+ expect(result.error).toBeUndefined();
+ expect(mockRequestPermissions).toHaveBeenCalledWith(false);
+ expect(mockSaveEvent).toHaveBeenCalledWith(
+ 'Dentist',
+ expect.objectContaining({
+ startDate: '2026-07-01T09:00:00.000Z',
+ endDate: '2026-07-01T10:00:00.000Z',
+ location: 'Clinic',
+ // notes (iOS) and description (Android) both set from the same input
+ notes: 'bring insurance card',
+ description: 'bring insurance card',
+ }),
+ );
+ expect(result.content).toContain('Dentist');
+ });
+
+ it('returns an error when permission is denied', async () => {
+ mockRequestPermissions.mockResolvedValue('denied');
+
+ const result = await runTool('create_calendar_event', validArgs);
+
+ expect(result.error).toBe('Calendar permission denied');
+ expect(mockSaveEvent).not.toHaveBeenCalled();
+ });
+
+ it('returns an error for an invalid start date', async () => {
+ mockRequestPermissions.mockResolvedValue('authorized');
+
+ const result = await runTool('create_calendar_event', {
+ title: 'Bad',
+ start_date: 'not-a-date',
+ end_date: 'also-bad',
+ });
+
+ expect(result.error).toContain('Invalid start_date');
+ expect(mockSaveEvent).not.toHaveBeenCalled();
+ });
+
+ it('defaults end_date to one hour after start when omitted', async () => {
+ mockRequestPermissions.mockResolvedValue('authorized');
+ mockSaveEvent.mockResolvedValue('event-id-3');
+
+ const result = await runTool('create_calendar_event', {
+ title: 'Lunch',
+ start_date: '2026-07-01T13:00:00.000Z',
+ });
+
+ expect(result.error).toBeUndefined();
+ expect(mockSaveEvent).toHaveBeenCalledWith(
+ 'Lunch',
+ expect.objectContaining({
+ startDate: '2026-07-01T13:00:00.000Z',
+ endDate: '2026-07-01T14:00:00.000Z',
+ }),
+ );
+ });
+
+ it('omits optional fields when not provided', async () => {
+ mockRequestPermissions.mockResolvedValue('authorized');
+ mockSaveEvent.mockResolvedValue('event-id-2');
+
+ await runTool('create_calendar_event', {
+ title: 'Standup',
+ start_date: '2026-07-01T09:00:00.000Z',
+ end_date: '2026-07-01T09:15:00.000Z',
+ });
+
+ const details = mockSaveEvent.mock.calls[0][1];
+ expect(details).not.toHaveProperty('location');
+ expect(details).not.toHaveProperty('notes');
+ expect(details).not.toHaveProperty('description');
+ });
+ });
+
+ describe('read_calendar_events', () => {
+ it('returns a formatted list of events', async () => {
+ mockRequestPermissions.mockResolvedValue('authorized');
+ mockFetchAllEvents.mockResolvedValue([
+ {
+ title: 'Lunch',
+ startDate: '2026-07-01T12:00:00.000Z',
+ endDate: '2026-07-01T13:00:00.000Z',
+ location: 'Cafe',
+ },
+ ]);
+
+ const result = await runTool('read_calendar_events', {});
+
+ expect(result.error).toBeUndefined();
+ expect(result.content).toContain('Lunch');
+ expect(result.content).toContain('Cafe');
+ });
+
+ it('reports when no events are found', async () => {
+ mockRequestPermissions.mockResolvedValue('authorized');
+ mockFetchAllEvents.mockResolvedValue([]);
+
+ const result = await runTool('read_calendar_events', {});
+
+ expect(result.error).toBeUndefined();
+ expect(result.content).toContain('No calendar events found');
+ });
+
+ it('returns an error when permission is denied', async () => {
+ mockRequestPermissions.mockResolvedValue('denied');
+
+ const result = await runTool('read_calendar_events', {});
+
+ expect(result.error).toBe('Calendar permission denied');
+ expect(mockFetchAllEvents).not.toHaveBeenCalled();
+ });
+ });
+ });
});
diff --git a/__tests__/unit/services/tools/registry.test.ts b/__tests__/unit/services/tools/registry.test.ts
index 94991672..0ce727ee 100644
--- a/__tests__/unit/services/tools/registry.test.ts
+++ b/__tests__/unit/services/tools/registry.test.ts
@@ -16,8 +16,8 @@ describe('Tool Registry', () => {
// AVAILABLE_TOOLS
// ========================================================================
describe('AVAILABLE_TOOLS', () => {
- it('has exactly 6 tools with correct IDs', () => {
- expect(AVAILABLE_TOOLS).toHaveLength(6);
+ it('has exactly 9 tools with correct IDs', () => {
+ expect(AVAILABLE_TOOLS).toHaveLength(9);
const ids = AVAILABLE_TOOLS.map(t => t.id);
expect(ids).toEqual([
@@ -27,6 +27,9 @@ describe('Tool Registry', () => {
'get_device_info',
'search_knowledge_base',
'read_url',
+ 'send_email',
+ 'create_calendar_event',
+ 'read_calendar_events',
]);
});
diff --git a/__tests__/unit/stores/debugLogsStore.test.ts b/__tests__/unit/stores/debugLogsStore.test.ts
deleted file mode 100644
index b0660faf..00000000
--- a/__tests__/unit/stores/debugLogsStore.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-jest.mock('@react-native-async-storage/async-storage', () => ({
- getItem: jest.fn(() => Promise.resolve(null)),
- setItem: jest.fn(() => Promise.resolve()),
- removeItem: jest.fn(() => Promise.resolve()),
-}));
-
-import AsyncStorage from '@react-native-async-storage/async-storage';
-import { useDebugLogsStore } from '../../../src/stores/debugLogsStore';
-
-const mockedGetItem = AsyncStorage.getItem as jest.Mock;
-const mockedRemoveItem = AsyncStorage.removeItem as jest.Mock;
-
-beforeEach(() => {
- jest.clearAllMocks();
- useDebugLogsStore.setState({ logs: [], loaded: false } as any);
-});
-
-describe('debugLogsStore', () => {
- describe('loadFromStorage', () => {
- it('loads logs from AsyncStorage when raw data exists', async () => {
- const stored = [{ timestamp: 1000, level: 'log', message: 'hello' }];
- mockedGetItem.mockResolvedValueOnce(JSON.stringify(stored));
-
- await useDebugLogsStore.getState().loadFromStorage();
-
- expect(useDebugLogsStore.getState().logs).toHaveLength(1);
- expect(useDebugLogsStore.getState().logs[0].message).toBe('hello');
- expect(useDebugLogsStore.getState().loaded).toBe(true);
- });
-
- it('sets loaded=true and keeps empty logs when AsyncStorage has no data', async () => {
- mockedGetItem.mockResolvedValueOnce(null);
-
- await useDebugLogsStore.getState().loadFromStorage();
-
- expect(useDebugLogsStore.getState().logs).toHaveLength(0);
- expect(useDebugLogsStore.getState().loaded).toBe(true);
- });
-
- it('skips the read when already loaded', async () => {
- useDebugLogsStore.setState({ loaded: true } as any);
-
- await useDebugLogsStore.getState().loadFromStorage();
-
- expect(mockedGetItem).not.toHaveBeenCalled();
- });
-
- it('sets loaded=true and keeps empty logs when AsyncStorage throws', async () => {
- mockedGetItem.mockRejectedValueOnce(new Error('storage error'));
-
- await useDebugLogsStore.getState().loadFromStorage();
-
- expect(useDebugLogsStore.getState().loaded).toBe(true);
- expect(useDebugLogsStore.getState().logs).toHaveLength(0);
- });
- });
-
- describe('clearLogs', () => {
- it('empties the logs array and calls AsyncStorage.removeItem', () => {
- useDebugLogsStore.setState({ logs: [{ timestamp: 1, level: 'log', message: 'x' }] } as any);
-
- useDebugLogsStore.getState().clearLogs();
-
- expect(useDebugLogsStore.getState().logs).toHaveLength(0);
- expect(mockedRemoveItem).toHaveBeenCalled();
- });
- });
-});
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 69f1e4be..ea8f0cf8 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -23,6 +23,10 @@