tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit f52127ca4b5937af403382f8bb0a46c410fa40d9
parent 32307f596cc94298f3dbbd64424b95a2895092ef
Author: Christopher DiPersio <cdipersio@mozilla.com>
Date:   Tue,  9 Dec 2025 17:00:23 +0000

Bug 2003671 - Fetch Insights - getRelevantInsights r=cgopal,ai-models-reviewers

Differential Revision: https://phabricator.services.mozilla.com/D275315

Diffstat:
Mbrowser/base/content/test/static/browser_all_files_referenced.js | 23++---------------------
Mbrowser/components/aiwindow/models/Insights.sys.mjs | 2+-
Abrowser/components/aiwindow/models/InsightsManager.sys.mjs | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/aiwindow/models/InsightsSchemas.sys.mjs | 30++++++++++++++++++++++++++++++
Mbrowser/components/aiwindow/models/moz.build | 1+
Mbrowser/components/aiwindow/models/prompts/insightsPrompts.sys.mjs | 29+++++++++++++++++++++++++++++
Abrowser/components/aiwindow/models/tests/xpcshell/test_InsightsManager.js | 506+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/aiwindow/models/tests/xpcshell/xpcshell.toml | 2++
8 files changed, 778 insertions(+), 22 deletions(-)

diff --git a/browser/base/content/test/static/browser_all_files_referenced.js b/browser/base/content/test/static/browser_all_files_referenced.js @@ -331,12 +331,6 @@ var allowlist = [ { file: "resource://app/modules/backup/CookiesBackupResource.sys.mjs", }, - - // Bug 2000725 importer lands (backed out due to unused file) - { - file: "moz-src:///browser/components/aiwindow/models/InsightsHistorySource.sys.mjs", - }, - // Bug 2000945 - Move query intent detection to AI-window r?mardak (backed out due to unused file) { file: "moz-src:///browser/components/aiwindow/models/IntentClassifier.sys.mjs", @@ -350,10 +344,6 @@ var allowlist = [ { file: "moz-src:///browser/components/aiwindow/models/Utils.sys.mjs", }, - // Bug 2002906 - Add insights storage - { - file: "moz-src:///browser/components/aiwindow/services/InsightStore.sys.mjs", - }, // Bug 2003623 - Add assistant system prompt { file: "moz-src:///browser/components/aiwindow/models/prompts/assistantPrompts.sys.mjs", @@ -362,18 +352,9 @@ var allowlist = [ { file: "moz-src:///browser/components/aiwindow/models/Tools.sys.mjs", }, - // Bug 2003330 - Implement initial insights list creation - { - file: "moz-src:///browser/components/aiwindow/models/Insights.sys.mjs", - }, - { - file: "moz-src:///browser/components/aiwindow/models/InsightsConstants.sys.mjs", - }, - { - file: "moz-src:///browser/components/aiwindow/models/prompts/insightsPrompts.sys.mjs", - }, + // Bug 2003671 - Fetch Insights - getRelevantInsights { - file: "moz-src:///browser/components/aiwindow/models/InsightsSchemas.sys.mjs", + file: "moz-src:///browser/components/aiwindow/models/InsightsManager.sys.mjs", }, ]; diff --git a/browser/components/aiwindow/models/Insights.sys.mjs b/browser/components/aiwindow/models/Insights.sys.mjs @@ -131,7 +131,7 @@ export function getFormattedInsightAttributeList(attributeName) { * @param {any} fallback Fallback value if parsing fails to protect downstream code * @returns {Map} Parsed JSON object */ -function parseAndExtractJSON(response, fallback) { +export function parseAndExtractJSON(response, fallback) { const rawContent = response?.finalOutput ?? ""; const markdownMatch = rawContent.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); const payload = markdownMatch ? markdownMatch[1] : rawContent; diff --git a/browser/components/aiwindow/models/InsightsManager.sys.mjs b/browser/components/aiwindow/models/InsightsManager.sys.mjs @@ -0,0 +1,207 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { + getRecentHistory, + sessionizeVisits, + generateProfileInputs, + aggregateSessions, + topkAggregates, +} from "moz-src:///browser/components/aiwindow/models/InsightsHistorySource.sys.mjs"; +import { + openAIEngine, + renderPrompt, +} from "moz-src:///browser/components/aiwindow/models/Utils.sys.mjs"; +import { InsightStore } from "moz-src:///browser/components/aiwindow/services/InsightStore.sys.mjs"; +import { + CATEGORIES, + INTENTS, +} from "moz-src:///browser/components/aiwindow/models/InsightsConstants.sys.mjs"; +import { + getFormattedInsightAttributeList, + parseAndExtractJSON, +} from "moz-src:///browser/components/aiwindow/models/Insights.sys.mjs"; +import { + messageInsightClassificationSystemPrompt, + messageInsightClassificationPrompt, +} from "moz-src:///browser/components/aiwindow/models/prompts/insightsPrompts.sys.mjs"; +import { INSIGHTS_MESSAGE_CLASSIFY_SCHEMA } from "moz-src:///browser/components/aiwindow/models/InsightsSchemas.sys.mjs"; + +const K_DOMAINS = 30; +const K_TITLES = 60; +const K_SEARCHES = 10; + +/** + * InsightsManager class + */ +export class InsightsManager { + static #openAIEnginePromise = null; + + /** + * Creates and returns an class-level openAIEngine instance if one has not already been created. + * This current pulls from the general browser.aiwindow.* prefs, but will likely pull from insights-specific ones in the future + * + * @returns {Promise<openAIEngine>} openAIEngine instance + */ + static async ensureOpenAIEngine() { + if (!this.#openAIEnginePromise) { + this.#openAIEnginePromise = await openAIEngine.build(); + } + return this.#openAIEnginePromise; + } + + /** + * Retrieves and aggregates recent browser history into top-k domain, title, and search aggregates. + * + * @param {object} [recentHistoryOpts={}] + * @param {number} [recentHistoryOpts.sinceMicros=null] + * Optional absolute cutoff in microseconds since epoch (Places + * visit_date). If provided, this is used directly as the cutoff: + * only visits with `visit_date >= sinceMicros` are returned. + * + * This is the recommended way to implement incremental reads: + * store the max `visitDateMicros` from the previous run and pass + * it (or max + 1) back in as `sinceMicros`. + * + * @param {number} [recentHistoryOpts.days=DEFAULT_DAYS] + * How far back to look if `sinceMicros` is not provided. + * The cutoff is computed as: + * cutoff = now() - days * MS_PER_DAY + * + * Ignored when `sinceMicros` is non-null. + * + * @param {number} [recentHistoryOpts.maxResults=DEFAULT_MAX_RESULTS] + * Maximum number of rows to return from the SQL query (after + * sorting by most recent visit). Note that this caps the number + * of visits, not distinct URLs. + * @param {object} [topkAggregatesOpts] + * @param {number} [topkAggregatesOpts.k_domains=30] Max number of domain aggregates to return + * @param {number} [topkAggregatesOpts.k_titles=60] Max number of title aggregates to return + * @param {number} [topkAggregatesOpts.k_searches=10] Max number of search aggregates to return + * @param {number} [topkAggregatesOpts.now] Current time; seconds or ms, normalized internally.} + * @returns {Promise<[Array, Array, Array]>} Top-k domain, title, and search aggregates + */ + static async getAggregatedBrowserHistory( + recentHistoryOpts = {}, + topkAggregatesOpts = { + k_domains: K_DOMAINS, + k_titles: K_TITLES, + k_searches: K_SEARCHES, + now: undefined, + } + ) { + const recentVisitRecords = await getRecentHistory(recentHistoryOpts); + const sessionized = sessionizeVisits(recentVisitRecords); + const profilePreparedInputs = generateProfileInputs(sessionized); + const [domainAgg, titleAgg, searchAgg] = aggregateSessions( + profilePreparedInputs + ); + + return await topkAggregates( + domainAgg, + titleAgg, + searchAgg, + topkAggregatesOpts + ); + } + + /** + * Retrieves all stored insights. + * This is a quick-access wrapper around InsightStore.getInsights() with no additional processing. + * + * @returns {Promise<Array<Map<{ + * insight_summary: string, + * category: string, + * intent: string, + * score: number, + * }>>>} List of insights + */ + static async getAllInsights() { + return await InsightStore.getInsights(); + } + + /** + * Builds the prompt to classify a user message into insight categories and intents. + * + * @param {string} message User message to classify + * @returns {Promise<string>} Prompt string to send to LLM for classifying the message + */ + static async buildMessageInsightClassificationPrompt(message) { + const categories = getFormattedInsightAttributeList(CATEGORIES); + const intents = getFormattedInsightAttributeList(INTENTS); + + return await renderPrompt(messageInsightClassificationPrompt, { + message, + categories, + intents, + }); + } + + /** + * Classifies a user message into insight categories and intents. + * + * @param {string} message User message to classify + * @returns {Promise<Map<{categories: Array<string>, intents: Array<string>}>>}} Categories and intents into which the message was classified + */ + static async insightClassifyMessage(message) { + const messageClassifPrompt = + await this.buildMessageInsightClassificationPrompt(message); + + const engine = await this.ensureOpenAIEngine(); + + const response = await engine.run({ + args: [ + { role: "system", content: messageInsightClassificationSystemPrompt }, + { role: "user", content: messageClassifPrompt }, + ], + responseFormat: { + type: "json_schema", + schema: INSIGHTS_MESSAGE_CLASSIFY_SCHEMA, + }, + }); + + const parsed = parseAndExtractJSON(response, { + categories: [], + intents: [], + }); + if (!parsed.categories || !parsed.intents) { + return { categories: [], intents: [] }; + } + + return parsed; + } + + /** + * Fetches relevant insights for a given user message. + * + * @param {string} message User message to find relevant insights for + * @returns {Promise<Array<Map<{ + * insight_summary: string, + * category: string, + * intent: string, + * score: number, + * }>>>} List of relevant insights + */ + static async getRelevantInsights(message) { + const existingInsights = await InsightsManager.getAllInsights(); + // Shortcut: if there aren't any existing insights, return empty list immediately + if (existingInsights.length === 0) { + return []; + } + + const messageClassification = + await InsightsManager.insightClassifyMessage(message); + // Shortcut: if the message's category and/or intent is null, return empty list immediately + if (!messageClassification.categories || !messageClassification.intents) { + return []; + } + + // Filter existing insights to those that match the message's category + const candidateRelevantInsights = existingInsights.filter(insight => { + return messageClassification.categories.includes(insight.category); + }); + + return candidateRelevantInsights; + } +} diff --git a/browser/components/aiwindow/models/InsightsSchemas.sys.mjs b/browser/components/aiwindow/models/InsightsSchemas.sys.mjs @@ -112,3 +112,33 @@ export const INSIGHTS_NON_SENSITIVE_SCHEMA = { }, }, }; + +/** + * JSON schema for classifying message category and intent + */ +export const INSIGHTS_MESSAGE_CLASSIFY_SCHEMA = { + name: "ClassifyMessage", + schema: { + type: "object", + additionalProperties: false, + required: ["categories", "intents"], + properties: { + category: { + type: "array", + minItems: 1, + items: { + type: ["string", "null"], + enum: [...CATEGORIES_LIST, null], + }, + }, + intent: { + type: "array", + minItems: 1, + items: { + type: ["string", "null"], + enum: [...INTENTS_LIST, null], + }, + }, + }, + }, +}; diff --git a/browser/components/aiwindow/models/moz.build b/browser/components/aiwindow/models/moz.build @@ -14,6 +14,7 @@ MOZ_SRC_FILES += [ "Insights.sys.mjs", "InsightsConstants.sys.mjs", "InsightsHistorySource.sys.mjs", + "InsightsManager.sys.mjs", "InsightsSchemas.sys.mjs", "IntentClassifier.sys.mjs", "SearchBrowsingHistory.sys.mjs", diff --git a/browser/components/aiwindow/models/prompts/insightsPrompts.sys.mjs b/browser/components/aiwindow/models/prompts/insightsPrompts.sys.mjs @@ -172,3 +172,32 @@ Return ONLY JSON per the schema below. ] } \`\`\``.trim(); + +export const messageInsightClassificationSystemPromptMetadata = { + version: "0.1", +}; + +export const messageInsightClassificationSystemPrompt = + "Classify the user's message into one more more high-level Categories and Intents. Return ONLY valid JSON per schema."; + +export const messageInsightClassificationPrompt = ` +{message} + +Pick Categories from: +{categories} + +Pick Intents from: +{intents} + +Guidance: +- Choose the most directly implied category/intent. +- If ambiguous, pick the closest likely choice. +- Keep it non-sensitive and general; do NOT fabricate specifics. + +Return ONLY JSON per the schema below. +\`\`\`json +{ + "categories": ["<category 1>", "<category 2>", ...], + "intents": ["<intent 1>", "<intent 2>", ...] +} +\`\`\``.trim(); diff --git a/browser/components/aiwindow/models/tests/xpcshell/test_InsightsManager.js b/browser/components/aiwindow/models/tests/xpcshell/test_InsightsManager.js @@ -0,0 +1,506 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { InsightsManager } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/models/InsightsManager.sys.mjs" +); +const { CATEGORIES, INTENTS } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/models/InsightsConstants.sys.mjs" +); +const { getFormattedInsightAttributeList } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/models/Insights.sys.mjs" +); +const { InsightStore } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/services/InsightStore.sys.mjs" +); + +/** + * Constants for test insights + */ +const TEST_MESSAGE = "Remember I like coffee."; +const TEST_INSIGHTS = [ + { + insight_summary: "Loves drinking coffee", + category: "Food & Drink", + intent: "Plan / Organize", + score: 3, + }, + { + insight_summary: "Buys dog food online", + category: "Pets & Animals", + intent: "Buy / Acquire", + score: 4, + }, +]; + +/** + * Constants for preference keys and test values + */ +const PREF_API_KEY = "browser.aiwindow.apiKey"; +const PREF_ENDPOINT = "browser.aiwindow.endpoint"; +const PREF_MODEL = "browser.aiwindow.model"; + +const API_KEY = "fake-key"; +const ENDPOINT = "https://api.fake-endpoint.com/v1"; +const MODEL = "fake-model"; + +/** + * Helper function bulk-add insights + */ +async function addInsights() { + for (const insight of TEST_INSIGHTS) { + await InsightStore.addInsight(insight); + } +} + +/** + * Helper function to delete all insights after a test + */ +async function deleteAllInsights() { + const insights = await InsightStore.getInsights(); + for (const insight of insights) { + await InsightStore.hardDeleteInsight(insight.id); + } +} + +add_setup(async function () { + // Setup prefs used across multiple tests + Services.prefs.setStringPref(PREF_API_KEY, API_KEY); + Services.prefs.setStringPref(PREF_ENDPOINT, ENDPOINT); + Services.prefs.setStringPref(PREF_MODEL, MODEL); + + // Clear prefs after testing + registerCleanupFunction(() => { + for (let pref of [PREF_API_KEY, PREF_ENDPOINT, PREF_MODEL]) { + if (Services.prefs.prefHasUserValue(pref)) { + Services.prefs.clearUserPref(pref); + } + } + }); +}); + +/** + * Tests getting aggregated browser history from InsightsHistorySource + */ +add_task(async function test_getAggregatedBrowserHistory() { + // Setup fake history data + const now = Date.now(); + const seeded = [ + { + url: "https://www.google.com/search?q=firefox+history", + title: "Google Search: firefox history", + visits: [{ date: new Date(now - 5 * 60 * 1000) }], + }, + { + url: "https://news.ycombinator.com/", + title: "Hacker News", + visits: [{ date: new Date(now - 15 * 60 * 1000) }], + }, + { + url: "https://mozilla.org/en-US/", + title: "Internet for people, not profit — Mozilla", + visits: [{ date: new Date(now - 25 * 60 * 1000) }], + }, + ]; + await PlacesUtils.history.clear(); + await PlacesUtils.history.insertMany(seeded); + + // Check that all 3 outputs are arrays + const [domainItems, titleItems, searchItems] = + await InsightsManager.getAggregatedBrowserHistory(); + Assert.ok(Array.isArray(domainItems), "Domain items should be an array"); + Assert.ok(Array.isArray(titleItems), "Title items should be an array"); + Assert.ok(Array.isArray(searchItems), "Search items should be an array"); + + // Check the length of each + Assert.equal(domainItems.length, 3, "Should have 3 domain items"); + Assert.equal(titleItems.length, 3, "Should have 3 title items"); + Assert.equal(searchItems.length, 1, "Should have 1 search item"); + + // Check the top entry in each aggregate + Assert.deepEqual( + domainItems[0], + ["mozilla.org", 100], + "Top domain should be `mozilla.org' with score 100" + ); + Assert.deepEqual( + titleItems[0], + ["Internet for people, not profit — Mozilla", 100], + "Top title should be 'Internet for people, not profit — Mozilla' with score 100" + ); + Assert.equal( + searchItems[0].q[0], + "Google Search: firefox history", + "Top search item query should be 'Google Search: firefox history'" + ); + Assert.equal(searchItems[0].r, 1, "Top search item rank should be 1"); +}); + +/** + * Tests retrieving all stored insights + */ +add_task(async function test_getAllInsights() { + await addInsights(); + + const insights = await InsightsManager.getAllInsights(); + + // Check that the right number of insights were retrieved + Assert.equal( + insights.length, + TEST_INSIGHTS.length, + "Should retrieve all stored insights." + ); + + // Check that the insights summaries are correct + const testInsightsSummaries = TEST_INSIGHTS.map( + insight => insight.insight_summary + ); + const retrievedInsightsSummaries = insights.map( + insight => insight.insight_summary + ); + retrievedInsightsSummaries.forEach(insightSummary => { + Assert.ok( + testInsightsSummaries.includes(insightSummary), + `Insight summary "${insightSummary}" should be in the test insights.` + ); + }); + + await deleteAllInsights(); +}); + +/** + * Tests building the message insight classification prompt + */ +add_task(async function test_buildMessageInsightClassificationPrompt() { + const prompt = + await InsightsManager.buildMessageInsightClassificationPrompt(TEST_MESSAGE); + + Assert.ok( + prompt.includes(TEST_MESSAGE), + "Prompt should include the original message." + ); + Assert.ok( + prompt.includes(getFormattedInsightAttributeList(CATEGORIES)), + "Prompt should include formatted categories." + ); + Assert.ok( + prompt.includes(getFormattedInsightAttributeList(INTENTS)), + "Prompt should include formatted intents." + ); +}); + +/** + * Tests classifying a user message into insight categories and intents + */ +add_task(async function test_insightClassifyMessage_happy_path() { + const sb = sinon.createSandbox(); + try { + const fakeEngine = { + run() { + return { + finalOutput: `{ + "categories": ["Food & Drink"], + "intents": ["Plan / Organize"] + }`, + }; + }, + }; + + const stub = sb + .stub(InsightsManager, "ensureOpenAIEngine") + .returns(fakeEngine); + const messageClassification = + await InsightsManager.insightClassifyMessage(TEST_MESSAGE); + // Check that the stub was called + Assert.ok(stub.calledOnce, "ensureOpenAIEngine should be called once"); + + // Check classification result was returned correctly + Assert.equal( + typeof messageClassification, + "object", + "Result should be an object." + ); + Assert.equal( + Object.keys(messageClassification).length, + 2, + "Result should have two keys." + ); + Assert.deepEqual( + messageClassification.categories, + ["Food & Drink"], + "Categories should match the fake response." + ); + Assert.deepEqual( + messageClassification.intents, + ["Plan / Organize"], + "Intents should match the fake response." + ); + } finally { + sb.restore(); + } +}); + +/** + * Tests failed message classification - LLM returns empty output + */ +add_task(async function test_insightClassifyMessage_sad_path_empty_output() { + const sb = sinon.createSandbox(); + try { + const fakeEngine = { + run() { + return { + finalOutput: ``, + }; + }, + }; + + const stub = sb + .stub(InsightsManager, "ensureOpenAIEngine") + .returns(fakeEngine); + const messageClassification = + await InsightsManager.insightClassifyMessage(TEST_MESSAGE); + // Check that the stub was called + Assert.ok(stub.calledOnce, "ensureOpenAIEngine should be called once"); + + // Check classification result was returned correctly despite empty output + Assert.equal( + typeof messageClassification, + "object", + "Result should be an object." + ); + Assert.equal( + Object.keys(messageClassification).length, + 2, + "Result should have two keys." + ); + Assert.equal( + messageClassification.category, + null, + "Category should be null for empty output." + ); + Assert.equal( + messageClassification.intent, + null, + "Intent should be null for empty output." + ); + } finally { + sb.restore(); + } +}); + +/** + * Tests failed message classification - LLM returns incorrect schema + */ +add_task(async function test_insightClassifyMessage_sad_path_bad_schema() { + const sb = sinon.createSandbox(); + try { + const fakeEngine = { + run() { + return { + finalOutput: `{ + "wrong_key": "some value" + }`, + }; + }, + }; + + const stub = sb + .stub(InsightsManager, "ensureOpenAIEngine") + .returns(fakeEngine); + const messageClassification = + await InsightsManager.insightClassifyMessage(TEST_MESSAGE); + // Check that the stub was called + Assert.ok(stub.calledOnce, "ensureOpenAIEngine should be called once"); + + // Check classification result was returned correctly despite bad schema + Assert.equal( + typeof messageClassification, + "object", + "Result should be an object." + ); + Assert.equal( + Object.keys(messageClassification).length, + 2, + "Result should have two keys." + ); + Assert.equal( + messageClassification.category, + null, + "Category should be null for bad schema output." + ); + Assert.equal( + messageClassification.intent, + null, + "Intent should be null for bad schema output." + ); + } finally { + sb.restore(); + } +}); + +/** + * Tests retrieving relevant insights for a user message + */ +add_task(async function test_getRelevantInsights_happy_path() { + // Add insights so that we pass the existing insights check in the `getRelevantInsights` method + await addInsights(); + + const sb = sinon.createSandbox(); + try { + const fakeEngine = { + run() { + return { + finalOutput: `{ + "categories": ["Food & Drink"], + "intents": ["Plan / Organize"] + }`, + }; + }, + }; + + const stub = sb + .stub(InsightsManager, "ensureOpenAIEngine") + .returns(fakeEngine); + const relevantInsights = + await InsightsManager.getRelevantInsights(TEST_MESSAGE); + // Check that the stub was called + Assert.ok(stub.calledOnce, "ensureOpenAIEngine should be called once"); + + // Check that the correct relevant insight was returned + Assert.ok(Array.isArray(relevantInsights), "Result should be an array."); + Assert.equal( + relevantInsights.length, + 1, + "Result should contain one relevant insight." + ); + Assert.equal( + relevantInsights[0].insight_summary, + "Loves drinking coffee", + "Relevant insight summary should match." + ); + + // Delete insights after test + await deleteAllInsights(); + } finally { + sb.restore(); + } +}); + +/** + * Tests failed insights retrieval - no existing insights stored + * + * We don't mock an engine for this test case because getRelevantInsights should immediately return an empty array + * because there aren't any existing insights -> No need to call the LLM. + */ +add_task( + async function test_getRelevantInsights_sad_path_no_existing_insights() { + const relevantInsights = + await InsightsManager.getRelevantInsights(TEST_MESSAGE); + + // Check that result is an empty array + Assert.ok(Array.isArray(relevantInsights), "Result should be an array."); + Assert.equal( + relevantInsights.length, + 0, + "Result should be an empty array when there are no existing insights." + ); + } +); + +/** + * Tests failed insights retrieval - null classification + */ +add_task( + async function test_getRelevantInsights_sad_path_null_classification() { + // Add insights so that we pass the existing insights check + await addInsights(); + + const sb = sinon.createSandbox(); + try { + const fakeEngine = { + run() { + return { + finalOutput: `{ + "categories": [], + "intents": [] + }`, + }; + }, + }; + + const stub = sb + .stub(InsightsManager, "ensureOpenAIEngine") + .returns(fakeEngine); + const relevantInsights = + await InsightsManager.getRelevantInsights(TEST_MESSAGE); + // Check that the stub was called + Assert.ok(stub.calledOnce, "ensureOpenAIEngine should be called once"); + + // Check that result is an empty array + Assert.ok(Array.isArray(relevantInsights), "Result should be an array."); + Assert.equal( + relevantInsights.length, + 0, + "Result should be an empty array when category is null." + ); + + // Delete insights after test + await deleteAllInsights(); + } finally { + sb.restore(); + } + } +); + +/** + * Tests failed insights retrieval - no insight in message's category + */ +add_task( + async function test_getRelevantInsights_sad_path_no_insights_in_message_category() { + // Add insights so that we pass the existing insights check + await addInsights(); + + const sb = sinon.createSandbox(); + try { + const fakeEngine = { + run() { + return { + finalOutput: `{ + "categories": ["Health & Fitness"], + "intents": ["Plan / Organize"] + }`, + }; + }, + }; + + const stub = sb + .stub(InsightsManager, "ensureOpenAIEngine") + .returns(fakeEngine); + const relevantInsights = + await InsightsManager.getRelevantInsights(TEST_MESSAGE); + // Check that the stub was called + Assert.ok(stub.calledOnce, "ensureOpenAIEngine should be called once"); + + // Check that result is an empty array + Assert.ok(Array.isArray(relevantInsights), "Result should be an array."); + Assert.equal( + relevantInsights.length, + 0, + "Result should be an empty array when no insights match the message category." + ); + + // Delete insights after test + await deleteAllInsights(); + } finally { + sb.restore(); + } + } +); diff --git a/browser/components/aiwindow/models/tests/xpcshell/xpcshell.toml b/browser/components/aiwindow/models/tests/xpcshell/xpcshell.toml @@ -12,6 +12,8 @@ support-files = [] ["test_InsightsHistorySource.js"] +["test_InsightsManager.js"] + ["test_SearchBrowsingHistory.js"] ["test_Tools_SearchBrowsingHistory.js"]