pi-mono/packages/durable/test/search.test.ts
xu0o0 79006f0619
fix(durable,coding-agent): support Unicode word segmentation in tool search (#11037)
This PR uses unicode-aware segmentation and normalization to handle tool
search for multilingual text.

Fixes #10816

---------

Co-authored-by: Petr Houška <houskape@gmail.com>
2026-10-30 23:34:00 +01:00

154 lines
7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from "vitest";
import { rankDocuments, type SearchDocument, tokenize } from "../src/search.ts";
function doc(name: string, text: string, namespaceText?: string): SearchDocument {
return namespaceText === undefined ? { name, text } : { name, text, namespaceText };
}
describe("tokenize", () => {
it("splits camelCase and snake_case, drops stop words, and folds plurals", () => {
expect(tokenize("listIssues for the GitHub_repo")).toEqual(["list", "issue", "git", "hub", "repo"]);
expect(tokenize("searches queries HTTPServer")).toEqual(["search", "query", "http", "server"]);
expect(tokenize("repo.issues/search:queries")).toEqual(["repo", "issue", "search", "query"]);
});
it.each([
["搜索仓库问题", ["搜索", "仓库", "问题"]],
["文書を検索", ["文書", "を", "検索"]],
["문서 검색", ["문서", "검색"]],
["ค้นหาเอกสาร", ["ค้นหา", "เอกสาร"]],
["Поиск документов", ["поиск", "документов"]],
["البحث عن الملفات", ["البحث", "عن", "الملفات"]],
["फ़ाइल खोजें", ["फ़ाइल", "खोजें"]],
["Éléments cafés", ["éléments", "cafés"]],
])("segments Unicode words in %s", (text, expected) => {
expect(tokenize(text)).toEqual(expected);
});
it("preserves non-ASCII words and splits mixed-language identifiers", () => {
expect(tokenize("搜索GitHub_repoIssues résumé cafés")).toEqual([
"搜索",
"git",
"hub",
"repo",
"issue",
"résumé",
"cafés",
]);
});
it("normalizes canonically equivalent Unicode text", () => {
expect(tokenize("CAFÉ cafe\u0301")).toEqual(["café", "café"]);
});
it("ignores whitespace, punctuation, and emoji", () => {
expect(tokenize(" \t,。!? 😀 ")).toEqual([]);
});
});
describe("rankDocuments", () => {
const documents = [
doc("mcp__github__list_issues", "List issues in a repository. state open or closed"),
doc("mcp__github__create_pull_request", "Open a pull request."),
doc("mcp__linear__search_issues", "Search Linear issues by text."),
doc("mcp__docs__search", "Search the documentation."),
];
it("ranks by term relevance and respects the limit", () => {
expect(rankDocuments("issue", documents, 8)).toEqual(["mcp__linear__search_issues", "mcp__github__list_issues"]);
expect(rankDocuments("pull requests", documents, 8)[0]).toBe("mcp__github__create_pull_request");
expect(rankDocuments("search", documents, 1)).toHaveLength(1);
expect(rankDocuments("closed", documents, 8)).toEqual(["mcp__github__list_issues"]);
});
it.each([
["搜索仓库问题", "问题"],
["文書を検索", "検索"],
["문서 검색", "검색"],
["ค้นหาเอกสาร", "เอกสาร"],
["Поиск документов", "документов"],
["البحث عن الملفات", "الملفات"],
["फ़ाइल खोजें", "खोजें"],
["Rechercher un café", "cafe\u0301"],
])("matches Unicode queries against %s", (text, query) => {
expect(rankDocuments(query, [...documents, doc("localized", text)], 8)).toEqual(["localized"]);
});
it("returns nothing for unknown or empty queries", () => {
expect(rankDocuments("kubernetes", documents, 8)).toEqual([]);
expect(rankDocuments("the", documents, 8)).toEqual([]);
expect(rankDocuments("未知", documents, 8)).toEqual([]);
});
it("searches the namespace text", () => {
const namespaced = [doc("mcp__x__run", "Run it.", "mcp__x Kubernetes cluster tools")];
expect(rankDocuments("kubernetes", namespaced, 8)).toEqual(["mcp__x__run"]);
});
// https://github.com/earendil-works/pi/issues/11331
it("ranks a tool first when the query is its name", () => {
const named = [
doc("mcp__app__queue_read", "Read a queued message of this thread."),
doc(
"mcp__dev-app__thread_read",
"Read durable state and a paginated timeline from any thread. The default view returns user messages, assistant messages and plans. Continue with afterPosition until hasMore is false.",
),
];
for (const query of ["thread_read", "dev_app__thread_read", "mcp__dev-app__thread_read", "Thread_Read"]) {
expect(rankDocuments(query, named, 1)).toEqual(["mcp__dev-app__thread_read"]);
}
// Partial names still rank by BM25.
expect(rankDocuments("read", named, 1)).toEqual(["mcp__app__queue_read"]);
});
// https://github.com/earendil-works/pi/issues/11331
it("ranks a name match above mentions in descriptions", () => {
const ci = [
doc("mcp__ci__status", "Deploy status: last deploy, deploy log, deploy errors."),
doc(
"mcp__ci__deploy_release",
"Start a release of the current branch to an environment, run the migrations, switch traffic over, and wait until the new version is live and healthy.",
),
];
expect(rankDocuments("deploy", ci, 1)).toEqual(["mcp__ci__deploy_release"]);
});
// https://github.com/earendil-works/pi/issues/11331
it("ranks a tool first when the query contains its whole name", () => {
const peekaboo = "mcp__peekaboo Peekaboo controls macOS apps by app name.";
const named = [
doc("mcp__peekaboo__see", "Capture the app window of the peekaboo app and describe the app.", peekaboo),
doc("mcp__peekaboo__menu", "Click a menu item of an app.", peekaboo),
doc(
"mcp__peekaboo__app",
"Launch, quit, relaunch, focus, hide, or switch applications. Targets an application by name, bundle identifier, or process ID, and can wait until it is ready.",
peekaboo,
),
doc("mcp__app__queue_read", "Read a queued message of this thread."),
doc(
"mcp__dev-app__thread_read",
"Read durable state and a paginated timeline from any thread. The default view returns user messages, assistant messages and plans.",
),
];
expect(rankDocuments("peekaboo app", named, 1)).toEqual(["mcp__peekaboo__app"]);
expect(rankDocuments("open the peekaboo app tool", named, 1)).toEqual(["mcp__peekaboo__app"]);
expect(rankDocuments("read thread_read state", named, 1)).toEqual(["mcp__dev-app__thread_read"]);
// Without the whole name, mentions in descriptions decide.
expect(rankDocuments("describe window", named, 1)).toEqual(["mcp__peekaboo__see"]);
});
// https://github.com/earendil-works/pi/issues/11331
it("ranks a tool matching more query terms first, whatever the scores", () => {
const peekaboo = [
doc("mcp__peekaboo__app", "Launch, quit, focus, or switch applications by name or bundle identifier."),
doc("mcp__peekaboo__see", "Capture the app window and describe the UI elements of the app."),
doc("mcp__peekaboo__window", "Move, resize, or close a window of an app."),
doc("mcp__peekaboo__image", "Take a screenshot of the screen, an app, or a window."),
];
// `window` and `app` are whole names, but each covers one of the two terms.
expect(rankDocuments("screenshot window", peekaboo, 1)).toEqual(["mcp__peekaboo__image"]);
expect(rankDocuments("describe app", peekaboo, 1)).toEqual(["mcp__peekaboo__see"]);
// An exact name still ranks first.
expect(rankDocuments("window", peekaboo, 1)).toEqual(["mcp__peekaboo__window"]);
});
});