From fec8beb76d8c77ccab4886ed875fd87b8268a2aa Mon Sep 17 00:00:00 2001 From: Marco Gancitano Date: Fri, 31 Jul 2026 11:48:30 -0400 Subject: [PATCH] fix(ai-obs): recognize legacy Gemini tool blocks in traces posthog-python 7.30.1-7.35.x copied Gemini function calls and their responses into `$ai_input` in the raw form the google-genai SDK emits: {"type": "function_call", "function_call": {name, args}} {"type": "function_response", "function_response": {name, response}} Neither matches any shape `isToolResult`/`isToolStepItem` know, so traces captured in that window misclassify their tool loop three ways: - `isToolMessage` computes `hasText` from content items that are neither a tool step nor a tool result, so a message carrying only a tool response counts as having real text and gets surfaced in the traces list as the human-readable answer. - `isInternalToolResultUserMessage` requires every content item to be a tool result, so the framework-appended tool-response user message isn't classified internal and leaks as a visible user bubble. - `isUnrenderableContentItem` treats the block as non-text content the transcript can't render. The SDK now converts both kinds (PostHog/posthog-python#823), but already-ingested traces keep the old form, so match it here too. `function_call` needs its nested object checked: OpenAI's Responses API uses the same `type` with `name`/`call_id` at the top level, and `isOpenAIResponsesFunctionCall` already owns that case. Co-Authored-By: Claude Opus 5 --- .../ai_observability/frontend/utils.test.ts | 19 ++++++++++++++++ products/ai_observability/frontend/utils.ts | 22 +++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/products/ai_observability/frontend/utils.test.ts b/products/ai_observability/frontend/utils.test.ts index 6c83e25f65d9..3b93f1f15bb0 100644 --- a/products/ai_observability/frontend/utils.test.ts +++ b/products/ai_observability/frontend/utils.test.ts @@ -1424,6 +1424,10 @@ describe.each(IMPLS)('AI observability utils [$name]', ({ normalizeMessage, norm 'custom {type:"function", tool_name, content}', { type: 'function', tool_name: 'lookup', content: 'opaque' }, ], + [ + 'legacy Gemini function_response (posthog-python 7.30.1-7.35.x)', + { type: 'function_response', function_response: { name: 'get_weather', response: { temp: '18C' } } }, + ], ])('returns true for: %s', (_, item) => { expect(isToolResult(item)).toBe(true) }) @@ -1431,6 +1435,11 @@ describe.each(IMPLS)('AI observability utils [$name]', ({ normalizeMessage, norm it.each<[name: string, item: unknown]>([ ['plain text part', { type: 'text', text: 'hi' }], ['Anthropic tool_use (a tool CALL, not a result)', { type: 'tool_use', id: 't1', name: 'x', input: {} }], + [ + 'legacy Gemini function_call (a tool CALL, not a result)', + { type: 'function_call', function_call: { name: 'get_weather', args: {} } }, + ], + ['function_response without the nested payload', { type: 'function_response' }], [ 'OpenAI tool CALL with nested function object', { type: 'function', function: { name: 'get_weather', arguments: '{}' } }, @@ -2564,6 +2573,16 @@ describe.each(IMPLS)('AI observability utils [$name]', ({ normalizeMessage, norm { type: 'tool-result', toolCallId: 'a', toolName: 'search_docs', result: 'ok' }, true, ], + [ + 'accepts legacy Gemini `function_call`', + { type: 'function_call', function_call: { name: 'get_weather', args: { city: 'SF' } } }, + true, + ], + [ + 'accepts legacy Gemini `function_response`', + { type: 'function_response', function_response: { name: 'get_weather', response: { temp: '18C' } } }, + true, + ], ['rejects text content items', { type: 'text', text: 'hi' }, false], ['rejects image items', { type: 'image_url', image_url: { url: 'x' } }, false], ['rejects file items', { type: 'file', file: { filename: 'f', file_data: 'd' } }, false], diff --git a/products/ai_observability/frontend/utils.ts b/products/ai_observability/frontend/utils.ts index 1af7cf19c6cc..03631438b48f 100644 --- a/products/ai_observability/frontend/utils.ts +++ b/products/ai_observability/frontend/utils.ts @@ -501,12 +501,28 @@ function isCustomFunctionToolResult(item: unknown): boolean { return isObject(item) && item.type === 'function' && isString(item.tool_name) && !isObject(item.function) } +// Gemini function calls and their responses, in the raw form the google-genai +// SDK emits. posthog-python 7.30.1 through 7.35.x copied that form into +// `$ai_input` without converting it (PostHog/posthog-python#725), so traces from +// that window still contain it. +// `function_call` needs its nested object checked. The OpenAI Responses API uses +// the same `type` with a top-level `name` and `call_id`, and +// `isOpenAIResponsesFunctionCall` already owns that case. +function isLegacyGeminiFunctionResponse(item: unknown): boolean { + return isObject(item) && item.type === 'function_response' && isObject(item.function_response) +} + +function isLegacyGeminiFunctionCall(item: unknown): boolean { + return isObject(item) && item.type === 'function_call' && isObject(item.function_call) +} + export function isToolResult(item: unknown): boolean { return ( isAnthropicToolResultMessage(item) || isVercelSDKToolResultMessage(item) || isOpenAIResponsesFunctionCallOutput(item) || - isCustomFunctionToolResult(item) + isCustomFunctionToolResult(item) || + isLegacyGeminiFunctionResponse(item) ) } @@ -531,7 +547,9 @@ export function isToolStepItem(item: unknown): boolean { isVercelSDKToolCallMessage(item) || isVercelSDKToolResultMessage(item) || isOpenAIResponsesFunctionCall(item) || - isOpenAIResponsesBuiltinToolCall(item) + isOpenAIResponsesBuiltinToolCall(item) || + isLegacyGeminiFunctionCall(item) || + isLegacyGeminiFunctionResponse(item) ) { return true }