feat(ask_sb): Improved search tool ; tools for listing / searching repos ; removed search scope constraint (#400)

* wip

* Add additional tools for repo searching and listing

* Remove search scope constraint

* Only show the selected search scopes when there is > 0

* changelog

* fix build
This commit is contained in:
Brendan Kellam 2025-07-29 10:41:01 -07:00 committed by GitHub
parent 4343b3c3d5
commit 211ad8fb12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 490 additions and 96 deletions

View file

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Search context refactor to search scope and demo card UI changes. [#405](https://github.com/sourcebot-dev/sourcebot/pull/405)
- Add GitHub star toast. [#409](https://github.com/sourcebot-dev/sourcebot/pull/409)
- Added a onboarding modal when first visiting the homepage when `ask` mode is selected. [#408](https://github.com/sourcebot-dev/sourcebot/pull/408)
- [ask sb] Added `searchReposTool` and `listAllReposTool`. [#400](https://github.com/sourcebot-dev/sourcebot/pull/400)
### Fixed
- Fixed multiple writes race condition on config file watcher. [#398](https://github.com/sourcebot-dev/sourcebot/pull/398)
@ -21,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bumped AI SDK and associated packages version. [#404](https://github.com/sourcebot-dev/sourcebot/pull/404)
- Bumped form-data package version. [#407](https://github.com/sourcebot-dev/sourcebot/pull/407)
- Bumped next version. [#406](https://github.com/sourcebot-dev/sourcebot/pull/406)
- [ask sb] Improved search code tool with filter options. [#400](https://github.com/sourcebot-dev/sourcebot/pull/400)
- [ask sb] Removed search scope constraint. [#400](https://github.com/sourcebot-dev/sourcebot/pull/400)
## [4.6.0] - 2025-07-25

View file

@ -51,7 +51,6 @@ export const NewChatPanel = ({
languageModels={languageModels}
selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
/>
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
<ChatBoxToolbar

View file

@ -58,7 +58,6 @@ export const AgenticSearch = ({
languageModels={languageModels}
selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
/>
<Separator />
<div className="relative">

View file

@ -6,7 +6,7 @@ import { ProviderOptions } from "@ai-sdk/provider-utils";
import { createLogger } from "@sourcebot/logger";
import { LanguageModel, ModelMessage, StopCondition, streamText } from "ai";
import { ANSWER_TAG, FILE_REFERENCE_PREFIX, toolNames } from "./constants";
import { createCodeSearchTool, findSymbolDefinitionsTool, findSymbolReferencesTool, readFilesTool } from "./tools";
import { createCodeSearchTool, findSymbolDefinitionsTool, findSymbolReferencesTool, readFilesTool, searchReposTool, listAllReposTool } from "./tools";
import { FileSource, Source } from "./types";
import { addLineNumbers, fileReferenceToString } from "./utils";
@ -54,6 +54,8 @@ export const createAgentStream = async ({
[toolNames.readFiles]: readFilesTool,
[toolNames.findSymbolReferences]: findSymbolReferencesTool,
[toolNames.findSymbolDefinitions]: findSymbolDefinitionsTool,
[toolNames.searchRepos]: searchReposTool,
[toolNames.listAllRepos]: listAllReposTool,
},
prepareStep: async ({ stepNumber }) => {
// The first step attaches any mentioned sources to the system prompt.
@ -185,13 +187,7 @@ ${searchScopeRepoNames.map(repo => `- ${repo}`).join('\n')}
</available_repositories>
<research_phase_instructions>
During the research phase, you have these tools available:
- \`${toolNames.searchCode}\`: Search for code patterns, functions, or text across repositories
- \`${toolNames.readFiles}\`: Read the contents of specific files
- \`${toolNames.findSymbolReferences}\`: Find where symbols are referenced
- \`${toolNames.findSymbolDefinitions}\`: Find where symbols are defined
Use these tools to gather comprehensive context before answering. Always explain why you're using each tool.
During the research phase, use the tools available to you to gather comprehensive context before answering. Always explain why you're using each tool. Depending on the user's question, you may need to use multiple tools. If the question is vague, ask the user for more information.
</research_phase_instructions>
${answerInstructions}

View file

@ -5,9 +5,10 @@ import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { CustomEditor, LanguageModelInfo, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types";
import { insertMention, slateContentToString } from "@/features/chat/utils";
import { SearchContextQuery } from "@/lib/types";
import { cn, IS_MAC } from "@/lib/utils";
import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react";
import { ArrowUp, Loader2, StopCircleIcon, TriangleAlertIcon } from "lucide-react";
import { ArrowUp, Loader2, StopCircleIcon } from "lucide-react";
import { Fragment, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { Descendant, insertText } from "slate";
@ -17,8 +18,6 @@ import { SuggestionBox } from "./suggestionsBox";
import { Suggestion } from "./types";
import { useSuggestionModeAndQuery } from "./useSuggestionModeAndQuery";
import { useSuggestionsData } from "./useSuggestionsData";
import { useToast } from "@/components/hooks/use-toast";
import { SearchContextQuery } from "@/lib/types";
interface ChatBoxProps {
onSubmit: (children: Descendant[], editor: CustomEditor) => void;
@ -30,7 +29,6 @@ interface ChatBoxProps {
languageModels: LanguageModelInfo[];
selectedSearchScopes: SearchScope[];
searchContexts: SearchContextQuery[];
onContextSelectorOpenChanged: (isOpen: boolean) => void;
}
export const ChatBox = ({
@ -43,7 +41,6 @@ export const ChatBox = ({
languageModels,
selectedSearchScopes,
searchContexts,
onContextSelectorOpenChanged,
}: ChatBoxProps) => {
const suggestionsBoxRef = useRef<HTMLDivElement>(null);
const [index, setIndex] = useState(0);
@ -70,7 +67,6 @@ export const ChatBox = ({
const { selectedLanguageModel } = useSelectedLanguageModel({
initialLanguageModel: languageModels.length > 0 ? languageModels[0] : undefined,
});
const { toast } = useToast();
// Reset the index when the suggestion mode changes.
useEffect(() => {
@ -101,9 +97,9 @@ export const ChatBox = ({
return <Leaf {...props} />
}, []);
const { isSubmitDisabled, isSubmitDisabledReason } = useMemo((): {
const { isSubmitDisabled } = useMemo((): {
isSubmitDisabled: true,
isSubmitDisabledReason: "empty" | "redirecting" | "generating" | "no-repos-selected" | "no-language-model-selected"
isSubmitDisabledReason: "empty" | "redirecting" | "generating" | "no-language-model-selected"
} | {
isSubmitDisabled: false,
isSubmitDisabledReason: undefined,
@ -129,13 +125,6 @@ export const ChatBox = ({
}
}
if (selectedSearchScopes.length === 0) {
return {
isSubmitDisabled: true,
isSubmitDisabledReason: "no-repos-selected",
}
}
if (selectedLanguageModel === undefined) {
return {
@ -149,29 +138,11 @@ export const ChatBox = ({
isSubmitDisabledReason: undefined,
}
}, [
editor.children,
isRedirecting,
isGenerating,
selectedSearchScopes.length,
selectedLanguageModel,
])
}, [editor.children, isRedirecting, isGenerating, selectedLanguageModel])
const onSubmit = useCallback(() => {
if (isSubmitDisabled) {
if (isSubmitDisabledReason === "no-repos-selected") {
toast({
description: "⚠️ You must select at least one search scope",
variant: "destructive",
});
onContextSelectorOpenChanged(true);
}
return;
}
_onSubmit(editor.children, editor);
}, [_onSubmit, editor, isSubmitDisabled, isSubmitDisabledReason, toast, onContextSelectorOpenChanged]);
}, [_onSubmit, editor]);
const onInsertSuggestion = useCallback((suggestion: Suggestion) => {
switch (suggestion.type) {
@ -310,39 +281,15 @@ export const ChatBox = ({
Stop
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<div
onClick={() => {
// @hack: When submission is disabled, we still want to issue
// a warning to the user as to why the submission is disabled.
// onSubmit on the Button will not be called because of the
// disabled prop, hence the call here.
if (isSubmitDisabled) {
onSubmit();
}
}}
>
<Button
variant={isSubmitDisabled ? "outline" : "default"}
size="sm"
className="w-6 h-6"
onClick={onSubmit}
disabled={isSubmitDisabled}
>
<ArrowUp className="w-4 h-4" />
</Button>
</div>
</TooltipTrigger>
{(isSubmitDisabled && isSubmitDisabledReason === "no-repos-selected") && (
<TooltipContent>
<div className="flex flex-row items-center">
<TriangleAlertIcon className="h-4 w-4 text-warning mr-1" />
<span className="text-destructive">You must select at least one search scope</span>
</div>
</TooltipContent>
)}
</Tooltip>
<Button
variant={isSubmitDisabled ? "outline" : "default"}
size="sm"
className="w-6 h-6"
onClick={onSubmit}
disabled={isSubmitDisabled}
>
<ArrowUp className="w-4 h-4" />
</Button>
)}
</div>
{suggestionMode !== "none" && (

View file

@ -321,7 +321,6 @@ export const ChatThread = ({
languageModels={languageModels}
selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts}
onContextSelectorOpenChanged={setIsContextSelectorOpen}
/>
<div className="w-full flex flex-row items-center bg-accent rounded-b-md px-2">
<ChatBoxToolbar

View file

@ -12,6 +12,8 @@ import { FindSymbolDefinitionsToolComponent } from './tools/findSymbolDefinition
import { FindSymbolReferencesToolComponent } from './tools/findSymbolReferencesToolComponent';
import { ReadFilesToolComponent } from './tools/readFilesToolComponent';
import { SearchCodeToolComponent } from './tools/searchCodeToolComponent';
import { SearchReposToolComponent } from './tools/searchReposToolComponent';
import { ListAllReposToolComponent } from './tools/listAllReposToolComponent';
import { SBChatMessageMetadata, SBChatMessagePart } from '../../types';
import { SearchScopeIcon } from '../searchScopeIcon';
@ -63,7 +65,7 @@ export const DetailsCard = ({
{!isStreaming && (
<>
<Separator orientation="vertical" className="h-4" />
{metadata?.selectedSearchScopes && (
{(metadata?.selectedSearchScopes && metadata.selectedSearchScopes.length > 0) && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center text-xs cursor-help">
@ -181,6 +183,20 @@ export const DetailsCard = ({
part={part}
/>
)
case 'tool-searchRepos':
return (
<SearchReposToolComponent
key={index}
part={part}
/>
)
case 'tool-listAllRepos':
return (
<ListAllReposToolComponent
key={index}
part={part}
/>
)
default:
return null;
}

View file

@ -221,7 +221,7 @@ export const ReferencedSourcesListView = ({
<span className="text-sm font-medium">{fileName}</span>
</div>
<div className="p-4 text-sm text-destructive bg-destructive/10 rounded border">
Failed to load file: {isServiceError(query.data) ? query.data.message : 'Unknown error'}
Failed to load file: {isServiceError(query.data) ? query.data.message : query.error?.message ?? 'Unknown error'}
</div>
</div>
);

View file

@ -0,0 +1,66 @@
'use client';
import { ListAllReposToolUIPart } from "@/features/chat/tools";
import { isServiceError } from "@/lib/utils";
import { useMemo, useState } from "react";
import { ToolHeader, TreeList } from "./shared";
import { CodeSnippet } from "@/app/components/codeSnippet";
import { Separator } from "@/components/ui/separator";
import { FolderOpenIcon } from "lucide-react";
export const ListAllReposToolComponent = ({ part }: { part: ListAllReposToolUIPart }) => {
const [isExpanded, setIsExpanded] = useState(false);
const label = useMemo(() => {
switch (part.state) {
case 'input-streaming':
return 'Loading all repositories...';
case 'output-error':
return '"List all repositories" tool call failed';
case 'input-available':
case 'output-available':
return 'Listed all repositories';
}
}, [part]);
return (
<div className="my-4">
<ToolHeader
isLoading={part.state !== 'output-available' && part.state !== 'output-error'}
isError={part.state === 'output-error' || (part.state === 'output-available' && isServiceError(part.output))}
isExpanded={isExpanded}
label={label}
Icon={FolderOpenIcon}
onExpand={setIsExpanded}
/>
{part.state === 'output-available' && isExpanded && (
<>
{isServiceError(part.output) ? (
<TreeList>
<span>Failed with the following error: <CodeSnippet className="text-sm text-destructive">{part.output.message}</CodeSnippet></span>
</TreeList>
) : (
<>
{part.output.length === 0 ? (
<span className="text-sm text-muted-foreground ml-[25px]">No repositories found</span>
) : (
<TreeList>
<div className="text-sm text-muted-foreground mb-2">
Found {part.output.length} repositories:
</div>
{part.output.map((repoName, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
<FolderOpenIcon className="h-4 w-4 text-muted-foreground" />
<span className="truncate">{repoName}</span>
</div>
))}
</TreeList>
)}
</>
)}
<Separator className='ml-[7px] my-2' />
</>
)}
</div>
)
}

View file

@ -11,24 +11,38 @@ import { SearchIcon } from "lucide-react";
import Link from "next/link";
import { SearchQueryParams } from "@/lib/types";
import { PlayIcon } from "@radix-ui/react-icons";
import { buildSearchQuery } from "@/features/chat/utils";
export const SearchCodeToolComponent = ({ part }: { part: SearchCodeToolUIPart }) => {
const [isExpanded, setIsExpanded] = useState(false);
const domain = useDomain();
const displayQuery = useMemo(() => {
if (part.state !== 'input-available' && part.state !== 'output-available') {
return '';
}
const query = buildSearchQuery({
query: part.input.queryRegexp,
repoNamesFilterRegexp: part.input.repoNamesFilterRegexp,
languageNamesFilter: part.input.languageNamesFilter,
fileNamesFilterRegexp: part.input.fileNamesFilterRegexp,
});
return query;
}, [part]);
const label = useMemo(() => {
switch (part.state) {
case 'input-streaming':
return 'Searching...';
case 'input-available':
return <span>Searching for <CodeSnippet>{part.input.query}</CodeSnippet></span>;
case 'output-error':
return '"Search code" tool call failed';
case 'input-available':
case 'output-available':
return <span>Searched for <CodeSnippet>{part.input.query}</CodeSnippet></span>;
return <span>Searched for <CodeSnippet>{displayQuery}</CodeSnippet></span>;
}
}, [part]);
}, [part, displayQuery]);
return (
<div className="my-4">

View file

@ -0,0 +1,63 @@
'use client';
import { SearchReposToolUIPart } from "@/features/chat/tools";
import { isServiceError } from "@/lib/utils";
import { useMemo, useState } from "react";
import { ToolHeader, TreeList } from "./shared";
import { CodeSnippet } from "@/app/components/codeSnippet";
import { Separator } from "@/components/ui/separator";
import { BookMarkedIcon } from "lucide-react";
export const SearchReposToolComponent = ({ part }: { part: SearchReposToolUIPart }) => {
const [isExpanded, setIsExpanded] = useState(false);
const label = useMemo(() => {
switch (part.state) {
case 'input-streaming':
return 'Searching repositories...';
case 'output-error':
return '"Search repositories" tool call failed';
case 'input-available':
case 'output-available':
return <span>Searched for repositories: <CodeSnippet className="truncate">{part.input.query}</CodeSnippet></span>;
}
}, [part]);
return (
<div className="my-4">
<ToolHeader
isLoading={part.state !== 'output-available' && part.state !== 'output-error'}
isError={part.state === 'output-error' || (part.state === 'output-available' && isServiceError(part.output))}
isExpanded={isExpanded}
label={label}
Icon={BookMarkedIcon}
onExpand={setIsExpanded}
/>
{part.state === 'output-available' && isExpanded && (
<>
{isServiceError(part.output) ? (
<TreeList>
<span>Failed with the following error: <CodeSnippet className="text-sm text-destructive">{part.output.message}</CodeSnippet></span>
</TreeList>
) : (
<>
{part.output.length === 0 ? (
<span className="text-sm text-muted-foreground ml-[25px]">No repositories found</span>
) : (
<TreeList>
{part.output.map((repoName, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
<BookMarkedIcon className="h-4 w-4 text-muted-foreground" />
<span className="truncate">{repoName}</span>
</div>
))}
</TreeList>
)}
</>
)}
<Separator className='ml-[7px] my-2' />
</>
)}
</div>
)
}

View file

@ -14,6 +14,8 @@ export const toolNames = {
readFiles: 'readFiles',
findSymbolReferences: 'findSymbolReferences',
findSymbolDefinitions: 'findSymbolDefinitions',
searchRepos: 'searchRepos',
listAllRepos: 'listAllRepos',
} as const;
// These part types are visible in the UI.
@ -24,4 +26,6 @@ export const uiVisiblePartTypes: SBChatMessagePart['type'][] = [
'tool-readFiles',
'tool-findSymbolDefinitions',
'tool-findSymbolReferences',
'tool-searchRepos',
'tool-listAllRepos',
] as const;

View file

@ -6,8 +6,10 @@ import { isServiceError } from "@/lib/utils";
import { getFileSource } from "../search/fileSourceApi";
import { findSearchBasedSymbolDefinitions, findSearchBasedSymbolReferences } from "../codeNav/actions";
import { FileSourceResponse } from "../search/types";
import { addLineNumbers } from "./utils";
import { addLineNumbers, buildSearchQuery } from "./utils";
import { toolNames } from "./constants";
import { getRepos } from "@/actions";
import Fuse from "fuse.js";
// @NOTE: When adding a new tool, follow these steps:
// 1. Add the tool to the `toolNames` constant in `constants.ts`.
@ -139,17 +141,48 @@ export const createCodeSearchTool = (selectedRepos: string[]) => tool({
description: `Fetches code that matches the provided regex pattern in \`query\`. This is NOT a semantic search.
Results are returned as an array of matching files, with the file's URL, repository, and language.`,
inputSchema: z.object({
query: z.string().describe("The regex pattern to search for in the code"),
queryRegexp: z
.string()
.describe(`The regex pattern to search for in the code.
Queries consist of space-seperated regular expressions. Wrapping expressions in "" combines them. By default, a file must have at least one match for each expression to be included. Examples:
\`foo\` - Match files with regex /foo/
\`foo bar\` - Match files with regex /foo/ and /bar/
\`"foo bar"\` - Match files with regex /foo bar/
\`console.log\` - Match files with regex /console.log/
Multiple expressions can be or'd together with or, negated with -, or grouped with (). Examples:
\`foo or bar\` - Match files with regex /foo/ or /bar/
\`foo -bar\` - Match files with regex /foo/ but not /bar/
\`foo (bar or baz)\` - Match files with regex /foo/ and either /bar/ or /baz/
`),
repoNamesFilterRegexp: z
.array(z.string())
.describe(`Filter results from repos that match the regex. By default all repos are searched.`)
.optional(),
languageNamesFilter: z
.array(z.string())
.describe(`Scope the search to the provided languages. The language MUST be formatted as a GitHub linguist language. Examples: Python, JavaScript, TypeScript, Java, C#, C++, PHP, Go, Rust, Ruby, Swift, Kotlin, Shell, C, Dart, HTML, CSS, PowerShell, SQL, R`)
.optional(),
fileNamesFilterRegexp: z
.array(z.string())
.describe(`Filter results from filepaths that match the regex. When this option is not specified, all files are searched.`)
.optional(),
limit: z.number().default(10).describe("Maximum number of matches to return (default: 100)"),
}),
execute: async ({ query: _query }) => {
let query = `${_query}`;
if (selectedRepos.length > 0) {
query += ` reposet:${selectedRepos.join(',')}`;
}
execute: async ({ queryRegexp: _query, repoNamesFilterRegexp, languageNamesFilter, fileNamesFilterRegexp, limit }) => {
const query = buildSearchQuery({
query: _query,
repoNamesFilter: selectedRepos,
repoNamesFilterRegexp,
languageNamesFilter,
fileNamesFilterRegexp,
});
const response = await search({
query,
matches: 100,
matches: limit ?? 100,
// @todo: we can make this configurable.
contextLines: 3,
whole: false,
@ -180,3 +213,59 @@ export type SearchCodeTool = InferUITool<ReturnType<typeof createCodeSearchTool>
export type SearchCodeToolInput = InferToolInput<ReturnType<typeof createCodeSearchTool>>;
export type SearchCodeToolOutput = InferToolOutput<ReturnType<typeof createCodeSearchTool>>;
export type SearchCodeToolUIPart = ToolUIPart<{ [toolNames.searchCode]: SearchCodeTool }>;
export const searchReposTool = tool({
description: `Search for repositories by name using fuzzy search. This helps find repositories in the codebase when you know part of their name.`,
inputSchema: z.object({
query: z.string().describe("The search query to find repositories by name (supports fuzzy matching)"),
limit: z.number().default(10).describe("Maximum number of repositories to return (default: 10)")
}),
execute: async ({ query, limit }) => {
const reposResponse = await getRepos(SINGLE_TENANT_ORG_DOMAIN);
if (isServiceError(reposResponse)) {
return reposResponse;
}
// Configure Fuse.js for fuzzy searching
const fuse = new Fuse(reposResponse, {
keys: [
{ name: 'repoName', weight: 0.7 },
{ name: 'repoDisplayName', weight: 0.3 }
],
threshold: 0.4, // Lower threshold = more strict matching
includeScore: true,
minMatchCharLength: 1,
});
const searchResults = fuse.search(query, { limit: limit ?? 10 });
searchResults.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
return searchResults.map(({ item }) => item.repoName);
}
});
export type SearchReposTool = InferUITool<typeof searchReposTool>;
export type SearchReposToolInput = InferToolInput<typeof searchReposTool>;
export type SearchReposToolOutput = InferToolOutput<typeof searchReposTool>;
export type SearchReposToolUIPart = ToolUIPart<{ [toolNames.searchRepos]: SearchReposTool }>;
export const listAllReposTool = tool({
description: `Lists all repositories in the codebase. This provides a complete overview of all available repositories.`,
inputSchema: z.object({}),
execute: async () => {
const reposResponse = await getRepos(SINGLE_TENANT_ORG_DOMAIN);
if (isServiceError(reposResponse)) {
return reposResponse;
}
return reposResponse.map((repo) => repo.repoName);
}
});
export type ListAllReposTool = InferUITool<typeof listAllReposTool>;
export type ListAllReposToolInput = InferToolInput<typeof listAllReposTool>;
export type ListAllReposToolOutput = InferToolOutput<typeof listAllReposTool>;
export type ListAllReposToolUIPart = ToolUIPart<{ [toolNames.listAllRepos]: ListAllReposTool }>;

View file

@ -3,7 +3,7 @@ import { BaseEditor, Descendant } from "slate";
import { HistoryEditor } from "slate-history";
import { ReactEditor, RenderElementProps } from "slate-react";
import { z } from "zod";
import { FindSymbolDefinitionsTool, FindSymbolReferencesTool, ReadFilesTool, SearchCodeTool } from "./tools";
import { FindSymbolDefinitionsTool, FindSymbolReferencesTool, ReadFilesTool, SearchCodeTool, SearchReposTool, ListAllReposTool } from "./tools";
import { toolNames } from "./constants";
import { LanguageModel } from "@sourcebot/schemas/v3/index.type";
@ -83,6 +83,8 @@ export type SBChatMessageToolTypes = {
[toolNames.readFiles]: ReadFilesTool,
[toolNames.findSymbolReferences]: FindSymbolReferencesTool,
[toolNames.findSymbolDefinitions]: FindSymbolDefinitionsTool,
[toolNames.searchRepos]: SearchReposTool,
[toolNames.listAllRepos]: ListAllReposTool,
}
export type SBChatMessageDataParts = {

View file

@ -1,5 +1,5 @@
import { expect, test, vi } from 'vitest'
import { fileReferenceToString, getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences } from './utils'
import { fileReferenceToString, getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences, buildSearchQuery } from './utils'
import { FILE_REFERENCE_REGEX, ANSWER_TAG } from './constants';
import { SBChatMessage, SBChatMessagePart } from './types';
@ -350,4 +350,165 @@ test('repairReferences handles malformed inline code blocks', () => {
const input = 'See `@file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts`} for details.';
const expected = 'See @file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts} for details.';
expect(repairReferences(input)).toBe(expected);
});
test('buildSearchQuery returns base query when no filters provided', () => {
const result = buildSearchQuery({
query: 'console.log'
});
expect(result).toBe('console.log');
});
test('buildSearchQuery adds repoNamesFilter correctly', () => {
const result = buildSearchQuery({
query: 'function test',
repoNamesFilter: ['repo1', 'repo2']
});
expect(result).toBe('function test reposet:repo1,repo2');
});
test('buildSearchQuery adds single repoNamesFilter correctly', () => {
const result = buildSearchQuery({
query: 'function test',
repoNamesFilter: ['myrepo']
});
expect(result).toBe('function test reposet:myrepo');
});
test('buildSearchQuery ignores empty repoNamesFilter', () => {
const result = buildSearchQuery({
query: 'function test',
repoNamesFilter: []
});
expect(result).toBe('function test');
});
test('buildSearchQuery adds languageNamesFilter correctly', () => {
const result = buildSearchQuery({
query: 'class definition',
languageNamesFilter: ['typescript', 'javascript']
});
expect(result).toBe('class definition ( lang:typescript or lang:javascript )');
});
test('buildSearchQuery adds single languageNamesFilter correctly', () => {
const result = buildSearchQuery({
query: 'class definition',
languageNamesFilter: ['python']
});
expect(result).toBe('class definition ( lang:python )');
});
test('buildSearchQuery ignores empty languageNamesFilter', () => {
const result = buildSearchQuery({
query: 'class definition',
languageNamesFilter: []
});
expect(result).toBe('class definition');
});
test('buildSearchQuery adds fileNamesFilterRegexp correctly', () => {
const result = buildSearchQuery({
query: 'import statement',
fileNamesFilterRegexp: ['*.ts', '*.js']
});
expect(result).toBe('import statement ( file:*.ts or file:*.js )');
});
test('buildSearchQuery adds single fileNamesFilterRegexp correctly', () => {
const result = buildSearchQuery({
query: 'import statement',
fileNamesFilterRegexp: ['*.tsx']
});
expect(result).toBe('import statement ( file:*.tsx )');
});
test('buildSearchQuery ignores empty fileNamesFilterRegexp', () => {
const result = buildSearchQuery({
query: 'import statement',
fileNamesFilterRegexp: []
});
expect(result).toBe('import statement');
});
test('buildSearchQuery adds repoNamesFilterRegexp correctly', () => {
const result = buildSearchQuery({
query: 'bug fix',
repoNamesFilterRegexp: ['org/repo1', 'org/repo2']
});
expect(result).toBe('bug fix ( repo:org/repo1 or repo:org/repo2 )');
});
test('buildSearchQuery adds single repoNamesFilterRegexp correctly', () => {
const result = buildSearchQuery({
query: 'bug fix',
repoNamesFilterRegexp: ['myorg/myrepo']
});
expect(result).toBe('bug fix ( repo:myorg/myrepo )');
});
test('buildSearchQuery ignores empty repoNamesFilterRegexp', () => {
const result = buildSearchQuery({
query: 'bug fix',
repoNamesFilterRegexp: []
});
expect(result).toBe('bug fix');
});
test('buildSearchQuery combines multiple filters correctly', () => {
const result = buildSearchQuery({
query: 'authentication',
repoNamesFilter: ['backend', 'frontend'],
languageNamesFilter: ['typescript', 'javascript'],
fileNamesFilterRegexp: ['*.ts', '*.js'],
repoNamesFilterRegexp: ['org/auth-*']
});
expect(result).toBe(
'authentication reposet:backend,frontend ( lang:typescript or lang:javascript ) ( file:*.ts or file:*.js ) ( repo:org/auth-* )'
);
});
test('buildSearchQuery handles mixed empty and non-empty filters', () => {
const result = buildSearchQuery({
query: 'error handling',
repoNamesFilter: [],
languageNamesFilter: ['python'],
fileNamesFilterRegexp: [],
repoNamesFilterRegexp: ['error/*']
});
expect(result).toBe('error handling ( lang:python ) ( repo:error/* )');
});
test('buildSearchQuery handles empty base query', () => {
const result = buildSearchQuery({
query: '',
repoNamesFilter: ['repo1'],
languageNamesFilter: ['typescript']
});
expect(result).toBe(' reposet:repo1 ( lang:typescript )');
});
test('buildSearchQuery handles query with special characters', () => {
const result = buildSearchQuery({
query: 'console.log("hello world")',
repoNamesFilter: ['test-repo']
});
expect(result).toBe('console.log("hello world") reposet:test-repo');
});

View file

@ -329,4 +329,40 @@ export const getAnswerPartFromAssistantMessage = (message: SBChatMessage, isStre
}
return undefined;
}
export const buildSearchQuery = (options: {
query: string,
repoNamesFilter?: string[],
repoNamesFilterRegexp?: string[],
languageNamesFilter?: string[],
fileNamesFilterRegexp?: string[],
}) => {
const {
query: _query,
repoNamesFilter,
repoNamesFilterRegexp,
languageNamesFilter,
fileNamesFilterRegexp,
} = options;
let query = `${_query}`;
if (repoNamesFilter && repoNamesFilter.length > 0) {
query += ` reposet:${repoNamesFilter.join(',')}`;
}
if (languageNamesFilter && languageNamesFilter.length > 0) {
query += ` ( lang:${languageNamesFilter.join(' or lang:')} )`;
}
if (fileNamesFilterRegexp && fileNamesFilterRegexp.length > 0) {
query += ` ( file:${fileNamesFilterRegexp.join(' or file:')} )`;
}
if (repoNamesFilterRegexp && repoNamesFilterRegexp.length > 0) {
query += ` ( repo:${repoNamesFilterRegexp.join(' or repo:')} )`;
}
return query;
}