From 8bbb68877b35966a9165ae15d75100034ec74337 Mon Sep 17 00:00:00 2001 From: Ognjen Bostjancic Date: Mon, 6 Jul 2026 14:40:35 +0200 Subject: [PATCH] fix(conversations): Show tool calls and redacted notice on empty transcript When a conversation's generation spans have no message content captured, the redesigned transcript now shows any tool calls that ran and a muted notice ('Message inputs and outputs were not captured') rather than a blank panel. Two bugs are fixed: - mergeEmptyTurns dropped pending tool calls when no generation span had assistantContent, so tool-only turns were silently lost. They are now flushed onto a synthetic turn anchored on the last generation span. - The empty-messages path in MessagesPanelNew now extracts orphaned tool spans directly from nodes and renders them via MessageToolCallsNew, so tool-only conversations (no generation spans at all) also surface their calls. A new buildToolCallsFromSpans helper is exported for direct conversion of tool span nodes to ToolCall objects without requiring a generation span. Refs TET-2607 Co-Authored-By: Claude Sonnet 4.5 --- .../components/messagesPanelNew.spec.tsx | 44 +++++++++- .../components/messagesPanelNew.tsx | 45 ++++++++++- .../utils/conversationMessages.spec.ts | 81 +++++++++++++++++++ .../utils/conversationMessages.ts | 52 ++++++++++-- 4 files changed, 211 insertions(+), 11 deletions(-) diff --git a/static/app/views/explore/conversations/components/messagesPanelNew.spec.tsx b/static/app/views/explore/conversations/components/messagesPanelNew.spec.tsx index eb902ce99f39..ca4cf4035da5 100644 --- a/static/app/views/explore/conversations/components/messagesPanelNew.spec.tsx +++ b/static/app/views/explore/conversations/components/messagesPanelNew.spec.tsx @@ -57,7 +57,7 @@ describe('MessagesPanelNew', () => { jest.clearAllMocks(); }); - it('renders empty message when no nodes provided', () => { + it('shows "not captured" notice when there are no nodes at all', () => { render( { /> ); - expect(screen.getByText('No messages found')).toBeInTheDocument(); + expect( + screen.getByText('Message inputs and outputs were not captured') + ).toBeInTheDocument(); + }); + + it('shows placeholder bubbles when generation spans have no message content', () => { + const genNode = createMockNode({id: 'gen-1', startTimestamp: 1000}); + + render( + + ); + + // Two "(not captured)" placeholders: one user bubble, one assistant bubble + expect(screen.getAllByText('(not captured)')).toHaveLength(2); + }); + + it('shows tool calls and notice when there are only tool spans and no generation spans', () => { + const toolNode = createMockToolNode({ + id: 'tool-1', + toolName: 'search', + startTimestamp: 1000, + }); + + render( + + ); + + expect(screen.getByText('search')).toBeInTheDocument(); + expect( + screen.getByText('Message inputs and outputs were not captured') + ).toBeInTheDocument(); }); it('renders user and assistant messages', () => { diff --git a/static/app/views/explore/conversations/components/messagesPanelNew.tsx b/static/app/views/explore/conversations/components/messagesPanelNew.tsx index 8f717629f0fa..aeb65cc07b08 100644 --- a/static/app/views/explore/conversations/components/messagesPanelNew.tsx +++ b/static/app/views/explore/conversations/components/messagesPanelNew.tsx @@ -11,7 +11,6 @@ import { MessageBlock, UserMessageBlock, } from 'sentry/components/ai/chat/messageBlock'; -import {EmptyMessage} from 'sentry/components/emptyMessage'; import {Placeholder} from 'sentry/components/placeholder'; import {t} from 'sentry/locale'; import {trackAnalytics} from 'sentry/utils/analytics'; @@ -20,8 +19,10 @@ import {useOrganization} from 'sentry/utils/useOrganization'; import {MessageToolCallsNew} from 'sentry/views/explore/conversations/components/messageToolCallsNew'; import {TurnMeta} from 'sentry/views/explore/conversations/components/turnMeta'; import { + buildToolCallsFromSpans, type ConversationMessage, extractMessagesFromNodes, + partitionSpansByType, } from 'sentry/views/explore/conversations/utils/conversationMessages'; import {EMPTY_TEXT_CONTENT} from 'sentry/views/insights/pages/agents/utils/aiMessageNormalizer'; import {getNumberAttr} from 'sentry/views/insights/pages/agents/utils/aiTraceNodes'; @@ -72,9 +73,49 @@ export function MessagesPanelNew({ }, [nodes]); if (messages.length === 0) { + // Even without message content, tool spans and generation spans may be + // present. Surface them so the user sees AI activity rather than a blank + // panel. Generation spans with no input/output get placeholder bubbles + // ("not captured") to preserve the conversational shape. + const {generationSpans, toolSpans} = partitionSpansByType(nodes); + const orphanToolCalls = buildToolCallsFromSpans(toolSpans); + const hasGenerations = generationSpans.length > 0; + return ( - {t('No messages found')} + + {hasGenerations && ( + + + {t('(not captured)')} + + + )} + {orphanToolCalls.length > 0 && ( + + + + )} + {hasGenerations ? ( + + + {t('(not captured)')} + + + ) : ( + + + {t('Message inputs and outputs were not captured')} + + + )} + ); } diff --git a/static/app/views/explore/conversations/utils/conversationMessages.spec.ts b/static/app/views/explore/conversations/utils/conversationMessages.spec.ts index 4476c3750e29..67dbcba0c055 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.spec.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.spec.ts @@ -2,6 +2,7 @@ import {SpanFields} from 'sentry/views/insights/types'; import { buildConversationTurns, + buildToolCallsFromSpans, extractMessagesFromNodes, getNodeTimestamp, mergeEmptyTurns, @@ -553,6 +554,25 @@ describe('conversationMessages utilities', () => { ]); }); + it('creates a synthetic turn for orphaned tool calls when result is empty', () => { + const turns = [ + makeTurn({ + generation: { + id: 'gen-1', + value: {start_timestamp: 1000, end_timestamp: 1100}, + } as any, + toolCalls: [{name: 'search', nodeId: 'tool-1', hasError: false}], + }), + ]; + + const merged = mergeEmptyTurns(turns); + + expect(merged).toHaveLength(1); + expect(merged[0]?.toolCalls).toHaveLength(1); + expect(merged[0]?.toolCalls[0]?.name).toBe('search'); + expect(merged[0]?.generation.id).toBe('gen-1'); + }); + it('flushes pending tool calls onto last turn when no subsequent turn has content', () => { const turns = [ makeTurn({ @@ -1072,6 +1092,67 @@ describe('conversationMessages utilities', () => { const tool = createMockToolNode({id: 'tool-1', toolName: 'search'}); expect(extractMessagesFromNodes([tool as any])).toEqual([]); }); + + it('surfaces tool calls from generation spans with no message content', () => { + // Generation spans exist but have no input/output. Tool spans between + // them should still appear as a tool-call-only assistant message. + const gen1 = createMockNode({id: 'gen-1', startTimestamp: 1000}); + const tool = createMockToolNode({ + id: 'tool-1', + toolName: 'search', + startTimestamp: 1500, + }); + const gen2 = createMockNode({id: 'gen-2', startTimestamp: 2000}); + + const messages = extractMessagesFromNodes([gen1, tool, gen2] as any); + + const assistantMessages = messages.filter(m => m.role === 'assistant'); + expect(assistantMessages).toHaveLength(1); + expect(assistantMessages[0]?.content).toBe(''); + expect(assistantMessages[0]?.toolCalls).toHaveLength(1); + expect(assistantMessages[0]?.toolCalls?.[0]?.name).toBe('search'); + }); + }); + + describe('buildToolCallsFromSpans', () => { + it('converts tool spans to ToolCall objects', () => { + const tool1 = createMockToolNode({ + id: 'tool-1', + toolName: 'search', + startTimestamp: 1000, + endTimestamp: 1200, + }); + const tool2 = createMockToolNode({ + id: 'tool-2', + toolName: 'calculator', + startTimestamp: 2000, + endTimestamp: 2050, + }); + + const toolCalls = buildToolCallsFromSpans([tool1, tool2] as any); + + expect(toolCalls).toHaveLength(2); + expect(toolCalls[0]).toMatchObject({ + name: 'search', + nodeId: 'tool-1', + hasError: false, + }); + expect(toolCalls[1]).toMatchObject({ + name: 'calculator', + nodeId: 'tool-2', + hasError: false, + }); + }); + + it('skips spans without a tool name', () => { + const noName = createMockNode({id: 'gen-1'}); + const toolCalls = buildToolCallsFromSpans([noName] as any); + expect(toolCalls).toHaveLength(0); + }); + + it('returns empty array for empty input', () => { + expect(buildToolCallsFromSpans([])).toEqual([]); + }); }); describe('parseAssistantContent with reasoning', () => { diff --git a/static/app/views/explore/conversations/utils/conversationMessages.ts b/static/app/views/explore/conversations/utils/conversationMessages.ts index e30fdff2e6a1..042fdd65a84e 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.ts @@ -141,8 +141,10 @@ export function mergeEmptyTurns(turns: ConversationTurn[]): ConversationTurn[] { const result: ConversationTurn[] = []; let pendingToolCalls: ToolCall[] = []; let pendingToolSpanNodes: AITraceSpanNode[] = []; + let lastSeenTurn: ConversationTurn | null = null; for (const turn of turns) { + lastSeenTurn = turn; const allToolCalls = [...pendingToolCalls, ...turn.toolCalls]; const allToolSpanNodes = [...pendingToolSpanNodes, ...(turn.toolSpanNodes ?? [])]; @@ -163,14 +165,26 @@ export function mergeEmptyTurns(turns: ConversationTurn[]): ConversationTurn[] { } } - // Flush any remaining pending tool calls as a tool-call-only turn + // Flush any remaining pending tool calls as a tool-call-only turn. const lastTurn = result.at(-1); - if (pendingToolCalls.length > 0 && lastTurn) { - result[result.length - 1] = { - ...lastTurn, - toolCalls: [...lastTurn.toolCalls, ...pendingToolCalls], - toolSpanNodes: [...(lastTurn.toolSpanNodes ?? []), ...pendingToolSpanNodes], - }; + if (pendingToolCalls.length > 0) { + if (lastTurn) { + result[result.length - 1] = { + ...lastTurn, + toolCalls: [...lastTurn.toolCalls, ...pendingToolCalls], + toolSpanNodes: [...(lastTurn.toolSpanNodes ?? []), ...pendingToolSpanNodes], + }; + } else if (lastSeenTurn) { + // All generation spans had no content; surface the orphaned tool calls + // as a tool-call-only assistant turn anchored on the last generation span. + result.push({ + ...lastSeenTurn, + userContent: null, + assistantContent: null, + toolCalls: pendingToolCalls, + toolSpanNodes: pendingToolSpanNodes, + }); + } } return result; @@ -368,6 +382,30 @@ function getNodeEndTimestamp(node: AITraceSpanNode): number { return 0; } +/** + * Converts raw tool span nodes into ToolCall objects for direct rendering. + * Use this when there are no generation spans to anchor tool calls (e.g. when + * all generation spans lack message content, or when only tool spans are present). + */ +export function buildToolCallsFromSpans(spans: AITraceSpanNode[]): ToolCall[] { + return spans.flatMap(span => { + const name = getStringAttr(span, SpanFields.GEN_AI_TOOL_NAME); + if (!name) { + return []; + } + const start = getNodeStartTimestamp(span); + const end = getNodeEndTimestamp(span); + const duration = end > start ? end - start : undefined; + const toolCall: ToolCall = { + name, + nodeId: span.id, + hasError: hasError(span), + duration, + }; + return [toolCall]; + }); +} + function getGenAiOpType(node: AITraceSpanNode): string | undefined { return getStringAttr(node, SpanFields.GEN_AI_OPERATION_TYPE); }