From d970d2e92687930ef3989cd91fc0db127a94332a Mon Sep 17 00:00:00 2001 From: Ognjen Bostjancic Date: Mon, 6 Jul 2026 12:41:08 +0200 Subject: [PATCH 1/6] fix(conversations): Keep repeated user messages in transcript The transcript view deduplicated user messages purely by content, so when a user genuinely sent the same message twice in separate turns, the second occurrence was dropped and its assistant reply appeared to have no prompt. Track the number of user messages in each generation's cumulative input history. A tool-loop follow-up reuses the same last user message and keeps a stable count, while a genuine repeat grows the count. Emit a user message when the count grows, in addition to the existing content-based dedup, so tool loops still collapse but real repeats are preserved. Refs TET-2602 Co-Authored-By: Claude --- .../utils/conversationMessages.spec.ts | 131 ++++++++++++++++++ .../utils/conversationMessages.ts | 40 ++++++ 2 files changed, 171 insertions(+) diff --git a/static/app/views/explore/conversations/utils/conversationMessages.spec.ts b/static/app/views/explore/conversations/utils/conversationMessages.spec.ts index 4476c3750e29..81a941060ff1 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, + countUserMessages, extractMessagesFromNodes, getNodeTimestamp, mergeEmptyTurns, @@ -239,6 +240,35 @@ describe('conversationMessages utilities', () => { }); }); + describe('countUserMessages', () => { + it('counts user messages in the input history', () => { + const node = createMockNode({ + id: 'node-1', + attributes: { + [SpanFields.GEN_AI_INPUT_MESSAGES]: JSON.stringify([ + {role: 'user', content: 'First'}, + {role: 'assistant', content: 'Reply'}, + {role: 'user', content: 'Second'}, + ]), + }, + }); + expect(countUserMessages(node as any)).toBe(2); + }); + + it('returns 0 when input is missing', () => { + const node = createMockNode({id: 'node-1'}); + expect(countUserMessages(node as any)).toBe(0); + }); + + it('returns 0 when input is scrubbed', () => { + const node = createMockNode({ + id: 'node-1', + attributes: {[SpanFields.GEN_AI_INPUT_MESSAGES]: '[Filtered]'}, + }); + expect(countUserMessages(node as any)).toBe(0); + }); + }); + describe('parseAssistantContent', () => { it('parses assistant message from gen_ai.output.messages', () => { const messages = JSON.stringify([{role: 'assistant', content: 'Output response'}]); @@ -647,6 +677,70 @@ describe('conversationMessages utilities', () => { expect(assistantMessages).toHaveLength(2); }); + it('keeps a repeated user message when the input user-message count grows', () => { + // The user asked the exact same question twice in two separate turns. + // The second generation's input history contains both user messages, so + // its userMessageCount is higher and the repeat must be preserved. + const turns = [ + makeTurn({ + generation: { + id: 'gen-1', + value: {start_timestamp: 1000, end_timestamp: 1100}, + } as any, + userContent: 'How is the weather in Vienna?', + assistantContent: 'It is sunny', + userMessageCount: 1, + }), + makeTurn({ + generation: { + id: 'gen-2', + value: {start_timestamp: 2000, end_timestamp: 2100}, + } as any, + userContent: 'How is the weather in Vienna?', // Exact same content + assistantContent: 'Still sunny', + userMessageCount: 2, + }), + ]; + + const messages = turnsToMessages(turns); + + const userMessages = messages.filter(m => m.role === 'user'); + const assistantMessages = messages.filter(m => m.role === 'assistant'); + + expect(userMessages).toHaveLength(2); + expect(assistantMessages).toHaveLength(2); + }); + + it('still collapses a repeated user message across a tool loop (stable count)', () => { + // Two generations in the same turn (tool loop) share the same last user + // message and the same userMessageCount, so only one user message shows. + const turns = [ + makeTurn({ + generation: { + id: 'gen-1', + value: {start_timestamp: 1000, end_timestamp: 1100}, + } as any, + userContent: 'How is the weather in Vienna?', + toolCalls: [{name: 'weather', nodeId: 'tool-1', hasError: false}], + userMessageCount: 1, + }), + makeTurn({ + generation: { + id: 'gen-2', + value: {start_timestamp: 2000, end_timestamp: 2100}, + } as any, + userContent: 'How is the weather in Vienna?', + assistantContent: 'It is sunny', + userMessageCount: 1, + }), + ]; + + const messages = turnsToMessages(turns); + + const userMessages = messages.filter(m => m.role === 'user'); + expect(userMessages).toHaveLength(1); + }); + it('does not deduplicate user messages with different whitespace or case', () => { const turns = [ makeTurn({ @@ -1064,6 +1158,43 @@ describe('conversationMessages utilities', () => { expect(assistantMessages).toHaveLength(2); }); + it('keeps a genuinely repeated user message across separate turns', () => { + // First turn: only the initial question in the input history. + const gen1 = createMockNode({ + id: 'gen-1', + startTimestamp: 1000, + attributes: { + [SpanFields.GEN_AI_INPUT_MESSAGES]: JSON.stringify([ + {role: 'user', content: 'How is the weather in Vienna?'}, + ]), + [SpanFields.GEN_AI_RESPONSE_TEXT]: 'It is sunny', + }, + }); + + // Second turn: the user asked the same question again, so the cumulative + // input history now contains two identical user messages. + const gen2 = createMockNode({ + id: 'gen-2', + startTimestamp: 2000, + attributes: { + [SpanFields.GEN_AI_INPUT_MESSAGES]: JSON.stringify([ + {role: 'user', content: 'How is the weather in Vienna?'}, + {role: 'assistant', content: 'It is sunny'}, + {role: 'user', content: 'How is the weather in Vienna?'}, + ]), + [SpanFields.GEN_AI_RESPONSE_TEXT]: 'Still sunny', + }, + }); + + const messages = extractMessagesFromNodes([gen1, gen2] as any); + + const userMessages = messages.filter(m => m.role === 'user'); + expect(userMessages).toHaveLength(2); + expect(userMessages.every(m => m.content === 'How is the weather in Vienna?')).toBe( + true + ); + }); + it('returns empty array for empty input', () => { expect(extractMessagesFromNodes([])).toEqual([]); }); diff --git a/static/app/views/explore/conversations/utils/conversationMessages.ts b/static/app/views/explore/conversations/utils/conversationMessages.ts index e30fdff2e6a1..4f118212ab13 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.ts @@ -47,6 +47,10 @@ interface ConversationTurn { userContent: string | null; userEmail: string | undefined; toolSpanNodes?: AITraceSpanNode[]; + // Number of user messages in this generation's input history. Used to tell + // apart a genuine repeated user message (count grows) from the same + // last-user-message carried across tool-loop generations (count is stable). + userMessageCount?: number; } /** @@ -128,6 +132,7 @@ export function buildConversationTurns( toolCalls, toolSpanNodes: toolCallSpans, userContent: parseUserContent(node), + userMessageCount: countUserMessages(node), assistantContent, reasoning, userEmail, @@ -180,15 +185,24 @@ export function turnsToMessages(turns: ConversationTurn[]): ConversationMessage[ const messages: ConversationMessage[] = []; const seenUserContent = new Set(); const seenAssistantContent = new Set(); + // Highest number of user messages seen in any generation's input so far. A + // growing count means the user genuinely sent another message, even when its + // text is identical to a previous one. + let maxUserMessageCount = 0; for (const turn of turns) { const startTs = getNodeStartTimestamp(turn.generation); const genEnd = getNodeEndTimestamp(turn.generation); + 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 || + userCountGrew || !seenUserContent.has(turn.userContent)) ) { seenUserContent.add(turn.userContent); @@ -289,6 +303,32 @@ export function parseUserContent(node: AITraceSpanNode): string | null { return userMessage.content; } +/** + * Counts user messages in a generation's input history. Because + * `gen_ai.input.messages` typically carries the full cumulative conversation, + * this count grows by one each time the user sends a new message — even if that + * message repeats earlier text — while staying stable across the extra + * generations produced by a single tool-loop turn. + * + * Returns 0 when the input is missing or scrubbed, so those turns fall back to + * content-based deduplication. + */ +export function countUserMessages(node: AITraceSpanNode): number { + const raw = + getStringAttr(node, SpanFields.GEN_AI_INPUT_MESSAGES) || + getStringAttr(node, SpanFields.GEN_AI_REQUEST_MESSAGES); + + if (!raw || raw === FILTERED) { + return 0; + } + + const {messages} = normalizeToMessages(raw, {defaultRole: 'user'}); + if (!messages) { + return 0; + } + return messages.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 From d751c094d562c4cb6c76adfc4d5d8554c8bed042 Mon Sep 17 00:00:00 2001 From: Ognjen Bostjancic Date: Mon, 6 Jul 2026 12:56:21 +0200 Subject: [PATCH 2/6] fix(conversations): Keep repeated user messages in transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transcript deduplicated user messages purely by content, so when a user sent the same message twice in separate turns the second occurrence — and its assistant reply — were dropped. Deduplication only makes sense for cumulative SDKs, where a single turn's tool loop replays the same last user message across several generation spans. With non-cumulative SDKs each generation carries only the current message, so two spans with identical content are two genuine turns. Detect this per generation from the input history: a single-message input is non-cumulative and is never deduplicated, while cumulative inputs keep the existing content dedup plus a user-message-count check so genuine repeats in a cumulative history are preserved too. The same single-message gate is applied to assistant messages so an identical answer to a repeated question still shows. Refs TET-2602 Co-Authored-By: Claude --- .../utils/conversationMessages.spec.ts | 124 ++++++++++++++++-- .../utils/conversationMessages.ts | 51 +++++-- 2 files changed, 150 insertions(+), 25 deletions(-) diff --git a/static/app/views/explore/conversations/utils/conversationMessages.spec.ts b/static/app/views/explore/conversations/utils/conversationMessages.spec.ts index 81a941060ff1..0f0e0f00116f 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.spec.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.spec.ts @@ -2,8 +2,8 @@ import {SpanFields} from 'sentry/views/insights/types'; import { buildConversationTurns, - countUserMessages, extractMessagesFromNodes, + getInputMessageStats, getNodeTimestamp, mergeEmptyTurns, messagesToMarkdown, @@ -240,8 +240,8 @@ describe('conversationMessages utilities', () => { }); }); - describe('countUserMessages', () => { - it('counts user messages in the input history', () => { + describe('getInputMessageStats', () => { + it('counts total and user messages in the input history', () => { const node = createMockNode({ id: 'node-1', attributes: { @@ -252,20 +252,44 @@ describe('conversationMessages utilities', () => { ]), }, }); - expect(countUserMessages(node as any)).toBe(2); + expect(getInputMessageStats(node as any)).toEqual({ + totalMessageCount: 3, + userMessageCount: 2, + }); + }); + + it('reports a single-message input as non-cumulative', () => { + const node = createMockNode({ + id: 'node-1', + attributes: { + [SpanFields.GEN_AI_INPUT_MESSAGES]: JSON.stringify([ + {role: 'user', content: 'Only message'}, + ]), + }, + }); + expect(getInputMessageStats(node as any)).toEqual({ + totalMessageCount: 1, + userMessageCount: 1, + }); }); - it('returns 0 when input is missing', () => { + it('returns zeroes when input is missing', () => { const node = createMockNode({id: 'node-1'}); - expect(countUserMessages(node as any)).toBe(0); + expect(getInputMessageStats(node as any)).toEqual({ + totalMessageCount: 0, + userMessageCount: 0, + }); }); - it('returns 0 when input is scrubbed', () => { + it('returns zeroes when input is scrubbed', () => { const node = createMockNode({ id: 'node-1', attributes: {[SpanFields.GEN_AI_INPUT_MESSAGES]: '[Filtered]'}, }); - expect(countUserMessages(node as any)).toBe(0); + expect(getInputMessageStats(node as any)).toEqual({ + totalMessageCount: 0, + userMessageCount: 0, + }); }); }); @@ -711,6 +735,38 @@ describe('conversationMessages utilities', () => { expect(assistantMessages).toHaveLength(2); }); + it('keeps a repeated user message when the input has no history', () => { + // Non-cumulative SDK: single-message inputs, so identical content in two + // turns is two genuine questions even though the count never grows. + const turns = [ + makeTurn({ + generation: { + id: 'gen-1', + value: {start_timestamp: 1000, end_timestamp: 1100}, + } as any, + userContent: 'How is the weather in Vienna?', + assistantContent: 'It is sunny', + hasInputHistory: false, + userMessageCount: 1, + }), + makeTurn({ + generation: { + id: 'gen-2', + value: {start_timestamp: 2000, end_timestamp: 2100}, + } as any, + userContent: 'How is the weather in Vienna?', // Exact same content + assistantContent: 'It is sunny', // Identical answer too + hasInputHistory: false, + userMessageCount: 1, + }), + ]; + + const messages = turnsToMessages(turns); + + expect(messages.filter(m => m.role === 'user')).toHaveLength(2); + expect(messages.filter(m => m.role === 'assistant')).toHaveLength(2); + }); + it('still collapses a repeated user message across a tool loop (stable count)', () => { // Two generations in the same turn (tool loop) share the same last user // message and the same userMessageCount, so only one user message shows. @@ -1128,14 +1184,17 @@ describe('conversationMessages utilities', () => { ]); }); - it('deduplicates messages', () => { - const sameMessage = JSON.stringify([{role: 'user', content: 'Duplicate'}]); - + it('collapses a repeated last user message carried across a cumulative tool loop', () => { + // Cumulative SDK: the second generation's input replays the same last + // user message from history (with only one user message total), so it is + // the same turn and should be shown once. const node1 = createMockNode({ id: 'span-1', startTimestamp: 1000, attributes: { - [SpanFields.GEN_AI_REQUEST_MESSAGES]: sameMessage, + [SpanFields.GEN_AI_INPUT_MESSAGES]: JSON.stringify([ + {role: 'user', content: 'Duplicate'}, + ]), [SpanFields.GEN_AI_RESPONSE_TEXT]: 'Response 1', }, }); @@ -1144,7 +1203,10 @@ describe('conversationMessages utilities', () => { id: 'span-2', startTimestamp: 2000, attributes: { - [SpanFields.GEN_AI_REQUEST_MESSAGES]: sameMessage, + [SpanFields.GEN_AI_INPUT_MESSAGES]: JSON.stringify([ + {role: 'user', content: 'Duplicate'}, + {role: 'assistant', content: 'Response 1'}, + ]), [SpanFields.GEN_AI_RESPONSE_TEXT]: 'Response 2', }, }); @@ -1158,6 +1220,42 @@ describe('conversationMessages utilities', () => { expect(assistantMessages).toHaveLength(2); }); + it('keeps repeated user messages when each span carries a single-message input', () => { + // Non-cumulative SDK (the TET-2602 case): each generation carries only + // the current message, so two spans with the same question are two + // genuine turns and must both appear. + const singleMessage = JSON.stringify([ + {role: 'user', content: 'How is the weather in Vienna?'}, + ]); + + const node1 = createMockNode({ + id: 'span-1', + startTimestamp: 1000, + attributes: { + [SpanFields.GEN_AI_INPUT_MESSAGES]: singleMessage, + [SpanFields.GEN_AI_RESPONSE_TEXT]: 'It is sunny', + }, + }); + + const node2 = createMockNode({ + id: 'span-2', + startTimestamp: 2000, + attributes: { + [SpanFields.GEN_AI_INPUT_MESSAGES]: singleMessage, + [SpanFields.GEN_AI_RESPONSE_TEXT]: 'It is sunny', + }, + }); + + const messages = extractMessagesFromNodes([node1, node2] as any); + + const userMessages = messages.filter(m => m.role === 'user'); + const assistantMessages = messages.filter(m => m.role === 'assistant'); + + // Both the repeated question and its (identical) answer are preserved. + expect(userMessages).toHaveLength(2); + expect(assistantMessages).toHaveLength(2); + }); + it('keeps a genuinely repeated user message across separate turns', () => { // First turn: only the initial question in the input history. const gen1 = createMockNode({ diff --git a/static/app/views/explore/conversations/utils/conversationMessages.ts b/static/app/views/explore/conversations/utils/conversationMessages.ts index 4f118212ab13..61cd3cee9c21 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.ts @@ -46,10 +46,16 @@ interface ConversationTurn { toolCalls: ToolCall[]; userContent: string | null; userEmail: string | undefined; + // Whether this generation's input carries prior conversation history (more + // than one message). Single-message inputs are non-cumulative SDKs where + // every generation is a genuine new turn, so they are never deduplicated. + // Defaults to true (assume cumulative) when unknown. + hasInputHistory?: boolean; toolSpanNodes?: AITraceSpanNode[]; // Number of user messages in this generation's input history. Used to tell // apart a genuine repeated user message (count grows) from the same - // last-user-message carried across tool-loop generations (count is stable). + // last-user-message carried across tool-loop generations (count is stable) + // in cumulative SDKs. userMessageCount?: number; } @@ -127,12 +133,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), - userMessageCount: countUserMessages(node), + hasInputHistory: inputStats.totalMessageCount > 1, + userMessageCount: inputStats.userMessageCount, assistantContent, reasoning, userEmail, @@ -194,6 +202,10 @@ export function turnsToMessages(turns: ConversationTurn[]): ConversationMessage[ const startTs = getNodeStartTimestamp(turn.generation); const genEnd = getNodeEndTimestamp(turn.generation); + // Non-cumulative SDKs send only the current message per generation, so a + // repeated user message is always a genuine new turn. Only cumulative + // inputs (history present) are deduplicated. + const hasHistory = turn.hasInputHistory ?? true; const userMessageCount = turn.userMessageCount ?? 0; const userCountGrew = userMessageCount > maxUserMessageCount; maxUserMessageCount = Math.max(maxUserMessageCount, userMessageCount); @@ -202,6 +214,7 @@ export function turnsToMessages(turns: ConversationTurn[]): ConversationMessage[ turn.userContent && (turn.userContent === FILTERED || turn.userContent === EMPTY_TEXT_CONTENT || + !hasHistory || userCountGrew || !seenUserContent.has(turn.userContent)) ) { @@ -220,6 +233,7 @@ export function turnsToMessages(turns: ConversationTurn[]): ConversationMessage[ turn.assistantContent && (turn.assistantContent === FILTERED || turn.assistantContent === EMPTY_TEXT_CONTENT || + !hasHistory || !seenAssistantContent.has(turn.assistantContent)); const hasToolCalls = turn.toolCalls.length > 0; @@ -303,30 +317,43 @@ export function parseUserContent(node: AITraceSpanNode): string | null { return userMessage.content; } +export interface InputMessageStats { + totalMessageCount: number; + userMessageCount: number; +} + /** - * Counts user messages in a generation's input history. Because - * `gen_ai.input.messages` typically carries the full cumulative conversation, - * this count grows by one each time the user sends a new message — even if that - * message repeats earlier text — while staying stable across the extra - * generations produced by a single tool-loop turn. + * Parses a generation's input history into the counts used to decide whether a + * repeated user message is a genuine new turn or a carry-forward. + * + * `totalMessageCount` separates cumulative SDKs (full history, more than one + * message) from non-cumulative SDKs (only the current message). A single + * message means every generation is a genuine turn, so it is never deduped. * - * Returns 0 when the input is missing or scrubbed, so those turns fall back to + * `userMessageCount` grows each time a cumulative history gains another user + * message, even when the text repeats, while staying stable across the extra + * generations of a single tool-loop turn. + * + * Both are 0 when the input is missing or scrubbed, so those turns fall back to * content-based deduplication. */ -export function countUserMessages(node: AITraceSpanNode): number { +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 0; + return {totalMessageCount: 0, userMessageCount: 0}; } const {messages} = normalizeToMessages(raw, {defaultRole: 'user'}); if (!messages) { - return 0; + return {totalMessageCount: 0, userMessageCount: 0}; } - return messages.filter(m => m.role === 'user').length; + return { + totalMessageCount: messages.length, + userMessageCount: messages.filter(m => m.role === 'user').length, + }; } /** From 17dbf9d124a6364e5ae2c630e4c6e930d439b616 Mon Sep 17 00:00:00 2001 From: Ognjen Bostjancic Date: Mon, 6 Jul 2026 13:10:38 +0200 Subject: [PATCH 3/6] fix(conversations): Revert assistant dedup change and fold tests The single-message gate must not apply to assistant messages: a non-cumulative SDK can emit the same assistant output across two spans within one turn, and that duplicate still needs collapsing. Keep the original global assistant dedup and scope the fix to user messages only. Also fold the overlapping dedup test cases into it.each tables and tighten the comments. Refs TET-2602 Co-Authored-By: Claude --- .../utils/conversationMessages.spec.ts | 334 +++++------------- .../utils/conversationMessages.ts | 36 +- 2 files changed, 108 insertions(+), 262 deletions(-) diff --git a/static/app/views/explore/conversations/utils/conversationMessages.spec.ts b/static/app/views/explore/conversations/utils/conversationMessages.spec.ts index 0f0e0f00116f..f66864bc5c0d 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.spec.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.spec.ts @@ -241,36 +241,32 @@ describe('conversationMessages utilities', () => { }); describe('getInputMessageStats', () => { - it('counts total and user messages in the input history', () => { + 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: '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]: JSON.stringify([ - {role: 'user', content: 'First'}, - {role: 'assistant', content: 'Reply'}, - {role: 'user', content: 'Second'}, - ]), - }, - }); - expect(getInputMessageStats(node as any)).toEqual({ - totalMessageCount: 3, - userMessageCount: 2, - }); - }); - - it('reports a single-message input as non-cumulative', () => { - const node = createMockNode({ - id: 'node-1', - attributes: { - [SpanFields.GEN_AI_INPUT_MESSAGES]: JSON.stringify([ - {role: 'user', content: 'Only message'}, - ]), - }, - }); - expect(getInputMessageStats(node as any)).toEqual({ - totalMessageCount: 1, - userMessageCount: 1, + attributes: {[SpanFields.GEN_AI_INPUT_MESSAGES]: input}, }); + expect(getInputMessageStats(node as any)).toEqual(expected); }); it('returns zeroes when input is missing', () => { @@ -280,17 +276,6 @@ describe('conversationMessages utilities', () => { userMessageCount: 0, }); }); - - it('returns zeroes when input is scrubbed', () => { - const node = createMockNode({ - id: 'node-1', - attributes: {[SpanFields.GEN_AI_INPUT_MESSAGES]: '[Filtered]'}, - }); - expect(getInputMessageStats(node as any)).toEqual({ - totalMessageCount: 0, - userMessageCount: 0, - }); - }); }); describe('parseAssistantContent', () => { @@ -671,130 +656,59 @@ describe('conversationMessages utilities', () => { }); }); - it('deduplicates user messages by exact content', () => { - const turns = [ - makeTurn({ - generation: { - id: 'gen-1', - value: {start_timestamp: 1000, end_timestamp: 1100}, - } as any, - userContent: 'Hello', - assistantContent: 'Response 1', - }), - makeTurn({ - generation: { - id: 'gen-2', - value: {start_timestamp: 2000, end_timestamp: 2100}, - } as any, - userContent: 'Hello', // Exact same content - assistantContent: 'Response 2', - }), - ]; - - 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); - }); - - it('keeps a repeated user message when the input user-message count grows', () => { - // The user asked the exact same question twice in two separate turns. - // The second generation's input history contains both user messages, so - // its userMessageCount is higher and the repeat must be preserved. - const turns = [ - makeTurn({ - generation: { - id: 'gen-1', - value: {start_timestamp: 1000, end_timestamp: 1100}, - } as any, - userContent: 'How is the weather in Vienna?', - assistantContent: 'It is sunny', - userMessageCount: 1, - }), - makeTurn({ - generation: { - id: 'gen-2', - value: {start_timestamp: 2000, end_timestamp: 2100}, - } as any, - userContent: 'How is the weather in Vienna?', // Exact same content - assistantContent: 'Still sunny', - userMessageCount: 2, - }), - ]; - - const messages = turnsToMessages(turns); - - const userMessages = messages.filter(m => m.role === 'user'); - const assistantMessages = messages.filter(m => m.role === 'assistant'); - - expect(userMessages).toHaveLength(2); - expect(assistantMessages).toHaveLength(2); - }); - - it('keeps a repeated user message when the input has no history', () => { - // Non-cumulative SDK: single-message inputs, so identical content in two - // turns is two genuine questions even though the count never grows. - const turns = [ - makeTurn({ - generation: { - id: 'gen-1', - value: {start_timestamp: 1000, end_timestamp: 1100}, - } as any, - userContent: 'How is the weather in Vienna?', - assistantContent: 'It is sunny', - hasInputHistory: false, - userMessageCount: 1, - }), - makeTurn({ - generation: { - id: 'gen-2', - value: {start_timestamp: 2000, end_timestamp: 2100}, - } as any, - userContent: 'How is the weather in Vienna?', // Exact same content - assistantContent: 'It is sunny', // Identical answer too - hasInputHistory: false, + // Same user text in two turns. Whether it collapses depends on the input + // shape: a cumulative tool loop replays the same last message (collapse), + // while a growing user-message count or a non-cumulative single-message + // input marks a genuine repeat (keep). + 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, - }), - ]; - - const messages = turnsToMessages(turns); - - expect(messages.filter(m => m.role === 'user')).toHaveLength(2); - expect(messages.filter(m => m.role === 'assistant')).toHaveLength(2); - }); - - it('still collapses a repeated user message across a tool loop (stable count)', () => { - // Two generations in the same turn (tool loop) share the same last user - // message and the same userMessageCount, so only one user message shows. - const turns = [ + }, + 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: 'How is the weather in Vienna?', - toolCalls: [{name: 'weather', nodeId: 'tool-1', hasError: false}], - userMessageCount: 1, + userContent: 'Hello', + ...turn1, }), makeTurn({ generation: { id: 'gen-2', value: {start_timestamp: 2000, end_timestamp: 2100}, } as any, - userContent: 'How is the weather in Vienna?', - assistantContent: 'It is sunny', - userMessageCount: 1, + userContent: 'Hello', + ...turn2, }), - ]; - - const messages = turnsToMessages(turns); + ]); - const userMessages = messages.filter(m => m.role === 'user'); - expect(userMessages).toHaveLength(1); + expect(messages.filter(m => m.role === 'user')).toHaveLength(users); }); it('does not deduplicate user messages with different whitespace or case', () => { @@ -1184,113 +1098,57 @@ describe('conversationMessages utilities', () => { ]); }); - it('collapses a repeated last user message carried across a cumulative tool loop', () => { - // Cumulative SDK: the second generation's input replays the same last - // user message from history (with only one user message total), so it is - // the same turn and should be shown once. + // The same question "Q" in two spans. It collapses only when the second + // span's input proves it is the same cumulative turn (a tool loop replaying + // the last message); a non-cumulative single-message input or a growing + // cumulative history keeps both. + 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_INPUT_MESSAGES]: JSON.stringify([ - {role: 'user', content: 'Duplicate'}, + {role: 'user', content: Q}, ]), - [SpanFields.GEN_AI_RESPONSE_TEXT]: 'Response 1', + [SpanFields.GEN_AI_RESPONSE_TEXT]: 'R1', }, }); - const node2 = createMockNode({ id: 'span-2', startTimestamp: 2000, attributes: { - [SpanFields.GEN_AI_INPUT_MESSAGES]: JSON.stringify([ - {role: 'user', content: 'Duplicate'}, - {role: 'assistant', content: 'Response 1'}, - ]), - [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(userMessages).toHaveLength(1); - expect(assistantMessages).toHaveLength(2); - }); - - it('keeps repeated user messages when each span carries a single-message input', () => { - // Non-cumulative SDK (the TET-2602 case): each generation carries only - // the current message, so two spans with the same question are two - // genuine turns and must both appear. - const singleMessage = JSON.stringify([ - {role: 'user', content: 'How is the weather in Vienna?'}, - ]); - - const node1 = createMockNode({ - id: 'span-1', - startTimestamp: 1000, - attributes: { - [SpanFields.GEN_AI_INPUT_MESSAGES]: singleMessage, - [SpanFields.GEN_AI_RESPONSE_TEXT]: 'It is sunny', - }, - }); - - const node2 = createMockNode({ - id: 'span-2', - startTimestamp: 2000, - attributes: { - [SpanFields.GEN_AI_INPUT_MESSAGES]: singleMessage, - [SpanFields.GEN_AI_RESPONSE_TEXT]: 'It is sunny', - }, - }); - - const messages = extractMessagesFromNodes([node1, node2] as any); - - const userMessages = messages.filter(m => m.role === 'user'); - const assistantMessages = messages.filter(m => m.role === 'assistant'); - - // Both the repeated question and its (identical) answer are preserved. - expect(userMessages).toHaveLength(2); - expect(assistantMessages).toHaveLength(2); - }); - - it('keeps a genuinely repeated user message across separate turns', () => { - // First turn: only the initial question in the input history. - const gen1 = createMockNode({ - id: 'gen-1', - startTimestamp: 1000, - attributes: { - [SpanFields.GEN_AI_INPUT_MESSAGES]: JSON.stringify([ - {role: 'user', content: 'How is the weather in Vienna?'}, - ]), - [SpanFields.GEN_AI_RESPONSE_TEXT]: 'It is sunny', - }, - }); - - // Second turn: the user asked the same question again, so the cumulative - // input history now contains two identical user messages. - const gen2 = createMockNode({ - id: 'gen-2', - startTimestamp: 2000, - attributes: { - [SpanFields.GEN_AI_INPUT_MESSAGES]: JSON.stringify([ - {role: 'user', content: 'How is the weather in Vienna?'}, - {role: 'assistant', content: 'It is sunny'}, - {role: 'user', content: 'How is the weather in Vienna?'}, - ]), - [SpanFields.GEN_AI_RESPONSE_TEXT]: 'Still sunny', - }, - }); - - const messages = extractMessagesFromNodes([gen1, gen2] as any); - - const userMessages = messages.filter(m => m.role === 'user'); - expect(userMessages).toHaveLength(2); - expect(userMessages.every(m => m.content === 'How is the weather in Vienna?')).toBe( - true - ); + expect(messages.filter(m => m.role === 'user')).toHaveLength(users); }); 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 61cd3cee9c21..639e60ca9d35 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.ts @@ -46,16 +46,13 @@ interface ConversationTurn { toolCalls: ToolCall[]; userContent: string | null; userEmail: string | undefined; - // Whether this generation's input carries prior conversation history (more - // than one message). Single-message inputs are non-cumulative SDKs where - // every generation is a genuine new turn, so they are never deduplicated. + // Whether the input carries history (>1 message). Single-message inputs are + // non-cumulative SDKs, so every generation is a genuine turn (never deduped). // Defaults to true (assume cumulative) when unknown. hasInputHistory?: boolean; toolSpanNodes?: AITraceSpanNode[]; - // Number of user messages in this generation's input history. Used to tell - // apart a genuine repeated user message (count grows) from the same - // last-user-message carried across tool-loop generations (count is stable) - // in cumulative SDKs. + // User messages in the cumulative input history. A growing count marks a + // genuine repeat vs. the same last message carried across a tool loop. userMessageCount?: number; } @@ -202,9 +199,8 @@ export function turnsToMessages(turns: ConversationTurn[]): ConversationMessage[ const startTs = getNodeStartTimestamp(turn.generation); const genEnd = getNodeEndTimestamp(turn.generation); - // Non-cumulative SDKs send only the current message per generation, so a - // repeated user message is always a genuine new turn. Only cumulative - // inputs (history present) are deduplicated. + // Only cumulative inputs are deduped; a non-cumulative (single-message) + // input is always a genuine new turn. const hasHistory = turn.hasInputHistory ?? true; const userMessageCount = turn.userMessageCount ?? 0; const userCountGrew = userMessageCount > maxUserMessageCount; @@ -233,7 +229,6 @@ export function turnsToMessages(turns: ConversationTurn[]): ConversationMessage[ turn.assistantContent && (turn.assistantContent === FILTERED || turn.assistantContent === EMPTY_TEXT_CONTENT || - !hasHistory || !seenAssistantContent.has(turn.assistantContent)); const hasToolCalls = turn.toolCalls.length > 0; @@ -323,19 +318,12 @@ export interface InputMessageStats { } /** - * Parses a generation's input history into the counts used to decide whether a - * repeated user message is a genuine new turn or a carry-forward. - * - * `totalMessageCount` separates cumulative SDKs (full history, more than one - * message) from non-cumulative SDKs (only the current message). A single - * message means every generation is a genuine turn, so it is never deduped. - * - * `userMessageCount` grows each time a cumulative history gains another user - * message, even when the text repeats, while staying stable across the extra - * generations of a single tool-loop turn. - * - * Both are 0 when the input is missing or scrubbed, so those turns fall back to - * content-based deduplication. + * Counts messages in a generation's input, used to decide whether a repeated + * user message is a genuine new turn or a carry-forward. `totalMessageCount` + * separates cumulative SDKs (>1 message) from non-cumulative ones (only the + * current message); `userMessageCount` grows on a genuine repeat within a + * cumulative history. Both are 0 for missing or scrubbed input, which falls + * back to content-based dedup. */ export function getInputMessageStats(node: AITraceSpanNode): InputMessageStats { const raw = From 76ee0f7e3c2f360d4622c2d372009e950a915abb Mon Sep 17 00:00:00 2001 From: Ognjen Bostjancic Date: Mon, 6 Jul 2026 13:21:46 +0200 Subject: [PATCH 4/6] test(conversations): Update legacy panel dedup test for cumulative input The MessagesPanel dedup test encoded the old single-message collapse. Reshape it to the cumulative tool-loop case, where deduplicating a replayed last user message is still correct. Refs TET-2602 Co-Authored-By: Claude --- .../components/messagesPanel.spec.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/static/app/views/explore/conversations/components/messagesPanel.spec.tsx b/static/app/views/explore/conversations/components/messagesPanel.spec.tsx index 3d6adc322ef5..993c1130e49f 100644 --- a/static/app/views/explore/conversations/components/messagesPanel.spec.tsx +++ b/static/app/views/explore/conversations/components/messagesPanel.spec.tsx @@ -309,23 +309,28 @@ 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 user message: same turn, so + // the question is shown once while both responses render. 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', }, }); From 70764cc7acbcbc0b679bfa04f4fbbb0178d80eb0 Mon Sep 17 00:00:00 2001 From: Ognjen Bostjancic Date: Mon, 6 Jul 2026 13:26:25 +0200 Subject: [PATCH 5/6] chore(conversations): Trim verbose comments Refs TET-2602 Co-Authored-By: Claude --- .../components/messagesPanel.spec.tsx | 3 +-- .../utils/conversationMessages.spec.ts | 10 ++------- .../utils/conversationMessages.ts | 22 +++++-------------- 3 files changed, 9 insertions(+), 26 deletions(-) diff --git a/static/app/views/explore/conversations/components/messagesPanel.spec.tsx b/static/app/views/explore/conversations/components/messagesPanel.spec.tsx index 993c1130e49f..fe31bda89a60 100644 --- a/static/app/views/explore/conversations/components/messagesPanel.spec.tsx +++ b/static/app/views/explore/conversations/components/messagesPanel.spec.tsx @@ -321,8 +321,7 @@ describe('MessagesPanel', () => { }, }); - // Cumulative history replaying the same last user message: same turn, so - // the question is shown once while both responses render. + // Cumulative history replaying the same last message: shown once. const node2 = createMockNode({ id: 'span-2', startTimestamp: 2000, diff --git a/static/app/views/explore/conversations/utils/conversationMessages.spec.ts b/static/app/views/explore/conversations/utils/conversationMessages.spec.ts index f66864bc5c0d..4d7b6e914992 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.spec.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.spec.ts @@ -656,10 +656,7 @@ describe('conversationMessages utilities', () => { }); }); - // Same user text in two turns. Whether it collapses depends on the input - // shape: a cumulative tool loop replays the same last message (collapse), - // while a growing user-message count or a non-cumulative single-message - // input marks a genuine repeat (keep). + // Same user text in two turns: collapse only for a cumulative tool loop. it.each([ { name: 'collapses identical content in a cumulative history', @@ -1098,10 +1095,7 @@ describe('conversationMessages utilities', () => { ]); }); - // The same question "Q" in two spans. It collapses only when the second - // span's input proves it is the same cumulative turn (a tool loop replaying - // the last message); a non-cumulative single-message input or a growing - // cumulative history keeps both. + // Same question in two spans: collapse only for a cumulative tool loop. const Q = 'How is the weather in Vienna?'; it.each([ { diff --git a/static/app/views/explore/conversations/utils/conversationMessages.ts b/static/app/views/explore/conversations/utils/conversationMessages.ts index 639e60ca9d35..99e69ae18354 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.ts @@ -46,13 +46,10 @@ interface ConversationTurn { toolCalls: ToolCall[]; userContent: string | null; userEmail: string | undefined; - // Whether the input carries history (>1 message). Single-message inputs are - // non-cumulative SDKs, so every generation is a genuine turn (never deduped). - // Defaults to true (assume cumulative) when unknown. + // Input carries history (>1 message). Single-message inputs are never deduped. hasInputHistory?: boolean; toolSpanNodes?: AITraceSpanNode[]; - // User messages in the cumulative input history. A growing count marks a - // genuine repeat vs. the same last message carried across a tool loop. + // User messages in the input history; a growing count marks a genuine repeat. userMessageCount?: number; } @@ -190,17 +187,13 @@ export function turnsToMessages(turns: ConversationTurn[]): ConversationMessage[ const messages: ConversationMessage[] = []; const seenUserContent = new Set(); const seenAssistantContent = new Set(); - // Highest number of user messages seen in any generation's input so far. A - // growing count means the user genuinely sent another message, even when its - // text is identical to a previous one. let maxUserMessageCount = 0; for (const turn of turns) { const startTs = getNodeStartTimestamp(turn.generation); const genEnd = getNodeEndTimestamp(turn.generation); - // Only cumulative inputs are deduped; a non-cumulative (single-message) - // input is always a genuine new turn. + // 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; @@ -318,12 +311,9 @@ export interface InputMessageStats { } /** - * Counts messages in a generation's input, used to decide whether a repeated - * user message is a genuine new turn or a carry-forward. `totalMessageCount` - * separates cumulative SDKs (>1 message) from non-cumulative ones (only the - * current message); `userMessageCount` grows on a genuine repeat within a - * cumulative history. Both are 0 for missing or scrubbed input, which falls - * back to content-based dedup. + * 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 = From 0c3d8f86dfc1733e020aa6c2dd32187d759d4454 Mon Sep 17 00:00:00 2001 From: Ognjen Bostjancic Date: Tue, 7 Jul 2026 13:35:23 +0200 Subject: [PATCH 6/6] fix(conversations): Exclude system messages from input history detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-cumulative SDK that prepends a system prompt sends [system, user] every turn. The previous code counted all messages including system, so totalMessageCount > 1 marked these as cumulative — causing genuine repeated user messages to be dropped by content-dedup. Exclude system messages from the count so [system, user] is correctly recognised as single-message (non-cumulative) input. Co-Authored-By: Claude Sonnet 4 --- .../utils/conversationMessages.spec.ts | 36 +++++++++++++++++++ .../utils/conversationMessages.ts | 8 +++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/static/app/views/explore/conversations/utils/conversationMessages.spec.ts b/static/app/views/explore/conversations/utils/conversationMessages.spec.ts index 4d7b6e914992..ad00a3cb4b3b 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.spec.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.spec.ts @@ -256,6 +256,14 @@ describe('conversationMessages utilities', () => { 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]', @@ -1145,6 +1153,34 @@ describe('conversationMessages utilities', () => { expect(messages.filter(m => m.role === 'user')).toHaveLength(users); }); + 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', () => { expect(extractMessagesFromNodes([])).toEqual([]); }); diff --git a/static/app/views/explore/conversations/utils/conversationMessages.ts b/static/app/views/explore/conversations/utils/conversationMessages.ts index 99e69ae18354..fd45a2309e5a 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.ts @@ -328,9 +328,13 @@ export function getInputMessageStats(node: AITraceSpanNode): InputMessageStats { 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: messages.length, - userMessageCount: messages.filter(m => m.role === 'user').length, + totalMessageCount: nonSystem.length, + userMessageCount: nonSystem.filter(m => m.role === 'user').length, }; }