Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MessagesPanelNew
nodes={[]}
Expand All @@ -67,7 +67,47 @@ describe('MessagesPanelNew', () => {
/>
);

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(
<MessagesPanelNew
nodes={[genNode] as any}
selectedNodeId={null}
onSelectNode={mockOnSelectNode}
nodeTraceMap={new Map()}
/>
);

// 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(
<MessagesPanelNew
nodes={[toolNode] as any}
selectedNodeId={null}
onSelectNode={mockOnSelectNode}
nodeTraceMap={new Map()}
/>
);

expect(screen.getByText('search')).toBeInTheDocument();
expect(
screen.getByText('Message inputs and outputs were not captured')
).toBeInTheDocument();
});

it('renders user and assistant messages', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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 (
<PanelContainer>
<EmptyMessage>{t('No messages found')}</EmptyMessage>
<Stack gap="0" width="100%">
{hasGenerations && (
<UserMessageBlock>
<MessageText align="left" variant="muted">
{t('(not captured)')}
</MessageText>
</UserMessageBlock>
)}
{orphanToolCalls.length > 0 && (
<MessageBlock>
<MessageToolCallsNew
toolCalls={orphanToolCalls}
selectedNodeId={selectedNodeId}
nodeMap={nodeMap}
nodeTraceMap={nodeTraceMap}
onSelectNode={onSelectNode}
/>
</MessageBlock>
)}
{hasGenerations ? (
<AssistantMessageBlock>
<MessageText align="left" variant="muted">
{t('(not captured)')}
</MessageText>
</AssistantMessageBlock>
) : (
<Flex padding="md xl" width="100%" justify="center">
<Text size="sm" variant="muted" align="center">
{t('Message inputs and outputs were not captured')}
</Text>
</Flex>
)}
</Stack>
</PanelContainer>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {SpanFields} from 'sentry/views/insights/types';

import {
buildConversationTurns,
buildToolCallsFromSpans,
extractMessagesFromNodes,
getNodeTimestamp,
mergeEmptyTurns,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [])];

Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Loading