From 235cf6222dd041f4dd37d6995d0c64fa2a039f2e Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Tue, 27 Jan 2026 15:24:10 -0700 Subject: [PATCH 01/11] feat: adds ai digest controller --- packages/ai-controllers/jest.config.js | 1 - packages/ai-controllers/package.json | 4 + .../src/AiDigestController.test.ts | 173 ++++++++++++++ .../ai-controllers/src/AiDigestController.ts | 212 ++++++++++++++++++ .../src/AiDigestService.test.ts | 67 ++++++ .../ai-controllers/src/AiDigestService.ts | 49 ++++ .../ai-controllers/src/ai-digest-constants.ts | 10 + .../ai-controllers/src/ai-digest-types.ts | 28 +++ packages/ai-controllers/src/index.test.ts | 9 - packages/ai-controllers/src/index.ts | 43 +++- packages/ai-controllers/tsconfig.build.json | 5 +- packages/ai-controllers/tsconfig.json | 5 +- yarn.lock | 32 ++- 13 files changed, 607 insertions(+), 31 deletions(-) create mode 100644 packages/ai-controllers/src/AiDigestController.test.ts create mode 100644 packages/ai-controllers/src/AiDigestController.ts create mode 100644 packages/ai-controllers/src/AiDigestService.test.ts create mode 100644 packages/ai-controllers/src/AiDigestService.ts create mode 100644 packages/ai-controllers/src/ai-digest-constants.ts create mode 100644 packages/ai-controllers/src/ai-digest-types.ts delete mode 100644 packages/ai-controllers/src/index.test.ts diff --git a/packages/ai-controllers/jest.config.js b/packages/ai-controllers/jest.config.js index ca084133399..55df7971f9f 100644 --- a/packages/ai-controllers/jest.config.js +++ b/packages/ai-controllers/jest.config.js @@ -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, diff --git a/packages/ai-controllers/package.json b/packages/ai-controllers/package.json index 702e28b0cef..44d70a40d48 100644 --- a/packages/ai-controllers/package.json +++ b/packages/ai-controllers/package.json @@ -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", diff --git a/packages/ai-controllers/src/AiDigestController.test.ts b/packages/ai-controllers/src/AiDigestController.test.ts new file mode 100644 index 00000000000..4e1cd1ed96c --- /dev/null +++ b/packages/ai-controllers/src/AiDigestController.test.ts @@ -0,0 +1,173 @@ +import { Messenger } from '@metamask/messenger'; + +import { + AiDigestController, + getDefaultAiDigestControllerState, + DIGEST_STATUS, + CACHE_DURATION_MS, + MAX_CACHE_ENTRIES, +} from '.'; +import type { AiDigestControllerMessenger } from '.'; + +const mockData = { + summary: 'Test summary', + analysis: 'Test analysis', +}; + +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.status).toBe(DIGEST_STATUS.SUCCESS); + expect(result.data).toStrictEqual(mockData); + expect(controller.state.digests['ethereum']).toBeDefined(); + }); + + 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('handles fetch errors', async () => { + const mockService = { fetchDigest: jest.fn().mockRejectedValue(new Error('Network error')) }; + const controller = new AiDigestController({ + messenger: createMessenger(), + digestService: mockService, + }); + + const result = await controller.fetchDigest('ethereum'); + + expect(result.status).toBe(DIGEST_STATUS.ERROR); + expect(result.error).toBe('Network error'); + }); + + it('handles non-Error throws', async () => { + const mockService = { fetchDigest: jest.fn().mockRejectedValue('string error') }; + const controller = new AiDigestController({ + messenger: createMessenger(), + digestService: mockService, + }); + + const result = await controller.fetchDigest('ethereum'); + + expect(result.status).toBe(DIGEST_STATUS.ERROR); + expect(result.error).toBe('Unknown error'); + }); + + 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).length).toBe(MAX_CACHE_ENTRIES); + expect(controller.state.digests['asset0']).toBeUndefined(); + }); + + it('registers action handlers', async () => { + const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + const messenger = createMessenger(); + new AiDigestController({ + messenger, + digestService: mockService, + }); + + const result = await messenger.call('AiDigestController:fetchDigest', 'ethereum'); + expect(result.status).toBe(DIGEST_STATUS.SUCCESS); + + messenger.call('AiDigestController:clearDigest', 'ethereum'); + messenger.call('AiDigestController:clearAllDigests'); + }); + + it('uses expected cache constants', () => { + expect(CACHE_DURATION_MS).toBe(10 * 60 * 1000); + expect(MAX_CACHE_ENTRIES).toBe(50); + }); +}); diff --git a/packages/ai-controllers/src/AiDigestController.ts b/packages/ai-controllers/src/AiDigestController.ts new file mode 100644 index 00000000000..2255a9f9d84 --- /dev/null +++ b/packages/ai-controllers/src/AiDigestController.ts @@ -0,0 +1,212 @@ +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 { DIGEST_STATUS } from './ai-digest-types'; +import type { + AiDigestControllerState, + DigestEntry, + IAiDigestService, +} 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; + digestService: IAiDigestService; +}; + +export function getDefaultAiDigestControllerState(): AiDigestControllerState { + return { + digests: {}, + }; +} + +const aiDigestControllerMetadata: StateMetadata = { + digests: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, +}; + +export class AiDigestController extends BaseController< + typeof controllerName, + AiDigestControllerState, + AiDigestControllerMessenger +> { + readonly #digestService: IAiDigestService; + + 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(coingeckoSlug: string): Promise { + const existingDigest = this.state.digests[coingeckoSlug]; + if ( + existingDigest?.status === DIGEST_STATUS.SUCCESS && + existingDigest.fetchedAt + ) { + const age = Date.now() - existingDigest.fetchedAt; + if (age < CACHE_DURATION_MS) { + return existingDigest; + } + } + + this.update((state) => { + state.digests[coingeckoSlug] = { + asset: coingeckoSlug, + status: DIGEST_STATUS.LOADING, + }; + }); + + try { + const data = await this.#digestService.fetchDigest(coingeckoSlug); + + const entry: DigestEntry = { + asset: coingeckoSlug, + status: DIGEST_STATUS.SUCCESS, + fetchedAt: Date.now(), + data, + }; + + this.update((state) => { + state.digests[coingeckoSlug] = entry; + this.#evictStaleEntries(state); + }); + + return entry; + } catch (error) { + const entry: DigestEntry = { + asset: coingeckoSlug, + status: DIGEST_STATUS.ERROR, + error: error instanceof Error ? error.message : 'Unknown error', + }; + + this.update((state) => { + state.digests[coingeckoSlug] = entry; + }); + + return entry; + } + } + + clearDigest(coingeckoSlug: string): void { + this.update((state) => { + delete state.digests[coingeckoSlug]; + }); + } + + clearAllDigests(): void { + this.update((state) => { + state.digests = {}; + }); + } + + /** + * Evicts stale (TTL expired) and oldest entries (FIFO) if cache exceeds max size. + */ + #evictStaleEntries(state: AiDigestControllerState): void { + const now = Date.now(); + const entries = Object.entries(state.digests); + const keysToDelete: string[] = []; + + // Collect stale and fresh entries + const freshEntries: [string, DigestEntry & { fetchedAt: number }][] = []; + for (const [key, entry] of entries) { + if (entry.fetchedAt && now - entry.fetchedAt >= CACHE_DURATION_MS) { + keysToDelete.push(key); + } else if (entry.fetchedAt !== undefined) { + freshEntries.push([key, entry as DigestEntry & { fetchedAt: number }]); + } + } + + // Collect oldest entries + 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]); + } + } + + // Delete the entries + for (const key of keysToDelete) { + delete state.digests[key]; + } + } +} diff --git a/packages/ai-controllers/src/AiDigestService.test.ts b/packages/ai-controllers/src/AiDigestService.test.ts new file mode 100644 index 00000000000..4f3f95d55d8 --- /dev/null +++ b/packages/ai-controllers/src/AiDigestService.test.ts @@ -0,0 +1,67 @@ +import { AiDigestService } from '.'; + +const mockData = { + summary: 'Test summary', + analysis: 'Test analysis', +}; + +describe('AiDigestService', () => { + const mockFetch = jest.fn(); + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = mockFetch; + mockFetch.mockReset(); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it('fetches digest from API', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, data: mockData }), + }); + + const service = new AiDigestService({ baseUrl: 'http://test.com' }); + const result = await service.fetchDigest('ethereum'); + + expect(result).toStrictEqual(mockData); + expect(mockFetch).toHaveBeenCalledWith('http://test.com/api/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ asset: 'ethereum', provider: 'litellm' }), + }); + }); + + it('throws on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + + const service = new AiDigestService({ baseUrl: 'http://test.com' }); + + await expect(service.fetchDigest('ethereum')).rejects.toThrow('API request failed: 500'); + }); + + it('throws on API error response', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: false, error: { message: 'Invalid asset' } }), + }); + + const service = new AiDigestService({ baseUrl: 'http://test.com' }); + + await expect(service.fetchDigest('invalid')).rejects.toThrow('Invalid asset'); + }); + + it('throws default error when no message', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: false }), + }); + + const service = new AiDigestService({ baseUrl: 'http://test.com' }); + + await expect(service.fetchDigest('invalid')).rejects.toThrow('API returned error'); + }); +}); diff --git a/packages/ai-controllers/src/AiDigestService.ts b/packages/ai-controllers/src/AiDigestService.ts new file mode 100644 index 00000000000..58e53992118 --- /dev/null +++ b/packages/ai-controllers/src/AiDigestService.ts @@ -0,0 +1,49 @@ +import { AiDigestControllerErrorMessage } from './ai-digest-constants'; +import type { IAiDigestService, DigestData } from './ai-digest-types'; + +export type AiDigestServiceConfig = { + baseUrl: string; +}; + +type ApiResponse = { + success: boolean; + data?: DigestData; + error?: { message?: string }; +}; + +export class AiDigestService implements IAiDigestService { + readonly #baseUrl: string; + + constructor(config: AiDigestServiceConfig) { + this.#baseUrl = config.baseUrl; + } + + async fetchDigest(coingeckoSlug: string): Promise { + const response = await fetch(`${this.#baseUrl}/api/analyze`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + asset: coingeckoSlug, + provider: 'litellm', + }), + }); + + if (!response.ok) { + throw new Error( + `${AiDigestControllerErrorMessage.API_REQUEST_FAILED}: ${response.status}`, + ); + } + + const data: ApiResponse = await response.json(); + + if (!data.success || data.data === undefined) { + throw new Error( + data.error?.message ?? AiDigestControllerErrorMessage.API_RETURNED_ERROR, + ); + } + + return data.data; + } +} diff --git a/packages/ai-controllers/src/ai-digest-constants.ts b/packages/ai-controllers/src/ai-digest-constants.ts new file mode 100644 index 00000000000..a13539f54f1 --- /dev/null +++ b/packages/ai-controllers/src/ai-digest-constants.ts @@ -0,0 +1,10 @@ +export const controllerName = 'AiDigestController'; + +export const CACHE_DURATION_MS = 10 * 60 * 1000; // 10 minutes + +export const MAX_CACHE_ENTRIES = 50; + +export const AiDigestControllerErrorMessage = { + API_REQUEST_FAILED: 'API request failed', + API_RETURNED_ERROR: 'API returned error', +} as const; diff --git a/packages/ai-controllers/src/ai-digest-types.ts b/packages/ai-controllers/src/ai-digest-types.ts new file mode 100644 index 00000000000..7afa51c6889 --- /dev/null +++ b/packages/ai-controllers/src/ai-digest-types.ts @@ -0,0 +1,28 @@ +export const DIGEST_STATUS = { + IDLE: 'idle', + LOADING: 'loading', + SUCCESS: 'success', + ERROR: 'error', +} as const; + +export type DigestStatus = (typeof DIGEST_STATUS)[keyof typeof DIGEST_STATUS]; + +export type DigestData = { + [key: string]: string | number | boolean | null; +}; + +export type DigestEntry = { + asset: string; + status: DigestStatus; + fetchedAt?: number; + data?: DigestData; + error?: string; +}; + +export type AiDigestControllerState = { + digests: Record; +}; + +export interface IAiDigestService { + fetchDigest(assetId: string): Promise; +} diff --git a/packages/ai-controllers/src/index.test.ts b/packages/ai-controllers/src/index.test.ts deleted file mode 100644 index bc062d3694a..00000000000 --- a/packages/ai-controllers/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/ai-controllers/src/index.ts b/packages/ai-controllers/src/index.ts index 6972c117292..807e047b54b 100644 --- a/packages/ai-controllers/src/index.ts +++ b/packages/ai-controllers/src/index.ts @@ -1,9 +1,34 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export type { + AiDigestControllerActions, + AiDigestControllerClearAllDigestsAction, + AiDigestControllerClearDigestAction, + AiDigestControllerEvents, + AiDigestControllerFetchDigestAction, + AiDigestControllerGetStateAction, + AiDigestControllerMessenger, + AiDigestControllerOptions, + AiDigestControllerStateChangeEvent, +} from './AiDigestController'; +export { + AiDigestController, + getDefaultAiDigestControllerState, +} from './AiDigestController'; + +export type { AiDigestServiceConfig } from './AiDigestService'; +export { AiDigestService } from './AiDigestService'; + +export type { + AiDigestControllerState, + DigestData, + DigestEntry, + DigestStatus, + IAiDigestService, +} from './ai-digest-types'; +export { DIGEST_STATUS } from './ai-digest-types'; + +export { + controllerName as aiDigestControllerName, + CACHE_DURATION_MS, + MAX_CACHE_ENTRIES, + AiDigestControllerErrorMessage, +} from './ai-digest-constants'; diff --git a/packages/ai-controllers/tsconfig.build.json b/packages/ai-controllers/tsconfig.build.json index 02a0eea03fe..931c4d6594b 100644 --- a/packages/ai-controllers/tsconfig.build.json +++ b/packages/ai-controllers/tsconfig.build.json @@ -5,6 +5,9 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], "include": ["../../types", "./src"] } diff --git a/packages/ai-controllers/tsconfig.json b/packages/ai-controllers/tsconfig.json index 025ba2ef7f4..8af2380112d 100644 --- a/packages/ai-controllers/tsconfig.json +++ b/packages/ai-controllers/tsconfig.json @@ -3,6 +3,9 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], + "references": [ + { "path": "../base-controller" }, + { "path": "../messenger" } + ], "include": ["../../types", "./src"] } diff --git a/yarn.lock b/yarn.lock index 9953501535d..4deb7581b3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2543,6 +2543,9 @@ __metadata: resolution: "@metamask/ai-controllers@workspace:packages/ai-controllers" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.2.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" @@ -5246,7 +5249,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": +"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.2.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": version: 11.9.0 resolution: "@metamask/utils@npm:11.9.0" dependencies: @@ -5316,7 +5319,16 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.2, @noble/curves@npm:~1.9.0": +"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.2": + version: 1.9.2 + resolution: "@noble/curves@npm:1.9.2" + dependencies: + "@noble/hashes": "npm:1.8.0" + checksum: 10/f60f00ad86296054566b67be08fd659999bb64b692bfbf11dbe3be1f422ad4d826bf5ebb2015ce2e246538eab2b677707e0a46ffa8323a6fae7a9a30ec1fe318 + languageName: node + linkType: hard + +"@noble/curves@npm:~1.9.0": version: 1.9.7 resolution: "@noble/curves@npm:1.9.7" dependencies: @@ -5583,20 +5595,20 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.2.5": - version: 1.2.6 - resolution: "@scure/base@npm:1.2.6" - checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 - languageName: node - linkType: hard - -"@scure/base@npm:~1.1.3, @scure/base@npm:~1.1.6": +"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.1.3, @scure/base@npm:~1.1.6": version: 1.1.7 resolution: "@scure/base@npm:1.1.7" checksum: 10/fc50ffaab36cb46ff9fa4dc5052a06089ab6a6707f63d596bb34aaaec76173c9a564ac312a0b981b5e7a5349d60097b8878673c75d6cbfc4da7012b63a82099b languageName: node linkType: hard +"@scure/base@npm:~1.2.5": + version: 1.2.6 + resolution: "@scure/base@npm:1.2.6" + checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 + languageName: node + linkType: hard + "@scure/bip32@npm:1.4.0": version: 1.4.0 resolution: "@scure/bip32@npm:1.4.0" From fce471059c7d6c65c00d9406bc6765a876e9c718 Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Tue, 27 Jan 2026 15:48:24 -0700 Subject: [PATCH 02/11] chore: changelog --- packages/ai-controllers/CHANGELOG.md | 1 + yarn.lock | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai-controllers/CHANGELOG.md b/packages/ai-controllers/CHANGELOG.md index 9cedbc4dfe9..b4d6ef7c5de 100644 --- a/packages/ai-controllers/CHANGELOG.md +++ b/packages/ai-controllers/CHANGELOG.md @@ -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/ diff --git a/yarn.lock b/yarn.lock index 4deb7581b3e..7e77c946613 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2545,7 +2545,6 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/utils": "npm:^11.2.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^27.4.1" deepmerge: "npm:^4.2.2" @@ -5249,7 +5248,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.2.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": +"@metamask/utils@npm:^11.0.1, @metamask/utils@npm:^11.1.0, @metamask/utils@npm:^11.4.0, @metamask/utils@npm:^11.4.2, @metamask/utils@npm:^11.8.1, @metamask/utils@npm:^11.9.0": version: 11.9.0 resolution: "@metamask/utils@npm:11.9.0" dependencies: From 9200d146ad05db5e69d735f934b1acb74c3a990b Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Tue, 27 Jan 2026 16:03:25 -0700 Subject: [PATCH 03/11] fix: lint --- .../src/AiDigestController.test.ts | 35 +++++++++++++------ .../ai-controllers/src/AiDigestController.ts | 20 ++++++----- .../src/AiDigestService.test.ts | 18 +++++++--- .../ai-controllers/src/AiDigestService.ts | 4 +-- .../ai-controllers/src/ai-digest-types.ts | 4 +-- packages/ai-controllers/src/index.ts | 2 +- 6 files changed, 54 insertions(+), 29 deletions(-) diff --git a/packages/ai-controllers/src/AiDigestController.test.ts b/packages/ai-controllers/src/AiDigestController.test.ts index 4e1cd1ed96c..d13d1b4d338 100644 --- a/packages/ai-controllers/src/AiDigestController.test.ts +++ b/packages/ai-controllers/src/AiDigestController.test.ts @@ -15,7 +15,9 @@ const mockData = { }; const createMessenger = (): AiDigestControllerMessenger => { - return new Messenger({ namespace: 'AiDigestController' }) as AiDigestControllerMessenger; + return new Messenger({ + namespace: 'AiDigestController', + }) as AiDigestControllerMessenger; }; describe('AiDigestController', () => { @@ -34,7 +36,7 @@ describe('AiDigestController', () => { expect(result.status).toBe(DIGEST_STATUS.SUCCESS); expect(result.data).toStrictEqual(mockData); - expect(controller.state.digests['ethereum']).toBeDefined(); + expect(controller.state.digests.ethereum).toBeDefined(); }); it('returns cached digest on subsequent calls', async () => { @@ -67,7 +69,9 @@ describe('AiDigestController', () => { }); it('handles fetch errors', async () => { - const mockService = { fetchDigest: jest.fn().mockRejectedValue(new Error('Network error')) }; + const mockService = { + fetchDigest: jest.fn().mockRejectedValue(new Error('Network error')), + }; const controller = new AiDigestController({ messenger: createMessenger(), digestService: mockService, @@ -80,7 +84,9 @@ describe('AiDigestController', () => { }); it('handles non-Error throws', async () => { - const mockService = { fetchDigest: jest.fn().mockRejectedValue('string error') }; + const mockService = { + fetchDigest: jest.fn().mockRejectedValue('string error'), + }; const controller = new AiDigestController({ messenger: createMessenger(), digestService: mockService, @@ -102,7 +108,7 @@ describe('AiDigestController', () => { await controller.fetchDigest('ethereum'); controller.clearDigest('ethereum'); - expect(controller.state.digests['ethereum']).toBeUndefined(); + expect(controller.state.digests.ethereum).toBeUndefined(); }); it('clears all digests', async () => { @@ -131,8 +137,8 @@ describe('AiDigestController', () => { jest.advanceTimersByTime(CACHE_DURATION_MS + 1); await controller.fetchDigest('bitcoin'); - expect(controller.state.digests['ethereum']).toBeUndefined(); - expect(controller.state.digests['bitcoin']).toBeDefined(); + expect(controller.state.digests.ethereum).toBeUndefined(); + expect(controller.state.digests.bitcoin).toBeDefined(); jest.useRealTimers(); }); @@ -147,23 +153,30 @@ describe('AiDigestController', () => { await controller.fetchDigest(`asset${i}`); } - expect(Object.keys(controller.state.digests).length).toBe(MAX_CACHE_ENTRIES); - expect(controller.state.digests['asset0']).toBeUndefined(); + 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(); - new AiDigestController({ + const controller = new AiDigestController({ messenger, digestService: mockService, }); - const result = await messenger.call('AiDigestController:fetchDigest', 'ethereum'); + const result = await messenger.call( + 'AiDigestController:fetchDigest', + 'ethereum', + ); expect(result.status).toBe(DIGEST_STATUS.SUCCESS); messenger.call('AiDigestController:clearDigest', 'ethereum'); messenger.call('AiDigestController:clearAllDigests'); + + expect(controller.state.digests).toStrictEqual({}); }); it('uses expected cache constants', () => { diff --git a/packages/ai-controllers/src/AiDigestController.ts b/packages/ai-controllers/src/AiDigestController.ts index 2255a9f9d84..f2877a896de 100644 --- a/packages/ai-controllers/src/AiDigestController.ts +++ b/packages/ai-controllers/src/AiDigestController.ts @@ -6,12 +6,16 @@ import type { 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 { + controllerName, + CACHE_DURATION_MS, + MAX_CACHE_ENTRIES, +} from './ai-digest-constants'; import { DIGEST_STATUS } from './ai-digest-types'; import type { AiDigestControllerState, DigestEntry, - IAiDigestService, + DigestService, } from './ai-digest-types'; export type AiDigestControllerFetchDigestAction = { @@ -56,7 +60,7 @@ export type AiDigestControllerMessenger = Messenger< export type AiDigestControllerOptions = { messenger: AiDigestControllerMessenger; state?: Partial; - digestService: IAiDigestService; + digestService: DigestService; }; export function getDefaultAiDigestControllerState(): AiDigestControllerState { @@ -79,13 +83,9 @@ export class AiDigestController extends BaseController< AiDigestControllerState, AiDigestControllerMessenger > { - readonly #digestService: IAiDigestService; + readonly #digestService: DigestService; - constructor({ - messenger, - state, - digestService, - }: AiDigestControllerOptions) { + constructor({ messenger, state, digestService }: AiDigestControllerOptions) { super({ name: controllerName, metadata: aiDigestControllerMetadata, @@ -179,6 +179,8 @@ export class AiDigestController extends BaseController< /** * 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(); diff --git a/packages/ai-controllers/src/AiDigestService.test.ts b/packages/ai-controllers/src/AiDigestService.test.ts index 4f3f95d55d8..8d82787ff4d 100644 --- a/packages/ai-controllers/src/AiDigestService.test.ts +++ b/packages/ai-controllers/src/AiDigestService.test.ts @@ -40,18 +40,26 @@ describe('AiDigestService', () => { const service = new AiDigestService({ baseUrl: 'http://test.com' }); - await expect(service.fetchDigest('ethereum')).rejects.toThrow('API request failed: 500'); + await expect(service.fetchDigest('ethereum')).rejects.toThrow( + 'API request failed: 500', + ); }); it('throws on API error response', async () => { mockFetch.mockResolvedValue({ ok: true, - json: () => Promise.resolve({ success: false, error: { message: 'Invalid asset' } }), + json: () => + Promise.resolve({ + success: false, + error: { message: 'Invalid asset' }, + }), }); const service = new AiDigestService({ baseUrl: 'http://test.com' }); - await expect(service.fetchDigest('invalid')).rejects.toThrow('Invalid asset'); + await expect(service.fetchDigest('invalid')).rejects.toThrow( + 'Invalid asset', + ); }); it('throws default error when no message', async () => { @@ -62,6 +70,8 @@ describe('AiDigestService', () => { const service = new AiDigestService({ baseUrl: 'http://test.com' }); - await expect(service.fetchDigest('invalid')).rejects.toThrow('API returned error'); + await expect(service.fetchDigest('invalid')).rejects.toThrow( + 'API returned error', + ); }); }); diff --git a/packages/ai-controllers/src/AiDigestService.ts b/packages/ai-controllers/src/AiDigestService.ts index 58e53992118..7ad7ea061d1 100644 --- a/packages/ai-controllers/src/AiDigestService.ts +++ b/packages/ai-controllers/src/AiDigestService.ts @@ -1,5 +1,5 @@ import { AiDigestControllerErrorMessage } from './ai-digest-constants'; -import type { IAiDigestService, DigestData } from './ai-digest-types'; +import type { DigestService, DigestData } from './ai-digest-types'; export type AiDigestServiceConfig = { baseUrl: string; @@ -11,7 +11,7 @@ type ApiResponse = { error?: { message?: string }; }; -export class AiDigestService implements IAiDigestService { +export class AiDigestService implements DigestService { readonly #baseUrl: string; constructor(config: AiDigestServiceConfig) { diff --git a/packages/ai-controllers/src/ai-digest-types.ts b/packages/ai-controllers/src/ai-digest-types.ts index 7afa51c6889..f845bb5f143 100644 --- a/packages/ai-controllers/src/ai-digest-types.ts +++ b/packages/ai-controllers/src/ai-digest-types.ts @@ -23,6 +23,6 @@ export type AiDigestControllerState = { digests: Record; }; -export interface IAiDigestService { +export type DigestService = { fetchDigest(assetId: string): Promise; -} +}; diff --git a/packages/ai-controllers/src/index.ts b/packages/ai-controllers/src/index.ts index 807e047b54b..1f1004f994d 100644 --- a/packages/ai-controllers/src/index.ts +++ b/packages/ai-controllers/src/index.ts @@ -21,8 +21,8 @@ export type { AiDigestControllerState, DigestData, DigestEntry, + DigestService, DigestStatus, - IAiDigestService, } from './ai-digest-types'; export { DIGEST_STATUS } from './ai-digest-types'; From cf1fca14548b6461c99412b2e9cd22c483d522f9 Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Tue, 27 Jan 2026 16:13:59 -0700 Subject: [PATCH 04/11] feat: per service provider --- .../src/AiDigestService.test.ts | 44 ++++++++++++++++--- .../ai-controllers/src/AiDigestService.ts | 11 ++++- packages/ai-controllers/src/index.ts | 2 +- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/ai-controllers/src/AiDigestService.test.ts b/packages/ai-controllers/src/AiDigestService.test.ts index 8d82787ff4d..d17c3c26235 100644 --- a/packages/ai-controllers/src/AiDigestService.test.ts +++ b/packages/ai-controllers/src/AiDigestService.test.ts @@ -18,27 +18,53 @@ describe('AiDigestService', () => { global.fetch = originalFetch; }); - it('fetches digest from API', async () => { + it('fetches digest from API with claude provider', async () => { mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ success: true, data: mockData }), }); - const service = new AiDigestService({ baseUrl: 'http://test.com' }); + const service = new AiDigestService({ + baseUrl: 'http://test.com', + provider: 'claude', + }); const result = await service.fetchDigest('ethereum'); expect(result).toStrictEqual(mockData); expect(mockFetch).toHaveBeenCalledWith('http://test.com/api/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ asset: 'ethereum', provider: 'litellm' }), + body: JSON.stringify({ asset: 'ethereum', provider: 'claude' }), + }); + }); + + it('fetches digest from API with xai provider', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, data: mockData }), + }); + + const service = new AiDigestService({ + baseUrl: 'http://test.com', + provider: 'xai', + }); + const result = await service.fetchDigest('ethereum'); + + expect(result).toStrictEqual(mockData); + expect(mockFetch).toHaveBeenCalledWith('http://test.com/api/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ asset: 'ethereum', provider: 'xai' }), }); }); it('throws on non-ok response', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500 }); - const service = new AiDigestService({ baseUrl: 'http://test.com' }); + const service = new AiDigestService({ + baseUrl: 'http://test.com', + provider: 'claude', + }); await expect(service.fetchDigest('ethereum')).rejects.toThrow( 'API request failed: 500', @@ -55,7 +81,10 @@ describe('AiDigestService', () => { }), }); - const service = new AiDigestService({ baseUrl: 'http://test.com' }); + const service = new AiDigestService({ + baseUrl: 'http://test.com', + provider: 'claude', + }); await expect(service.fetchDigest('invalid')).rejects.toThrow( 'Invalid asset', @@ -68,7 +97,10 @@ describe('AiDigestService', () => { json: () => Promise.resolve({ success: false }), }); - const service = new AiDigestService({ baseUrl: 'http://test.com' }); + const service = new AiDigestService({ + baseUrl: 'http://test.com', + provider: 'claude', + }); await expect(service.fetchDigest('invalid')).rejects.toThrow( 'API returned error', diff --git a/packages/ai-controllers/src/AiDigestService.ts b/packages/ai-controllers/src/AiDigestService.ts index 7ad7ea061d1..da57d2510b5 100644 --- a/packages/ai-controllers/src/AiDigestService.ts +++ b/packages/ai-controllers/src/AiDigestService.ts @@ -1,8 +1,11 @@ import { AiDigestControllerErrorMessage } from './ai-digest-constants'; import type { DigestService, DigestData } from './ai-digest-types'; +export type DigestProvider = 'claude' | 'xai'; + export type AiDigestServiceConfig = { baseUrl: string; + provider: DigestProvider; }; type ApiResponse = { @@ -14,8 +17,11 @@ type ApiResponse = { export class AiDigestService implements DigestService { readonly #baseUrl: string; + readonly #provider: DigestProvider; + constructor(config: AiDigestServiceConfig) { this.#baseUrl = config.baseUrl; + this.#provider = config.provider; } async fetchDigest(coingeckoSlug: string): Promise { @@ -26,7 +32,7 @@ export class AiDigestService implements DigestService { }, body: JSON.stringify({ asset: coingeckoSlug, - provider: 'litellm', + provider: this.#provider, }), }); @@ -40,7 +46,8 @@ export class AiDigestService implements DigestService { if (!data.success || data.data === undefined) { throw new Error( - data.error?.message ?? AiDigestControllerErrorMessage.API_RETURNED_ERROR, + data.error?.message ?? + AiDigestControllerErrorMessage.API_RETURNED_ERROR, ); } diff --git a/packages/ai-controllers/src/index.ts b/packages/ai-controllers/src/index.ts index 1f1004f994d..3a649ff79ea 100644 --- a/packages/ai-controllers/src/index.ts +++ b/packages/ai-controllers/src/index.ts @@ -14,7 +14,7 @@ export { getDefaultAiDigestControllerState, } from './AiDigestController'; -export type { AiDigestServiceConfig } from './AiDigestService'; +export type { AiDigestServiceConfig, DigestProvider } from './AiDigestService'; export { AiDigestService } from './AiDigestService'; export type { From f324231518cab717fb0b04a1cbcfdab032f6952f Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Tue, 27 Jan 2026 19:58:35 -0700 Subject: [PATCH 05/11] fix: lint --- packages/ai-controllers/tsconfig.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ai-controllers/tsconfig.json b/packages/ai-controllers/tsconfig.json index 8af2380112d..68c3ddfc2cd 100644 --- a/packages/ai-controllers/tsconfig.json +++ b/packages/ai-controllers/tsconfig.json @@ -3,9 +3,6 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [ - { "path": "../base-controller" }, - { "path": "../messenger" } - ], + "references": [{ "path": "../base-controller" }, { "path": "../messenger" }], "include": ["../../types", "./src"] } From bd21f1e06c5fb08b3cc4e21b02feb7b4c0d01aa9 Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Wed, 28 Jan 2026 08:31:20 -0700 Subject: [PATCH 06/11] fix: cursor bug bot, bypass cache size, missing null checks --- .../src/AiDigestController.test.ts | 25 +++++++++++++++++ .../ai-controllers/src/AiDigestController.ts | 16 ++++++++--- .../src/AiDigestService.test.ts | 16 +++++++++++ .../ai-controllers/src/AiDigestService.ts | 2 +- yarn.lock | 27 +++++++------------ 5 files changed, 63 insertions(+), 23 deletions(-) diff --git a/packages/ai-controllers/src/AiDigestController.test.ts b/packages/ai-controllers/src/AiDigestController.test.ts index d13d1b4d338..4f2c3d72a27 100644 --- a/packages/ai-controllers/src/AiDigestController.test.ts +++ b/packages/ai-controllers/src/AiDigestController.test.ts @@ -183,4 +183,29 @@ describe('AiDigestController', () => { expect(CACHE_DURATION_MS).toBe(10 * 60 * 1000); expect(MAX_CACHE_ENTRIES).toBe(50); }); + + it('evicts error entries on next successful fetch', async () => { + const mockService = { + fetchDigest: jest + .fn() + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValue(mockData), + }; + const controller = new AiDigestController({ + messenger: createMessenger(), + digestService: mockService, + }); + + // Create an error entry + await controller.fetchDigest('failing-asset'); + expect(controller.state.digests['failing-asset']?.status).toBe( + DIGEST_STATUS.ERROR, + ); + + // Fetch a successful entry - should evict the error entry + await controller.fetchDigest('ethereum'); + + expect(controller.state.digests['failing-asset']).toBeUndefined(); + expect(controller.state.digests.ethereum).toBeDefined(); + }); }); diff --git a/packages/ai-controllers/src/AiDigestController.ts b/packages/ai-controllers/src/AiDigestController.ts index f2877a896de..0331b449e1c 100644 --- a/packages/ai-controllers/src/AiDigestController.ts +++ b/packages/ai-controllers/src/AiDigestController.ts @@ -178,7 +178,7 @@ export class AiDigestController extends BaseController< } /** - * Evicts stale (TTL expired) and oldest entries (FIFO) if cache exceeds max size. + * Evicts stale (TTL expired), error, and oldest entries (FIFO) if cache exceeds max size. * * @param state - The current controller state to evict entries from. */ @@ -187,17 +187,25 @@ export class AiDigestController extends BaseController< const entries = Object.entries(state.digests); const keysToDelete: string[] = []; - // Collect stale and fresh entries + // Collect fresh entries (with fetchedAt) and mark stale/error entries for deletion const freshEntries: [string, DigestEntry & { fetchedAt: number }][] = []; for (const [key, entry] of entries) { - if (entry.fetchedAt && now - entry.fetchedAt >= CACHE_DURATION_MS) { + // Evict error entries to prevent unbounded accumulation + if (entry.status === DIGEST_STATUS.ERROR) { + keysToDelete.push(key); + } else if ( + entry.fetchedAt !== undefined && + now - entry.fetchedAt >= CACHE_DURATION_MS + ) { + // Evict stale entries (TTL expired) keysToDelete.push(key); } else if (entry.fetchedAt !== undefined) { + // Keep fresh entries for size-based eviction check freshEntries.push([key, entry as DigestEntry & { fetchedAt: number }]); } } - // Collect oldest entries + // 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; diff --git a/packages/ai-controllers/src/AiDigestService.test.ts b/packages/ai-controllers/src/AiDigestService.test.ts index d17c3c26235..b0943c9977e 100644 --- a/packages/ai-controllers/src/AiDigestService.test.ts +++ b/packages/ai-controllers/src/AiDigestService.test.ts @@ -106,4 +106,20 @@ describe('AiDigestService', () => { 'API returned error', ); }); + + it('throws when API returns null data', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, data: null }), + }); + + const service = new AiDigestService({ + baseUrl: 'http://test.com', + provider: 'claude', + }); + + await expect(service.fetchDigest('ethereum')).rejects.toThrow( + 'API returned error', + ); + }); }); diff --git a/packages/ai-controllers/src/AiDigestService.ts b/packages/ai-controllers/src/AiDigestService.ts index da57d2510b5..1736ece469b 100644 --- a/packages/ai-controllers/src/AiDigestService.ts +++ b/packages/ai-controllers/src/AiDigestService.ts @@ -44,7 +44,7 @@ export class AiDigestService implements DigestService { const data: ApiResponse = await response.json(); - if (!data.success || data.data === undefined) { + if (!data.success || data.data === undefined || data.data === null) { throw new Error( data.error?.message ?? AiDigestControllerErrorMessage.API_RETURNED_ERROR, diff --git a/yarn.lock b/yarn.lock index 7e77c946613..e4755c23c04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5318,16 +5318,7 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.2": - version: 1.9.2 - resolution: "@noble/curves@npm:1.9.2" - dependencies: - "@noble/hashes": "npm:1.8.0" - checksum: 10/f60f00ad86296054566b67be08fd659999bb64b692bfbf11dbe3be1f422ad4d826bf5ebb2015ce2e246538eab2b677707e0a46ffa8323a6fae7a9a30ec1fe318 - languageName: node - linkType: hard - -"@noble/curves@npm:~1.9.0": +"@noble/curves@npm:^1.2.0, @noble/curves@npm:^1.8.1, @noble/curves@npm:^1.9.2, @noble/curves@npm:~1.9.0": version: 1.9.7 resolution: "@noble/curves@npm:1.9.7" dependencies: @@ -5594,20 +5585,20 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.1.3, @scure/base@npm:~1.1.6": - version: 1.1.7 - resolution: "@scure/base@npm:1.1.7" - checksum: 10/fc50ffaab36cb46ff9fa4dc5052a06089ab6a6707f63d596bb34aaaec76173c9a564ac312a0b981b5e7a5349d60097b8878673c75d6cbfc4da7012b63a82099b - languageName: node - linkType: hard - -"@scure/base@npm:~1.2.5": +"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.2.5": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 languageName: node linkType: hard +"@scure/base@npm:~1.1.3, @scure/base@npm:~1.1.6": + version: 1.1.7 + resolution: "@scure/base@npm:1.1.7" + checksum: 10/fc50ffaab36cb46ff9fa4dc5052a06089ab6a6707f63d596bb34aaaec76173c9a564ac312a0b981b5e7a5349d60097b8878673c75d6cbfc4da7012b63a82099b + languageName: node + linkType: hard + "@scure/bip32@npm:1.4.0": version: 1.4.0 resolution: "@scure/bip32@npm:1.4.0" From 9192f682f79b422370938fc4a5ec09db55e668d8 Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Wed, 28 Jan 2026 09:00:52 -0700 Subject: [PATCH 07/11] fix: evict loading as well --- .../src/AiDigestController.test.ts | 27 +++++++++++++++++++ .../ai-controllers/src/AiDigestController.ts | 11 +++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/ai-controllers/src/AiDigestController.test.ts b/packages/ai-controllers/src/AiDigestController.test.ts index 4f2c3d72a27..949639414f4 100644 --- a/packages/ai-controllers/src/AiDigestController.test.ts +++ b/packages/ai-controllers/src/AiDigestController.test.ts @@ -208,4 +208,31 @@ describe('AiDigestController', () => { expect(controller.state.digests['failing-asset']).toBeUndefined(); expect(controller.state.digests.ethereum).toBeDefined(); }); + + it('evicts orphaned loading entries on next successful fetch', async () => { + const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + const controller = new AiDigestController({ + messenger: createMessenger(), + digestService: mockService, + state: { + digests: { + 'orphaned-asset': { + asset: 'orphaned-asset', + status: DIGEST_STATUS.LOADING, + }, + }, + }, + }); + + // Verify loading entry exists + expect(controller.state.digests['orphaned-asset']?.status).toBe( + DIGEST_STATUS.LOADING, + ); + + // Fetch a successful entry - should evict the orphaned loading entry + await controller.fetchDigest('ethereum'); + + expect(controller.state.digests['orphaned-asset']).toBeUndefined(); + expect(controller.state.digests.ethereum).toBeDefined(); + }); }); diff --git a/packages/ai-controllers/src/AiDigestController.ts b/packages/ai-controllers/src/AiDigestController.ts index 0331b449e1c..1916a2859b5 100644 --- a/packages/ai-controllers/src/AiDigestController.ts +++ b/packages/ai-controllers/src/AiDigestController.ts @@ -178,7 +178,7 @@ export class AiDigestController extends BaseController< } /** - * Evicts stale (TTL expired), error, and oldest entries (FIFO) if cache exceeds max size. + * Evicts stale (TTL expired), error, loading, and oldest entries (FIFO) if cache exceeds max size. * * @param state - The current controller state to evict entries from. */ @@ -187,11 +187,14 @@ export class AiDigestController extends BaseController< const entries = Object.entries(state.digests); const keysToDelete: string[] = []; - // Collect fresh entries (with fetchedAt) and mark stale/error entries for deletion + // Collect fresh entries (with fetchedAt) and mark stale/error/loading entries for deletion const freshEntries: [string, DigestEntry & { fetchedAt: number }][] = []; for (const [key, entry] of entries) { - // Evict error entries to prevent unbounded accumulation - if (entry.status === DIGEST_STATUS.ERROR) { + // Evict error and loading entries to prevent unbounded accumulation + if ( + entry.status === DIGEST_STATUS.ERROR || + entry.status === DIGEST_STATUS.LOADING + ) { keysToDelete.push(key); } else if ( entry.fetchedAt !== undefined && From 3f4d1e82fcc4515706828475624492d3449009d2 Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Wed, 28 Jan 2026 09:24:11 -0700 Subject: [PATCH 08/11] feat: use api structured response and use new endpoint --- .../src/AiDigestController.test.ts | 11 +- .../src/AiDigestService.test.ts | 107 ++++++------------ .../ai-controllers/src/AiDigestService.ts | 36 ++---- .../ai-controllers/src/ai-digest-types.ts | 14 ++- packages/ai-controllers/src/index.ts | 2 +- 5 files changed, 66 insertions(+), 104 deletions(-) diff --git a/packages/ai-controllers/src/AiDigestController.test.ts b/packages/ai-controllers/src/AiDigestController.test.ts index 949639414f4..7a700fb3008 100644 --- a/packages/ai-controllers/src/AiDigestController.test.ts +++ b/packages/ai-controllers/src/AiDigestController.test.ts @@ -10,8 +10,15 @@ import { import type { AiDigestControllerMessenger } from '.'; const mockData = { - summary: 'Test summary', - analysis: 'Test analysis', + 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 => { diff --git a/packages/ai-controllers/src/AiDigestService.test.ts b/packages/ai-controllers/src/AiDigestService.test.ts index b0943c9977e..866c350e7f2 100644 --- a/packages/ai-controllers/src/AiDigestService.test.ts +++ b/packages/ai-controllers/src/AiDigestService.test.ts @@ -1,8 +1,15 @@ import { AiDigestService } from '.'; -const mockData = { - summary: 'Test summary', - analysis: 'Test analysis', +const mockDigestResponse = { + 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', }; describe('AiDigestService', () => { @@ -18,107 +25,63 @@ describe('AiDigestService', () => { global.fetch = originalFetch; }); - it('fetches digest from API with claude provider', async () => { + it('fetches latest digest from API', async () => { mockFetch.mockResolvedValue({ ok: true, - json: () => Promise.resolve({ success: true, data: mockData }), + json: () => Promise.resolve(mockDigestResponse), }); - const service = new AiDigestService({ - baseUrl: 'http://test.com', - provider: 'claude', - }); - const result = await service.fetchDigest('ethereum'); - - expect(result).toStrictEqual(mockData); - expect(mockFetch).toHaveBeenCalledWith('http://test.com/api/analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ asset: 'ethereum', provider: 'claude' }), - }); - }); - - it('fetches digest from API with xai provider', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ success: true, data: mockData }), - }); + const service = new AiDigestService({ baseUrl: 'http://test.com' }); + const result = await service.fetchDigest('eth-ethereum'); - const service = new AiDigestService({ - baseUrl: 'http://test.com', - provider: 'xai', - }); - const result = await service.fetchDigest('ethereum'); - - expect(result).toStrictEqual(mockData); - expect(mockFetch).toHaveBeenCalledWith('http://test.com/api/analyze', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ asset: 'ethereum', provider: 'xai' }), - }); + expect(result).toStrictEqual(mockDigestResponse); + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.com/digests/assets/eth-ethereum/latest', + ); }); it('throws on non-ok response', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500 }); - const service = new AiDigestService({ - baseUrl: 'http://test.com', - provider: 'claude', - }); + const service = new AiDigestService({ baseUrl: 'http://test.com' }); - await expect(service.fetchDigest('ethereum')).rejects.toThrow( + await expect(service.fetchDigest('eth-ethereum')).rejects.toThrow( 'API request failed: 500', ); }); - it('throws on API error response', async () => { + it('throws on unsuccessful response', async () => { mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ + ...mockDigestResponse, success: false, - error: { message: 'Invalid asset' }, + error: 'Asset not found', }), }); - const service = new AiDigestService({ - baseUrl: 'http://test.com', - provider: 'claude', - }); + const service = new AiDigestService({ baseUrl: 'http://test.com' }); - await expect(service.fetchDigest('invalid')).rejects.toThrow( - 'Invalid asset', + await expect(service.fetchDigest('invalid-asset')).rejects.toThrow( + 'Asset not found', ); }); - it('throws default error when no message', async () => { + it('throws default error when no error message provided', async () => { mockFetch.mockResolvedValue({ ok: true, - json: () => Promise.resolve({ success: false }), - }); - - const service = new AiDigestService({ - baseUrl: 'http://test.com', - provider: 'claude', - }); - - await expect(service.fetchDigest('invalid')).rejects.toThrow( - 'API returned error', - ); - }); - - it('throws when API returns null data', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ success: true, data: null }), + json: () => + Promise.resolve({ + ...mockDigestResponse, + success: false, + error: undefined, + }), }); - const service = new AiDigestService({ - baseUrl: 'http://test.com', - provider: 'claude', - }); + const service = new AiDigestService({ baseUrl: 'http://test.com' }); - await expect(service.fetchDigest('ethereum')).rejects.toThrow( + await expect(service.fetchDigest('invalid-asset')).rejects.toThrow( 'API returned error', ); }); diff --git a/packages/ai-controllers/src/AiDigestService.ts b/packages/ai-controllers/src/AiDigestService.ts index 1736ece469b..17a9b08a020 100644 --- a/packages/ai-controllers/src/AiDigestService.ts +++ b/packages/ai-controllers/src/AiDigestService.ts @@ -1,40 +1,21 @@ import { AiDigestControllerErrorMessage } from './ai-digest-constants'; import type { DigestService, DigestData } from './ai-digest-types'; -export type DigestProvider = 'claude' | 'xai'; - export type AiDigestServiceConfig = { baseUrl: string; - provider: DigestProvider; -}; - -type ApiResponse = { - success: boolean; - data?: DigestData; - error?: { message?: string }; }; export class AiDigestService implements DigestService { readonly #baseUrl: string; - readonly #provider: DigestProvider; - constructor(config: AiDigestServiceConfig) { this.#baseUrl = config.baseUrl; - this.#provider = config.provider; } - async fetchDigest(coingeckoSlug: string): Promise { - const response = await fetch(`${this.#baseUrl}/api/analyze`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - asset: coingeckoSlug, - provider: this.#provider, - }), - }); + async fetchDigest(assetId: string): Promise { + const response = await fetch( + `${this.#baseUrl}/digests/assets/${assetId}/latest`, + ); if (!response.ok) { throw new Error( @@ -42,15 +23,14 @@ export class AiDigestService implements DigestService { ); } - const data: ApiResponse = await response.json(); + const data: DigestData = await response.json(); - if (!data.success || data.data === undefined || data.data === null) { + if (!data.success) { throw new Error( - data.error?.message ?? - AiDigestControllerErrorMessage.API_RETURNED_ERROR, + data.error ?? AiDigestControllerErrorMessage.API_RETURNED_ERROR, ); } - return data.data; + return data; } } diff --git a/packages/ai-controllers/src/ai-digest-types.ts b/packages/ai-controllers/src/ai-digest-types.ts index f845bb5f143..0f529ea8116 100644 --- a/packages/ai-controllers/src/ai-digest-types.ts +++ b/packages/ai-controllers/src/ai-digest-types.ts @@ -7,8 +7,20 @@ export const DIGEST_STATUS = { export type DigestStatus = (typeof DIGEST_STATUS)[keyof typeof DIGEST_STATUS]; +/** + * Response from the digest API. + */ export type DigestData = { - [key: string]: string | number | boolean | null; + id: string; + assetId: string; + assetSymbol?: string; + digest: string; + generatedAt: string; + processingTime: number; + success: boolean; + error?: string; + createdAt: string; + updatedAt: string; }; export type DigestEntry = { diff --git a/packages/ai-controllers/src/index.ts b/packages/ai-controllers/src/index.ts index 3a649ff79ea..1f1004f994d 100644 --- a/packages/ai-controllers/src/index.ts +++ b/packages/ai-controllers/src/index.ts @@ -14,7 +14,7 @@ export { getDefaultAiDigestControllerState, } from './AiDigestController'; -export type { AiDigestServiceConfig, DigestProvider } from './AiDigestService'; +export type { AiDigestServiceConfig } from './AiDigestService'; export { AiDigestService } from './AiDigestService'; export type { From dabbb53635f041d02e176c88dd9c9a565d9bfd0c Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Wed, 28 Jan 2026 09:42:16 -0700 Subject: [PATCH 09/11] fix: corrupted state edge cases --- .../src/AiDigestController.test.ts | 23 +++++++++++++++++++ .../ai-controllers/src/AiDigestController.ts | 10 ++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/ai-controllers/src/AiDigestController.test.ts b/packages/ai-controllers/src/AiDigestController.test.ts index 7a700fb3008..a0eafa5804c 100644 --- a/packages/ai-controllers/src/AiDigestController.test.ts +++ b/packages/ai-controllers/src/AiDigestController.test.ts @@ -242,4 +242,27 @@ describe('AiDigestController', () => { expect(controller.state.digests['orphaned-asset']).toBeUndefined(); expect(controller.state.digests.ethereum).toBeDefined(); }); + + it('evicts invalid entries without fetchedAt on next successful fetch', async () => { + const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + const controller = new AiDigestController({ + messenger: createMessenger(), + digestService: mockService, + state: { + digests: { + 'invalid-entry': { + asset: 'invalid-entry', + status: DIGEST_STATUS.SUCCESS, + // Missing fetchedAt - invalid state from corruption + }, + }, + }, + }); + + // Fetch a successful entry - should evict the invalid entry + await controller.fetchDigest('ethereum'); + + expect(controller.state.digests['invalid-entry']).toBeUndefined(); + expect(controller.state.digests.ethereum).toBeDefined(); + }); }); diff --git a/packages/ai-controllers/src/AiDigestController.ts b/packages/ai-controllers/src/AiDigestController.ts index 1916a2859b5..fd223208d67 100644 --- a/packages/ai-controllers/src/AiDigestController.ts +++ b/packages/ai-controllers/src/AiDigestController.ts @@ -196,13 +196,13 @@ export class AiDigestController extends BaseController< entry.status === DIGEST_STATUS.LOADING ) { keysToDelete.push(key); - } else if ( - entry.fetchedAt !== undefined && - now - entry.fetchedAt >= CACHE_DURATION_MS - ) { + } else if (entry.fetchedAt === undefined) { + // Evict invalid entries (e.g., SUCCESS/IDLE without fetchedAt from corrupted state) + keysToDelete.push(key); + } else if (now - entry.fetchedAt >= CACHE_DURATION_MS) { // Evict stale entries (TTL expired) keysToDelete.push(key); - } else if (entry.fetchedAt !== undefined) { + } else { // Keep fresh entries for size-based eviction check freshEntries.push([key, entry as DigestEntry & { fetchedAt: number }]); } From 5d2c8fea0e8b1e9f0d51ad851fcbb9eb6c061f07 Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Wed, 28 Jan 2026 10:02:43 -0700 Subject: [PATCH 10/11] fix: bugbot url encoding issue --- packages/ai-controllers/src/AiDigestService.test.ts | 2 +- packages/ai-controllers/src/AiDigestService.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai-controllers/src/AiDigestService.test.ts b/packages/ai-controllers/src/AiDigestService.test.ts index 866c350e7f2..93a60c24f25 100644 --- a/packages/ai-controllers/src/AiDigestService.test.ts +++ b/packages/ai-controllers/src/AiDigestService.test.ts @@ -36,7 +36,7 @@ describe('AiDigestService', () => { expect(result).toStrictEqual(mockDigestResponse); expect(mockFetch).toHaveBeenCalledWith( - 'http://test.com/digests/assets/eth-ethereum/latest', + `http://test.com/digests/assets/${encodeURIComponent('eth-ethereum')}/latest`, ); }); diff --git a/packages/ai-controllers/src/AiDigestService.ts b/packages/ai-controllers/src/AiDigestService.ts index 17a9b08a020..da90e19d78f 100644 --- a/packages/ai-controllers/src/AiDigestService.ts +++ b/packages/ai-controllers/src/AiDigestService.ts @@ -14,7 +14,7 @@ export class AiDigestService implements DigestService { async fetchDigest(assetId: string): Promise { const response = await fetch( - `${this.#baseUrl}/digests/assets/${assetId}/latest`, + `${this.#baseUrl}/digests/assets/${encodeURIComponent(assetId)}/latest`, ); if (!response.ok) { From c818f7da89247511713de9ff34b4ff02fa44c6c8 Mon Sep 17 00:00:00 2001 From: Bigshmow Date: Thu, 29 Jan 2026 14:03:13 -0700 Subject: [PATCH 11/11] chore: rename fetch digest param, simplified controller, digest, and eviction logic --- .../src/AiDigestController.test.ts | 107 ++---------------- .../ai-controllers/src/AiDigestController.ts | 82 ++++---------- .../ai-controllers/src/ai-digest-types.ts | 18 +-- packages/ai-controllers/src/index.ts | 2 - 4 files changed, 35 insertions(+), 174 deletions(-) diff --git a/packages/ai-controllers/src/AiDigestController.test.ts b/packages/ai-controllers/src/AiDigestController.test.ts index a0eafa5804c..a0003183f13 100644 --- a/packages/ai-controllers/src/AiDigestController.test.ts +++ b/packages/ai-controllers/src/AiDigestController.test.ts @@ -3,7 +3,6 @@ import { Messenger } from '@metamask/messenger'; import { AiDigestController, getDefaultAiDigestControllerState, - DIGEST_STATUS, CACHE_DURATION_MS, MAX_CACHE_ENTRIES, } from '.'; @@ -41,9 +40,9 @@ describe('AiDigestController', () => { const result = await controller.fetchDigest('ethereum'); - expect(result.status).toBe(DIGEST_STATUS.SUCCESS); - expect(result.data).toStrictEqual(mockData); + 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 () => { @@ -75,7 +74,7 @@ describe('AiDigestController', () => { jest.useRealTimers(); }); - it('handles fetch errors', async () => { + it('throws on fetch errors', async () => { const mockService = { fetchDigest: jest.fn().mockRejectedValue(new Error('Network error')), }; @@ -84,25 +83,10 @@ describe('AiDigestController', () => { digestService: mockService, }); - const result = await controller.fetchDigest('ethereum'); - - expect(result.status).toBe(DIGEST_STATUS.ERROR); - expect(result.error).toBe('Network error'); - }); - - it('handles non-Error throws', async () => { - const mockService = { - fetchDigest: jest.fn().mockRejectedValue('string error'), - }; - const controller = new AiDigestController({ - messenger: createMessenger(), - digestService: mockService, - }); - - const result = await controller.fetchDigest('ethereum'); - - expect(result.status).toBe(DIGEST_STATUS.ERROR); - expect(result.error).toBe('Unknown error'); + await expect(controller.fetchDigest('ethereum')).rejects.toThrow( + 'Network error', + ); + expect(controller.state.digests.ethereum).toBeUndefined(); }); it('clears a specific digest', async () => { @@ -178,7 +162,7 @@ describe('AiDigestController', () => { 'AiDigestController:fetchDigest', 'ethereum', ); - expect(result.status).toBe(DIGEST_STATUS.SUCCESS); + expect(result).toStrictEqual(mockData); messenger.call('AiDigestController:clearDigest', 'ethereum'); messenger.call('AiDigestController:clearAllDigests'); @@ -190,79 +174,4 @@ describe('AiDigestController', () => { expect(CACHE_DURATION_MS).toBe(10 * 60 * 1000); expect(MAX_CACHE_ENTRIES).toBe(50); }); - - it('evicts error entries on next successful fetch', async () => { - const mockService = { - fetchDigest: jest - .fn() - .mockRejectedValueOnce(new Error('Network error')) - .mockResolvedValue(mockData), - }; - const controller = new AiDigestController({ - messenger: createMessenger(), - digestService: mockService, - }); - - // Create an error entry - await controller.fetchDigest('failing-asset'); - expect(controller.state.digests['failing-asset']?.status).toBe( - DIGEST_STATUS.ERROR, - ); - - // Fetch a successful entry - should evict the error entry - await controller.fetchDigest('ethereum'); - - expect(controller.state.digests['failing-asset']).toBeUndefined(); - expect(controller.state.digests.ethereum).toBeDefined(); - }); - - it('evicts orphaned loading entries on next successful fetch', async () => { - const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; - const controller = new AiDigestController({ - messenger: createMessenger(), - digestService: mockService, - state: { - digests: { - 'orphaned-asset': { - asset: 'orphaned-asset', - status: DIGEST_STATUS.LOADING, - }, - }, - }, - }); - - // Verify loading entry exists - expect(controller.state.digests['orphaned-asset']?.status).toBe( - DIGEST_STATUS.LOADING, - ); - - // Fetch a successful entry - should evict the orphaned loading entry - await controller.fetchDigest('ethereum'); - - expect(controller.state.digests['orphaned-asset']).toBeUndefined(); - expect(controller.state.digests.ethereum).toBeDefined(); - }); - - it('evicts invalid entries without fetchedAt on next successful fetch', async () => { - const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; - const controller = new AiDigestController({ - messenger: createMessenger(), - digestService: mockService, - state: { - digests: { - 'invalid-entry': { - asset: 'invalid-entry', - status: DIGEST_STATUS.SUCCESS, - // Missing fetchedAt - invalid state from corruption - }, - }, - }, - }); - - // Fetch a successful entry - should evict the invalid entry - await controller.fetchDigest('ethereum'); - - expect(controller.state.digests['invalid-entry']).toBeUndefined(); - expect(controller.state.digests.ethereum).toBeDefined(); - }); }); diff --git a/packages/ai-controllers/src/AiDigestController.ts b/packages/ai-controllers/src/AiDigestController.ts index fd223208d67..f215e4412d4 100644 --- a/packages/ai-controllers/src/AiDigestController.ts +++ b/packages/ai-controllers/src/AiDigestController.ts @@ -11,11 +11,11 @@ import { CACHE_DURATION_MS, MAX_CACHE_ENTRIES, } from './ai-digest-constants'; -import { DIGEST_STATUS } from './ai-digest-types'; import type { AiDigestControllerState, DigestEntry, DigestService, + DigestData, } from './ai-digest-types'; export type AiDigestControllerFetchDigestAction = { @@ -115,59 +115,34 @@ export class AiDigestController extends BaseController< ); } - async fetchDigest(coingeckoSlug: string): Promise { - const existingDigest = this.state.digests[coingeckoSlug]; - if ( - existingDigest?.status === DIGEST_STATUS.SUCCESS && - existingDigest.fetchedAt - ) { + async fetchDigest(assetId: string): Promise { + const existingDigest = this.state.digests[assetId]; + if (existingDigest) { const age = Date.now() - existingDigest.fetchedAt; if (age < CACHE_DURATION_MS) { - return existingDigest; + return existingDigest.data; } } + const data = await this.#digestService.fetchDigest(assetId); + + const entry: DigestEntry = { + asset: assetId, + fetchedAt: Date.now(), + data, + }; + this.update((state) => { - state.digests[coingeckoSlug] = { - asset: coingeckoSlug, - status: DIGEST_STATUS.LOADING, - }; + state.digests[assetId] = entry; + this.#evictStaleEntries(state); }); - try { - const data = await this.#digestService.fetchDigest(coingeckoSlug); - - const entry: DigestEntry = { - asset: coingeckoSlug, - status: DIGEST_STATUS.SUCCESS, - fetchedAt: Date.now(), - data, - }; - - this.update((state) => { - state.digests[coingeckoSlug] = entry; - this.#evictStaleEntries(state); - }); - - return entry; - } catch (error) { - const entry: DigestEntry = { - asset: coingeckoSlug, - status: DIGEST_STATUS.ERROR, - error: error instanceof Error ? error.message : 'Unknown error', - }; - - this.update((state) => { - state.digests[coingeckoSlug] = entry; - }); - - return entry; - } + return data; } - clearDigest(coingeckoSlug: string): void { + clearDigest(assetId: string): void { this.update((state) => { - delete state.digests[coingeckoSlug]; + delete state.digests[assetId]; }); } @@ -178,7 +153,7 @@ export class AiDigestController extends BaseController< } /** - * Evicts stale (TTL expired), error, loading, and oldest entries (FIFO) if cache exceeds max size. + * Evicts stale (TTL expired) and oldest entries (FIFO) if cache exceeds max size. * * @param state - The current controller state to evict entries from. */ @@ -186,25 +161,13 @@ export class AiDigestController extends BaseController< const now = Date.now(); const entries = Object.entries(state.digests); const keysToDelete: string[] = []; + const freshEntries: [string, DigestEntry][] = []; - // Collect fresh entries (with fetchedAt) and mark stale/error/loading entries for deletion - const freshEntries: [string, DigestEntry & { fetchedAt: number }][] = []; for (const [key, entry] of entries) { - // Evict error and loading entries to prevent unbounded accumulation - if ( - entry.status === DIGEST_STATUS.ERROR || - entry.status === DIGEST_STATUS.LOADING - ) { - keysToDelete.push(key); - } else if (entry.fetchedAt === undefined) { - // Evict invalid entries (e.g., SUCCESS/IDLE without fetchedAt from corrupted state) - keysToDelete.push(key); - } else if (now - entry.fetchedAt >= CACHE_DURATION_MS) { - // Evict stale entries (TTL expired) + if (now - entry.fetchedAt >= CACHE_DURATION_MS) { keysToDelete.push(key); } else { - // Keep fresh entries for size-based eviction check - freshEntries.push([key, entry as DigestEntry & { fetchedAt: number }]); + freshEntries.push([key, entry]); } } @@ -217,7 +180,6 @@ export class AiDigestController extends BaseController< } } - // Delete the entries for (const key of keysToDelete) { delete state.digests[key]; } diff --git a/packages/ai-controllers/src/ai-digest-types.ts b/packages/ai-controllers/src/ai-digest-types.ts index 0f529ea8116..248a9c383f2 100644 --- a/packages/ai-controllers/src/ai-digest-types.ts +++ b/packages/ai-controllers/src/ai-digest-types.ts @@ -1,12 +1,3 @@ -export const DIGEST_STATUS = { - IDLE: 'idle', - LOADING: 'loading', - SUCCESS: 'success', - ERROR: 'error', -} as const; - -export type DigestStatus = (typeof DIGEST_STATUS)[keyof typeof DIGEST_STATUS]; - /** * Response from the digest API. */ @@ -23,12 +14,13 @@ export type DigestData = { updatedAt: string; }; +/** + * A cached digest entry. Only successful fetches are stored. + */ export type DigestEntry = { asset: string; - status: DigestStatus; - fetchedAt?: number; - data?: DigestData; - error?: string; + fetchedAt: number; + data: DigestData; }; export type AiDigestControllerState = { diff --git a/packages/ai-controllers/src/index.ts b/packages/ai-controllers/src/index.ts index 1f1004f994d..865becf87bc 100644 --- a/packages/ai-controllers/src/index.ts +++ b/packages/ai-controllers/src/index.ts @@ -22,9 +22,7 @@ export type { DigestData, DigestEntry, DigestService, - DigestStatus, } from './ai-digest-types'; -export { DIGEST_STATUS } from './ai-digest-types'; export { controllerName as aiDigestControllerName,