tor-browser

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

commit 76f21a1fb3da75c64b2dfd7754b14f28f3eb8674
parent 737abc822d76ead4e4d5d601e4db9c4786efb862
Author: Bastien Orivel <borivel@mozilla.com>
Date:   Fri,  5 Dec 2025 22:07:27 +0000

Bug 2002638 - Move search browsing history to AI-window r=ai-models-reviewers,tzhang

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

Diffstat:
Mbrowser/base/content/test/static/browser_all_files_referenced.js | 4++++
Abrowser/components/aiwindow/models/SearchBrowsingHistory.sys.mjs | 437+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/models/Tools.sys.mjs | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/aiwindow/models/moz.build | 2++
Abrowser/components/aiwindow/models/tests/xpcshell/test_SearchBrowsingHistory.js | 324+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/models/tests/xpcshell/test_Tools_SearchBrowsingHistory.js | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/aiwindow/models/tests/xpcshell/xpcshell.toml | 4++++
7 files changed, 1113 insertions(+), 0 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 @@ -358,6 +358,10 @@ var allowlist = [ { file: "moz-src:///browser/components/aiwindow/models/prompts/assistantPrompts.sys.mjs", }, + // Bug 2002638 - Move search browsing history to AI-window r?mardak (backed out due to unused file) + { + file: "moz-src:///browser/components/aiwindow/models/Tools.sys.mjs", + }, ]; if (AppConstants.NIGHTLY_BUILD) { diff --git a/browser/components/aiwindow/models/SearchBrowsingHistory.sys.mjs b/browser/components/aiwindow/models/SearchBrowsingHistory.sys.mjs @@ -0,0 +1,437 @@ +/** + * 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/. + */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs", + PageThumbsStorage: "resource://gre/modules/PageThumbs.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + getPlacesSemanticHistoryManager: + "resource://gre/modules/PlacesSemanticHistoryManager.sys.mjs", +}); + +/** + * Convert ISO timestamp string to microseconds (moz_places format). + * + * @param {string|null} iso + * @returns {number|null} + */ +function isoToMicroseconds(iso) { + if (!iso) { + return null; + } + const ms = new Date(iso).getTime(); + return Number.isFinite(ms) ? ms * 1000 : null; +} + +/** + * Normalize a history row from either: + * - semantic SQL result (mozIStorageRow), or + * - Places history node (plain object from nsINavHistoryResultNode). + * + * @param {object} row + * @param {boolean} [fromNode=false] // true if row came from Places node + * @returns {Promise<object>} // normalized history entry + */ +async function buildHistoryRow(row, fromNode = false) { + let title, url, visitDateIso, visitCount, distance, frecency, previewImageURL; + + if (!fromNode) { + // from semantic / SQL result (mozIStorageRow) + title = row.getResultByName("title"); + url = row.getResultByName("url"); + visitCount = row.getResultByName("visit_count"); + distance = row.getResultByName("distance"); + frecency = row.getResultByName("frecency"); + previewImageURL = row.getResultByName("preview_image_url"); + + // convert last_visit_date to ISO format + const lastVisitRaw = row.getResultByName("last_visit_date"); + // last_visit_date is in microseconds from moz_places + if (typeof lastVisitRaw === "number") { + visitDateIso = new Date(Math.round(lastVisitRaw / 1000)).toISOString(); + } else if (lastVisitRaw instanceof Date) { + visitDateIso = lastVisitRaw.toISOString(); + } else { + visitDateIso = null; + } + } else { + // from basic / Places history node (nsINavHistoryResultNode) + title = row.title; + url = row.uri; + visitCount = row.accessCount; + frecency = row.frecency; + + // convert time to ISO format + const lastVisitDate = lazy.PlacesUtils.toDate(row.time); + visitDateIso = lastVisitDate ? lastVisitDate.toISOString() : null; + } + + let relevanceScore; + if (typeof distance === "number") { + relevanceScore = 1 - distance; + } else { + relevanceScore = frecency; + } + + // Get thumbnail URL for the page if preview_image_url does not exist + try { + if (!previewImageURL) { + if (await lazy.PageThumbsStorage.fileExistsForURL(url)) { + previewImageURL = lazy.PageThumbs.getThumbnailURL(url); + } + } + } catch (e) { + // If thumbnail lookup fails, skip it + } + + // Get favicon URL for the page + let faviconUrl = null; + try { + const faviconURI = Services.io.newURI(url); + faviconUrl = `page-icon:${faviconURI.spec}`; + } catch (e) { + // If favicon lookup fails, skip it + } + + return { + title: title || url, + url, + visitDate: visitDateIso, // ISO timestamp format + visitCount: visitCount || 0, + relevanceScore: relevanceScore || 0, // Use embedding's distance as relevance score when available + ...(faviconUrl && { favicon: faviconUrl }), // Only include favicon if available + ...(previewImageURL && { thumbnail: previewImageURL }), // Only include thumbnail if available + }; +} + +/** + * Plain time-range browsing history search without search term (no semantic search). + * + * @param {object} params + * @param {number|null} params.startTs + * @param {number|null} params.endTs + * @param {number} params.historyLimit + * @returns {Promise<object[]>} + */ +async function searchBrowsingHistoryTimeRange({ + startTs, + endTs, + historyLimit, +}) { + const semanticManager = lazy.getPlacesSemanticHistoryManager(); + const conn = await semanticManager.getConnection(); + + const results = await conn.executeCached( + ` + SELECT id, + title, + url, + NULL AS distance, + visit_count, + frecency, + last_visit_date, + preview_image_url + FROM moz_places + WHERE frecency <> 0 + AND (:startTs IS NULL OR last_visit_date >= :startTs) + AND (:endTs IS NULL OR last_visit_date <= :endTs) + ORDER BY last_visit_date DESC, frecency DESC + LIMIT :limit + `, + { + startTs, + endTs, + limit: historyLimit, + } + ); + + const rows = []; + for (let row of results) { + rows.push(await buildHistoryRow(row)); + } + return rows; +} + +/** + * Normalize tensor/output format from the embedder into a single vector. + * + * @param {Array|object} tensor + * @returns {Array|Float32Array} + */ +function extractVectorFromTensor(tensor) { + if (!tensor) { + throw new Error("Unexpected empty tensor"); + } + + // Case 1: { output: ... } or { metrics, output } + if (tensor.output) { + if ( + Array.isArray(tensor.output) && + (Array.isArray(tensor.output[0]) || ArrayBuffer.isView(tensor.output[0])) + ) { + // output is an array of vectors, return the first + return tensor.output[0]; + } + // output is already a single vector + return tensor.output; + } + + // Case 2: tensor is nested like [[...]] + if ( + Array.isArray(tensor) && + tensor.length === 1 && + Array.isArray(tensor[0]) + ) { + tensor = tensor[0]; + } + + // Then we check if it's an array of arrays or just a single value. + if ( + Array.isArray(tensor) && + (Array.isArray(tensor[0]) || ArrayBuffer.isView(tensor[0])) + ) { + return tensor[0]; + } + + return tensor; +} + +/** + * Semantic browsing history search using embeddings. + * + * This performs a two-stage retrieval for performance: + * 1. Coarse search: over the quantized embeddings (`embedding_coarse`) to + * quickly select up to 100 candidate rows. This hard limit keeps the + * expensive cosine-distance computation bounded. + * 2. Refined search: computes the exact cosine distance for those candidates + * and applies the caller-provided `historyLimit` and `distanceThreshold` + * filters. + * + * @param {object} params + * @param {string} params.searchTerm + * @param {number|null} params.startTs + * @param {number|null} params.endTs + * @param {number} params.historyLimit + * @param {number} params.distanceThreshold + * @returns {Promise<object[]>} + */ +async function searchBrowsingHistorySemantic({ + searchTerm, + startTs, + endTs, + historyLimit, + distanceThreshold, +}) { + const semanticManager = lazy.getPlacesSemanticHistoryManager(); + await semanticManager.embedder.ensureEngine(); + + // Embed search term + let tensor = await semanticManager.embedder.embed(searchTerm); + const vec = extractVectorFromTensor(tensor); + const vector = lazy.PlacesUtils.tensorToSQLBindable(vec); + + let conn = await semanticManager.getConnection(); + const results = await conn.executeCached( + ` + WITH coarse_matches AS ( + SELECT rowid, + embedding + FROM vec_history + WHERE embedding_coarse match vec_quantize_binary(:vector) + ORDER BY distance + LIMIT 100 + ), + matches AS ( + SELECT url_hash, vec_distance_cosine(embedding, :vector) AS distance + FROM vec_history_mapping + JOIN coarse_matches USING (rowid) + WHERE distance <= :distanceThreshold + ORDER BY distance + LIMIT :limit + ) + SELECT id, + title, + url, + distance, + visit_count, + frecency, + last_visit_date, + preview_image_url + FROM moz_places + JOIN matches USING (url_hash) + WHERE frecency <> 0 + AND (:startTs IS NULL OR last_visit_date >= :startTs) + AND (:endTs IS NULL OR last_visit_date <= :endTs) + ORDER BY distance + `, + { + vector, + distanceThreshold, + limit: historyLimit, + startTs, + endTs, + } + ); + + const rows = []; + for (let row of results) { + rows.push(await buildHistoryRow(row)); + } + return rows; +} + +/** + * Browsing history search using the default history search. + * + * @param {object} params + * @param {string} params.searchTerm + * @param {number} params.historyLimit + * @returns {Promise<object[]>} + */ +async function searchBrowsingHistoryBasic({ searchTerm, historyLimit }) { + let root; + let openedRoot = false; + + try { + const currentHistory = lazy.PlacesUtils.history; + const query = currentHistory.getNewQuery(); + const opts = currentHistory.getNewQueryOptions(); + + // Use Places' built-in text filtering + query.searchTerms = searchTerm; + + // Simple URI results, ranked by frecency + opts.resultType = Ci.nsINavHistoryQueryOptions.RESULTS_AS_URI; + opts.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_FRECENCY_DESCENDING; + opts.maxResults = historyLimit; + opts.excludeQueries = false; + opts.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY; + + const result = currentHistory.executeQuery(query, opts); + root = result.root; + + if (!root.containerOpen) { + root.containerOpen = true; + openedRoot = true; + } + + const rows = []; + for (let i = 0; i < root.childCount && rows.length < historyLimit; i++) { + const node = root.getChild(i); + rows.push(await buildHistoryRow(node, true)); + } + return rows; + } catch (error) { + console.error("Error searching browser history:", error); + return []; + } finally { + if (root && openedRoot) { + root.containerOpen = false; + } + } +} + +/** + * Searches browser history using semantic search when possible, otherwise basic + * text search or time-range filtering. + * + * Rules: + * - Empty searchTerm: time-range search (if start/end given) or recent history. + * - Non-empty searchTerm: semantic search when available, otherwise basic text + * search (ignore time filtering). + * + * @param {object} params + * The search parameters. + * @param {string} params.searchTerm + * The search string. If null or empty, semantic search is skipped and + * results are filtered by time range and sorted by last_visit_date and frecency. + * @param {string|null} params.startTs + * Optional ISO-8601 start timestamp (e.g. "2025-11-07T09:00:00-05:00"). + * @param {string|null} params.endTs + * Optional ISO-8601 end timestamp (e.g. "2025-11-07T09:00:00-05:00"). + * @param {number} params.historyLimit + * Maximum number of history results to return. + * @returns {Promise<object>} + * A promise resolving to an object with the search term and history results. + * Includes `count` when matches exist, a `message` when none are found, or an + * `error` string on failure. + */ +export async function searchBrowsingHistory({ + searchTerm = "", + startTs = null, + endTs = null, + historyLimit = 15, +}) { + let rows = []; + + try { + // Convert ISO timestamp strings to microseconds to match the format used in moz_places + const startUs = isoToMicroseconds(startTs); + const endUs = isoToMicroseconds(endTs); + + const distanceThreshold = Services.prefs.getFloatPref( + "places.semanticHistory.distanceThreshold", + 0.6 + ); + + const semanticManager = lazy.getPlacesSemanticHistoryManager(); + + // If semantic search cannot be used or we don't have enough entries, always + // fall back to plain time-range search. + const canUseSemantic = + semanticManager.canUseSemanticSearch && + (await semanticManager.hasSufficientEntriesForSearching()); + + if (!searchTerm?.trim()) { + // Plain time-range search (no searchTerm) + rows = await searchBrowsingHistoryTimeRange({ + startTs: startUs, + endTs: endUs, + historyLimit, + }); + } else if (canUseSemantic) { + // Semantic search + rows = await searchBrowsingHistorySemantic({ + searchTerm, + startTs: startUs, + endTs: endUs, + historyLimit, + distanceThreshold, + }); + } else { + // Fallback to basic search without time window if semantic search not enable or insufficient records. + rows = await searchBrowsingHistoryBasic({ + searchTerm, + historyLimit, + }); + } + + if (rows.length === 0) { + return JSON.stringify({ + searchTerm, + results: [], + message: searchTerm + ? `No browser history found for "${searchTerm}".` + : "No browser history found in the requested time range.", + }); + } + + // Return as JSON string with metadata + return JSON.stringify({ + searchTerm, + count: rows.length, + results: rows, + }); + } catch (error) { + console.error("Error searching browser history:", error); + return JSON.stringify({ + searchTerm, + error: `Error searching browser history: ${error.message}`, + results: [], + }); + } +} diff --git a/browser/components/aiwindow/models/Tools.sys.mjs b/browser/components/aiwindow/models/Tools.sys.mjs @@ -0,0 +1,125 @@ +/** + * 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/. + */ + +/** + * This file contains LLM tool abscrations and tool definitions. + */ + +import { searchBrowsingHistory as implSearchBrowsingHistory } from "moz-src:///browser/components/aiwindow/models/SearchBrowsingHistory.sys.mjs"; + +// const SEARCH_BROWSING_HISTORY = "search_browsing_history"; + +// const TOOLS = [SEARCH_BROWSING_HISTORY]; + +// const toolsConfig = [ +// { +// type: "function", +// function: { +// name: SEARCH_BROWSING_HISTORY, +// description: +// "Search the user's browser history stored in sqlite-vec using an embedding model. If a search term is provided, performs vector search and ranks by semantic distance with frecency tie-breaks. If no search term is provided, returns the most relevant pages within a time window ranked by recency and frecency. Supports optional time range filtering using ISO 8601 datetime strings. This is to find previously visited pages related to specific keywords or topics. This helps find relevant pages the user has visited before, even if they're not currently open. All datetime must be before the user's current datetime. For parsing time window from dates and holidays, must depend on the user's current datetime, timezone, and locale.", +// parameters: { +// type: "object", +// properties: { +// searchTerm: { +// type: "string", +// description: +// "A detailed, noun-heavy phrase (~5-12 meaningful tokens) summarizing the user's intent for semantic retrieval. Include the main entity/topic plus 1-3 contextual qualifiers (e.g., library name, purpose, site, or timeframe). Avoid vague or single-word queries.", +// }, +// startTs: { +// type: "string", +// description: +// "Inclusive lower bound of the time window as an ISO 8601 datetime string (e.g., '2025-11-07T09:00:00-05:00'). Use when the user asks for results within a time or range start, such as 'last week', 'since yesterday', or 'last night'. This must be before the user's current datetime.", +// default: null, +// }, +// endTs: { +// type: "string", +// description: +// "Inclusive upper bound of the time window as an ISO 8601 datetime string (e.g., '2025-11-07T21:00:00-05:00'). Use when the user asks for results within a time or range end, such as 'last week', 'between 2025-10-01 and 2025-10-31', or 'before Monday'. This must be before the user's current datetime.", +// default: null, +// }, +// }, +// required: [], +// }, +// }, +// }, +// ]; + +/** + * Tool entrypoint for browsing history search. + * + * Parameters (defaults shown): + * - searchTerm: "" - string used for search + * - startTs: null - ISO timestamp lower bound, or null + * - endTs: null - ISO timestamp upper bound, or null + * - historyLimit: 15 - max number of results + * + * Detailed behavior and implementation are in SearchBrowsingHistory.sys.mjs. + * + * @param {object} params + * The search parameters. + * @param {string} params.searchTerm + * The search string. If null or empty, semantic search is skipped and + * results are filtered by time range and sorted by last_visit_date and frecency. + * @param {string|null} params.startTs + * Optional ISO-8601 start timestamp (e.g. "2025-11-07T09:00:00-05:00"). + * @param {string|null} params.endTs + * Optional ISO-8601 end timestamp (e.g. "2025-11-07T09:00:00-05:00"). + * @param {number} params.historyLimit + * Maximum number of history results to return. + * @returns {Promise<object>} + * A promise resolving to an object with the search term and history results. + * Includes `count` when matches exist, a `message` when none are found, or an + * `error` string on failure. + */ +export async function searchBrowsingHistory({ + searchTerm = "", + startTs = null, + endTs = null, + historyLimit = 15, +}) { + return implSearchBrowsingHistory({ + searchTerm, + startTs, + endTs, + historyLimit, + }); +} + +/** + * Strips heavy or unnecessary fields from a browser history search result. + * + * @param {string} result + * A JSON string representing the history search response. + * @returns {string} + * The sanitized JSON string with large fields (e.g., favicon, thumbnail) + * removed, or the original string if parsing fails. + */ +export function stripSearchBrowsingHistoryFields(result) { + try { + const data = JSON.parse(result); + if ( + data.error || + !Array.isArray(data.results) || + data.results.length === 0 + ) { + return result; + } + + // Remove large or unnecessary fields to save tokens + const OMIT_KEYS = ["favicon", "thumbnail"]; + for (const item of data.results) { + if (item && typeof item === "object") { + for (const k of OMIT_KEYS) { + delete item[k]; + } + } + } + return JSON.stringify(data); + } catch { + return result; + } +} diff --git a/browser/components/aiwindow/models/moz.build b/browser/components/aiwindow/models/moz.build @@ -13,6 +13,8 @@ MOZ_SRC_FILES += [ "ChatUtils.sys.mjs", "InsightsHistorySource.sys.mjs", "IntentClassifier.sys.mjs", + "SearchBrowsingHistory.sys.mjs", + "Tools.sys.mjs", "Utils.sys.mjs", ] diff --git a/browser/components/aiwindow/models/tests/xpcshell/test_SearchBrowsingHistory.js b/browser/components/aiwindow/models/tests/xpcshell/test_SearchBrowsingHistory.js @@ -0,0 +1,324 @@ +/** + * 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/. + */ + +const { searchBrowsingHistory } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/models/SearchBrowsingHistory.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +let sb; + +// setup +add_task(async function setup() { + sb = sinon.createSandbox(); + registerCleanupFunction(() => { + sb.restore(); + Services.prefs.clearUserPref("browser.ml.enable"); + Services.prefs.clearUserPref("places.semanticHistory.featureGate"); + Services.prefs.clearUserPref("browser.search.region"); + }); + + Services.prefs.setBoolPref("browser.ml.enable", true); + Services.prefs.setBoolPref("places.semanticHistory.featureGate", true); + Services.prefs.setCharPref("browser.search.region", "US"); + + await PlacesUtils.history.clear(); +}); + +// test: empty searchTerm, no time window +add_task(async function test_basic_history_fetch_and_shape() { + await PlacesUtils.history.clear(); + + 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) }], // 5 min ago + }, + { + url: "https://developer.mozilla.org/en-US/docs/Web/JavaScript", + title: "JavaScript | MDN", + visits: [{ date: new Date(now - 10 * 60 * 1000) }], // 10 min ago + }, + { + url: "https://news.ycombinator.com/", + title: "Hacker News", + visits: [{ date: new Date(now - 15 * 60 * 1000) }], + }, + { + url: "https://search.brave.com/search?q=mozsqlite", + title: "Brave Search: mozsqlite", + visits: [{ date: new Date(now - 20 * 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.insertMany(seeded); + + const allRowsStr = await searchBrowsingHistory({ + searchTerm: "", + startTs: null, + endTs: null, + historyLimit: 15, + }); + const allRowsObj = JSON.parse(allRowsStr); + + // check count match + Assert.equal( + allRowsObj.count, + seeded.length, + "Should return all seeded records" + ); + + // check all url match + const urls = allRowsObj.results.map(r => r.url).sort(); + const expectedUrls = seeded.map(s => s.url).sort(); + Assert.deepEqual(urls, expectedUrls, "Should return all seeded URLs"); + + // check title and url match + const byUrl = new Map(allRowsObj.results.map(r => [r.url, r])); + for (const { url, title } of seeded) { + Assert.ok(byUrl.has(url), `Has entry for ${url}`); + Assert.equal(byUrl.get(url).title, title, `Title matches for ${url}`); + } + + // check visitDate iso string + for (const r of allRowsObj.results) { + Assert.ok( + !isNaN(Date.parse(r.visitDate)), + "visitDate is a valid ISO timestamp" + ); + } +}); + +// test: startTs only +add_task(async function test_time_range_only_startTs() { + await PlacesUtils.history.clear(); + + const now = Date.now(); + + const older = { + url: "https://example.com/older", + title: "Older Page", + visits: [{ date: new Date(now - 60 * 60 * 1000) }], // 60 min ago + }; + const recent = { + url: "https://example.com/recent", + title: "Recent Page", + visits: [{ date: new Date(now - 5 * 60 * 1000) }], // 5 min ago + }; + + await PlacesUtils.history.insertMany([older, recent]); + + // records after last 10 minutes + const startTs = new Date(now - 10 * 60 * 1000).toISOString(); // ISO input + + const rowsStr = await searchBrowsingHistory({ + searchTerm: "", + startTs, + endTs: null, + historyLimit: 15, + }); + const rows = JSON.parse(rowsStr); + const urls = rows.results.map(r => r.url); + + Assert.ok( + urls.includes(recent.url), + "Recent entry should be included when only startTs is set" + ); + Assert.ok( + !urls.includes(older.url), + "Older entry should be excluded when only startTs is set" + ); +}); + +// test: endTs only +add_task(async function test_time_range_only_endTs() { + await PlacesUtils.history.clear(); + + const now = Date.now(); + + const older = { + url: "https://example.com/older", + title: "Older Page", + visits: [{ date: new Date(now - 60 * 60 * 1000) }], // 60 min ago + }; + const recent = { + url: "https://example.com/recent", + title: "Recent Page", + visits: [{ date: new Date(now - 5 * 60 * 1000) }], // 5 min ago + }; + + await PlacesUtils.history.insertMany([older, recent]); + + // Anything before last 10 minutes + const endTs = new Date(now - 10 * 60 * 1000).toISOString(); // ISO input + + const rowsStr = await searchBrowsingHistory({ + searchTerm: "", + startTs: null, + endTs, + historyLimit: 15, + }); + const rows = JSON.parse(rowsStr); + const urls = rows.results.map(r => r.url); + + Assert.ok( + urls.includes(older.url), + "Older entry should be included when only endTs is set" + ); + Assert.ok( + !urls.includes(recent.url), + "Recent entry should be excluded when only endTs is set" + ); +}); + +// test: startTs + endTs +add_task(async function test_time_range_start_and_endTs() { + await PlacesUtils.history.clear(); + + const now = Date.now(); + + const beforeWindow = { + url: "https://example.com/before-window", + title: "Before Window", + visits: [{ date: new Date(now - 3 * 60 * 60 * 1000) }], // 3h ago + }; + const inWindow = { + url: "https://example.com/in-window", + title: "In Window", + visits: [{ date: new Date(now - 30 * 60 * 1000) }], // 30 min ago + }; + const afterWindow = { + url: "https://example.com/after-window", + title: "After Window", + visits: [{ date: new Date(now - 5 * 60 * 1000) }], // 5 min ago + }; + + await PlacesUtils.history.insertMany([beforeWindow, inWindow, afterWindow]); + + // Time window: [45min ago, 15min ago] + const startTs = new Date(now - 45 * 60 * 1000).toISOString(); + const endTs = new Date(now - 15 * 60 * 1000).toISOString(); + + const rowsStr = await searchBrowsingHistory({ + searchTerm: "", + startTs, + endTs, + historyLimit: 15, + }); + const rows = JSON.parse(rowsStr); + const urls = rows.results.map(r => r.url); + + Assert.ok(urls.includes(inWindow.url), "In window entry should be included"); + Assert.ok( + !urls.includes(beforeWindow.url), + "Before window entry should be excluded" + ); + Assert.ok( + !urls.includes(afterWindow.url), + "After window entry should be excluded" + ); +}); + +/** + * Test no results behavior: empty history with and without searchTerm. + * + * We don't try to force the semantic here (that would require a + * running ML engine). Instead we just assert the wrapper's messaging + * when there are no rows. + */ +add_task(async function test_no_results_messages() { + await PlacesUtils.history.clear(); + + // No search term: time range message. + let outputStr = await searchBrowsingHistory({ + searchTerm: "", + startTs: null, + endTs: null, + historyLimit: 15, + }); + let output = JSON.parse(outputStr); + + Assert.equal(output.results.length, 0, "No results when history is empty"); + Assert.ok( + output.message.includes("requested time range"), + "Message explains empty time-range search" + ); + + // With search term: search specific message. + outputStr = await searchBrowsingHistory({ + searchTerm: "mozilla", + startTs: null, + endTs: null, + historyLimit: 15, + }); + output = JSON.parse(outputStr); + + Assert.equal(output.results.length, 0, "No results for semantic search"); + Assert.ok( + output.message.includes("mozilla"), + "Message mentions the search term when there are no matches" + ); +}); + +// test: non-empty searchTerm falls back to basic history search +// when semantic search is disabled via prefs. +add_task(async function test_basic_text_search_when_semantic_disabled() { + await PlacesUtils.history.clear(); + + const now = Date.now(); + + const seeded = [ + { + url: "https://www.mozilla.org/en-US/", + title: "Internet for people, not profit — Mozilla", + visits: [{ date: new Date(now - 5 * 60 * 1000) }], // 5 min ago + }, + { + url: "https://example.com/other", + title: "Some Other Site", + visits: [{ date: new Date(now - 10 * 60 * 1000) }], // 10 min ago + }, + ]; + + await PlacesUtils.history.insertMany(seeded); + + // Disable semantic search so searchBrowsingHistory must fall back + // to the basic history search. + Services.prefs.setBoolPref("browser.ml.enable", false); + Services.prefs.setBoolPref("places.semanticHistory.featureGate", false); + + const outputStr = await searchBrowsingHistory({ + searchTerm: "mozilla", + startTs: null, + endTs: null, + historyLimit: 15, + }); + const output = JSON.parse(outputStr); + + Assert.equal(output.searchTerm, "mozilla", "searchTerm match"); + Assert.equal(output.results.length, 1, "One history entry is returned"); + + const urls = output.results.map(r => r.url); + Assert.ok( + urls.includes("https://www.mozilla.org/en-US/"), + "Basic history search should find the Mozilla entry" + ); + + // Restore prefs + Services.prefs.setBoolPref("browser.ml.enable", true); + Services.prefs.setBoolPref("places.semanticHistory.featureGate", true); +}); diff --git a/browser/components/aiwindow/models/tests/xpcshell/test_Tools_SearchBrowsingHistory.js b/browser/components/aiwindow/models/tests/xpcshell/test_Tools_SearchBrowsingHistory.js @@ -0,0 +1,217 @@ +/** + * 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/. + */ + +const { searchBrowsingHistory, stripSearchBrowsingHistoryFields } = + ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/models/Tools.sys.mjs" + ); + +/** + * searchBrowsingHistory tests + * + * Wrapper test: ensures Tools.searchBrowsingHistory() returns a valid JSON + * structure and surfaces the underlying error when the semantic DB is not + * initialized. Real search behavior is tested elsewhere. + */ + +add_task(async function test_searchBrowsingHistory_wrapper() { + const outputStr = await searchBrowsingHistory({ + searchTerm: "", + startTs: null, + endTs: null, + historyLimit: 15, + }); + + const output = JSON.parse(outputStr); + + Assert.equal(output.searchTerm, "", "searchTerm match"); + Assert.ok("results" in output, "results field present"); + Assert.ok(Array.isArray(output.results), "results is an array"); + + // Error expected + Assert.ok("error" in output, "error field present"); +}); + +/** + * stripSearchBrowsingHistoryFields tests + */ + +add_task(function test_stripSearchBrowsingHistoryFields_omits_large_fields() { + const input = JSON.stringify({ + searchTerm: "firefox", + count: 6, + results: [ + { + title: "Firefox Privacy Notice — Mozilla", + url: "https://www.mozilla.org/en-US/privacy/firefox/", + visitDate: "2025-11-28T05:32:33.096Z", + visitCount: 2, + relevanceScore: 0.7472805678844452, + favicon: "page-icon:https://www.mozilla.org/en-US/privacy/firefox/", + thumbnail: "https://www.mozilla.org/media/img/m24/og.3a69dffad83e.png", + }, + // no thumbnail + { + title: "Planet Mozilla", + url: "https://planet.mozilla.org/", + visitDate: "2025-11-28T05:32:37.796Z", + visitCount: 1, + relevanceScore: 0.5503441691398621, + favicon: "page-icon:https://planet.mozilla.org/", + }, + // fake record: no favicon + { + title: "Firefox Privacy Notice — Mozilla 2", + url: "https://www.mozilla.org/en-US/privacy/firefox/2/", + visitDate: "2025-11-28T05:32:33.096Z", + visitCount: 1, + relevanceScore: 0.54, + thumbnail: "https://www.mozilla.org/media/img/m24/og.3a69dffad83e.png", + }, + // fake record: no favicon and no thumbnail + { + title: "Planet Mozilla 2", + url: "https://planet.mozilla.org/2/", + visitDate: "2025-11-28T05:32:37.796Z", + visitCount: 1, + relevanceScore: 0.53, + }, + { + title: "Bugzilla Main Page", + url: "https://bugzilla.mozilla.org/home", + visitDate: "2025-11-28T05:32:49.807Z", + visitCount: 1, + relevanceScore: 0.5060983002185822, + favicon: "page-icon:https://bugzilla.mozilla.org/home", + thumbnail: + "https://bugzilla.mozilla.org/extensions/OpenGraph/web/moz-social-bw-rgb-1200x1200.png", + }, + { + title: "Bugzilla Main Page", + url: "https://bugzilla.mozilla.org/", + visitDate: "2025-11-28T05:32:49.272Z", + visitCount: 1, + relevanceScore: 0.5060983002185822, + favicon: "page-icon:https://bugzilla.mozilla.org/", + }, + ], + }); + + const outputStr = stripSearchBrowsingHistoryFields(input); + const output = JSON.parse(outputStr); + + Assert.equal( + output.searchTerm, + "firefox", + "searchTerm preserved after stripping" + ); + Assert.equal(output.count, 6, "count preserved"); + Assert.equal(output.results.length, 6, "results length preserved"); + + Assert.equal( + output.results[0].title, + "Firefox Privacy Notice — Mozilla", + "title same" + ); + Assert.equal( + output.results[0].url, + "https://www.mozilla.org/en-US/privacy/firefox/", + "url same" + ); + Assert.equal( + output.results[0].visitDate, + "2025-11-28T05:32:33.096Z", + "visitDate same" + ); + Assert.equal(output.results[0].visitCount, 2, "visitCount same"); + Assert.equal( + output.results[0].relevanceScore, + 0.7472805678844452, + "relevanceScore same" + ); + Assert.equal(output.results[0].favicon, undefined, "favicon removed"); + Assert.equal(output.results[0].thumbnail, undefined, "thumbnail removed"); + + for (const [idx, r] of output.results.entries()) { + Assert.ok(!("favicon" in r), `favicon removed for result[${idx}]`); + Assert.ok(!("thumbnail" in r), `thumbnail removed for result[${idx}]`); + } +}); + +add_task( + function test_stripSearchBrowsingHistoryFields_passthrough_on_empty_results() { + // empty result + const noResults = JSON.stringify({ + searchTerm: "test", + count: 0, + results: [], + }); + + const outputStr = stripSearchBrowsingHistoryFields(noResults); + Assert.equal( + outputStr, + noResults, + "When results is empty, function should return original string" + ); + + const withError = JSON.stringify({ + searchTerm: "test", + error: "something went wrong", + results: [], + }); + + // no result with error + const outputErr = stripSearchBrowsingHistoryFields(withError); + Assert.equal( + outputErr, + withError, + "When error is present, function should return original string" + ); + + const notJSON = "this is not json"; + const outputErrNonJSON = stripSearchBrowsingHistoryFields(notJSON); + Assert.equal( + outputErrNonJSON, + notJSON, + "Invalid JSON input should be returned unchanged" + ); + } +); + +add_task( + function test_stripSearchBrowsingHistoryFields_passthrough_on_unexpected_results_structure() { + // result = null + const unexpectedResults = null; + + const outputStr = stripSearchBrowsingHistoryFields(unexpectedResults); + Assert.equal( + outputStr, + unexpectedResults, + "When any other data structure besides JSON (null), return original data" + ); + + // reulst = "" + const emptyStringResults = ""; + + const outputStrEmpty = stripSearchBrowsingHistoryFields(emptyStringResults); + Assert.equal( + outputStrEmpty, + emptyStringResults, + "When any other data structure besides JSON (''), return original data" + ); + + // result = "abc" + const nonJSONStringResults = "abc"; + + const outputStrNonJSON = + stripSearchBrowsingHistoryFields(nonJSONStringResults); + Assert.equal( + outputStrNonJSON, + nonJSONStringResults, + "When any other data structure besides JSON ('abc'), return original data" + ); + } +); diff --git a/browser/components/aiwindow/models/tests/xpcshell/xpcshell.toml b/browser/components/aiwindow/models/tests/xpcshell/xpcshell.toml @@ -10,6 +10,10 @@ support-files = [] ["test_InsightsHistorySource.js"] +["test_SearchBrowsingHistory.js"] + +["test_Tools_SearchBrowsingHistory.js"] + ["test_Utils.js"] ["test_intent_classifier.js"]