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:
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"]