Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "pro"]
path = pro
url = https://github.com/wednesday-solutions/offgrid-pro.git
26 changes: 25 additions & 1 deletion App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ import { NavigationContainer } from '@react-navigation/native';
import { AppNavigator } from './src/navigation';
import { useTheme } from './src/theme';
import { hardwareService, modelManager, authService, ragService, remoteServerManager } from './src/services';
import logger from './src/utils/logger';
import logger, { setLogListener } from './src/utils/logger';
import { useAppStore, useAuthStore, useRemoteServerStore } from './src/stores';
import { useDebugLogsStore } from './src/stores/debugLogsStore';
import { loadProFeatures } from './src/bootstrap/loadProFeatures';
import { configureRevenueCat, checkProStatus } from './src/services/proLicenseService';
import { hydrateDownloadStore } from './src/services/downloadHydration';
import { useDownloadListeners } from './src/hooks/useDownloads';
import { LockScreen } from './src/screens';
Expand All @@ -22,6 +25,9 @@ import { useDownloadStore } from './src/stores/downloadStore';

LogBox.ignoreAllLogs(); // Suppress all logs

// Wire logger → in-app debug viewer (runs before any component mounts)
setLogListener((entry) => useDebugLogsStore.getState().addLog(entry));

const ensureRemoteServerStoreHydrated = async () => {
const persistApi = useRemoteServerStore.persist;
if (!persistApi?.hasHydrated || !persistApi.rehydrate) return;
Expand Down Expand Up @@ -166,6 +172,24 @@ function App() {
// Initialize RAG database tables
ragService.ensureReady().catch((err) => logger.error('Failed to initialize RAG service on startup', err));

// Configure RevenueCat and read the cached entitlement before Pro features load.
// configureRevenueCat is sync; checkProStatus reads the keychain cache immediately
// and fires a background RC network sync so the next launch stays fresh.
//
// Pro is optional: a failure here (missing native module, keychain locked,
// bad RC config) must never abort app init or hang the splash screen, so the
// whole block is isolated and only logs on error.
try {
configureRevenueCat();
const isPro = await checkProStatus();

// Load pro features — only activates if the keychain entitlement is set.
// Reuse the entitlement read above to avoid a second keychain round-trip.
await loadProFeatures(isPro);
} catch (proError) {
logger.error('[App] Pro initialization failed, continuing without Pro:', proError);
}

// Show the UI immediately
setIsInitializing(false);

Expand Down
178 changes: 178 additions & 0 deletions __tests__/integration/generation/toolExtensionLoop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* Integration test: registered ToolExtension flows through runToolLoop.
*
* Verifies:
* 1. Extension system-prompt hint is appended to the system message.
* 2. Extension tool calls parsed from LLM text are collected.
* 3. Extension executor is called instead of the built-in executeToolCall.
* 4. Extension tool calls result in a tool-result message in the chat store.
* 5. Free path (no extensions): behaviour is identical to today.
*/

import { runToolLoop, ToolLoopContext } from '../../../src/services/generationToolLoop';
import {
registerToolExtension,
_clearExtensionsForTesting,
ToolExtension,
} from '../../../src/services/tools/extensions';
import type { ToolCall } from '../../../src/services/tools/types';
import { useChatStore } from '../../../src/stores';
import { resetStores } from '../../utils/testHelpers';

// Mock the LLM so we control what it "says"
jest.mock('../../../src/services/llm');
jest.mock('../../../src/services/litert');
jest.mock('../../../src/services/activeModelService');

const { llmService } = require('../../../src/services/llm');
const { liteRTService } = require('../../../src/services/litert');

// Mock executeToolCall so built-in tools don't actually run
jest.mock('../../../src/services/tools', () => ({
getToolsAsOpenAISchema: jest.fn(() => []),
executeToolCall: jest.fn().mockResolvedValue({ name: 'builtin', content: 'builtin-result', durationMs: 1 }),
}));

// Mock the stores index: pull in the REAL chat + app stores (the loop reads/writes
// them and the test asserts on stored messages), stub the remote server store so we
// stay on the local path. Requiring the submodules directly avoids loading the full
// stores index (auth/whisper/project), which fails to initialise in this test env.
jest.mock('../../../src/stores', () => {
const { useChatStore: realChatStore } = jest.requireActual('../../../src/stores/chatStore');
const { useAppStore: realAppStore } = jest.requireActual('../../../src/stores/appStore');
return {
useChatStore: realChatStore,
useAppStore: realAppStore,
useRemoteServerStore: {
getState: () => ({ activeServerId: null, activeRemoteTextModelId: null }),
},
};
});

// Fake extension that parses <mcp_call>tool_name</mcp_call> tags
const MCP_TOOL_NAME = 'mcp_fake_tool';
const MCP_HINT = '\n\nMCP tools available:\n- mcp_fake_tool: A fake MCP tool';
const MCP_RESULT = 'mcp-result-content';

function makeFakeExtension(executorMock: jest.Mock): ToolExtension {
return {
id: 'mcp',
getSystemPromptHint: () => MCP_HINT,
parseToolCalls: (text: string): ToolCall[] => {
const match = /<mcp_call>([\s\S]*?)<\/mcp_call>/.exec(text);
if (!match) return [];
return [{ id: 'mcp-tc-1', name: match[1].trim(), arguments: {} }];
},
stripFromVisibleText: (text: string) => text.replace(/<mcp_call>[\s\S]*?<\/mcp_call>/g, '').trim(),
canHandle: (name: string) => name === MCP_TOOL_NAME,
execute: executorMock,
enabledToolCount: () => 1,
};
}

function makeCtx(overrides: Partial<ToolLoopContext> = {}): ToolLoopContext {
// createConversation takes a modelId and returns the generated conversation UUID
const conversationId = useChatStore.getState().createConversation('test-model');
return {
conversationId,
messages: [
{ id: 'sys', role: 'system', content: 'You are helpful.', timestamp: 0 },
{ id: 'u1', role: 'user', content: 'Run the MCP tool.', timestamp: 1 },
],
enabledToolIds: [],
isAborted: () => false,
onThinkingDone: jest.fn(),
onFinalResponse: jest.fn(),
...overrides,
};
}

describe('tool extension loop integration', () => {
beforeEach(() => {
resetStores();
_clearExtensionsForTesting();
jest.clearAllMocks();
liteRTService.isModelLoaded.mockReturnValue(false);
llmService.isModelLoaded.mockReturnValue(true);
llmService.stopGeneration.mockResolvedValue(undefined);
});

describe('free path — no extensions registered', () => {
it('calls onFinalResponse with the LLM text', async () => {
llmService.generateResponseWithTools.mockResolvedValue({
fullResponse: 'Hello world',
toolCalls: [],
});
const ctx = makeCtx();
await runToolLoop(ctx);
expect(ctx.onFinalResponse).toHaveBeenCalledWith('Hello world');
});
});

function setupProExtension(
firstResponse = `<mcp_call>${MCP_TOOL_NAME}</mcp_call>`,
secondResponse = 'Done.',
): jest.Mock {
const executorMock = jest.fn().mockResolvedValue({
name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5,
});
registerToolExtension(makeFakeExtension(executorMock));
llmService.generateResponseWithTools
.mockResolvedValueOnce({ fullResponse: firstResponse, toolCalls: [] })
.mockResolvedValueOnce({ fullResponse: secondResponse, toolCalls: [] });
return executorMock;
}

describe('pro path — extension registered', () => {
it('appends extension hint to the system prompt sent to LLM', async () => {
setupProExtension();

const ctx = makeCtx();
await runToolLoop(ctx);

// System prompt in the first LLM call should contain the extension hint
const firstCallMessages = llmService.generateResponseWithTools.mock.calls[0][0] as any[];
const sysMsg = firstCallMessages.find((m: any) => m.role === 'system');
expect(sysMsg.content).toContain(MCP_HINT);
});

it('routes execution to the extension executor, not built-in executeToolCall', async () => {
const executorMock = setupProExtension();

const ctx = makeCtx();
await runToolLoop(ctx);

expect(executorMock).toHaveBeenCalledTimes(1);
expect(executorMock).toHaveBeenCalledWith(
expect.objectContaining({ name: MCP_TOOL_NAME }),
);

const { executeToolCall } = require('../../../src/services/tools');
expect(executeToolCall).not.toHaveBeenCalled();
});

it('stores tool result in chat store', async () => {
setupProExtension();

const ctx = makeCtx();
await runToolLoop(ctx);

const messages = useChatStore.getState().conversations.find(c => c.id === ctx.conversationId)?.messages ?? [];
const toolResultMsg = messages.find(m => m.role === 'tool' && m.toolName === MCP_TOOL_NAME);
expect(toolResultMsg).toBeDefined();
expect(toolResultMsg?.content).toBe(MCP_RESULT);
});

it('strips extension syntax from visible text', async () => {
setupProExtension(`Thinking...<mcp_call>${MCP_TOOL_NAME}</mcp_call>`, 'Final answer.');

const ctx = makeCtx();
await runToolLoop(ctx);

// The assistant message stored for the tool-call turn must not contain the raw tag
const messages = useChatStore.getState().conversations.find(c => c.id === ctx.conversationId)?.messages ?? [];
const assistantMsg = messages.find(m => m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0);
expect(assistantMsg?.content).not.toContain('<mcp_call>');
});
});
});
115 changes: 115 additions & 0 deletions __tests__/integration/onboarding/proBootFlow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Integration: Pro boot flow
*
* Verifies that configureRevenueCat + checkProStatus run before loadProFeatures,
* and that Pro features only activate when the keychain entitlement is set.
*/

jest.mock('react-native-purchases', () => ({
__esModule: true,
default: {
setLogLevel: jest.fn(),
configure: jest.fn(),
getCustomerInfo: jest.fn().mockResolvedValue({ entitlements: { active: {} }, originalAppUserId: 'anon', allPurchaseDates: {} }),
invalidateCustomerInfoCache: jest.fn().mockResolvedValue(undefined),
getOfferings: jest.fn(),
purchasePackage: jest.fn(),
restorePurchases: jest.fn(),
logIn: jest.fn().mockResolvedValue({ customerInfo: { entitlements: { active: {} }, originalAppUserId: 'anon' }, created: false }),
logOut: jest.fn().mockResolvedValue(undefined),
ENTITLEMENT_VERIFICATION_MODE: { DISABLED: 'DISABLED', INFORMATIONAL: 'INFORMATIONAL' },
VERIFICATION_RESULT: { NOT_REQUESTED: 'NOT_REQUESTED', VERIFIED: 'VERIFIED', FAILED: 'FAILED', VERIFIED_ON_DEVICE: 'VERIFIED_ON_DEVICE' },
},
LOG_LEVEL: { DEBUG: 'debug', ERROR: 'error' },
}));

jest.mock('react-native-keychain', () => ({
getGenericPassword: jest.fn(),
setGenericPassword: jest.fn(),
resetGenericPassword: jest.fn(),
ACCESSIBLE: { AFTER_FIRST_UNLOCK: 'AfterFirstUnlock' },
}));

jest.mock('../../../src/stores/appStore', () => {
const setHasRegisteredPro = jest.fn();
return { useAppStore: { getState: () => ({ setHasRegisteredPro }) } };
});

jest.mock('../../../src/services/tools/extensions', () => ({ registerToolExtension: jest.fn() }));
jest.mock('../../../src/navigation/screenRegistry', () => ({ registerScreen: jest.fn() }));
jest.mock('../../../src/components/settings/sectionRegistry', () => ({ registerSettingsSection: jest.fn() }));
jest.mock('@offgrid/pro', () => ({ activate: jest.fn() }), { virtual: true });

import { configureRevenueCat, checkProStatus } from '../../../src/services/proLicenseService';
import { loadProFeatures } from '../../../src/bootstrap/loadProFeatures';

const Purchases = require('react-native-purchases').default;
const Keychain = require('react-native-keychain');
const mockConfigure = Purchases.configure;
const mockGetCustomerInfo = Purchases.getCustomerInfo;
const mockGetGenericPassword = Keychain.getGenericPassword;
const mockSetGenericPassword = Keychain.setGenericPassword;
const mockActivate = require('@offgrid/pro').activate;
const mockSetHasRegisteredPro = require('../../../src/stores/appStore').useAppStore.getState().setHasRegisteredPro;

describe('Pro boot flow integration', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('configures RevenueCat, reads entitlement, and skips Pro activation when not subscribed', async () => {
mockGetGenericPassword.mockResolvedValue(false);
mockGetCustomerInfo.mockResolvedValue({ entitlements: { active: {} } });

configureRevenueCat();
await checkProStatus();
await loadProFeatures();

expect(mockConfigure).toHaveBeenCalledTimes(1);
expect(mockActivate).not.toHaveBeenCalled();
});

it('configures RevenueCat, reads entitlement, and activates Pro when subscribed', async () => {
const license = JSON.stringify({ isPro: true, verifiedAt: 0 });
mockGetGenericPassword.mockResolvedValue({ password: license });
mockGetCustomerInfo.mockResolvedValue({
entitlements: { active: { pro: { productIdentifier: 'offgrid_pro' } } },
});

configureRevenueCat();
await checkProStatus();
await loadProFeatures();

expect(mockConfigure).toHaveBeenCalledTimes(1);
expect(mockActivate).toHaveBeenCalledWith(
expect.objectContaining({
registerToolExtension: expect.any(Function),
registerScreen: expect.any(Function),
registerSettingsSection: expect.any(Function),
}),
);
});

it('background RC sync writes updated entitlement to store after boot', async () => {
// Keychain is empty but RC says the user is subscribed (e.g. new device install)
mockGetGenericPassword
.mockResolvedValueOnce(false) // first read in checkProStatus (returns cached false)
.mockResolvedValue({ password: JSON.stringify({ isPro: true, verifiedAt: 1 }) });
mockGetCustomerInfo.mockResolvedValue({
entitlements: { active: { pro: { productIdentifier: 'offgrid_pro' } } },
});
mockSetGenericPassword.mockResolvedValue(true);

configureRevenueCat();
const isPro = await checkProStatus();

// Cached value from empty keychain is false; background sync fires async
expect(isPro).toBe(false);

// Allow the background syncWithRevenueCat to complete
await new Promise(resolve => setImmediate(resolve));

expect(mockSetGenericPassword).toHaveBeenCalledTimes(1);
expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true);
});
});
Loading
Loading