Skip to content
Merged
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
13 changes: 13 additions & 0 deletions packages/ai-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Initial release ([#7693](https://github.com/MetaMask/core/pull/7693))
- Add `AiDigestController` for fetching and caching AI-generated asset digests ([#7746](https://github.com/MetaMask/core/pull/7746))
- Add Market Insights support to `AiDigestController` with `fetchMarketInsights` action ([#7930](https://github.com/MetaMask/core/pull/7930))
- Add `searchDigest` method to `AiDigestService` for calling the GET endpoint (currently mocked) ([#7930](https://github.com/MetaMask/core/pull/7930))

### Changed

- Validate `searchDigest` API responses and throw when the payload does not match the expected `MarketInsightsReport` shape.
- Normalize `searchDigest` responses from either direct report payloads or `digest` envelope payloads.

### Removed

- Remove legacy digest APIs and digest cache from `AiDigestController` and `AiDigestService`; only market insights APIs remain.
- Removes `fetchDigest`, `clearDigest`, and `clearAllDigests` actions from the controller action surface.
- Removes `DigestData`/`DigestEntry` types and the `digests` state branch.

[Unreleased]: https://github.com/MetaMask/core/
3 changes: 2 additions & 1 deletion packages/ai-controllers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
},
"dependencies": {
"@metamask/base-controller": "^9.0.0",
"@metamask/messenger": "^0.3.0"
"@metamask/messenger": "^0.3.0",
"@metamask/utils": "^11.9.0"
},
"devDependencies": {
"@metamask/auto-changelog": "^3.4.4",
Expand Down
201 changes: 88 additions & 113 deletions packages/ai-controllers/src/AiDigestController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,175 +3,150 @@ import { Messenger } from '@metamask/messenger';
import {
AiDigestController,
getDefaultAiDigestControllerState,
AiDigestControllerErrorMessage,
CACHE_DURATION_MS,
MAX_CACHE_ENTRIES,
} from '.';
import type { AiDigestControllerMessenger } from '.';

const mockData = {
id: '123e4567-e89b-12d3-a456-426614174000',
assetId: 'eth-ethereum',
assetSymbol: 'ETH',
digest: 'ETH is trading at $3,245.67 (+2.3% 24h).',
generatedAt: '2026-01-21T10:30:00.000Z',
processingTime: 1523,
success: true,
createdAt: '2026-01-21T10:30:00.000Z',
updatedAt: '2026-01-21T10:30:00.000Z',
import type {
AiDigestControllerMessenger,
DigestService,
MarketInsightsReport,
} from '.';

const mockReport: MarketInsightsReport = {
version: '1.0',
asset: 'btc',
generatedAt: '2026-02-11T10:32:52.403Z',
headline: 'BTC update',
summary: 'Momentum remains positive.',
trends: [],
sources: [],
};

const createMessenger = (): AiDigestControllerMessenger => {
return new Messenger({
const createMessenger = (): AiDigestControllerMessenger =>
new Messenger({
namespace: 'AiDigestController',
}) as AiDigestControllerMessenger;
};

describe('AiDigestController', () => {
const createService = (overrides?: Partial<DigestService>): DigestService => ({
searchDigest: jest.fn().mockResolvedValue(mockReport),
...overrides,
});

describe('AiDigestController (market insights)', () => {
it('returns default state', () => {
expect(getDefaultAiDigestControllerState()).toStrictEqual({ digests: {} });
expect(getDefaultAiDigestControllerState()).toStrictEqual({
marketInsights: {},
});
});

it('fetches and caches a digest', async () => {
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
});
it('uses expected cache constants', () => {
expect(CACHE_DURATION_MS).toBe(10 * 60 * 1000);
expect(MAX_CACHE_ENTRIES).toBe(50);
});

const result = await controller.fetchDigest('ethereum');
it('registers fetch action on messenger', async () => {
const digestService = createService();
const messenger = createMessenger();
const controller = new AiDigestController({ messenger, digestService });

const result = await messenger.call(
'AiDigestController:fetchMarketInsights',
'eip155:1/slip44:0',
);

expect(result).toStrictEqual(mockData);
expect(controller.state.digests.ethereum).toBeDefined();
expect(controller.state.digests.ethereum.data).toStrictEqual(mockData);
expect(result).toStrictEqual(mockReport);
expect(controller.state.marketInsights['eip155:1/slip44:0']).toBeDefined();
});

it('returns cached digest on subsequent calls', async () => {
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
it('caches successful response and returns cache while fresh', async () => {
const digestService = createService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
digestService,
});

await controller.fetchDigest('ethereum');
await controller.fetchDigest('ethereum');
await controller.fetchMarketInsights('eip155:1/slip44:0');
await controller.fetchMarketInsights('eip155:1/slip44:0');

expect(mockService.fetchDigest).toHaveBeenCalledTimes(1);
expect(digestService.searchDigest).toHaveBeenCalledTimes(1);
});

it('refetches after cache expires', async () => {
it('refetches after cache expiration', async () => {
jest.useFakeTimers();
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const digestService = createService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
digestService,
});

await controller.fetchDigest('ethereum');
await controller.fetchMarketInsights('eip155:1/slip44:0');
jest.advanceTimersByTime(CACHE_DURATION_MS + 1);
await controller.fetchDigest('ethereum');
await controller.fetchMarketInsights('eip155:1/slip44:0');

expect(mockService.fetchDigest).toHaveBeenCalledTimes(2);
expect(digestService.searchDigest).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});

it('throws on fetch errors', async () => {
const mockService = {
fetchDigest: jest.fn().mockRejectedValue(new Error('Network error')),
};
it('throws for invalid CAIP asset type', async () => {
const digestService = createService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
digestService,
});

await expect(controller.fetchDigest('ethereum')).rejects.toThrow(
'Network error',
);
expect(controller.state.digests.ethereum).toBeUndefined();
await expect(
controller.fetchMarketInsights('invalid-caip'),
).rejects.toThrow(AiDigestControllerErrorMessage.INVALID_CAIP_ASSET_TYPE);
expect(digestService.searchDigest).not.toHaveBeenCalled();
});

it('clears a specific digest', async () => {
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
it('removes stale entry when service returns null', async () => {
const digestService = createService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
digestService,
});

await controller.fetchDigest('ethereum');
controller.clearDigest('ethereum');

expect(controller.state.digests.ethereum).toBeUndefined();
});

it('clears all digests', async () => {
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
});

await controller.fetchDigest('ethereum');
await controller.fetchDigest('bitcoin');
controller.clearAllDigests();
await controller.fetchMarketInsights('eip155:1/slip44:0');
(digestService.searchDigest as jest.Mock).mockResolvedValue(null);
jest.useFakeTimers();
jest.advanceTimersByTime(CACHE_DURATION_MS + 1);
const result = await controller.fetchMarketInsights('eip155:1/slip44:0');
jest.useRealTimers();

expect(controller.state.digests).toStrictEqual({});
expect(result).toBeNull();
expect(
controller.state.marketInsights['eip155:1/slip44:0'],
).toBeUndefined();
});

it('evicts stale entries on fetch', async () => {
it('evicts stale and oldest entries', async () => {
jest.useFakeTimers();
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const digestService = createService();
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
digestService,
});

await controller.fetchDigest('ethereum');
await controller.fetchMarketInsights('eip155:1/slip44:1');
jest.advanceTimersByTime(CACHE_DURATION_MS + 1);
await controller.fetchDigest('bitcoin');

expect(controller.state.digests.ethereum).toBeUndefined();
expect(controller.state.digests.bitcoin).toBeDefined();
jest.useRealTimers();
});

it('evicts oldest entries when exceeding max cache size', async () => {
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
});
await controller.fetchMarketInsights('eip155:1/slip44:2');
expect(
controller.state.marketInsights['eip155:1/slip44:1'],
).toBeUndefined();

for (let i = 0; i < MAX_CACHE_ENTRIES + 1; i++) {
await controller.fetchDigest(`asset${i}`);
await controller.fetchMarketInsights(`eip155:1/slip44:${100 + i}`);
jest.advanceTimersByTime(1);
}

expect(Object.keys(controller.state.digests)).toHaveLength(
expect(Object.keys(controller.state.marketInsights)).toHaveLength(
MAX_CACHE_ENTRIES,
);
expect(controller.state.digests.asset0).toBeUndefined();
});

it('registers action handlers', async () => {
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const messenger = createMessenger();
const controller = new AiDigestController({
messenger,
digestService: mockService,
});

const result = await messenger.call(
'AiDigestController:fetchDigest',
'ethereum',
);
expect(result).toStrictEqual(mockData);

messenger.call('AiDigestController:clearDigest', 'ethereum');
messenger.call('AiDigestController:clearAllDigests');

expect(controller.state.digests).toStrictEqual({});
});

it('uses expected cache constants', () => {
expect(CACHE_DURATION_MS).toBe(10 * 60 * 1000);
expect(MAX_CACHE_ENTRIES).toBe(50);
expect(
controller.state.marketInsights['eip155:1/slip44:100'],
).toBeUndefined();
jest.useRealTimers();
});
});
Loading
Loading