From 25d16ea27c4ca775f8a39f0b8c62fedaf3f9e6ff Mon Sep 17 00:00:00 2001 From: VamsiKrishna0101 Date: Sun, 21 Jun 2026 19:59:43 +0530 Subject: [PATCH 1/3] Improve session transcript search --- .../domains/session/search/session-search.ts | 187 ++++++++++++++++-- apps/app/tests/session-search.test.ts | 73 +++++++ 2 files changed, 243 insertions(+), 17 deletions(-) create mode 100644 apps/app/tests/session-search.test.ts diff --git a/apps/app/src/react-app/domains/session/search/session-search.ts b/apps/app/src/react-app/domains/session/search/session-search.ts index 33d31bf36..1a0b454b7 100644 --- a/apps/app/src/react-app/domains/session/search/session-search.ts +++ b/apps/app/src/react-app/domains/session/search/session-search.ts @@ -37,6 +37,27 @@ type CacheEntry = { failedAt?: number; }; +type QueryToken = { + value: string; +}; + +type WordRange = { + value: string; + start: number; + end: number; +}; + +type TokenRange = { + start: number; + end: number; + score: number; +}; + +type TokenizedMatch = { + ranges: TokenRange[]; + score: number; +}; + export type SessionMessageFetcher = ( workspaceId: string, sessionId: string, @@ -46,6 +67,23 @@ const SNIPPET_BEFORE = 36; const SNIPPET_AFTER = 72; const DEFAULT_CONCURRENCY = 6; const FAILURE_RETRY_MS = 30_000; +const MIN_TOKEN_LENGTH = 2; +const WORD_PATTERN = /[a-z0-9_./-]+/g; +const STOP_WORDS = new Set([ + "a", + "an", + "and", + "are", + "for", + "in", + "is", + "of", + "on", + "or", + "the", + "to", + "with", +]); function collapseWhitespace(value: string): string { return value.replace(/\s+/g, " "); @@ -55,11 +93,127 @@ function collapseWhitespace(value: string): string { export function buildSnippet(text: string, index: number, length: number): SessionSearchSnippet { const start = Math.max(0, index - SNIPPET_BEFORE); const end = Math.min(text.length, index + length + SNIPPET_AFTER); - const before = `${start > 0 ? "…" : ""}${collapseWhitespace(text.slice(start, index)).trimStart()}`; - const after = `${collapseWhitespace(text.slice(index + length, end)).trimEnd()}${end < text.length ? "…" : ""}`; + const before = `${start > 0 ? "..." : ""}${collapseWhitespace(text.slice(start, index)).trimStart()}`; + const after = `${collapseWhitespace(text.slice(index + length, end)).trimEnd()}${end < text.length ? "..." : ""}`; return { before, match: text.slice(index, index + length), after }; } +function tokenizeQuery(query: string): QueryToken[] { + const seen = new Set(); + const tokens: QueryToken[] = []; + for (const match of query.toLowerCase().matchAll(WORD_PATTERN)) { + const value = match[0]; + if (value.length < MIN_TOKEN_LENGTH || STOP_WORDS.has(value) || seen.has(value)) continue; + seen.add(value); + tokens.push({ value }); + } + return tokens; +} + +function wordRanges(lower: string): WordRange[] { + const ranges: WordRange[] = []; + for (const match of lower.matchAll(WORD_PATTERN)) { + ranges.push({ + value: match[0], + start: match.index, + end: match.index + match[0].length, + }); + } + return ranges; +} + +function editDistanceWithin(left: string, right: string, maxDistance: number): boolean { + if (Math.abs(left.length - right.length) > maxDistance) return false; + + let previous = Array.from({ length: right.length + 1 }, (_, index) => index); + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + const current = [leftIndex]; + let rowBest = current[0]; + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; + const value = Math.min( + previous[rightIndex] + 1, + current[rightIndex - 1] + 1, + previous[rightIndex - 1] + cost, + ); + current[rightIndex] = value; + rowBest = Math.min(rowBest, value); + } + if (rowBest > maxDistance) return false; + previous = current; + } + + return previous[right.length] <= maxDistance; +} + +function scoreWordForToken(word: WordRange, token: QueryToken): TokenRange | null { + if (word.value === token.value) { + return { start: word.start, end: word.end, score: 120 }; + } + if (word.value.startsWith(token.value)) { + return { start: word.start, end: word.end, score: 95 }; + } + + const index = word.value.indexOf(token.value); + if (index >= 0) { + return { + start: word.start + index, + end: word.start + index + token.value.length, + score: 75, + }; + } + + const maxDistance = token.value.length >= 7 ? 2 : 1; + if (editDistanceWithin(token.value, word.value, maxDistance)) { + return { start: word.start, end: word.end, score: 55 }; + } + + return null; +} + +function findBestTokenRange(words: WordRange[], token: QueryToken): TokenRange | null { + let best: TokenRange | null = null; + for (const word of words) { + const range = scoreWordForToken(word, token); + if (!range) continue; + if (!best || range.score > best.score) { + best = range; + } + } + return best; +} + +function matchTokenizedQuery(lower: string, tokens: QueryToken[]): TokenizedMatch | null { + if (tokens.length === 0) return null; + + const words = wordRanges(lower); + const ranges: TokenRange[] = []; + for (const token of tokens) { + const range = findBestTokenRange(words, token); + if (!range) return null; + ranges.push(range); + } + + const sorted = [...ranges].sort((a, b) => a.start - b.start); + const span = sorted[sorted.length - 1].end - sorted[0].start; + const proximityBonus = Math.max(0, 120 - span); + const score = ranges.reduce((sum, range) => sum + range.score, 0) + proximityBonus; + + return { ranges: sorted, score }; +} + +function buildTokenSnippet(text: string, ranges: TokenRange[]): SessionSearchSnippet { + const first = ranges[0]; + const last = ranges[ranges.length - 1]; + const start = Math.max(0, first.start - SNIPPET_BEFORE); + const end = Math.min(text.length, last.end + SNIPPET_AFTER); + return { + before: `${start > 0 ? "..." : ""}${collapseWhitespace(text.slice(start, first.start)).trimStart()}`, + match: collapseWhitespace(text.slice(first.start, last.end)), + after: `${collapseWhitespace(text.slice(last.end, end)).trimEnd()}${end < text.length ? "..." : ""}`, + }; +} + function toCacheEntry(updatedAt: number, messages: OpenworkSessionMessage[]): CacheEntry { const texts: CacheEntry["texts"] = []; for (const message of messages) { @@ -79,23 +233,24 @@ function toCacheEntry(updatedAt: number, messages: OpenworkSessionMessage[]): Ca function matchEntry( session: SearchableSession, entry: CacheEntry, - queryLower: string, + queryTokens: QueryToken[], ): SessionSearchMatch | null { - // Prefer the user's own prompts: they are usually what people remember typing. - let fallback: SessionSearchMatch | null = null; + let best: { match: SessionSearchMatch; score: number } | null = null; for (const item of entry.texts) { - const index = item.lower.indexOf(queryLower); - if (index < 0) continue; + const tokenMatch = matchTokenizedQuery(item.lower, queryTokens); + if (!tokenMatch) continue; + const score = tokenMatch.score + (item.role === "user" ? 50 : 0); const match: SessionSearchMatch = { session, kind: "message", role: item.role, - snippet: buildSnippet(item.text, index, queryLower.length), + snippet: buildTokenSnippet(item.text, tokenMatch.ranges), }; - if (item.role === "user") return match; - if (!fallback) fallback = match; + if (!best || score > best.score) { + best = { match, score }; + } } - return fallback; + return best?.match ?? null; } export type SessionSearchRun = { @@ -138,9 +293,7 @@ export function createSessionSearcher(fetchMessages: SessionMessageFetcher): Ses const messages = await fetchMessages(session.workspaceId, session.sessionId); entry = toCacheEntry(session.updatedAt, messages); } catch { - // Unreachable session (stale workspace, server hiccup): record an empty - // entry with a cool-down so one bad session cannot stall every later - // keystroke, but still gets retried once the cool-down expires. + // Keep one stale workspace or server hiccup from blocking every keystroke. entry = { ...toCacheEntry(session.updatedAt, []), failedAt: Date.now() }; } cache.set(session.sessionId, entry); @@ -149,7 +302,7 @@ export function createSessionSearcher(fetchMessages: SessionMessageFetcher): Ses return { search({ query, sessions, onMatch, onProgress, concurrency = DEFAULT_CONCURRENCY }) { - const queryLower = query.trim().toLowerCase(); + const queryTokens = tokenizeQuery(query); let cancelled = false; // Scan newest sessions first so the most relevant hits stream in early. @@ -169,14 +322,14 @@ export function createSessionSearcher(fetchMessages: SessionMessageFetcher): Ses const entry = await getEntry(session); if (cancelled) return; scanned += 1; - const match = matchEntry(session, entry, queryLower); + const match = matchEntry(session, entry, queryTokens); if (match) onMatch(match); report(); } }; const done = (async () => { - if (!queryLower) { + if (queryTokens.length === 0) { scanned = total; report(); return; diff --git a/apps/app/tests/session-search.test.ts b/apps/app/tests/session-search.test.ts new file mode 100644 index 000000000..119cf2f2b --- /dev/null +++ b/apps/app/tests/session-search.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; + +import { + createSessionSearcher, + type SearchableSession, + type SessionMessageFetcher, +} from "../src/react-app/domains/session/search/session-search"; + +const session: SearchableSession = { + workspaceId: "workspace-a", + sessionId: "session-a", + title: "Auth callback debugging", + workspaceTitle: "OpenWork", + updatedAt: 123, +}; + +function message(role: "user" | "assistant", text: string) { + return JSON.parse(JSON.stringify({ + info: { role }, + parts: [{ type: "text", text }], + })); +} + +async function search(query: string, fetchMessages: SessionMessageFetcher) { + const searcher = createSessionSearcher(fetchMessages); + const results: string[] = []; + const run = searcher.search({ + query, + sessions: [session], + onMatch: (match) => { + results.push(`${match.role ?? "unknown"}:${match.snippet?.match ?? ""}`); + }, + onProgress: () => undefined, + concurrency: 1, + }); + await run.done; + return results; +} + +describe("session transcript search", () => { + test("matches remembered words without requiring the exact phrase", async () => { + const fetchMessages: SessionMessageFetcher = async () => [ + message("assistant", "The OAuth redirect failed after the authentication callback returned."), + ]; + + const results = await search("auth redirect", fetchMessages); + + expect(results).toEqual([ + "assistant:redirect failed after the authentication", + ]); + }); + + test("prefers the user's own matching message", async () => { + const fetchMessages: SessionMessageFetcher = async () => [ + message("assistant", "The deployment error came from a missing Vercel token."), + message("user", "Can you fix the Vercel deployment failure?"), + ]; + + const results = await search("deploy vercel", fetchMessages); + + expect(results[0]?.startsWith("user:")).toBe(true); + }); + + test("matches small typos", async () => { + const fetchMessages: SessionMessageFetcher = async () => [ + message("assistant", "Updated the environment variable configuration."), + ]; + + const results = await search("enviroment config", fetchMessages); + + expect(results.length).toBe(1); + }); +}); From 476376b333cf6059150d1b2f09b867bd38ffb4e3 Mon Sep 17 00:00:00 2001 From: VamsiKrishna0101 Date: Sun, 21 Jun 2026 20:17:47 +0530 Subject: [PATCH 2/3] Support Unicode terms in session search --- .../react-app/domains/session/search/session-search.ts | 2 +- apps/app/tests/session-search.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/app/src/react-app/domains/session/search/session-search.ts b/apps/app/src/react-app/domains/session/search/session-search.ts index 1a0b454b7..f4a58de07 100644 --- a/apps/app/src/react-app/domains/session/search/session-search.ts +++ b/apps/app/src/react-app/domains/session/search/session-search.ts @@ -68,7 +68,7 @@ const SNIPPET_AFTER = 72; const DEFAULT_CONCURRENCY = 6; const FAILURE_RETRY_MS = 30_000; const MIN_TOKEN_LENGTH = 2; -const WORD_PATTERN = /[a-z0-9_./-]+/g; +const WORD_PATTERN = /[\p{L}\p{N}_./-]+/gu; const STOP_WORDS = new Set([ "a", "an", diff --git a/apps/app/tests/session-search.test.ts b/apps/app/tests/session-search.test.ts index 119cf2f2b..dca8ad111 100644 --- a/apps/app/tests/session-search.test.ts +++ b/apps/app/tests/session-search.test.ts @@ -70,4 +70,14 @@ describe("session transcript search", () => { expect(results.length).toBe(1); }); + + test("matches non-ASCII query terms", async () => { + const fetchMessages: SessionMessageFetcher = async () => [ + message("assistant", "Summarized the café menu translation notes."), + ]; + + const results = await search("café", fetchMessages); + + expect(results.length).toBe(1); + }); }); From 59899c5afbdc982d78a16020ecfe659eeb957d24 Mon Sep 17 00:00:00 2001 From: VamsiKrishna0101 Date: Mon, 22 Jun 2026 06:45:09 +0530 Subject: [PATCH 3/3] Add visible session search entry point --- .../domains/session/chat/session-page.tsx | 2 + .../domains/session/search/session-search.ts | 64 ++++--------------- .../session/sidebar/app-sidebar-provider.tsx | 1 + .../domains/session/sidebar/app-sidebar.tsx | 17 +++++ .../app/src/react-app/shell/session-route.tsx | 1 + apps/app/tests/session-search.test.ts | 48 +++++++++++--- 6 files changed, 74 insertions(+), 59 deletions(-) diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index e5cd571c9..ef38c6df2 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -109,6 +109,7 @@ export type SessionPageSidebarProps = { onPrefetchSession?: (workspaceId: string, sessionId: string) => void; onCreateTaskInWorkspace: (workspaceId: string) => void; onCreateTaskWithPrompt?: (workspaceId: string, prompt: string) => void; + onOpenSessionSearch?: () => void; onOpenRenameWorkspace: (workspaceId: string) => void; onShareWorkspace: (workspaceId: string) => void; onRevealWorkspace: (workspaceId: string) => void; @@ -831,6 +832,7 @@ export function SessionPage(props: SessionPageProps) { onOpenSession={openSessionTab} onPrefetchSession={props.sidebar.onPrefetchSession} onCreateTaskInWorkspace={props.sidebar.onCreateTaskInWorkspace} + onOpenSessionSearch={props.sidebar.onOpenSessionSearch} onOpenRenameSession={props.onRenameSession ? openRenameModal : undefined} onOpenDeleteSession={props.onDeleteSession ? (sessionId) => { setSessionActionId(sessionId); diff --git a/apps/app/src/react-app/domains/session/search/session-search.ts b/apps/app/src/react-app/domains/session/search/session-search.ts index f4a58de07..787e2a1c7 100644 --- a/apps/app/src/react-app/domains/session/search/session-search.ts +++ b/apps/app/src/react-app/domains/session/search/session-search.ts @@ -32,7 +32,7 @@ export type SessionSearchProgress = { type CacheEntry = { updatedAt: number; /** One entry per message that contains searchable text. */ - texts: Array<{ role: "user" | "assistant"; text: string; lower: string }>; + texts: Array<{ role: "user" | "assistant"; text: string; words: WordRange[] }>; /** Set when the transcript fetch failed; retried after a short cool-down. */ failedAt?: number; }; @@ -89,15 +89,6 @@ function collapseWhitespace(value: string): string { return value.replace(/\s+/g, " "); } -/** Build a compact snippet centered on the first occurrence of the query. */ -export function buildSnippet(text: string, index: number, length: number): SessionSearchSnippet { - const start = Math.max(0, index - SNIPPET_BEFORE); - const end = Math.min(text.length, index + length + SNIPPET_AFTER); - const before = `${start > 0 ? "..." : ""}${collapseWhitespace(text.slice(start, index)).trimStart()}`; - const after = `${collapseWhitespace(text.slice(index + length, end)).trimEnd()}${end < text.length ? "..." : ""}`; - return { before, match: text.slice(index, index + length), after }; -} - function tokenizeQuery(query: string): QueryToken[] { const seen = new Set(); const tokens: QueryToken[] = []; @@ -122,30 +113,6 @@ function wordRanges(lower: string): WordRange[] { return ranges; } -function editDistanceWithin(left: string, right: string, maxDistance: number): boolean { - if (Math.abs(left.length - right.length) > maxDistance) return false; - - let previous = Array.from({ length: right.length + 1 }, (_, index) => index); - for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { - const current = [leftIndex]; - let rowBest = current[0]; - for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { - const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; - const value = Math.min( - previous[rightIndex] + 1, - current[rightIndex - 1] + 1, - previous[rightIndex - 1] + cost, - ); - current[rightIndex] = value; - rowBest = Math.min(rowBest, value); - } - if (rowBest > maxDistance) return false; - previous = current; - } - - return previous[right.length] <= maxDistance; -} - function scoreWordForToken(word: WordRange, token: QueryToken): TokenRange | null { if (word.value === token.value) { return { start: word.start, end: word.end, score: 120 }; @@ -163,11 +130,6 @@ function scoreWordForToken(word: WordRange, token: QueryToken): TokenRange | nul }; } - const maxDistance = token.value.length >= 7 ? 2 : 1; - if (editDistanceWithin(token.value, word.value, maxDistance)) { - return { start: word.start, end: word.end, score: 55 }; - } - return null; } @@ -183,10 +145,9 @@ function findBestTokenRange(words: WordRange[], token: QueryToken): TokenRange | return best; } -function matchTokenizedQuery(lower: string, tokens: QueryToken[]): TokenizedMatch | null { +function matchTokenizedQuery(words: WordRange[], tokens: QueryToken[]): TokenizedMatch | null { if (tokens.length === 0) return null; - const words = wordRanges(lower); const ranges: TokenRange[] = []; for (const token of tokens) { const range = findBestTokenRange(words, token); @@ -203,14 +164,16 @@ function matchTokenizedQuery(lower: string, tokens: QueryToken[]): TokenizedMatc } function buildTokenSnippet(text: string, ranges: TokenRange[]): SessionSearchSnippet { - const first = ranges[0]; - const last = ranges[ranges.length - 1]; - const start = Math.max(0, first.start - SNIPPET_BEFORE); - const end = Math.min(text.length, last.end + SNIPPET_AFTER); + const highlight = ranges.reduce((best, range) => { + if (range.score !== best.score) return range.score > best.score ? range : best; + return range.start < best.start ? range : best; + }, ranges[0]); + const start = Math.max(0, highlight.start - SNIPPET_BEFORE); + const end = Math.min(text.length, highlight.end + SNIPPET_AFTER); return { - before: `${start > 0 ? "..." : ""}${collapseWhitespace(text.slice(start, first.start)).trimStart()}`, - match: collapseWhitespace(text.slice(first.start, last.end)), - after: `${collapseWhitespace(text.slice(last.end, end)).trimEnd()}${end < text.length ? "..." : ""}`, + before: `${start > 0 ? "…" : ""}${collapseWhitespace(text.slice(start, highlight.start)).trimStart()}`, + match: collapseWhitespace(text.slice(highlight.start, highlight.end)), + after: `${collapseWhitespace(text.slice(highlight.end, end)).trimEnd()}${end < text.length ? "…" : ""}`, }; } @@ -224,7 +187,8 @@ function toCacheEntry(updatedAt: number, messages: OpenworkSessionMessage[]): Ca if (part.synthetic || part.ignored) continue; const text = part.text.trim(); if (!text) continue; - texts.push({ role, text, lower: text.toLowerCase() }); + const lower = text.toLowerCase(); + texts.push({ role, text, words: wordRanges(lower) }); } } return { updatedAt, texts }; @@ -237,7 +201,7 @@ function matchEntry( ): SessionSearchMatch | null { let best: { match: SessionSearchMatch; score: number } | null = null; for (const item of entry.texts) { - const tokenMatch = matchTokenizedQuery(item.lower, queryTokens); + const tokenMatch = matchTokenizedQuery(item.words, queryTokens); if (!tokenMatch) continue; const score = tokenMatch.score + (item.role === "user" ? 50 : 0); const match: SessionSearchMatch = { diff --git a/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx index b8efd3bc1..73e21f647 100644 --- a/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx +++ b/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx @@ -19,6 +19,7 @@ export type SidebarContextValue = { onOpenDeleteSession?: (sessionId: string) => void; onArchiveSession?: (sessionId: string, archived: boolean) => void; onOpenCreateGroupModal?: (workspaceId: string) => void; + onOpenSessionSearch?: () => void; onOpenRenameWorkspace: (workspaceId: string) => void; onShareWorkspace: (workspaceId: string) => void; onRevealWorkspace: (workspaceId: string) => void; diff --git a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx index 08bcc998b..4cc1d3341 100644 --- a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx +++ b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx @@ -12,6 +12,7 @@ import { Pin, PinOff, Plus, + Search, Share2, Trash2, RefreshCw, @@ -33,6 +34,7 @@ import { isRemoteConnectionErrorMessage, getWorkspaceTaskLoadErrorDisplay, isRemoteConnectionWorkspace, + isMacPlatform, isWindowsPlatform, } from "../../../../app/utils"; import { t } from "../../../../i18n"; @@ -573,6 +575,7 @@ export type AppSidebarProps = { onOpenDeleteSession?: (sessionId: string) => void; onArchiveSession?: (sessionId: string, archived: boolean) => void; onOpenCreateGroupModal?: (workspaceId: string) => void; + onOpenSessionSearch?: () => void; onOpenRenameWorkspace: (workspaceId: string) => void; onShareWorkspace: (workspaceId: string) => void; onRevealWorkspace: (workspaceId: string) => void; @@ -663,6 +666,7 @@ export function AppSidebar(props: AppSidebarProps) { [workspaceId]: Math.min((current[workspaceId] ?? MAX_SESSIONS_PREVIEW) + MAX_SESSIONS_PREVIEW, totalRoots), })); }; + const sessionSearchShortcut = isMacPlatform() ? "Cmd+Shift+F" : "Ctrl+Shift+F"; React.useEffect(() => { const workspaceId = props.selectedWorkspaceId.trim(); @@ -709,6 +713,7 @@ export function AppSidebar(props: AppSidebarProps) { onOpenDeleteSession: props.onOpenDeleteSession, onArchiveSession: props.onArchiveSession, onOpenCreateGroupModal: props.onOpenCreateGroupModal, + onOpenSessionSearch: props.onOpenSessionSearch, onOpenRenameWorkspace: props.onOpenRenameWorkspace, onShareWorkspace: props.onShareWorkspace, onRevealWorkspace: props.onRevealWorkspace, @@ -760,6 +765,18 @@ export function AppSidebar(props: AppSidebarProps) { + {props.onOpenSessionSearch ? ( + + + + {t("session.cmd_sessions_title")} + + + ) : null} diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index 56e1b20ab..e52e3643c 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -1671,6 +1671,7 @@ export function SessionRoute() { onCreateTaskInWorkspace: (workspaceId) => { void handleCreateTaskInWorkspace(workspaceId); }, + onOpenSessionSearch: () => setSessionSearchOpen(true), onCreateTaskWithPrompt: (workspaceId, prompt) => { void (async () => { const workspace = workspaces.find((item) => item.id === workspaceId); diff --git a/apps/app/tests/session-search.test.ts b/apps/app/tests/session-search.test.ts index dca8ad111..6dc8a4662 100644 --- a/apps/app/tests/session-search.test.ts +++ b/apps/app/tests/session-search.test.ts @@ -23,12 +23,17 @@ function message(role: "user" | "assistant", text: string) { async function search(query: string, fetchMessages: SessionMessageFetcher) { const searcher = createSessionSearcher(fetchMessages); - const results: string[] = []; + const results: Array<{ role: string; match: string; before: string; after: string }> = []; const run = searcher.search({ query, sessions: [session], onMatch: (match) => { - results.push(`${match.role ?? "unknown"}:${match.snippet?.match ?? ""}`); + results.push({ + role: match.role ?? "unknown", + match: match.snippet?.match ?? "", + before: match.snippet?.before ?? "", + after: match.snippet?.after ?? "", + }); }, onProgress: () => undefined, concurrency: 1, @@ -45,9 +50,11 @@ describe("session transcript search", () => { const results = await search("auth redirect", fetchMessages); - expect(results).toEqual([ - "assistant:redirect failed after the authentication", - ]); + expect(results[0]).toMatchObject({ + role: "assistant", + match: "redirect", + }); + expect(results[0]?.after).toContain("authentication"); }); test("prefers the user's own matching message", async () => { @@ -58,17 +65,40 @@ describe("session transcript search", () => { const results = await search("deploy vercel", fetchMessages); - expect(results[0]?.startsWith("user:")).toBe(true); + expect(results[0]?.role).toBe("user"); }); - test("matches small typos", async () => { + test("does not highlight the full span when remembered words are far apart", async () => { const fetchMessages: SessionMessageFetcher = async () => [ - message("assistant", "Updated the environment variable configuration."), + message("assistant", `deploy ${"lorem ipsum ".repeat(80)} vercel`), ]; - const results = await search("enviroment config", fetchMessages); + const results = await search("deploy vercel", fetchMessages); expect(results.length).toBe(1); + expect(results[0]?.match).toBe("deploy"); + expect(results[0]?.match).not.toContain("vercel"); + expect(results[0]?.match.length).toBeLessThan(20); + }); + + test("does not fuzzy-match short identifiers or numeric ids", async () => { + const searcher = createSessionSearcher(async () => [ + message("assistant", "Investigated issue 2331 in the node startup path."), + ]); + const results: string[] = []; + + for (const query of ["2332", "code"]) { + const run = searcher.search({ + query, + sessions: [session], + onMatch: (match) => results.push(match.snippet?.match ?? ""), + onProgress: () => undefined, + concurrency: 1, + }); + await run.done; + } + + expect(results).toEqual([]); }); test("matches non-ASCII query terms", async () => {