fix(coding-agent): filtered weak pure-fuzzy session matches from ranking

- Updated fuzzy-search indexing to track compact word starts and required phrase matches to start at word boundaries.
- Adjusted token scoring and compact/phrase matching rules so exact and boundary-aware matches now rank above noisy substring matches.
- Filtered session search results to drop weak pure-fuzzy matches unless they contained literal tokens, and updated ranking tests for the new behavior.
This commit is contained in:
can1357
2026-06-12 06:31:40 +02:00
parent 35d3afc929
commit e74268d840
4 changed files with 45 additions and 18 deletions
@@ -67,13 +67,15 @@ function compareSessionRecency(a: SessionInfo, b: SessionInfo): number {
return b.modified.getTime() - a.modified.getTime();
}
const MIN_PURE_FUZZY_TOKEN_SCORE = -20;
/**
* Filter and rank session picker search results.
*
* Resume search narrows a recency-sorted list: once every query token appears
* as a literal substring, newer sessions should beat a slightly better fuzzy
* position match. Pure fuzzy/acronym matches still sort by fuzzy score after
* literal matches.
* literal matches, but weak pure fuzzy tokens are dropped as noise.
*/
export function rankSessionSearchMatches(allSessions: SessionInfo[], query: string): SessionInfo[] {
const tokens = tokenizeSessionQuery(query);
@@ -85,6 +87,7 @@ export function rankSessionSearchMatches(allSessions: SessionInfo[], query: stri
const text = sessionSearchText(session);
const textLower = text.toLowerCase();
let score = 0;
let worstTokenScore = Number.NEGATIVE_INFINITY;
let literal = true;
let matches = true;
@@ -95,10 +98,13 @@ export function rankSessionSearchMatches(allSessions: SessionInfo[], query: stri
break;
}
score += match.score;
worstTokenScore = Math.max(worstTokenScore, match.score);
if (!textLower.includes(token)) literal = false;
}
if (matches) results.push({ session, score, literal, index });
if (matches && (literal || worstTokenScore < MIN_PURE_FUZZY_TOKEN_SCORE)) {
results.push({ session, score, literal, index });
}
}
results.sort((a, b) => {
@@ -27,6 +27,7 @@ interface FakeAcpBuiltinSession {
formatSessionAsText: () => string;
getLastAssistantText: () => string | undefined;
messages: unknown[];
settings: Settings;
model: { provider: string; id: string } | undefined;
newSession(opts?: { drop?: boolean; parentSession?: string }): Promise<boolean>;
fork(): Promise<boolean>;
@@ -46,6 +47,7 @@ interface FakeAcpBuiltinSession {
}
function createRuntime() {
const settings = Settings.isolated();
const output: string[] = [];
const session: FakeAcpBuiltinSession = {
fastMode: false,
@@ -98,6 +100,7 @@ function createRuntime() {
getLastAssistantText: () => undefined,
messages: [],
model: undefined,
settings,
getToolByName: (_name: string) => undefined,
async compact(_args?: string) {},
getContextUsage: () => undefined,
@@ -152,7 +155,7 @@ function createRuntime() {
runtime: {
session: typedSession,
sessionManager: fakeSessionManager as unknown as SessionManager,
settings: Settings.isolated(),
settings,
cwd: "/tmp/project",
output: (text: string) => {
output.push(text);
@@ -886,9 +889,6 @@ describe("wave 5 — adapters and polish", () => {
(session as unknown as Record<string, unknown>).skills = [];
(session as unknown as Record<string, unknown>).agent = { state: { tools: [] } };
(session as unknown as Record<string, unknown>).systemPrompt = ["You are a helpful assistant."];
(session as unknown as Record<string, unknown>).settings = {
getGroup: () => ({ enabled: false, strategy: "off" }),
};
session.messages = [
{ role: "user", content: "Hello, how are you?" },
{ role: "assistant", content: "I am doing well." },
@@ -49,18 +49,26 @@ describe("rankSessionSearchMatches", () => {
it("keeps literal substring matches ahead of pure fuzzy matches", () => {
const fuzzyRecent = makeSession("fuzzy-recent", {
title: "Render Shape Index Zone Endpoint",
title: "Render Buffer",
modified: new Date("2024-01-03T00:00:00Z"),
});
const literalOld = makeSession("literal-old", {
title: "Resize Buffer Issue",
title: "RB Notes",
modified: new Date("2024-01-01T00:00:00Z"),
});
expect(ids(rankSessionSearchMatches([fuzzyRecent, literalOld], "resize"))).toEqual([
"literal-old",
"fuzzy-recent",
]);
expect(ids(rankSessionSearchMatches([fuzzyRecent, literalOld], "rb"))).toEqual(["literal-old", "fuzzy-recent"]);
});
it("filters low-quality pure fuzzy matches while keeping exact matches", () => {
const exact = makeSession("exact", {
title: "MN Discussion",
});
const lowQuality = makeSession("low-quality", {
title: "Random Notes",
});
expect(ids(rankSessionSearchMatches([exact, lowQuality], "mn"))).toEqual(["exact"]);
});
it("returns all sessions unchanged for an empty query", () => {
+19 -6
View File
@@ -33,6 +33,8 @@ interface SearchWord {
interface SearchIndex {
normalized: string;
compact: string;
/** Start offsets of each word within `compact` (cumulative word lengths). */
compactWordStarts: Set<number>;
words: SearchWord[];
}
@@ -53,19 +55,23 @@ function normalizeForSearch(value: string): string {
function buildSearchIndex(text: string): SearchIndex {
const normalized = normalizeForSearch(text);
if (normalized.length === 0) {
return { normalized, compact: "", words: [] };
return { normalized, compact: "", compactWordStarts: new Set(), words: [] };
}
const words: SearchWord[] = [];
const compactWordStarts = new Set<number>();
let index = 0;
let compactIndex = 0;
let ordinal = 0;
for (const word of normalized.split(" ")) {
words.push({ text: word, index, ordinal });
compactWordStarts.add(compactIndex);
index += word.length + 1;
compactIndex += word.length;
ordinal++;
}
return { normalized, compact: normalized.replaceAll(" ", ""), words };
return { normalized, compact: normalized.replaceAll(" ", ""), compactWordStarts, words };
}
function scoreCharacters(queryLower: string, textLower: string): CharacterMatch {
@@ -129,6 +135,13 @@ function withPosition(score: number, index: number): number {
return score + index * 0.01;
}
function isWordBoundaryPhrase(normalized: string, index: number, length: number): boolean {
const before = index === 0 || normalized[index - 1] === " ";
const afterIndex = index + length;
const after = afterIndex === normalized.length || normalized[afterIndex] === " ";
return before && after;
}
function scoreTokenAgainstWord(token: string, word: SearchWord): FuzzyMatch | null {
if (word.text === token) {
return { matches: true, score: withPosition(-200, word.index) };
@@ -144,7 +157,7 @@ function scoreTokenAgainstWord(token: string, word: SearchWord): FuzzyMatch | nu
const substringIndex = word.text.indexOf(token);
if (substringIndex >= 0) {
return { matches: true, score: withPosition(-120 + substringIndex, word.index) };
return { matches: true, score: withPosition(-20 + substringIndex, word.index) };
}
const characterMatch = scoreCharacters(token, word.text);
@@ -188,7 +201,7 @@ function scoreTokenDirect(token: string, index: SearchIndex): FuzzyMatch {
let best: FuzzyMatch | null = null;
const compactIndex = index.compact.indexOf(token);
if (compactIndex >= 0) {
if (compactIndex >= 0 && index.compactWordStarts.has(compactIndex)) {
best = { matches: true, score: withPosition(-140, compactIndex) };
}
@@ -236,14 +249,14 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
let totalScore = 0;
const phraseIndex = index.normalized.indexOf(normalizedQuery);
if (phraseIndex >= 0) {
if (phraseIndex >= 0 && isWordBoundaryPhrase(index.normalized, phraseIndex, normalizedQuery.length)) {
totalScore -= PHRASE_BONUS;
totalScore += phraseIndex * 0.01;
}
const compactQuery = normalizedQuery.replaceAll(" ", "");
const compactPhraseIndex = index.compact.indexOf(compactQuery);
if (compactPhraseIndex >= 0) {
if (compactPhraseIndex >= 0 && index.compactWordStarts.has(compactPhraseIndex)) {
totalScore -= COMPACT_PHRASE_BONUS;
totalScore += compactPhraseIndex * 0.01;
}