Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3dadd1e
feat: add pro feature plug-in registry and license seam
dishit-wednesday Jun 9, 2026
d7d720d
chore: vendor pro package as private submodule at pro/
dishit-wednesday Jun 10, 2026
d76a5a6
bump pro
dishit-wednesday Jun 10, 2026
32ebc07
test: fix proLicenseService dev-bypass under jest; load toolExtension…
dishit-wednesday Jun 10, 2026
82ada8a
feat: wire debug log viewer, fix MCP tool hint injection
dishit-wednesday Jun 10, 2026
0500bcd
feat: add calendar tools with native permissions and null guards
dishit-wednesday Jun 11, 2026
774405b
feat: add ToolExtension plug-in seam wiring MCP tools into the genera…
dishit-wednesday Jun 11, 2026
00b2e11
fix: show correct tool count and independent amber warning per badge
dishit-wednesday Jun 11, 2026
e0b0d5b
fix: replace hardcoded spacing with design tokens in ToolPickerSheet
dishit-wednesday Jun 11, 2026
98100b7
feat: improve context-full dialog with prominent message and action b…
dishit-wednesday Jun 11, 2026
00ae3b2
chore: bump pro submodule ref to include MCP redesign and new screens
dishit-wednesday Jun 11, 2026
f91aaa8
fix: resolve pre-push lint errors in CustomAlert, handlers and ChatSc…
dishit-wednesday Jun 11, 2026
505230b
chore: remove stale debugLogsStore test referencing removed API
dishit-wednesday Jun 11, 2026
d273d7f
test: update registry count and fix conversation ID in extension loop…
dishit-wednesday Jun 11, 2026
dd35168
fix: rename inner mock destructures to avoid no-shadow lint error
dishit-wednesday Jun 11, 2026
c212f02
fix: address Gemini review comments and remove debug logs
dishit-wednesday Jun 12, 2026
ea30fc4
fix: address Gemini review comments and remove debug logs
dishit-wednesday Jun 12, 2026
1a78c7e
fix: suppress lint warning and ts errors for uninstalled RC packages
dishit-wednesday Jun 12, 2026
ddbc3b7
fix: revert screenRegistry dedup and use virtual mocks for RC packages
dishit-wednesday Jun 12, 2026
38dc724
fix: replace deprecated react-native-add-calendar-event with maintain…
dishit-wednesday Jun 12, 2026
76cec8d
fix: address SonarCloud annotations and implement RevenueCat pro lice…
dishit-wednesday Jun 12, 2026
85f7117
chore: auto-configure pro submodule git hooks on npm install
dishit-wednesday Jun 12, 2026
b0d85e0
fix: fix SonarCloud duplication and push branch coverage above 80%
dishit-wednesday Jun 12, 2026
5af30ea
feat: wire RevenueCat pro license service with cross-platform offering
dishit-wednesday Jun 18, 2026
92d0312
fix: harden Pro boot/purchase flow per PR review
dishit-wednesday Jun 18, 2026
0a167fc
fix: guard RC entry points and reuse boot entitlement read
dishit-wednesday Jun 18, 2026
64758c2
Merge pull request #404 from dishit-wednesday/feat/revenuecat-payments
dishit-wednesday Jun 18, 2026
98b32bc
fix: make calendar tool reliable with date context and iOS 17 permission
dishit-wednesday Jun 18, 2026
0a06cd1
fix: detect Ollama vision capability from capabilities array
dishit-wednesday Jun 18, 2026
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/dishit-wednesday/private-offgrid.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>');
});
});
});
112 changes: 112 additions & 0 deletions __tests__/integration/onboarding/proBootFlow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* 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(),
logOut: jest.fn(),
},
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