diff --git a/static/app/views/explore/conversations/components/messagesPanel.spec.tsx b/static/app/views/explore/conversations/components/messagesPanel.spec.tsx index 3d6adc322ef5..fe31bda89a60 100644 --- a/static/app/views/explore/conversations/components/messagesPanel.spec.tsx +++ b/static/app/views/explore/conversations/components/messagesPanel.spec.tsx @@ -309,23 +309,27 @@ describe('MessagesPanel', () => { expect(screen.getByText('test@example.com')).toBeInTheDocument(); }); - it('deduplicates identical user messages', () => { - const sameMessage = JSON.stringify([{role: 'user', content: 'Duplicate message'}]); - + it('deduplicates a last user message replayed across a cumulative tool loop', () => { const node1 = createMockNode({ id: 'span-1', startTimestamp: 1000, attributes: { - [SpanFields.GEN_AI_REQUEST_MESSAGES]: sameMessage, + [SpanFields.GEN_AI_REQUEST_MESSAGES]: JSON.stringify([ + {role: 'user', content: 'Duplicate message'}, + ]), [SpanFields.GEN_AI_RESPONSE_TEXT]: 'Response 1', }, }); + // Cumulative history replaying the same last message: shown once. const node2 = createMockNode({ id: 'span-2', startTimestamp: 2000, attributes: { - [SpanFields.GEN_AI_REQUEST_MESSAGES]: sameMessage, + [SpanFields.GEN_AI_REQUEST_MESSAGES]: JSON.stringify([ + {role: 'user', content: 'Duplicate message'}, + {role: 'assistant', content: 'Response 1'}, + ]), [SpanFields.GEN_AI_RESPONSE_TEXT]: 'Response 2', }, }); diff --git a/static/app/views/explore/conversations/utils/conversationMessages.spec.ts b/static/app/views/explore/conversations/utils/conversationMessages.spec.ts index 4476c3750e29..ad00a3cb4b3b 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.spec.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.spec.ts @@ -3,6 +3,7 @@ import {SpanFields} from 'sentry/views/insights/types'; import { buildConversationTurns, extractMessagesFromNodes, + getInputMessageStats, getNodeTimestamp, mergeEmptyTurns, messagesToMarkdown, @@ -239,6 +240,52 @@ describe('conversationMessages utilities', () => { }); }); + describe('getInputMessageStats', () => { + it.each([ + { + name: 'counts total and user messages in a cumulative history', + input: JSON.stringify([ + {role: 'user', content: 'First'}, + {role: 'assistant', content: 'Reply'}, + {role: 'user', content: 'Second'}, + ]), + expected: {totalMessageCount: 3, userMessageCount: 2}, + }, + { + name: 'reports a single-message input as non-cumulative', + input: JSON.stringify([{role: 'user', content: 'Only message'}]), + expected: {totalMessageCount: 1, userMessageCount: 1}, + }, + { + name: 'excludes system messages from counts', + input: JSON.stringify([ + {role: 'system', content: 'You are a helpful assistant'}, + {role: 'user', content: 'Hello'}, + ]), + expected: {totalMessageCount: 1, userMessageCount: 1}, + }, + { + name: 'returns zeroes when input is scrubbed', + input: '[Filtered]', + expected: {totalMessageCount: 0, userMessageCount: 0}, + }, + ])('$name', ({input, expected}) => { + const node = createMockNode({ + id: 'node-1', + attributes: {[SpanFields.GEN_AI_INPUT_MESSAGES]: input}, + }); + expect(getInputMessageStats(node as any)).toEqual(expected); + }); + + it('returns zeroes when input is missing', () => { + const node = createMockNode({id: 'node-1'}); + expect(getInputMessageStats(node as any)).toEqual({ + totalMessageCount: 0, + userMessageCount: 0, + }); + }); + }); + describe('parseAssistantContent', () => { it('parses assistant message from gen_ai.output.messages', () => { const messages = JSON.stringify([{role: 'assistant', content: 'Output response'}]); @@ -617,34 +664,56 @@ describe('conversationMessages utilities', () => { }); }); - it('deduplicates user messages by exact content', () => { - const turns = [ + // Same user text in two turns: collapse only for a cumulative tool loop. + it.each([ + { + name: 'collapses identical content in a cumulative history', + turn1: {assistantContent: 'A1'}, + turn2: {assistantContent: 'A2'}, + users: 1, + }, + { + name: 'keeps a repeat when the cumulative user-message count grows', + turn1: {assistantContent: 'A1', userMessageCount: 1}, + turn2: {assistantContent: 'A2', userMessageCount: 2}, + users: 2, + }, + { + name: 'keeps a repeat from non-cumulative single-message inputs', + turn1: {assistantContent: 'A', hasInputHistory: false}, + turn2: {assistantContent: 'A', hasInputHistory: false}, + users: 2, + }, + { + name: 'collapses a repeat carried across a tool loop (stable count)', + turn1: { + toolCalls: [{name: 'weather', nodeId: 'tool-1', hasError: false}], + userMessageCount: 1, + }, + turn2: {assistantContent: 'A', userMessageCount: 1}, + users: 1, + }, + ])('deduplicates user messages: $name', ({turn1, turn2, users}) => { + const messages = turnsToMessages([ makeTurn({ generation: { id: 'gen-1', value: {start_timestamp: 1000, end_timestamp: 1100}, } as any, userContent: 'Hello', - assistantContent: 'Response 1', + ...turn1, }), makeTurn({ generation: { id: 'gen-2', value: {start_timestamp: 2000, end_timestamp: 2100}, } as any, - userContent: 'Hello', // Exact same content - assistantContent: 'Response 2', + userContent: 'Hello', + ...turn2, }), - ]; - - const messages = turnsToMessages(turns); - - // Should have 1 user message and 2 assistant messages - const userMessages = messages.filter(m => m.role === 'user'); - const assistantMessages = messages.filter(m => m.role === 'assistant'); + ]); - expect(userMessages).toHaveLength(1); - expect(assistantMessages).toHaveLength(2); + expect(messages.filter(m => m.role === 'user')).toHaveLength(users); }); it('does not deduplicate user messages with different whitespace or case', () => { @@ -1034,34 +1103,82 @@ describe('conversationMessages utilities', () => { ]); }); - it('deduplicates messages', () => { - const sameMessage = JSON.stringify([{role: 'user', content: 'Duplicate'}]); - + // Same question in two spans: collapse only for a cumulative tool loop. + const Q = 'How is the weather in Vienna?'; + it.each([ + { + name: 'collapses a last message replayed across a cumulative tool loop', + input2: [ + {role: 'user', content: Q}, + {role: 'assistant', content: 'R1'}, + ], + users: 1, + }, + { + name: 'keeps repeats from non-cumulative single-message inputs', + input2: [{role: 'user', content: Q}], + users: 2, + }, + { + name: 'keeps a repeat when the cumulative history gains a user message', + input2: [ + {role: 'user', content: Q}, + {role: 'assistant', content: 'R1'}, + {role: 'user', content: Q}, + ], + users: 2, + }, + ])('user-message dedup end to end: $name', ({input2, users}) => { const node1 = createMockNode({ id: 'span-1', startTimestamp: 1000, attributes: { - [SpanFields.GEN_AI_REQUEST_MESSAGES]: sameMessage, - [SpanFields.GEN_AI_RESPONSE_TEXT]: 'Response 1', + [SpanFields.GEN_AI_INPUT_MESSAGES]: JSON.stringify([ + {role: 'user', content: Q}, + ]), + [SpanFields.GEN_AI_RESPONSE_TEXT]: 'R1', }, }); - const node2 = createMockNode({ id: 'span-2', startTimestamp: 2000, attributes: { - [SpanFields.GEN_AI_REQUEST_MESSAGES]: sameMessage, - [SpanFields.GEN_AI_RESPONSE_TEXT]: 'Response 2', + [SpanFields.GEN_AI_INPUT_MESSAGES]: JSON.stringify(input2), + [SpanFields.GEN_AI_RESPONSE_TEXT]: 'R2', }, }); const messages = extractMessagesFromNodes([node1, node2] as any); - const userMessages = messages.filter(m => m.role === 'user'); - const assistantMessages = messages.filter(m => m.role === 'assistant'); + expect(messages.filter(m => m.role === 'user')).toHaveLength(users); + }); - expect(userMessages).toHaveLength(1); - expect(assistantMessages).toHaveLength(2); + it('keeps repeated user messages from non-cumulative inputs with a system prompt', () => { + const sysInput = (userText: string) => + JSON.stringify([ + {role: 'system', content: 'You are helpful'}, + {role: 'user', content: userText}, + ]); + + const node1 = createMockNode({ + id: 'span-1', + startTimestamp: 1000, + attributes: { + [SpanFields.GEN_AI_INPUT_MESSAGES]: sysInput(Q), + [SpanFields.GEN_AI_RESPONSE_TEXT]: 'R1', + }, + }); + const node2 = createMockNode({ + id: 'span-2', + startTimestamp: 2000, + attributes: { + [SpanFields.GEN_AI_INPUT_MESSAGES]: sysInput(Q), + [SpanFields.GEN_AI_RESPONSE_TEXT]: 'R2', + }, + }); + + const messages = extractMessagesFromNodes([node1, node2] as any); + expect(messages.filter(m => m.role === 'user')).toHaveLength(2); }); it('returns empty array for empty input', () => { diff --git a/static/app/views/explore/conversations/utils/conversationMessages.ts b/static/app/views/explore/conversations/utils/conversationMessages.ts index e30fdff2e6a1..fd45a2309e5a 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.ts @@ -46,7 +46,11 @@ interface ConversationTurn { toolCalls: ToolCall[]; userContent: string | null; userEmail: string | undefined; + // Input carries history (>1 message). Single-message inputs are never deduped. + hasInputHistory?: boolean; toolSpanNodes?: AITraceSpanNode[]; + // User messages in the input history; a growing count marks a genuine repeat. + userMessageCount?: number; } /** @@ -123,11 +127,14 @@ export function buildConversationTurns( .filter((tc): tc is ToolCall => tc !== null); const {content: assistantContent, reasoning} = parseAssistantContent(node); + const inputStats = getInputMessageStats(node); turns.push({ generation: node, toolCalls, toolSpanNodes: toolCallSpans, userContent: parseUserContent(node), + hasInputHistory: inputStats.totalMessageCount > 1, + userMessageCount: inputStats.userMessageCount, assistantContent, reasoning, userEmail, @@ -180,15 +187,24 @@ export function turnsToMessages(turns: ConversationTurn[]): ConversationMessage[ const messages: ConversationMessage[] = []; const seenUserContent = new Set(); const seenAssistantContent = new Set(); + let maxUserMessageCount = 0; for (const turn of turns) { const startTs = getNodeStartTimestamp(turn.generation); const genEnd = getNodeEndTimestamp(turn.generation); + // Only cumulative inputs are deduped; single-message inputs are genuine turns. + const hasHistory = turn.hasInputHistory ?? true; + const userMessageCount = turn.userMessageCount ?? 0; + const userCountGrew = userMessageCount > maxUserMessageCount; + maxUserMessageCount = Math.max(maxUserMessageCount, userMessageCount); + if ( turn.userContent && (turn.userContent === FILTERED || turn.userContent === EMPTY_TEXT_CONTENT || + !hasHistory || + userCountGrew || !seenUserContent.has(turn.userContent)) ) { seenUserContent.add(turn.userContent); @@ -289,6 +305,39 @@ export function parseUserContent(node: AITraceSpanNode): string | null { return userMessage.content; } +export interface InputMessageStats { + totalMessageCount: number; + userMessageCount: number; +} + +/** + * Counts messages in a generation's input to distinguish a genuine repeated + * user message from a carry-forward. Returns zeroes for missing or scrubbed + * input. + */ +export function getInputMessageStats(node: AITraceSpanNode): InputMessageStats { + const raw = + getStringAttr(node, SpanFields.GEN_AI_INPUT_MESSAGES) || + getStringAttr(node, SpanFields.GEN_AI_REQUEST_MESSAGES); + + if (!raw || raw === FILTERED) { + return {totalMessageCount: 0, userMessageCount: 0}; + } + + const {messages} = normalizeToMessages(raw, {defaultRole: 'user'}); + if (!messages) { + return {totalMessageCount: 0, userMessageCount: 0}; + } + // System prompts are not conversation history; exclude them so a + // non-cumulative SDK that always prepends a system message is still + // recognised as single-message (non-cumulative) input. + const nonSystem = messages.filter(m => m.role !== 'system'); + return { + totalMessageCount: nonSystem.length, + userMessageCount: nonSystem.filter(m => m.role === 'user').length, + }; +} + /** * Returns the assistant text response, checking `gen_ai.output.messages` * (every supported shape, including plain strings) and falling back to