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
1 change: 1 addition & 0 deletions packages/ai-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- 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))

[Unreleased]: https://github.com/MetaMask/core/
1 change: 0 additions & 1 deletion packages/ai-controllers/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ module.exports = merge(baseConfig, {
// The display name when running multiple projects
displayName,

// An object that configures minimum threshold enforcement for coverage results
coverageThreshold: {
global: {
branches: 100,
Expand Down
4 changes: 4 additions & 0 deletions packages/ai-controllers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
},
"dependencies": {
"@metamask/base-controller": "^9.0.0",
"@metamask/messenger": "^0.3.0"
},
"devDependencies": {
"@metamask/auto-changelog": "^3.4.4",
"@ts-bridge/cli": "^0.6.4",
Expand Down
177 changes: 177 additions & 0 deletions packages/ai-controllers/src/AiDigestController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { Messenger } from '@metamask/messenger';

import {
AiDigestController,
getDefaultAiDigestControllerState,
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',
};

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

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

it('fetches and caches a digest', async () => {
const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) };
const controller = new AiDigestController({
messenger: createMessenger(),
digestService: mockService,
});

const result = await controller.fetchDigest('ethereum');

expect(result).toStrictEqual(mockData);
expect(controller.state.digests.ethereum).toBeDefined();
expect(controller.state.digests.ethereum.data).toStrictEqual(mockData);
});

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

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

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

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

await controller.fetchDigest('ethereum');
jest.advanceTimersByTime(CACHE_DURATION_MS + 1);
await controller.fetchDigest('ethereum');

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

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

await expect(controller.fetchDigest('ethereum')).rejects.toThrow(
'Network error',
);
expect(controller.state.digests.ethereum).toBeUndefined();
});

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

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();

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

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

await controller.fetchDigest('ethereum');
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,
});

for (let i = 0; i < MAX_CACHE_ENTRIES + 1; i++) {
await controller.fetchDigest(`asset${i}`);
}

expect(Object.keys(controller.state.digests)).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);
});
});
187 changes: 187 additions & 0 deletions packages/ai-controllers/src/AiDigestController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import type {
StateMetadata,
ControllerStateChangeEvent,
ControllerGetStateAction,
} from '@metamask/base-controller';
import { BaseController } from '@metamask/base-controller';
import type { Messenger } from '@metamask/messenger';

import {
controllerName,
CACHE_DURATION_MS,
MAX_CACHE_ENTRIES,
} from './ai-digest-constants';
import type {
AiDigestControllerState,
DigestEntry,
DigestService,
DigestData,
} from './ai-digest-types';

export type AiDigestControllerFetchDigestAction = {
type: `${typeof controllerName}:fetchDigest`;
handler: AiDigestController['fetchDigest'];
};

export type AiDigestControllerClearDigestAction = {
type: `${typeof controllerName}:clearDigest`;
handler: AiDigestController['clearDigest'];
};

export type AiDigestControllerClearAllDigestsAction = {
type: `${typeof controllerName}:clearAllDigests`;
handler: AiDigestController['clearAllDigests'];
};

export type AiDigestControllerGetStateAction = ControllerGetStateAction<
typeof controllerName,
AiDigestControllerState
>;

export type AiDigestControllerActions =
| AiDigestControllerFetchDigestAction
| AiDigestControllerClearDigestAction
| AiDigestControllerClearAllDigestsAction
| AiDigestControllerGetStateAction;

export type AiDigestControllerStateChangeEvent = ControllerStateChangeEvent<
typeof controllerName,
AiDigestControllerState
>;

export type AiDigestControllerEvents = AiDigestControllerStateChangeEvent;

export type AiDigestControllerMessenger = Messenger<
typeof controllerName,
AiDigestControllerActions,
AiDigestControllerEvents
>;

export type AiDigestControllerOptions = {
messenger: AiDigestControllerMessenger;
state?: Partial<AiDigestControllerState>;
digestService: DigestService;
};

export function getDefaultAiDigestControllerState(): AiDigestControllerState {
return {
digests: {},
};
}

const aiDigestControllerMetadata: StateMetadata<AiDigestControllerState> = {
digests: {
persist: true,
includeInDebugSnapshot: true,
includeInStateLogs: true,
usedInUi: true,
},
};

export class AiDigestController extends BaseController<
typeof controllerName,
AiDigestControllerState,
AiDigestControllerMessenger
> {
readonly #digestService: DigestService;

constructor({ messenger, state, digestService }: AiDigestControllerOptions) {
super({
name: controllerName,
metadata: aiDigestControllerMetadata,
state: {
...getDefaultAiDigestControllerState(),
...state,
},
messenger,
});

this.#digestService = digestService;
this.#registerMessageHandlers();
}

#registerMessageHandlers(): void {
this.messenger.registerActionHandler(
`${controllerName}:fetchDigest`,
this.fetchDigest.bind(this),
);
this.messenger.registerActionHandler(
`${controllerName}:clearDigest`,
this.clearDigest.bind(this),
);
this.messenger.registerActionHandler(
`${controllerName}:clearAllDigests`,
this.clearAllDigests.bind(this),
);
}

async fetchDigest(assetId: string): Promise<DigestData> {
const existingDigest = this.state.digests[assetId];
if (existingDigest) {
const age = Date.now() - existingDigest.fetchedAt;
if (age < CACHE_DURATION_MS) {
return existingDigest.data;
}
}

const data = await this.#digestService.fetchDigest(assetId);

const entry: DigestEntry = {
asset: assetId,
fetchedAt: Date.now(),
data,
};

this.update((state) => {
state.digests[assetId] = entry;
this.#evictStaleEntries(state);
});

return data;
}

clearDigest(assetId: string): void {
this.update((state) => {
delete state.digests[assetId];
});
}

clearAllDigests(): void {
this.update((state) => {
state.digests = {};
});
}

/**
* Evicts stale (TTL expired) and oldest entries (FIFO) if cache exceeds max size.
*
* @param state - The current controller state to evict entries from.
*/
#evictStaleEntries(state: AiDigestControllerState): void {
const now = Date.now();
const entries = Object.entries(state.digests);
const keysToDelete: string[] = [];
const freshEntries: [string, DigestEntry][] = [];

for (const [key, entry] of entries) {
if (now - entry.fetchedAt >= CACHE_DURATION_MS) {
keysToDelete.push(key);
} else {
freshEntries.push([key, entry]);
}
}

// Evict oldest entries if over max cache size
if (freshEntries.length > MAX_CACHE_ENTRIES) {
freshEntries.sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
const entriesToRemove = freshEntries.length - MAX_CACHE_ENTRIES;
for (let i = 0; i < entriesToRemove; i++) {
keysToDelete.push(freshEntries[i][0]);
}
}

for (const key of keysToDelete) {
delete state.digests[key];
}
}
}
Loading
Loading