-
-
Notifications
You must be signed in to change notification settings - Fork 267
feat: adds ai digest controller #7746
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+574
−21
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
235cf62
feat: adds ai digest controller
Bigshmow fce4710
chore: changelog
Bigshmow 9200d14
fix: lint
Bigshmow cf1fca1
feat: per service provider
Bigshmow f324231
fix: lint
Bigshmow 487b493
Merge branch 'main' into feat/adds-ai-digest-controller
Bigshmow bd21f1e
fix: cursor bug bot, bypass cache size, missing null checks
Bigshmow 440942a
Merge branch 'feat/adds-ai-digest-controller' of github.com:MetaMask/…
Bigshmow 9192f68
fix: evict loading as well
Bigshmow 3f4d1e8
feat: use api structured response and use new endpoint
Bigshmow 933daee
Merge branch 'main' into feat/adds-ai-digest-controller
Bigshmow dabbb53
fix: corrupted state edge cases
Bigshmow e3417b6
Merge branch 'feat/adds-ai-digest-controller' of github.com:MetaMask/…
Bigshmow 5d2c8fe
fix: bugbot url encoding issue
Bigshmow c818f7d
chore: rename fetch digest param, simplified controller, digest, and …
Bigshmow 53c4a06
Merge branch 'main' into feat/adds-ai-digest-controller
Bigshmow dcfb466
Merge branch 'main' into feat/adds-ai-digest-controller
Bigshmow 6a24fd3
Merge branch 'main' into feat/adds-ai-digest-controller
Bigshmow File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
Bigshmow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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]); | ||
| } | ||
Bigshmow marked this conversation as resolved.
Show resolved
Hide resolved
Bigshmow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
Bigshmow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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]; | ||
| } | ||
Bigshmow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.