Skip to content

Commit b0d2abe

Browse files
Mihir-MavalankarClaude Opus 4.6
andcommitted
fix(seer): Replace internal Unicode operators with readable labels in LLM context
Widget conditions use \uf00d-delimited operators (e.g. Containsqueue.task) that are invisible but garble the Seer agent's understanding, causing broken tool calls. Replace these with readable labels (e.g. " contains ") via simple string replacement that preserves all query structure — AND, OR, parens, free text, and comparison operators pass through unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@example.com>
1 parent d0d69d2 commit b0d2abe

File tree

3 files changed

+142
-3
lines changed

3 files changed

+142
-3
lines changed

static/app/views/dashboards/widgetCard/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ import {
6464
useTransactionsDeprecationWarning,
6565
} from './widgetCardContextMenu';
6666
import {WidgetFrame} from './widgetFrame';
67-
import {getWidgetQueryLLMHint} from './widgetLLMContext';
67+
import {getWidgetQueryLLMHint, readableConditions} from './widgetLLMContext';
6868

6969
export type OnDataFetchedParams = {
7070
tableResults?: TableDataWithTitle[];
@@ -165,7 +165,7 @@ function WidgetCard(props: Props) {
165165
queryHint: getWidgetQueryLLMHint(resolvedDisplayType),
166166
queries: props.widget.queries.map(q => ({
167167
name: q.name,
168-
conditions: q.conditions,
168+
conditions: readableConditions(q.conditions),
169169
aggregates: q.aggregates,
170170
columns: q.columns,
171171
orderby: q.orderby,

static/app/views/dashboards/widgetCard/widgetLLMContext.spec.tsx

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {DisplayType} from 'sentry/views/dashboards/types';
22

3-
import {getWidgetQueryLLMHint} from './widgetLLMContext';
3+
import {getWidgetQueryLLMHint, readableConditions} from './widgetLLMContext';
44

55
describe('getWidgetQueryLLMHint', () => {
66
it.each([
@@ -26,3 +26,125 @@ describe('getWidgetQueryLLMHint', () => {
2626
expect(getWidgetQueryLLMHint(DisplayType.WHEEL)).toContain('table query');
2727
});
2828
});
29+
30+
describe('readableConditions', () => {
31+
it('replaces Contains operator with readable label', () => {
32+
expect(readableConditions('span.name:\uf00dContains\uf00dfoo')).toBe(
33+
'span.name: contains foo'
34+
);
35+
});
36+
37+
it('replaces Contains with IN list brackets', () => {
38+
expect(readableConditions('span.name:\uf00dContains\uf00d[a,b,c]')).toBe(
39+
'span.name: contains [a,b,c]'
40+
);
41+
});
42+
43+
it('replaces DoesNotContain operator', () => {
44+
expect(readableConditions('key:\uf00dDoesNotContain\uf00dval')).toBe(
45+
'key: does not contain val'
46+
);
47+
});
48+
49+
it('replaces StartsWith and EndsWith operators', () => {
50+
expect(readableConditions('key:\uf00dStartsWith\uf00d/api')).toBe(
51+
'key: starts with /api'
52+
);
53+
expect(readableConditions('key:\uf00dEndsWith\uf00d.json')).toBe(
54+
'key: ends with .json'
55+
);
56+
});
57+
58+
it('replaces DoesNotStartWith and DoesNotEndWith operators', () => {
59+
expect(readableConditions('key:\uf00dDoesNotStartWith\uf00d/api')).toBe(
60+
'key: does not start with /api'
61+
);
62+
expect(readableConditions('key:\uf00dDoesNotEndWith\uf00d.json')).toBe(
63+
'key: does not end with .json'
64+
);
65+
});
66+
67+
it('preserves negated filter prefix', () => {
68+
expect(readableConditions('!path:\uf00dContains\uf00dfoo')).toBe(
69+
'!path: contains foo'
70+
);
71+
});
72+
73+
it('replaces multiple operators in one string', () => {
74+
const input =
75+
'span.name:\uf00dContains\uf00dqueue.task !trigger_path:\uf00dContains\uf00dold_seer';
76+
expect(readableConditions(input)).toBe(
77+
'span.name: contains queue.task !trigger_path: contains old_seer'
78+
);
79+
});
80+
81+
it('passes through plain filters unchanged', () => {
82+
expect(readableConditions('browser.name:Firefox')).toBe('browser.name:Firefox');
83+
});
84+
85+
it('passes through free text unchanged', () => {
86+
expect(readableConditions('some free text')).toBe('some free text');
87+
});
88+
89+
it('passes through empty string', () => {
90+
expect(readableConditions('')).toBe('');
91+
});
92+
93+
it('preserves OR and parentheses', () => {
94+
expect(readableConditions('(a:1 OR b:2) error')).toBe('(a:1 OR b:2) error');
95+
});
96+
97+
it('preserves comparison operators', () => {
98+
expect(readableConditions('count():>100 duration:<=5s')).toBe(
99+
'count():>100 duration:<=5s'
100+
);
101+
});
102+
103+
it('handles real-world widget query', () => {
104+
const input =
105+
'span.description:\uf00dContains\uf00d[sentry.tasks.autofix.generate_issue_summary_only,sentry.tasks.autofix.run_automation_only_task] span.name:\uf00dContains\uf00dqueue.task.taskworker !trigger_path:\uf00dContains\uf00dold_seer_automation';
106+
expect(readableConditions(input)).toBe(
107+
'span.description: contains [sentry.tasks.autofix.generate_issue_summary_only,sentry.tasks.autofix.run_automation_only_task] span.name: contains queue.task.taskworker !trigger_path: contains old_seer_automation'
108+
);
109+
});
110+
111+
it('does not replace DoesNotContain partially as Contains', () => {
112+
// DoesNotContain must be replaced before Contains to avoid partial match
113+
expect(readableConditions('key:\uf00dDoesNotContain\uf00dval')).toBe(
114+
'key: does not contain val'
115+
);
116+
// Should NOT produce "key: does not contains val" or "key:DoesNot contains val"
117+
expect(readableConditions('key:\uf00dDoesNotContain\uf00dval')).not.toContain(
118+
'\uf00d'
119+
);
120+
});
121+
122+
it('handles mixed operator types in one query', () => {
123+
const input =
124+
'url:\uf00dStartsWith\uf00d/api span.description:\uf00dContains\uf00dfoo !path:\uf00dDoesNotEndWith\uf00d.js';
125+
expect(readableConditions(input)).toBe(
126+
'url: starts with /api span.description: contains foo !path: does not end with .js'
127+
);
128+
});
129+
130+
it('handles the same operator appearing multiple times', () => {
131+
const input =
132+
'a:\uf00dContains\uf00dfoo b:\uf00dContains\uf00dbar c:\uf00dContains\uf00dbaz';
133+
expect(readableConditions(input)).toBe(
134+
'a: contains foo b: contains bar c: contains baz'
135+
);
136+
});
137+
138+
it('preserves OR with wildcard operators inside parens', () => {
139+
const input =
140+
'(span.name:\uf00dContains\uf00dfoo OR span.name:\uf00dContains\uf00dbar)';
141+
expect(readableConditions(input)).toBe(
142+
'(span.name: contains foo OR span.name: contains bar)'
143+
);
144+
});
145+
146+
it('does not replace literal "Contains" text without unicode markers', () => {
147+
// The word "Contains" in a value or free text should NOT be replaced
148+
expect(readableConditions('message:Contains error')).toBe('message:Contains error');
149+
});
150+
});

static/app/views/dashboards/widgetCard/widgetLLMContext.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
import {DisplayType} from 'sentry/views/dashboards/types';
22

3+
/**
4+
* Replace internal \uf00d-delimited wildcard operators with readable labels
5+
* so the Seer Explorer agent can understand widget filter conditions.
6+
*
7+
* All other query structure (AND, OR, parens, free text, comparison operators)
8+
* passes through unchanged since only wildcard operators use \uf00d markers.
9+
*/
10+
export function readableConditions(query: string): string {
11+
return query
12+
.replaceAll('\uf00dDoesNotContain\uf00d', ' does not contain ')
13+
.replaceAll('\uf00dDoesNotStartWith\uf00d', ' does not start with ')
14+
.replaceAll('\uf00dDoesNotEndWith\uf00d', ' does not end with ')
15+
.replaceAll('\uf00dContains\uf00d', ' contains ')
16+
.replaceAll('\uf00dStartsWith\uf00d', ' starts with ')
17+
.replaceAll('\uf00dEndsWith\uf00d', ' ends with ');
18+
}
19+
320
/**
421
* Returns a hint for the Seer Explorer agent describing how to re-query this
522
* widget's data using a tool call, if the user wants to dig deeper.

0 commit comments

Comments
 (0)