diff --git a/packages/mcp-core/src/tools/catalog/search-events.test.ts b/packages/mcp-core/src/tools/catalog/search-events.test.ts index 5f6a82246..8aa869ee0 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -3419,6 +3419,68 @@ describe("search_events", () => { expect(validatedQueries.at(-1)).not.toContain("message:"); }); + it.each([ + "environment:production is:unresolved", + // Parentheses inside a quoted value are not grouping. + 'environment:production message:"err (timeout)"', + ])( + "keeps explicit timestamp bounds the agent moved into an overridden time range: %s", + async (filters) => { + // The agent moves the bounds into an absolute timeRange, but the explicit + // period wins over it, so dropping them would search the whole period. + mockGenerateText.mockResolvedValueOnce( + mockAIResponse( + "errors", + filters, + ["count()", "count_unique(issue)"], + undefined, + "-count()", + { start: "2025-01-01T00:00:00Z", end: "2025-01-02T00:00:00Z" }, + ), + ); + const bounds = + "timestamp:>=2025-01-01T00:00:00Z timestamp:<2025-01-02T00:00:00Z"; + const requests: URLSearchParams[] = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + requests.push(new URL(request.url).searchParams); + return HttpResponse.json({ data: [] }); + }, + ), + ); + + await runSearchEvents( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: `${filters} ${bounds}`, + fields: ["count()", "count_unique(issue)"], + period: "7d", + limit: 10, + includeExplanation: false, + }, + { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }, + { lockDataset: true }, + ); + + expect(requests).toHaveLength(1); + expect(requests[0]!.get("statsPeriod")).toBe("7d"); + expect(requests[0]!.get("query")).toBe(`${filters} ${bounds}`); + }, + ); + it("rejects partial full-text downgrades when a duplicate structured key remains", async () => { // Set-based key comparison would miss this: custom remains present after // custom:foo is dropped into message full-text. diff --git a/packages/mcp-core/src/tools/support/search-events/search.ts b/packages/mcp-core/src/tools/support/search-events/search.ts index 2f070b83f..c13a0e768 100644 --- a/packages/mcp-core/src/tools/support/search-events/search.ts +++ b/packages/mcp-core/src/tools/support/search-events/search.ts @@ -351,6 +351,46 @@ function preservesSearchTokens(originalQuery: string, repairedQuery: string) { ); } +const TIME_BOUND_FILTER_PATTERN = /^timestamp:\S/; +const QUOTED_VALUE_PATTERN = /(["'])(?:\\.|(?!\1).)*\1/g; + +/** + * Put back explicit `timestamp:` bounds that the agent dropped from a + * structured query. + * + * The agent often moves them into an absolute `timeRange`, but an explicit + * `period` wins over that range, so dropping them silently widens the search to + * the whole period. Kept bounds only narrow the period, so they are always + * safe to send. Grouped or boolean queries are left as they are, because a + * bound appended outside its group would change what it means. + */ +function restoreDroppedTimeBounds( + originalQuery: string, + repairedQuery: string, +): string { + const originalTokens = tokenizeSearchQuery(originalQuery); + if ( + originalTokens.some( + (token) => + token === "OR" || + token === "AND" || + // Parentheses inside a quoted value, e.g. message:"err (timeout)", + // are not grouping. + /[()]/.test(token.replace(QUOTED_VALUE_PATTERN, "")), + ) + ) { + return repairedQuery; + } + + return originalTokens + .filter( + (token) => + TIME_BOUND_FILTER_PATTERN.test(token) && + !containsSearchToken(repairedQuery, token), + ) + .reduce((query, token) => appendSearchFilter(query, token), repairedQuery); +} + function choosePreservingRepairedQuery(params: { originalQuery: string; repairedQuery?: string | null; @@ -917,13 +957,14 @@ async function executeSearchEvents( filter: environmentFilter, dataset, }) - : looksLikeSentrySearchSyntax(params.query, dataset) && - isSemanticFilterDowngrade( + : looksLikeSentrySearchSyntax(params.query, dataset) + ? isSemanticFilterDowngrade( params.query ?? "", parsed.query || "", dataset, ) - ? (params.query ?? "") + ? (params.query ?? "") + : restoreDroppedTimeBounds(params.query ?? "", parsed.query || "") : parsed.query || ""; sortParam = shouldTrustExplicitSearchParams && explicitSort