Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 40 additions & 23 deletions static/app/views/seerExplorer/callRecords.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,21 @@ import type {CallRecord} from 'sentry/views/seerExplorer/types';
*/

/**
* The title seer shipped for a call, or null when it shipped none.
* What a row reads as: the agent's own line when it wrote one, seer's title otherwise.
*
* A fallback, not a decision: a row whose call matches a rule in `links.tsx` is labeled by that rule
* instead. Returning null rather than the route or an operation id is deliberate — a raw identifier
* on screen is worse than one fewer row.
* The title is not discarded — `callRecordDetail` keeps what ran, so the line stays checkable.
* A row matching a rule in `links.tsx` is labeled by that rule instead.
*/
export function callRecordLabel(record: CallRecord): string | null {
return record.title?.trim() || null;
return record.llm_description?.trim() || record.title?.trim() || null;
}

/**
* A readable stand-in for a record nothing could name — generic, because a route or an operation
* id reads worse. Reported rather than dropped: a vanishing record is how an endpoint disappears.
*/
export function fallbackCallLabel(record: CallRecord): string {
return record.kind === 'api' ? t('Sentry API request') : t('Working…');
}

/**
Expand Down Expand Up @@ -71,23 +78,29 @@ export function callRecordDetail(record: CallRecord): {
body: string | null;
request: string;
} | null {
// A lib call is a heading for the api calls nested under it, and those carry the detail. Giving
// it its own expander would add a control that reveals less than the rows already below it.
if (record.kind !== 'api' || !record.method) {
// Built before any fallback: the literal URL beats a generated sentence as the account of
// what ran, which is what a described row needs to stay checkable.
if (record.kind === 'api' && record.method) {
const path = record.resolved_path ?? record.path;
if (path) {
// Seer composes the query string into `resolved_path`, so the request line is the whole URL —
// a list of params underneath would restate what the URL already says.
return {
request: `${record.method} ${path}`,
body: withEllipsis(record.body, record.body_truncated),
};
}
return null;
}

const path = record.resolved_path ?? record.path;
if (!path) {
return null;
// Nothing else ran a request of its own, so a described row falls back to the generated title
// — without it the description would be an unfalsifiable claim.
const described = record.llm_description?.trim();
const title = record.title?.trim();
if (described && title && described !== title) {
return {request: title, body: null};
}
Comment thread
cursor[bot] marked this conversation as resolved.

// Seer composes the query string into `resolved_path`, so the request line is the whole URL —
// a list of params underneath would restate what the URL already says.
return {
request: `${record.method} ${path}`,
body: withEllipsis(record.body, record.body_truncated),
};
return null;
}

// Query params that scope or format a request rather than describe what it looked for. Decomposing
Expand Down Expand Up @@ -233,6 +246,9 @@ const PREFER_LIB_OVER_CHILDREN = new Set(['get_span_details']);
* children. A lib call with no api children is kept — the Explorer-backed helpers (`code_search`,
* `bash`, `ask_user_question`) never touch the transport, so their own row is the only trace they
* leave. Helpers in `PREFER_LIB_OVER_CHILDREN` keep their own row and suppress children instead.
*
* A described parent inverts that premise — the heading now says what none of the requests
* underneath can — so it is kept and its children hidden. The description is what earns the row.
*/
export function visibleCallRecords(records: CallRecord[]): CallRecord[] {
const hasChildren = new Set(
Expand All @@ -241,14 +257,15 @@ export function visibleCallRecords(records: CallRecord[]): CallRecord[] {
)
);

const prefersOwnRow = (record: CallRecord): boolean =>
Boolean(record.llm_description?.trim()) ||
Boolean(record.name && PREFER_LIB_OVER_CHILDREN.has(record.name));

const hideChildrenOf = new Set(
records
.filter(
record =>
record.kind === 'lib' &&
record.name &&
PREFER_LIB_OVER_CHILDREN.has(record.name) &&
hasChildren.has(record.id)
record.kind === 'lib' && prefersOwnRow(record) && hasChildren.has(record.id)
)
.map(record => record.id)
);
Expand All @@ -264,6 +281,6 @@ export function visibleCallRecords(records: CallRecord[]): CallRecord[] {
if (record.kind !== 'lib' || !hasChildren.has(record.id)) {
return true;
}
return Boolean(record.name && PREFER_LIB_OVER_CHILDREN.has(record.name));
return prefersOwnRow(record);
});
}
172 changes: 168 additions & 4 deletions static/app/views/seerExplorer/components/chat/callRecords.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,27 @@ function apiRecord(overrides?: Partial<CallRecord>): CallRecord {
};
}

/**
* A block whose tool calls are still running: no results yet, so only in-flight reporting shows.
*/
function inFlightBlock(toolCallIds: string[], overrides: Partial<Block> = {}): Block {
return {
id: 'tool-1',
message: {
role: 'tool_use',
content: null,
tool_calls: toolCallIds.map(id => ({
id,
function: 'sentry_api_execute',
args: '{"code":"..."}',
})),
},
timestamp: '2024-01-01T00:01:00Z',
loading: true,
...overrides,
};
}

describe('call record rendering', () => {
it('renders a row per call using the title seer shipped', () => {
const block = codeModeBlock([
Expand All @@ -75,17 +96,113 @@ describe('call record rendering', () => {
expect(screen.queryByText(/Used sentry_api_execute tool/)).not.toBeInTheDocument();
});

it('drops a record with no title rather than showing its route', () => {
// The surviving row's expansion legitimately shows a route, so assert on the row count:
// the titleless record contributes nothing rather than falling back to its path.
it('reports a record with no title rather than deleting it', () => {
// Given a generic label rather than its route, but reported: a record vanishing for want of
// wording is how a whole endpoint disappears the day it is added.
const block = codeModeBlock([
apiRecord({title: undefined, path: '/api/0/dropped/{thing_id}/'}),
apiRecord({id: 2, title: 'List Your Organizations'}),
]);
render(<BlockComponent block={block} blockIndex={0} />);

expect(screen.getByText('List Your Organizations')).toBeInTheDocument();
expect(screen.queryByText(/dropped/)).not.toBeInTheDocument();
expect(screen.getByText('Sentry API request')).toBeInTheDocument();
});

it('keeps the request details on a described api row', () => {
// The description leads the row, but an api call's own request is what makes that claim
// checkable — swapping it for the generated title would lose the literal URL and the body.
const record = apiRecord({
llm_description: 'Working out which org owns the failing project',
resolved_path: '/api/0/organizations/acme/',
});

expect(callRecordDetail(record)).toEqual({
request: 'GET /api/0/organizations/acme/',
body: null,
});
});

it('falls back to the title for a described row that ran no request', () => {
const record: CallRecord = {
id: 1,
parent: null,
kind: 'lib',
name: 'bash',
title: 'Running command grep -rn retry in getsentry/sentry',
llm_description: 'Checking whether the retry ceiling changed',
};

expect(callRecordDetail(record)).toEqual({
request: 'Running command grep -rn retry in getsentry/sentry',
body: null,
});
});

it('leads with what the agent said the call was for', () => {
const block = codeModeBlock([
apiRecord({
title: 'Retrieve an Organization',
llm_description: 'Working out which org owns the failing project',
}),
]);
render(<BlockComponent block={block} blockIndex={0} />);

expect(
screen.getByText('Working out which org owns the failing project')
).toBeInTheDocument();
});

it('keeps a described composite helper instead of only its requests', () => {
// Without a description the children say more and the parent is dropped. With one, the parent
// says what the operation was *for*, which none of the requests underneath can.
const block = codeModeBlock([
{
id: 1,
parent: null,
kind: 'lib',
name: 'get_issue_details',
title: 'Getting enriched issue details for issue 4521',
llm_description: 'Working out which span makes checkout slow',
},
apiRecord({id: 2, parent: 1, title: 'Retrieve an Issue'}),
]);
render(<BlockComponent block={block} blockIndex={0} />);

expect(
screen.getByText('Working out which span makes checkout slow')
).toBeInTheDocument();
expect(screen.queryByText('Retrieve an Issue')).not.toBeInTheDocument();
});

it('drops an undescribed composite helper in favour of its requests', () => {
const block = codeModeBlock([
{
id: 1,
parent: null,
kind: 'lib',
name: 'get_issue_details',
title: 'Getting enriched issue details for issue 4521',
},
apiRecord({id: 2, parent: 1, title: 'Retrieve an Issue'}),
]);
render(<BlockComponent block={block} blockIndex={0} />);

expect(screen.getByText('Retrieve an Issue')).toBeInTheDocument();
expect(
screen.queryByText('Getting enriched issue details for issue 4521')
).not.toBeInTheDocument();
});

it('renders a note in the order it was written', () => {
const block = codeModeBlock([
apiRecord({id: 1, title: 'Retrieve an Organization'}),
{id: 2, parent: null, kind: 'note', llm_description: 'Comparing the two traces'},
apiRecord({id: 3, title: 'List Your Organizations'}),
]);
render(<BlockComponent block={block} blockIndex={0} />);

expect(screen.getByText('Comparing the two traces')).toBeInTheDocument();
});

it('links a record that identifies a navigable resource', () => {
Expand Down Expand Up @@ -510,3 +627,50 @@ describe('live call rendering', () => {
expect(screen.getByText('In-flight row')).toBeInTheDocument();
});
});

describe('in-flight progress', () => {
it('shows work as it happens for a running tool call', () => {
const block = inFlightBlock(['call-1'], {
progress: [{token: 'call-1', progress: 1, message: 'Retrieving issue 4521'}],
});
render(<BlockComponent block={block} blockIndex={0} />);

expect(screen.getByText('Retrieving issue 4521')).toBeInTheDocument();
});

it('reports both calls when two are in flight at once', () => {
// The block-level mirror could not say which outstanding call its records belonged to, so it
// showed them on neither — the agent looked hung while it worked. An event names its own call.
const block = inFlightBlock(['call-1', 'call-2'], {
progress: [
{
token: 'call-1',
progress: 1,
message: 'Searching the filesystem for retry logic',
},
{
token: 'call-2',
progress: 1,
message: 'Querying telemetry for the p95 regression',
},
],
});
render(<BlockComponent block={block} blockIndex={0} />);

expect(
screen.getByText('Searching the filesystem for retry logic')
).toBeInTheDocument();
expect(
screen.getByText('Querying telemetry for the p95 regression')
).toBeInTheDocument();
});

it('falls back to the block mirror when a seer sends no progress', () => {
const block = inFlightBlock(['call-1'], {
live_calls: [apiRecord({title: 'Retrieve an Organization'})],
});
render(<BlockComponent block={block} blockIndex={0} />);

expect(screen.getByText('Retrieve an Organization')).toBeInTheDocument();
});
});
46 changes: 41 additions & 5 deletions static/app/views/seerExplorer/components/chat/toolUse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
callRecordFailure,
callRecordInputQuery,
callRecordLabel,
fallbackCallLabel,
callRecordStatus,
visibleCallRecords,
} from 'sentry/views/seerExplorer/callRecords';
Expand Down Expand Up @@ -240,6 +241,20 @@ function useToolLinks(block: Block) {
// The mirror lives on the block, not per tool call, so it can only be attributed to a call that
// has not reported yet. With several still in flight there is no way to tell whose calls these
// are, so it is shown on none of them rather than duplicated across all.
// Grouped by the call that emitted them: an event names its own, so unlike the mirror below
// several calls can be in flight and each still reports.
const progressForCallId = useMemo(() => {
const grouped = new Map<string, string[]>();
for (const event of block.progress ?? []) {
const message = event?.message?.trim();
if (!event?.token || !message) {
continue;
}
grouped.set(event.token, [...(grouped.get(event.token) ?? []), message]);
}
return grouped;
}, [block.progress]);

const liveCallsForCallId = useMemo(() => {
const calls = block.live_calls ?? [];
if (!calls.length) {
Expand All @@ -263,6 +278,7 @@ function useToolLinks(block: Block) {
structuredContentMarkdownByCallId,
callRecordsByCallId,
liveCallsForCallId,
progressForCallId,
settledCallIds,
organization,
projects,
Expand All @@ -285,6 +301,7 @@ export function ToolCallList({block, blocks, getPageReferrer}: ToolCallListProps
structuredContentMarkdownByCallId,
callRecordsByCallId,
liveCallsForCallId,
progressForCallId,
settledCallIds,
organization,
projects,
Expand Down Expand Up @@ -405,6 +422,11 @@ export function ToolCallList({block, blocks, getPageReferrer}: ToolCallListProps
const finishedCalls = toolCall.id
? (callRecordsByCallId.get(toolCall.id) ?? [])
: [];
// Progress first: attributed by token, so it survives several calls in flight — exactly
// when the mirror shows nothing. The mirror stays the fallback for an older seer.
const progressLines = toolCall.id
? (progressForCallId.get(toolCall.id) ?? [])
: [];
const live = toolCall.id ? (liveCallsForCallId.get(toolCall.id) ?? []) : [];
// A result exists, so the execute returned and nothing it reported is still running. Read
// off the result itself rather than off the records it carried: a call that reports none
Expand All @@ -419,7 +441,19 @@ export function ToolCallList({block, blocks, getPageReferrer}: ToolCallListProps
// claiming it wholesale would starve later rows of their bus twins. Those are paired one
// bus link at a time below instead.
const claimedLinkKinds = new Set<string>();
const callRows = visibleCallRecords(finishedCalls.length ? finishedCalls : live)
// Progress carries a string, so each line becomes a note-shaped record and rides the same
// renderer. Negative ids keep them clear of the per-execute counter.
const inFlightRows: CallRecord[] = progressLines.map((message, index) => ({
id: -(index + 1),
kind: 'note' as const,
llm_description: message,
}));
const rowSource = finishedCalls.length
? finishedCalls
: inFlightRows.length
? inFlightRows
: live;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Progress persists after call settles

Medium Severity

rowSource uses progress whenever finishedCalls is empty, even after the tool result has arrived. Leftover in-flight notes then render as settled rows, including when a completed session is replayed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5df5772. Configure here.

const callRows = visibleCallRecords(rowSource)
.map(record => {
const subject = subjectFromCallRecord(record);
const link = resolveLink(subject, {organization, projects});
Expand All @@ -443,10 +477,12 @@ export function ToolCallList({block, blocks, getPageReferrer}: ToolCallListProps
linkLabel: genericLink?.label ?? null,
};
})
// A record we have no label for is dropped rather than rendered as a route or an
// internal identifier — one fewer row beats a raw string on screen. The predicate
// narrows `label` for the render below, which is why it is not a plain Boolean check.
.filter((row): row is typeof row & {label: string} => Boolean(row.label));
// Reported rather than deleted — a row should never disappear for want of wording — but

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Description hidden on linked rows

Medium Severity

A matching link rule still names the row from the generated title, so llm_description never leads navigable records. The agent's line is dropped on the rows this change is meant to elevate, including described composite helpers that resolve to a destination.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5df5772. Configure here.

// given a generic label, since a raw route reads worse than no row at all.
.map(row => ({
...row,
label: row.label ?? fallbackCallLabel(row.record),
}));

const residualNavItems = navItems.filter(
item => !claimedLinkKinds.has(item.kind)
Expand Down
Loading
Loading