From ee25d8d1c3a5f6375e8d5196c40faef1f6273870 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 27 Aug 2026 11:39:18 -0700 Subject: [PATCH 1/7] feat(seer-explorer): show work in flight per tool call, and lead rows with the agent's own words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumes the per-tool-call progress channel seer now emits, preferring it over `block.live_calls` and falling back where a seer does not send it. The mirror lives on the block, so with several tool calls outstanding it cannot be attributed and is shown on none of them — the reported hang. Three rendering changes alongside it. A lib record carrying an agent-written description keeps its row and hides its children: the rule's premise, that the heading says less than the rows beneath it, inverts once the heading says what the operation was for. A description leads its row, with the machine title still reachable in the expander so the claim stays checkable against what ran. And a record nothing can label is reported generically rather than deleted — an untitled record vanishing is how a whole endpoint disappears the day it is added. Co-Authored-By: Claude Opus 5 (1M context) --- static/app/views/seerExplorer/callRecords.tsx | 43 +++++- .../components/chat/callRecords.spec.tsx | 143 +++++++++++++++++- .../seerExplorer/components/chat/toolUse.tsx | 52 ++++++- static/app/views/seerExplorer/types.tsx | 31 +++- 4 files changed, 252 insertions(+), 17 deletions(-) diff --git a/static/app/views/seerExplorer/callRecords.tsx b/static/app/views/seerExplorer/callRecords.tsx index a8c60edfaa4d..c22b0643af57 100644 --- a/static/app/views/seerExplorer/callRecords.tsx +++ b/static/app/views/seerExplorer/callRecords.tsx @@ -13,14 +13,29 @@ 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 title answers what ran; only the agent knows a `grep` was checking whether retries caused the + * regression, and that is the half a user needs. The title is not discarded — `callRecordDetail` + * still shows what actually happened, so a description is always checkable against it. * * 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. */ export function callRecordLabel(record: CallRecord): string | null { - return record.title?.trim() || null; + return record.description?.trim() || record.title?.trim() || null; +} + +/** + * A readable stand-in for a record nothing could name. + * + * Deliberately generic: the alternative is a route or an operation id, which reads worse on screen + * than saying nothing specific. But the call did happen, so it is reported — an untitled record + * silently vanishing is how a whole endpoint disappears from the UI the day it is added. + */ +export function fallbackCallLabel(record: CallRecord): string { + return record.kind === 'api' ? t('Sentry API request') : t('Working…'); } /** @@ -71,6 +86,15 @@ export function callRecordDetail(record: CallRecord): { body: string | null; request: string; } | null { + // A described row reads as the agent's own words, so what actually ran has to stay reachable — + // otherwise a description is an unfalsifiable claim. The title is that: "Running command + // grep -rn retry in getsentry/sentry" beneath "Checking whether retries are the cause". + const description = record.description?.trim(); + const title = record.title?.trim(); + if (description && title && description !== title) { + return {request: title, body: 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) { @@ -233,6 +257,10 @@ 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 parent the agent described inverts that first premise, so it is kept and its children hidden: + * the heading now says what the operation was *for*, which none of the requests underneath can. + * Without a description the old behaviour stands, so the description is what earns the row. */ export function visibleCallRecords(records: CallRecord[]): CallRecord[] { const hasChildren = new Set( @@ -241,14 +269,15 @@ export function visibleCallRecords(records: CallRecord[]): CallRecord[] { ) ); + const prefersOwnRow = (record: CallRecord): boolean => + Boolean(record.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) ); @@ -264,6 +293,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); }); } diff --git a/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx b/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx index 118ca5f61636..a612c64a6790 100644 --- a/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx +++ b/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx @@ -51,6 +51,27 @@ function apiRecord(overrides?: Partial): 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 { + 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([ @@ -75,9 +96,10 @@ 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', () => { + // An untitled record still happened. It gets a generic label rather than its route, which + // reads worse than saying nothing specific — but it is reported, because 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'}), @@ -85,7 +107,73 @@ describe('call record rendering', () => { render(); expect(screen.getByText('List Your Organizations')).toBeInTheDocument(); - expect(screen.queryByText(/dropped/)).not.toBeInTheDocument(); + expect(screen.getByText('Sentry API request')).toBeInTheDocument(); + }); + + it('leads with what the agent said the call was for', () => { + const block = codeModeBlock([ + apiRecord({ + title: 'Retrieve an Organization', + description: 'Checking which org owns the failing project', + }), + ]); + render(); + + expect( + screen.getByText('Checking 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', + description: 'Pulling the failing event to see which span is slow', + }, + apiRecord({id: 2, parent: 1, title: 'Retrieve an Issue'}), + ]); + render(); + + expect( + screen.getByText('Pulling the failing event to see which span is 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(); + + 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', description: 'Comparing the two traces'}, + apiRecord({id: 3, title: 'List Your Organizations'}), + ]); + render(); + + expect(screen.getByText('Comparing the two traces')).toBeInTheDocument(); }); it('links a record that identifies a navigable resource', () => { @@ -510,3 +598,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(); + + 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(); + + 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(); + + expect(screen.getByText('Retrieve an Organization')).toBeInTheDocument(); + }); +}); diff --git a/static/app/views/seerExplorer/components/chat/toolUse.tsx b/static/app/views/seerExplorer/components/chat/toolUse.tsx index 6ec9e5d896ef..5a4374ed51e9 100644 --- a/static/app/views/seerExplorer/components/chat/toolUse.tsx +++ b/static/app/views/seerExplorer/components/chat/toolUse.tsx @@ -33,6 +33,7 @@ import { callRecordFailure, callRecordInputQuery, callRecordLabel, + fallbackCallLabel, callRecordStatus, visibleCallRecords, } from 'sentry/views/seerExplorer/callRecords'; @@ -240,6 +241,21 @@ 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. + // In-flight lines from the progress channel, grouped by the tool call that emitted them. Unlike + // the mirror below there is nothing to infer: an event names its own call, so several calls can + // be in flight at once and each still reports. + const progressForCallId = useMemo(() => { + const grouped = new Map(); + 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) { @@ -263,6 +279,7 @@ function useToolLinks(block: Block) { structuredContentMarkdownByCallId, callRecordsByCallId, liveCallsForCallId, + progressForCallId, settledCallIds, organization, projects, @@ -285,6 +302,7 @@ export function ToolCallList({block, blocks, getPageReferrer}: ToolCallListProps structuredContentMarkdownByCallId, callRecordsByCallId, liveCallsForCallId, + progressForCallId, settledCallIds, organization, projects, @@ -405,6 +423,12 @@ export function ToolCallList({block, blocks, getPageReferrer}: ToolCallListProps const finishedCalls = toolCall.id ? (callRecordsByCallId.get(toolCall.id) ?? []) : []; + // Progress first when the tool call reported any: it is attributed by its own token, so it + // survives several calls being in flight, which is exactly when the mirror shows nothing. + // The mirror remains the fallback for a seer that does not send progress yet. + 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 @@ -419,7 +443,20 @@ 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(); - const callRows = visibleCallRecords(finishedCalls.length ? finishedCalls : live) + // Progress carries a string and no structure — that is all the protocol offers mid-call — + // so each line becomes a note-shaped record and rides the same row renderer as everything + // else. Negative ids keep them clear of the real per-execute counter. + const inFlightRows: CallRecord[] = progressLines.map((message, index) => ({ + id: -(index + 1), + kind: 'note' as const, + description: message, + })); + const rowSource = finishedCalls.length + ? finishedCalls + : inFlightRows.length + ? inFlightRows + : live; + const callRows = visibleCallRecords(rowSource) .map(record => { const subject = subjectFromCallRecord(record); const link = resolveLink(subject, {organization, projects}); @@ -443,10 +480,15 @@ 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)); + // A record with no label still happened, so it is reported rather than deleted — seer's + // own contract is that a row never disappears for want of wording. What it is not given + // is a raw route or an operation id: those read worse than no row at all, which is why + // the degraded form is generic. An api row keeps its expander, so what actually ran is + // still one click away. + .map(row => ({ + ...row, + label: row.label ?? fallbackCallLabel(row.record), + })); const residualNavItems = navItems.filter( item => !claimedLinkKinds.has(item.kind) diff --git a/static/app/views/seerExplorer/types.tsx b/static/app/views/seerExplorer/types.tsx index 21840aefaa7a..215f5496846c 100644 --- a/static/app/views/seerExplorer/types.tsx +++ b/static/app/views/seerExplorer/types.tsx @@ -97,11 +97,22 @@ export type AgentWriteApproval = EmbedOutput<'agentWriteApproval'>; */ export interface CallRecord { id: number; - kind: 'api' | 'lib'; + /** + * `api` and `lib` are calls seer observed. `note` is a line the agent wrote itself, about work + * no call describes — a correlation, a join, a decision between calls. + * + * Read additively: an unknown kind from a newer seer must be ignored, not break the render. + */ + kind: 'api' | 'lib' | 'note'; /** Bounded slice of the request body, if the call had one. */ body?: string; /** Whether `body` was cut short. */ body_truncated?: boolean; + /** + * What the agent said the call was for, in its own words. Carried beside `title`, never instead + * of it, so an agent-authored line is always readable next to what actually ran. + */ + description?: string; /** Transport-level failure (no HTTP response), e.g. `ConnectError`. */ error?: string; method?: string; @@ -127,6 +138,23 @@ export interface CallRecord { title?: string; } +/** + * One in-flight update for a tool call, shaped as an MCP progress notification. + * + * `token` is the `tool_call_id` it belongs to — which is the point. `live_calls` lives on the block + * and cannot be attributed when more than one tool call is outstanding, so it is shown on none of + * them; an event names its own call instead of leaving it to be inferred. + * + * Carries a string and no structured payload, because that is all the protocol offers mid-call. The + * full records arrive on the tool result when the call finishes. + */ +export interface ProgressEvent { + progress: number; + token: string; + message?: string; + total?: number; +} + export interface ToolResult { content: string; tool_call_function: string; @@ -173,6 +201,7 @@ export interface Block { loading?: boolean; merged_file_patches?: ExplorerFilePatch[] | null; pr_commit_shas?: Record | null; + progress?: ProgressEvent[] | null; todos?: TodoItem[] | null; tool_links?: Array | null; tool_results?: Array | null; From 9ab874a1d2b3852cc450f974527c179086457b48 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 27 Aug 2026 14:07:26 -0700 Subject: [PATCH 2/7] fix(seer-explorer): stop exporting ProgressEvent, which nothing imports Co-Authored-By: Claude Opus 5 (1M context) --- static/app/views/seerExplorer/types.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/seerExplorer/types.tsx b/static/app/views/seerExplorer/types.tsx index 215f5496846c..a9036b3491fa 100644 --- a/static/app/views/seerExplorer/types.tsx +++ b/static/app/views/seerExplorer/types.tsx @@ -148,7 +148,7 @@ export interface CallRecord { * Carries a string and no structured payload, because that is all the protocol offers mid-call. The * full records arrive on the tool result when the call finishes. */ -export interface ProgressEvent { +interface ProgressEvent { progress: number; token: string; message?: string; From f419f983169c9db33e2dc1275f11148db27916a3 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 27 Aug 2026 14:43:12 -0700 Subject: [PATCH 3/7] refactor(seer-explorer): rename the agent's line to llm_description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A record carries `title` next to it and nothing in the old name said which was which. The generated title and the agent's own account of what it was trying to accomplish are different kinds of claim — one is derived from the call, the other is unverifiable on its own — and the rendering rules turn on exactly that distinction. Naming the field for its author makes it legible at every layer instead of only in the comments. Co-Authored-By: Claude Opus 5 (1M context) --- static/app/views/seerExplorer/callRecords.tsx | 8 ++++---- .../seerExplorer/components/chat/callRecords.spec.tsx | 10 +++++----- .../views/seerExplorer/components/chat/toolUse.tsx | 2 +- static/app/views/seerExplorer/types.tsx | 11 ++++++----- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/static/app/views/seerExplorer/callRecords.tsx b/static/app/views/seerExplorer/callRecords.tsx index c22b0643af57..95c1f6e429f5 100644 --- a/static/app/views/seerExplorer/callRecords.tsx +++ b/static/app/views/seerExplorer/callRecords.tsx @@ -24,7 +24,7 @@ import type {CallRecord} from 'sentry/views/seerExplorer/types'; * on screen is worse than one fewer row. */ export function callRecordLabel(record: CallRecord): string | null { - return record.description?.trim() || record.title?.trim() || null; + return record.llm_description?.trim() || record.title?.trim() || null; } /** @@ -89,9 +89,9 @@ export function callRecordDetail(record: CallRecord): { // A described row reads as the agent's own words, so what actually ran has to stay reachable — // otherwise a description is an unfalsifiable claim. The title is that: "Running command // grep -rn retry in getsentry/sentry" beneath "Checking whether retries are the cause". - const description = record.description?.trim(); + const described = record.llm_description?.trim(); const title = record.title?.trim(); - if (description && title && description !== title) { + if (described && title && described !== title) { return {request: title, body: null}; } @@ -270,7 +270,7 @@ export function visibleCallRecords(records: CallRecord[]): CallRecord[] { ); const prefersOwnRow = (record: CallRecord): boolean => - Boolean(record.description?.trim()) || + Boolean(record.llm_description?.trim()) || Boolean(record.name && PREFER_LIB_OVER_CHILDREN.has(record.name)); const hideChildrenOf = new Set( diff --git a/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx b/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx index a612c64a6790..ba5480967328 100644 --- a/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx +++ b/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx @@ -114,13 +114,13 @@ describe('call record rendering', () => { const block = codeModeBlock([ apiRecord({ title: 'Retrieve an Organization', - description: 'Checking which org owns the failing project', + llm_description: 'Working out which org owns the failing project', }), ]); render(); expect( - screen.getByText('Checking which org owns the failing project') + screen.getByText('Working out which org owns the failing project') ).toBeInTheDocument(); }); @@ -134,14 +134,14 @@ describe('call record rendering', () => { kind: 'lib', name: 'get_issue_details', title: 'Getting enriched issue details for issue 4521', - description: 'Pulling the failing event to see which span is slow', + llm_description: 'Working out which span makes checkout slow', }, apiRecord({id: 2, parent: 1, title: 'Retrieve an Issue'}), ]); render(); expect( - screen.getByText('Pulling the failing event to see which span is slow') + screen.getByText('Working out which span makes checkout slow') ).toBeInTheDocument(); expect(screen.queryByText('Retrieve an Issue')).not.toBeInTheDocument(); }); @@ -168,7 +168,7 @@ describe('call record rendering', () => { 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', description: 'Comparing the two traces'}, + {id: 2, parent: null, kind: 'note', llm_description: 'Comparing the two traces'}, apiRecord({id: 3, title: 'List Your Organizations'}), ]); render(); diff --git a/static/app/views/seerExplorer/components/chat/toolUse.tsx b/static/app/views/seerExplorer/components/chat/toolUse.tsx index 5a4374ed51e9..78f1c7fbf12b 100644 --- a/static/app/views/seerExplorer/components/chat/toolUse.tsx +++ b/static/app/views/seerExplorer/components/chat/toolUse.tsx @@ -449,7 +449,7 @@ export function ToolCallList({block, blocks, getPageReferrer}: ToolCallListProps const inFlightRows: CallRecord[] = progressLines.map((message, index) => ({ id: -(index + 1), kind: 'note' as const, - description: message, + llm_description: message, })); const rowSource = finishedCalls.length ? finishedCalls diff --git a/static/app/views/seerExplorer/types.tsx b/static/app/views/seerExplorer/types.tsx index a9036b3491fa..5d84208b1da6 100644 --- a/static/app/views/seerExplorer/types.tsx +++ b/static/app/views/seerExplorer/types.tsx @@ -108,13 +108,14 @@ export interface CallRecord { body?: string; /** Whether `body` was cut short. */ body_truncated?: boolean; - /** - * What the agent said the call was for, in its own words. Carried beside `title`, never instead - * of it, so an agent-authored line is always readable next to what actually ran. - */ - description?: string; /** Transport-level failure (no HTTP response), e.g. `ConnectError`. */ error?: string; + /** + * What the agent said it was trying to accomplish, in its own words. Carried beside `title`, + * never instead of it, so the agent's line is always readable next to what actually ran. Named + * for its author: `title` is generated, this is not. + */ + llm_description?: string; method?: string; /** Lib records only. */ name?: string; From a8562ea199c595c6030ca4369b8a79be47a2e52e Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 27 Aug 2026 14:48:38 -0700 Subject: [PATCH 4/7] fix(seer-explorer): keep the request details on a described api row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback that gives a described row its generated title ran before the api branch, so an api call carrying a description had its request line and body preview replaced by that title. An api record's own request is the better account of what ran — it is the literal URL rather than a generated sentence — so it is built first, and the title fallback now serves only the records that ran no request of their own. Found by Cursor Bugbot on the PR. Co-Authored-By: Claude Opus 5 (1M context) --- static/app/views/seerExplorer/callRecords.tsx | 42 +++++++++---------- .../components/chat/callRecords.spec.tsx | 30 +++++++++++++ 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/static/app/views/seerExplorer/callRecords.tsx b/static/app/views/seerExplorer/callRecords.tsx index 95c1f6e429f5..1b702ecadaa6 100644 --- a/static/app/views/seerExplorer/callRecords.tsx +++ b/static/app/views/seerExplorer/callRecords.tsx @@ -86,32 +86,32 @@ export function callRecordDetail(record: CallRecord): { body: string | null; request: string; } | null { - // A described row reads as the agent's own words, so what actually ran has to stay reachable — - // otherwise a description is an unfalsifiable claim. The title is that: "Running command - // grep -rn retry in getsentry/sentry" beneath "Checking whether retries are the cause". + // An api record's own request is the best account of what ran, and it is what a described row + // needs to stay checkable — so it is built first, before any fallback. Returning the title here + // instead would trade the literal URL and the request body for a generated sentence. + 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; + } + + // Everything else has no request of its own: a lib call is a heading for the api rows nested + // under it, and a note ran nothing at all. A described one still needs what happened to be + // reachable, or the description is an unfalsifiable claim — the generated title is that: + // "Running command grep -rn retry in getsentry/sentry" beneath "Checking the retry ceiling". const described = record.llm_description?.trim(); const title = record.title?.trim(); if (described && title && described !== title) { return {request: title, body: 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) { - return null; - } - - const path = record.resolved_path ?? record.path; - if (!path) { - return null; - } - - // 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 diff --git a/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx b/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx index ba5480967328..67107640f05b 100644 --- a/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx +++ b/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx @@ -110,6 +110,36 @@ describe('call record rendering', () => { 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({ From 2c9fd77614ea3431a984a59e9212582d892e7ddf Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Thu, 27 Aug 2026 15:03:58 -0700 Subject: [PATCH 5/7] docs(seer-explorer): cut the comments back to what the code does not already say Co-Authored-By: Claude Opus 5 (1M context) --- static/app/views/seerExplorer/callRecords.tsx | 32 ++++++------------- .../components/chat/callRecords.spec.tsx | 5 ++- .../seerExplorer/components/chat/toolUse.tsx | 22 +++++-------- static/app/views/seerExplorer/types.tsx | 19 ++++------- 4 files changed, 26 insertions(+), 52 deletions(-) diff --git a/static/app/views/seerExplorer/callRecords.tsx b/static/app/views/seerExplorer/callRecords.tsx index 1b702ecadaa6..b9631a09b108 100644 --- a/static/app/views/seerExplorer/callRecords.tsx +++ b/static/app/views/seerExplorer/callRecords.tsx @@ -15,24 +15,16 @@ import type {CallRecord} from 'sentry/views/seerExplorer/types'; /** * What a row reads as: the agent's own line when it wrote one, seer's title otherwise. * - * A title answers what ran; only the agent knows a `grep` was checking whether retries caused the - * regression, and that is the half a user needs. The title is not discarded — `callRecordDetail` - * still shows what actually happened, so a description is always checkable against it. - * - * 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.llm_description?.trim() || record.title?.trim() || null; } /** - * A readable stand-in for a record nothing could name. - * - * Deliberately generic: the alternative is a route or an operation id, which reads worse on screen - * than saying nothing specific. But the call did happen, so it is reported — an untitled record - * silently vanishing is how a whole endpoint disappears from the UI the day it is added. + * 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…'); @@ -86,9 +78,8 @@ export function callRecordDetail(record: CallRecord): { body: string | null; request: string; } | null { - // An api record's own request is the best account of what ran, and it is what a described row - // needs to stay checkable — so it is built first, before any fallback. Returning the title here - // instead would trade the literal URL and the request body for a generated sentence. + // 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) { @@ -102,10 +93,8 @@ export function callRecordDetail(record: CallRecord): { return null; } - // Everything else has no request of its own: a lib call is a heading for the api rows nested - // under it, and a note ran nothing at all. A described one still needs what happened to be - // reachable, or the description is an unfalsifiable claim — the generated title is that: - // "Running command grep -rn retry in getsentry/sentry" beneath "Checking the retry ceiling". + // 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) { @@ -258,9 +247,8 @@ const PREFER_LIB_OVER_CHILDREN = new Set(['get_span_details']); * `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 parent the agent described inverts that first premise, so it is kept and its children hidden: - * the heading now says what the operation was *for*, which none of the requests underneath can. - * Without a description the old behaviour stands, so the description is what earns the row. + * 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( diff --git a/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx b/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx index 67107640f05b..d707769c83ff 100644 --- a/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx +++ b/static/app/views/seerExplorer/components/chat/callRecords.spec.tsx @@ -97,9 +97,8 @@ describe('call record rendering', () => { }); it('reports a record with no title rather than deleting it', () => { - // An untitled record still happened. It gets a generic label rather than its route, which - // reads worse than saying nothing specific — but it is reported, because a record vanishing - // for want of wording is how a whole endpoint disappears the day it is added. + // 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'}), diff --git a/static/app/views/seerExplorer/components/chat/toolUse.tsx b/static/app/views/seerExplorer/components/chat/toolUse.tsx index 78f1c7fbf12b..83b183ad9cee 100644 --- a/static/app/views/seerExplorer/components/chat/toolUse.tsx +++ b/static/app/views/seerExplorer/components/chat/toolUse.tsx @@ -241,9 +241,8 @@ 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. - // In-flight lines from the progress channel, grouped by the tool call that emitted them. Unlike - // the mirror below there is nothing to infer: an event names its own call, so several calls can - // be in flight at once and each still reports. + // 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(); for (const event of block.progress ?? []) { @@ -423,9 +422,8 @@ export function ToolCallList({block, blocks, getPageReferrer}: ToolCallListProps const finishedCalls = toolCall.id ? (callRecordsByCallId.get(toolCall.id) ?? []) : []; - // Progress first when the tool call reported any: it is attributed by its own token, so it - // survives several calls being in flight, which is exactly when the mirror shows nothing. - // The mirror remains the fallback for a seer that does not send progress yet. + // 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) ?? []) : []; @@ -443,9 +441,8 @@ 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(); - // Progress carries a string and no structure — that is all the protocol offers mid-call — - // so each line becomes a note-shaped record and rides the same row renderer as everything - // else. Negative ids keep them clear of the real per-execute counter. + // 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, @@ -480,11 +477,8 @@ export function ToolCallList({block, blocks, getPageReferrer}: ToolCallListProps linkLabel: genericLink?.label ?? null, }; }) - // A record with no label still happened, so it is reported rather than deleted — seer's - // own contract is that a row never disappears for want of wording. What it is not given - // is a raw route or an operation id: those read worse than no row at all, which is why - // the degraded form is generic. An api row keeps its expander, so what actually ran is - // still one click away. + // Reported rather than deleted — a row should never disappear for want of wording — but + // given a generic label, since a raw route reads worse than no row at all. .map(row => ({ ...row, label: row.label ?? fallbackCallLabel(row.record), diff --git a/static/app/views/seerExplorer/types.tsx b/static/app/views/seerExplorer/types.tsx index 5d84208b1da6..f0829b48a216 100644 --- a/static/app/views/seerExplorer/types.tsx +++ b/static/app/views/seerExplorer/types.tsx @@ -98,10 +98,8 @@ export type AgentWriteApproval = EmbedOutput<'agentWriteApproval'>; export interface CallRecord { id: number; /** - * `api` and `lib` are calls seer observed. `note` is a line the agent wrote itself, about work - * no call describes — a correlation, a join, a decision between calls. - * - * Read additively: an unknown kind from a newer seer must be ignored, not break the render. + * `api` and `lib` are observed calls; `note` is a line the agent wrote itself. Read additively: + * an unknown kind from a newer seer must be ignored, not break the render. */ kind: 'api' | 'lib' | 'note'; /** Bounded slice of the request body, if the call had one. */ @@ -111,9 +109,8 @@ export interface CallRecord { /** Transport-level failure (no HTTP response), e.g. `ConnectError`. */ error?: string; /** - * What the agent said it was trying to accomplish, in its own words. Carried beside `title`, - * never instead of it, so the agent's line is always readable next to what actually ran. Named - * for its author: `title` is generated, this is not. + * What the agent said it was trying to accomplish. Carried beside `title`, never instead of it. + * Named for its author: `title` is generated, this is not. */ llm_description?: string; method?: string; @@ -142,12 +139,8 @@ export interface CallRecord { /** * One in-flight update for a tool call, shaped as an MCP progress notification. * - * `token` is the `tool_call_id` it belongs to — which is the point. `live_calls` lives on the block - * and cannot be attributed when more than one tool call is outstanding, so it is shown on none of - * them; an event names its own call instead of leaving it to be inferred. - * - * Carries a string and no structured payload, because that is all the protocol offers mid-call. The - * full records arrive on the tool result when the call finishes. + * `token` is the `tool_call_id`, which is the point: `live_calls` lives on the block and cannot be + * attributed when several calls are outstanding. Carries a string; full records arrive on the result. */ interface ProgressEvent { progress: number; From 1d7d54551f6ccd009223ef7b21f128bcb23c9477 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Mon, 31 Aug 2026 09:46:27 -0700 Subject: [PATCH 6/7] fix(seer-explorer): lead a navigable row with the agent's line too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A matching link rule named the row from the generated title, so llm_description was dropped on exactly the rows this is meant to elevate — every record a rule resolved to a destination. The subject now carries the same precedence the row label uses. The generic destination chip is unaffected: it re-resolves with no title on purpose. Co-Authored-By: Claude Opus 5 (1M context) --- static/app/views/seerExplorer/links.spec.tsx | 18 ++++++++++++++++++ static/app/views/seerExplorer/links.tsx | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/static/app/views/seerExplorer/links.spec.tsx b/static/app/views/seerExplorer/links.spec.tsx index 91980adbf6a1..16ab6875ae4e 100644 --- a/static/app/views/seerExplorer/links.spec.tsx +++ b/static/app/views/seerExplorer/links.spec.tsx @@ -451,6 +451,24 @@ describe('project links', () => { }); }); + it('leads a navigable row with the agent line, not the generated title', () => { + // Without this the agent's own words are dropped on exactly the rows this is meant to + // elevate: any record a link rule matched. + expect( + resolveLink( + subjectFromCallRecord({ + ...record({project_id_or_slug: 'python'}), + llm_description: 'Checking whether the python project still ingests', + }), + ctx + ) + ).toEqual({ + id: 'get_project_details', + label: 'Checking whether the python project still ingests', + url: {pathname: '/organizations/org-slug/insights/projects/python/'}, + }); + }); + it('resolves a numeric id to its slug, since project pages route on slug', () => { expect( resolveLink(subjectFromCallRecord(record({project_id_or_slug: '2'})), ctx) diff --git a/static/app/views/seerExplorer/links.tsx b/static/app/views/seerExplorer/links.tsx index 8c61ed84d8f2..5a4e74103703 100644 --- a/static/app/views/seerExplorer/links.tsx +++ b/static/app/views/seerExplorer/links.tsx @@ -29,6 +29,7 @@ import {VisualizeFunction} from 'sentry/views/explore/queryParams/visualize'; import {makeReleasesPathname} from 'sentry/views/explore/releases/utils/pathnames'; import {makeReplaysPathname} from 'sentry/views/explore/replays/pathnames'; import {makeProjectsPathname} from 'sentry/views/projects/pathname'; +import {callRecordLabel} from 'sentry/views/seerExplorer/callRecords'; import type {CallRecord, ToolLink} from 'sentry/views/seerExplorer/types'; /** @@ -677,7 +678,9 @@ export function subjectFromCallRecord(record: CallRecord): LinkSubject { pathname: pathname || undefined, query: query ? (queryString.parse(query) as Record) : undefined, status: record.status, - title: record.title?.trim() || undefined, + // Same precedence as the row's own label, so a rule that names the row from its title + // still leads with the agent's line rather than dropping it on every navigable row. + title: callRecordLabel(record) ?? undefined, }; } From 99b2583353b9babacd0d649d3420c22ed78b4f62 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Mon, 31 Aug 2026 20:16:34 -0700 Subject: [PATCH 7/7] =?UTF-8?q?fix(seer-explorer):=20Stop=20stale=20narrat?= =?UTF-8?q?ion=20and=20Working=E2=80=A6=20on=20settled=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Progress describes work in flight, so it is stale once the call reports back. Seer clears it then, but an older seer or a failed clear left narration rendering as settled rows, including on replay of a finished session. The unlabeled fallback said `Working…` for every non-api record, so a lib method with no title read as still running forever — reachable today, since some sandbox methods ship without one. Co-Authored-By: Claude Opus 5 (1M context) --- static/app/views/seerExplorer/callRecords.tsx | 4 ++- .../components/chat/toolUse.spec.tsx | 33 +++++++++++++++++++ .../seerExplorer/components/chat/toolUse.tsx | 8 +++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/static/app/views/seerExplorer/callRecords.tsx b/static/app/views/seerExplorer/callRecords.tsx index b9631a09b108..89c3fb103c4b 100644 --- a/static/app/views/seerExplorer/callRecords.tsx +++ b/static/app/views/seerExplorer/callRecords.tsx @@ -27,7 +27,9 @@ export function callRecordLabel(record: CallRecord): string | null { * 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…'); + // A noun, not a progressive verb: the row may well have settled, and a lib method that reached + // here has no title at all — `Working…` would leave it reading as still running forever. + return record.kind === 'api' ? t('Sentry API request') : t('Sentry operation'); } /** diff --git a/static/app/views/seerExplorer/components/chat/toolUse.spec.tsx b/static/app/views/seerExplorer/components/chat/toolUse.spec.tsx index d1d30c971417..8303843ea2c6 100644 --- a/static/app/views/seerExplorer/components/chat/toolUse.spec.tsx +++ b/static/app/views/seerExplorer/components/chat/toolUse.spec.tsx @@ -1217,6 +1217,39 @@ describe('ToolUseBlock', () => { }); } + it('drops in-flight narration once the call reports back', () => { + // Progress describes work in flight. Seer clears it when the call settles, but an older + // seer or a failed clear must not leave narration rendering as settled rows. + const settled = executeBlock(null); + settled.loading = false; + settled.progress = [ + {token: 'call-1', progress: 1, message: 'Searching for the issue'}, + ]; + settled.tool_results = [ + { + tool_call_id: 'call-1', + tool_call_function: 'sentry_api_execute', + content: 'ok', + structuredContent: null, + }, + ]; + + render(); + + expect(screen.queryByText('Searching for the issue')).not.toBeInTheDocument(); + }); + + it('still shows narration while the call is in flight', () => { + const running = executeBlock(null); + running.progress = [ + {token: 'call-1', progress: 1, message: 'Searching for the issue'}, + ]; + + render(); + + expect(screen.getByText('Searching for the issue')).toBeInTheDocument(); + }); + it('keeps the placeholder up while a search runs, which reports no calls', () => { render(); expect(screen.getByRole('status', {name: 'Loading'})).toBeInTheDocument(); diff --git a/static/app/views/seerExplorer/components/chat/toolUse.tsx b/static/app/views/seerExplorer/components/chat/toolUse.tsx index 83b183ad9cee..6fba13fffab9 100644 --- a/static/app/views/seerExplorer/components/chat/toolUse.tsx +++ b/static/app/views/seerExplorer/components/chat/toolUse.tsx @@ -448,10 +448,14 @@ export function ToolCallList({block, blocks, getPageReferrer}: ToolCallListProps kind: 'note' as const, llm_description: message, })); + // Progress describes work in flight, so it is stale the moment the call reports back — + // seer clears it then, but an older seer or a failed clear must not leave narration + // rendering as settled rows, least of all when a finished session is replayed. + const inFlight = callsAreSettled ? [] : inFlightRows; const rowSource = finishedCalls.length ? finishedCalls - : inFlightRows.length - ? inFlightRows + : inFlight.length + ? inFlight : live; const callRows = visibleCallRecords(rowSource) .map(record => {