Skip to content

Commit d78054b

Browse files
fix(web): build agent search queries as IR (#1573)
* fix(web): build agent search queries as IR * chore: update changelog for #1573 * fix(web): omit search query from tool metadata * test: split search tool tests
1 parent 8aae78d commit d78054b

7 files changed

Lines changed: 425 additions & 59 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Fixed a server-side memory leak where a single shared react-query cache retained state from every server render; the cache is now created per-request. [#1575](https://github.com/sourcebot-dev/sourcebot/pull/1575)
2222
- Fixed code host retry warnings to include the HTTP response status. [#1576](https://github.com/sourcebot-dev/sourcebot/pull/1576)
2323
- Fixed streamed code search updates silently cancelling in-flight result navigation. [#1577](https://github.com/sourcebot-dev/sourcebot/pull/1577)
24+
- Fixed the `grep` and `glob` agent tools mis-parsing structured search inputs containing spaces, commas, or quotes. [#1573](https://github.com/sourcebot-dev/sourcebot/pull/1573)
2425

2526
## [5.1.6] - 2026-08-10
2627

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const mocks = vi.hoisted(() => ({
4+
search: vi.fn(),
5+
getRepoInfoByName: vi.fn(),
6+
}));
7+
8+
vi.mock('@/features/search', () => ({
9+
search: mocks.search,
10+
}));
11+
12+
vi.mock('@/actions', () => ({
13+
getRepoInfoByName: mocks.getRepoInfoByName,
14+
}));
15+
16+
vi.mock('@/lib/utils', () => ({
17+
isServiceError: () => false,
18+
}));
19+
20+
vi.mock('./logger', () => ({
21+
logger: { debug: vi.fn() },
22+
}));
23+
24+
import { globDefinition } from './glob';
25+
import { buildGlobSearchQuery } from './searchQuery';
26+
27+
const emptySearchResponse = {
28+
files: [],
29+
repositoryInfo: [],
30+
stats: { actualMatchCount: 0 },
31+
isSearchExhaustive: true,
32+
};
33+
34+
describe('glob', () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks();
37+
mocks.search.mockResolvedValue(emptySearchResponse);
38+
});
39+
40+
it('executes with QueryIR', async () => {
41+
const result = await globDefinition.execute({
42+
pattern: 'My Folder/**/*.ts',
43+
ref: 'feature/my feature',
44+
}, {
45+
source: 'test',
46+
selectedRepos: ['Repo One'],
47+
});
48+
49+
expect(mocks.search).toHaveBeenCalledWith({
50+
queryType: 'ir',
51+
query: buildGlobSearchQuery({
52+
pattern: 'My Folder/**/*.ts',
53+
ref: 'feature/my feature',
54+
selectedRepos: ['Repo One'],
55+
}),
56+
options: {
57+
matches: 100,
58+
contextLines: 0,
59+
},
60+
source: 'test',
61+
});
62+
expect(result.metadata).not.toHaveProperty('query');
63+
});
64+
});

packages/web/src/features/tools/glob.ts

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,16 @@
11
import { z } from "zod";
2-
import globToRegexp from "glob-to-regexp";
32
import { isServiceError } from "@/lib/utils";
43
import { search } from "@/features/search";
5-
import escapeStringRegexp from "escape-string-regexp";
64
import { Source, ToolDefinition } from "./types";
75
import { logger } from "./logger";
86
import description from "./glob.txt";
97
import { CodeHostType } from "@sourcebot/db";
108
import { getRepoInfoByName } from "@/actions";
9+
import { buildGlobSearchQuery } from "./searchQuery";
1110

1211
const DEFAULT_LIMIT = 100;
1312
const TRUNCATION_MESSAGE = `(Results truncated. Consider using a more specific pattern, specifying a repo, or increasing the limit.)`;
1413

15-
function globToFileRegexp(glob: string): string {
16-
const re = globToRegexp(glob, { extended: true, globstar: true });
17-
return re.source.replace(/^\^/, '');
18-
}
19-
2014
const globShape = {
2115
pattern: z
2216
.string()
@@ -55,7 +49,6 @@ export type GlobRepoInfo = {
5549
export type GlobMetadata = {
5650
files: GlobFile[];
5751
pattern: string;
58-
query: string;
5952
fileCount: number;
6053
repoCount: number;
6154
repoInfoMap: Record<string, GlobRepoInfo>;
@@ -81,30 +74,20 @@ export const globDefinition: ToolDefinition<'glob', typeof globShape, GlobMetada
8174

8275
logger.debug('glob', { pattern, repo, ref, path, limit });
8376

84-
let query = `file:${globToFileRegexp(pattern)}`;
85-
86-
if (path) {
87-
query += ` file:${escapeStringRegexp(path)}`;
88-
}
89-
90-
if (repo) {
91-
query += ` repo:${escapeStringRegexp(repo)}`;
92-
} else if (context.selectedRepos && context.selectedRepos.length > 0) {
93-
query += ` reposet:${context.selectedRepos.join(',')}`;
94-
}
95-
96-
if (ref) {
97-
query += ` rev:${ref}`;
98-
}
77+
const query = buildGlobSearchQuery({
78+
pattern,
79+
path,
80+
repo,
81+
ref,
82+
selectedRepos: context.selectedRepos,
83+
});
9984

10085
const response = await search({
101-
queryType: 'string',
86+
queryType: 'ir',
10287
query,
10388
options: {
10489
matches: limit,
10590
contextLines: 0,
106-
isCaseSensitivityEnabled: true,
107-
isRegexEnabled: true,
10891
},
10992
source: context.source,
11093
});
@@ -144,7 +127,6 @@ export const globDefinition: ToolDefinition<'glob', typeof globShape, GlobMetada
144127
const metadata: GlobMetadata = {
145128
files,
146129
pattern,
147-
query,
148130
fileCount: files.length,
149131
repoCount: new Set(files.map((f) => f.repo)).size,
150132
repoInfoMap,
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const mocks = vi.hoisted(() => ({
4+
search: vi.fn(),
5+
getRepoInfoByName: vi.fn(),
6+
}));
7+
8+
vi.mock('@/features/search', () => ({
9+
search: mocks.search,
10+
}));
11+
12+
vi.mock('@/actions', () => ({
13+
getRepoInfoByName: mocks.getRepoInfoByName,
14+
}));
15+
16+
vi.mock('@/lib/utils', () => ({
17+
isServiceError: () => false,
18+
}));
19+
20+
vi.mock('./logger', () => ({
21+
logger: { debug: vi.fn() },
22+
}));
23+
24+
import { grepDefinition } from './grep';
25+
import { buildGrepSearchQuery } from './searchQuery';
26+
27+
const emptySearchResponse = {
28+
files: [],
29+
repositoryInfo: [],
30+
stats: { actualMatchCount: 0 },
31+
isSearchExhaustive: true,
32+
};
33+
34+
describe('grep', () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks();
37+
mocks.search.mockResolvedValue(emptySearchResponse);
38+
});
39+
40+
it('executes with QueryIR', async () => {
41+
const result = await grepDefinition.execute({
42+
pattern: 'needle',
43+
path: 'src/my dir',
44+
limit: 25,
45+
}, {
46+
source: 'test',
47+
selectedRepos: ['Repo One'],
48+
});
49+
50+
expect(mocks.search).toHaveBeenCalledWith({
51+
queryType: 'ir',
52+
query: buildGrepSearchQuery({
53+
pattern: 'needle',
54+
path: 'src/my dir',
55+
selectedRepos: ['Repo One'],
56+
}),
57+
options: {
58+
matches: 25,
59+
contextLines: 0,
60+
},
61+
source: 'test',
62+
});
63+
expect(result.metadata).not.toHaveProperty('query');
64+
});
65+
});

packages/web/src/features/tools/grep.ts

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,19 @@
11
import { z } from "zod";
2-
import globToRegexp from "glob-to-regexp";
32
import { isServiceError } from "@/lib/utils";
43
import { search } from "@/features/search";
5-
import escapeStringRegexp from "escape-string-regexp";
64
import { Source, ToolDefinition } from "./types";
75
import { logger } from "./logger";
86
import description from "./grep.txt";
97
import { CodeHostType } from "@sourcebot/db";
108
import { getRepoInfoByName } from "@/actions";
9+
import { buildGrepSearchQuery } from "./searchQuery";
1110

1211
const DEFAULT_LIMIT = 100;
1312
const DEFAULT_GROUP_BY_REPO_LIMIT = 10_000;
1413
const MAX_LINE_LENGTH = 2000;
1514
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
1615
const TRUNCATION_MESSAGE = `(Results truncated. Consider using a more specific path or pattern, specifying a repo, or increasing the limit.)`;
1716

18-
function globToFileRegexp(glob: string): string {
19-
const re = globToRegexp(glob, { extended: true, globstar: true });
20-
return re.source.replace(/^\^/, '');
21-
}
22-
2317
const grepShape = {
2418
pattern: z
2519
.string()
@@ -66,7 +60,6 @@ export type GrepRepoInfo = {
6660
export type GrepMetadata = {
6761
files: GrepFile[];
6862
pattern: string;
69-
query: string;
7063
matchCount: number;
7164
repoCount: number;
7265
repoInfoMap: Record<string, GrepRepoInfo>;
@@ -95,35 +88,21 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada
9588

9689
logger.debug('grep', { pattern, path, include, repo, ref, limit, groupByRepo });
9790

98-
const quotedPattern = `"${pattern.replace(/"/g, '\\"')}"`;
99-
let query = quotedPattern;
100-
101-
if (path) {
102-
query += ` file:${escapeStringRegexp(path)}`;
103-
}
104-
105-
if (include) {
106-
query += ` file:${globToFileRegexp(include)}`;
107-
}
108-
109-
if (repo) {
110-
query += ` repo:${escapeStringRegexp(repo)}`;
111-
} else if (context.selectedRepos && context.selectedRepos.length > 0) {
112-
query += ` reposet:${context.selectedRepos.join(',')}`;
113-
}
114-
115-
if (ref) {
116-
query += ` rev:${ref}`;
117-
}
91+
const query = buildGrepSearchQuery({
92+
pattern,
93+
path,
94+
include,
95+
repo,
96+
ref,
97+
selectedRepos: context.selectedRepos,
98+
});
11899

119100
const response = await search({
120-
queryType: 'string',
101+
queryType: 'ir',
121102
query,
122103
options: {
123104
matches: limit,
124105
contextLines: 0,
125-
isCaseSensitivityEnabled: true,
126-
isRegexEnabled: true,
127106
},
128107
source: context.source,
129108
});
@@ -161,7 +140,6 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada
161140
const metadata: GrepMetadata = {
162141
files,
163142
pattern,
164-
query,
165143
matchCount: response.stats.actualMatchCount,
166144
repoCount: new Set(files.map((f) => f.repo)).size,
167145
repoInfoMap,

0 commit comments

Comments
 (0)