diff --git a/.size-limit.js b/.size-limit.js index 38540ed229bd..9f5330e9ce56 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '130 KB', + limit: '131 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -452,7 +452,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '109 KB', + limit: '110 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts index f61735845f3e..0139c52439a1 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts @@ -4,6 +4,7 @@ import { GEN_AI_EMBEDDINGS_INPUT, GEN_AI_INPUT_MESSAGES, GEN_AI_OPERATION_NAME, + GEN_AI_OUTPUT_MESSAGES, GEN_AI_PROVIDER_NAME, GEN_AI_REQUEST_FREQUENCY_PENALTY, GEN_AI_REQUEST_MAX_TOKENS, @@ -72,7 +73,14 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ type: 'string', }, // The create `history` stays off the span; only the message being sent is reported. - [GEN_AI_INPUT_MESSAGES]: { value: '[{"role":"user","content":"Tell me a joke"}]', type: 'string' }, + [GEN_AI_INPUT_MESSAGES]: { + value: '[{"role":"user","parts":[{"type":"text","content":"Tell me a joke"}]}]', + type: 'string', + }, + [GEN_AI_OUTPUT_MESSAGES]: { + value: '[{"role":"assistant","parts":[{"type":"text","content":"Hello from Google GenAI!"}]}]', + type: 'string', + }, [GEN_AI_RESPONSE_TEXT]: { value: 'Hello from Google GenAI!', type: 'string' }, [GEN_AI_USAGE_INPUT_TOKENS]: { value: 8, type: 'integer' }, [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 12, type: 'integer' }, @@ -99,7 +107,11 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ [GEN_AI_REQUEST_TOP_P]: { value: 0.9, type: 'double' }, [GEN_AI_REQUEST_MAX_TOKENS]: { value: 100, type: 'integer' }, [GEN_AI_INPUT_MESSAGES]: { - value: '[{"role":"user","parts":[{"text":"What is the capital of France?"}]}]', + value: '[{"role":"user","parts":[{"type":"text","content":"What is the capital of France?"}]}]', + type: 'string', + }, + [GEN_AI_OUTPUT_MESSAGES]: { + value: '[{"role":"assistant","parts":[{"type":"text","content":"Hello from Google GenAI!"}]}]', type: 'string', }, [GEN_AI_USAGE_INPUT_TOKENS]: { value: 8, type: 'integer' }, diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-system-instructions.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-system-instructions.mjs index 1a6f7d81d49e..4ae7932e9d14 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-system-instructions.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-system-instructions.mjs @@ -43,6 +43,18 @@ async function run() { }, contents: [{ role: 'user', parts: [{ text: 'Hello' }] }], }); + + await client.models.generateContent({ + model: 'gemini-1.5-flash', + config: { + systemInstruction: { parts: [{ text: 'You are a helpful assistant' }] }, + }, + contents: [ + { role: 'user', parts: [{ text: 'What time is it in Tokyo?' }] }, + { role: 'model', parts: [{ functionCall: { name: 'get_time', args: { timezone: 'Asia/Tokyo' } } }] }, + { role: 'user', parts: [{ functionResponse: { name: 'get_time', response: { output: '10:00' } } }] }, + ], + }); }); await Sentry.flush(2000); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 08035b527a46..80e6b9c25b33 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -3,6 +3,7 @@ import { GEN_AI_EMBEDDINGS_INPUT, GEN_AI_INPUT_MESSAGES, GEN_AI_OPERATION_NAME, + GEN_AI_OUTPUT_MESSAGES, GEN_AI_PROVIDER_NAME, GEN_AI_REQUEST_MAX_TOKENS, GEN_AI_REQUEST_MODEL, @@ -199,6 +200,22 @@ describe('Google GenAI integration', () => { expect(nonStreamingToolsSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined(); expect(nonStreamingToolsSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeDefined(); expect(nonStreamingToolsSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS]).toBeDefined(); + expect(nonStreamingToolsSpan!.attributes[GEN_AI_OUTPUT_MESSAGES].value).toBe( + JSON.stringify([ + { + role: 'assistant', + parts: [ + { type: 'text', content: 'I need to check the light status first.' }, + { + type: 'tool_call', + id: 'call_light_control_1', + name: 'controlLight', + arguments: '{"brightness":0.3,"colorTemperature":"warm"}', + }, + ], + }, + ]), + ); expect(nonStreamingToolsSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(15); expect(nonStreamingToolsSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS].value).toBe(8); expect(nonStreamingToolsSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS].value).toBe(23); @@ -215,6 +232,24 @@ describe('Google GenAI integration', () => { expect(streamingToolsSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined(); expect(streamingToolsSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeDefined(); expect(streamingToolsSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS]).toBeDefined(); + // The text arrives either side of the tool call, so it stays two parts. + expect(streamingToolsSpan!.attributes[GEN_AI_OUTPUT_MESSAGES].value).toBe( + JSON.stringify([ + { + role: 'assistant', + parts: [ + { type: 'text', content: 'Let me control the lights for you.' }, + { + type: 'tool_call', + id: 'call_light_stream_1', + name: 'controlLight', + arguments: '{"brightness":0.5,"colorTemperature":"cool"}', + }, + { type: 'text', content: ' Done!' }, + ], + }, + ]), + ); expect(streamingToolsSpan!.attributes[GEN_AI_RESPONSE_ID].value).toBe('mock-response-tools-id'); expect(streamingToolsSpan!.attributes[GEN_AI_RESPONSE_MODEL].value).toBe('gemini-2.0-flash-001'); expect(streamingToolsSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS].value).toBe(12); @@ -390,20 +425,41 @@ describe('Google GenAI integration', () => { 'scenario-system-instructions.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { - test('extracts system instructions from messages', async () => { + test('extracts system instructions and normalizes messages', async () => { await createRunner() .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { - expect(container.items).toHaveLength(1); - const [firstSpan] = container.items; + expect(container.items).toHaveLength(2); + const [firstSpan, secondSpan] = container.items; - // [0] generate_content with system instructions extracted + // [0] generate_content with a string system instruction expect(firstSpan!.name).toBe('generate_content gemini-1.5-flash'); expect(firstSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content'); expect(firstSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS].value).toBe( JSON.stringify([{ type: 'text', content: 'You are a helpful assistant' }]), ); + expect(firstSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe( + JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: 'Hello' }] }]), + ); + + // [1] generate_content with a `Content` system instruction and a tool-call turn + expect(secondSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS].value).toBe( + JSON.stringify([{ type: 'text', content: 'You are a helpful assistant' }]), + ); + expect(secondSpan!.attributes[GEN_AI_INPUT_MESSAGES].value).toBe( + JSON.stringify([ + { role: 'user', parts: [{ type: 'text', content: 'What time is it in Tokyo?' }] }, + { + role: 'assistant', + parts: [{ type: 'tool_call', name: 'get_time', arguments: '{"timezone":"Asia/Tokyo"}' }], + }, + { + role: 'user', + parts: [{ type: 'tool_call_response', name: 'get_time', result: '{"output":"10:00"}' }], + }, + ]), + ); }, }) .start() diff --git a/packages/server-utils/src/ai/google-genai/index.ts b/packages/server-utils/src/ai/google-genai/index.ts index 45aef0fd3136..01b7cfb0f6c0 100644 --- a/packages/server-utils/src/ai/google-genai/index.ts +++ b/packages/server-utils/src/ai/google-genai/index.ts @@ -33,12 +33,17 @@ import { GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import type { InstrumentedMethodEntry } from '../core/utils'; -import { buildMethodPath, extractSystemInstructions, getGenAiSpanOp, resolveAIRecordingOptions } from '../core/utils'; +import { buildMethodPath, getGenAiSpanOp, resolveAIRecordingOptions } from '../core/utils'; import { GOOGLE_GENAI_METHOD_REGISTRY, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { Candidate, ContentPart, GoogleGenAIOptions, GoogleGenAIResponse } from './types'; import type { ContentListUnion, Message, PartListUnion } from './utils'; -import { contentUnionToMessages } from './utils'; +import { + candidatesToMessageParts, + contentUnionToMessages, + setOutputMessagesAttribute, + systemInstructionToText, +} from './utils'; /** * Extract model from parameters or chat context object @@ -148,9 +153,7 @@ export function addPrivateRequestAttributes(span: Span, params: Record 0) { const responseTexts = response.candidates diff --git a/packages/server-utils/src/ai/google-genai/streaming.ts b/packages/server-utils/src/ai/google-genai/streaming.ts index 646884b62e4b..a846d549d516 100644 --- a/packages/server-utils/src/ai/google-genai/streaming.ts +++ b/packages/server-utils/src/ai/google-genai/streaming.ts @@ -2,6 +2,8 @@ import { captureException, SPAN_STATUS_ERROR } from '@sentry/core'; import type { Span } from '@sentry/core'; import { endStreamSpan } from '../core/utils'; import type { GoogleGenAIResponse } from './types'; +import type { MessagePart } from './utils'; +import { candidatesToMessageParts, setOutputMessagesAttribute } from './utils'; /** * State object used to accumulate information from a stream of Google GenAI events. @@ -23,6 +25,8 @@ interface StreamingState { totalTokens?: number; /** Accumulated tool calls (finalized) */ toolCalls: Array>; + /** Accumulated output message parts (for output recording). */ + outputParts: MessagePart[]; } /** @@ -72,6 +76,10 @@ function handleCandidateContent(chunk: GoogleGenAIResponse, state: StreamingStat state.toolCalls.push(...chunk.functionCalls); } + if (recordOutputs) { + state.outputParts.push(...candidatesToMessageParts(chunk.candidates)); + } + for (const candidate of chunk.candidates ?? []) { if (candidate?.finishReason && !state.finishReasons.includes(candidate.finishReason)) { state.finishReasons.push(candidate.finishReason); @@ -118,6 +126,7 @@ export async function* instrumentStream( responseTexts: [], finishReasons: [], toolCalls: [], + outputParts: [], }; try { @@ -126,6 +135,9 @@ export async function* instrumentStream( yield chunk; } } finally { + if (recordOutputs) { + setOutputMessagesAttribute(span, state.outputParts); + } endStreamSpan(span, state, recordOutputs); } } diff --git a/packages/server-utils/src/ai/google-genai/utils.ts b/packages/server-utils/src/ai/google-genai/utils.ts index 007ab9859a35..72d1a2c143f7 100644 --- a/packages/server-utils/src/ai/google-genai/utils.ts +++ b/packages/server-utils/src/ai/google-genai/utils.ts @@ -1,3 +1,7 @@ +import { isObjectLike, stringify } from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { GEN_AI_OUTPUT_MESSAGES } from '@sentry/conventions/attributes'; + // Copied from https://googleapis.github.io/js-genai/release_docs/index.html export type ContentListUnion = Content | Content[] | PartListUnion; export type ContentUnion = Content | PartUnion[] | PartUnion; @@ -16,29 +20,188 @@ export type Part = Record & { }; export type PartListUnion = PartUnion[] | PartUnion; -// our consistent span message shape -export type Message = Record & { +/** + * A message part as described in https://develop.sentry.dev/sdk/telemetry/traces/modules/ai-agents/. + * Parts Sentry cannot type are dropped when the message is rendered, so Google's own `Part` shape + * (`{ text }`, `{ functionCall }`, ...) has to be translated into this one. + */ +export type MessagePart = Record & { type: string }; + +export type Message = { role: string; - content?: PartListUnion; - parts?: PartListUnion; + parts: MessagePart[]; }; +// `gen_ai` roles are `user`, `assistant`, `tool` and `system`; Google only ever emits `user` or `model`. +function normalizeRole(role: string): string { + return role === 'model' ? 'assistant' : role; +} + +function mimeTypeOf(value: Record): string | undefined { + return typeof value.mimeType === 'string' ? value.mimeType : undefined; +} + /** - * + * Binary payloads are reported by media type only: `inlineData.data` is base64 bytes, which the + * conventions require to be dropped rather than recorded. */ -export function contentUnionToMessages(content: ContentListUnion, role = 'user'): Message[] { - if (typeof content === 'string') { - return [{ role, content }]; +export function partToMessagePart(part: unknown): MessagePart | undefined { + if (typeof part === 'string') { + return { type: 'text', content: part }; + } + + if (!isObjectLike(part)) { + return undefined; + } + + if (typeof part.text === 'string') { + return { type: part.thought === true ? 'reasoning' : 'text', content: part.text }; + } + + const { functionCall, functionResponse, inlineData, fileData } = part; + + if (isObjectLike(functionCall)) { + return { + type: 'tool_call', + id: functionCall.id, + name: functionCall.name, + arguments: stringify(functionCall.args ?? {}, String), + }; + } + + if (isObjectLike(functionResponse)) { + return { + type: 'tool_call_response', + id: functionResponse.id, + name: functionResponse.name, + result: stringify(functionResponse.response ?? {}, String), + }; + } + + if (isObjectLike(inlineData)) { + return { type: 'blob', mime_type: mimeTypeOf(inlineData) }; + } + + if (isObjectLike(fileData)) { + return { type: 'uri', mime_type: mimeTypeOf(fileData), uri: fileData.fileUri }; } + + // Part kinds we don't know yet render as JSON rather than disappearing. + return { type: 'object', content: part }; +} + +function partsToMessageParts(parts: unknown): MessagePart[] { + const list = Array.isArray(parts) ? parts : parts != null ? [parts] : []; + return list.map(partToMessagePart).filter((part): part is MessagePart => part !== undefined); +} + +function isContent(value: unknown): value is Content { + return isObjectLike(value) && (typeof value.role === 'string' || Array.isArray(value.parts)); +} + +function contentToMessage(content: Content, role: string): Message { + return { + role: normalizeRole(typeof content.role === 'string' ? content.role : role), + parts: partsToMessageParts(content.parts), + }; +} + +/** + * Bare parts are collected into one message rather than one message each: `[{ text }, { inlineData }]` + * is a single multimodal turn in Google's API, not two turns. + */ +export function contentUnionToMessages(content: ContentListUnion, role = 'user'): Message[] { if (Array.isArray(content)) { - return content.flatMap(content => contentUnionToMessages(content, role)); + const messages: Message[] = []; + let looseParts: MessagePart[] = []; + + const flushLooseParts = (): void => { + if (looseParts.length) { + messages.push({ role: normalizeRole(role), parts: looseParts }); + looseParts = []; + } + }; + + for (const item of content) { + if (isContent(item)) { + flushLooseParts(); + const message = contentToMessage(item, role); + if (message.parts.length) { + messages.push(message); + } + } else { + const part = partToMessagePart(item); + if (part) { + looseParts.push(part); + } + } + } + + flushLooseParts(); + return messages; } - if (typeof content !== 'object' || !content) return []; - if ('role' in content && typeof content.role === 'string') { - return [content as Message]; + + if (isContent(content)) { + const message = contentToMessage(content, role); + return message.parts.length ? [message] : []; } - if ('parts' in content) { - return [{ ...content, role }]; + + const part = partToMessagePart(content); + return part ? [{ role: normalizeRole(role), parts: [part] }] : []; +} + +/** Collect the message parts of every candidate in a response. */ +export function candidatesToMessageParts(candidates: unknown): MessagePart[] { + if (!Array.isArray(candidates)) { + return []; } - return [{ role, content }]; + + return candidates.flatMap(candidate => + isObjectLike(candidate) && isObjectLike(candidate.content) + ? partsToMessageParts(candidate.content.parts as PartListUnion | undefined) + : [], + ); +} + +/** + * A streamed response delivers its text a few characters at a time, so the parts collected across + * chunks would otherwise be hundreds of one-word fragments. + */ +function mergeAdjacentTextParts(parts: MessagePart[]): MessagePart[] { + const merged: MessagePart[] = []; + + for (const part of parts) { + const previous = merged[merged.length - 1]; + const mergeable = part.type === 'text' || part.type === 'reasoning'; + + if (mergeable && previous?.type === part.type && typeof previous.content === 'string') { + previous.content += typeof part.content === 'string' ? part.content : ''; + } else { + merged.push({ ...part }); + } + } + + return merged; +} + +/** + * Set alongside the deprecated `gen_ai.response.text` / `gen_ai.response.tool_calls`: Relay migrates + * those into `gen_ai.output.messages`, but the tool-calls half of that migration is lossy, so a turn + * that only calls a tool would otherwise render an empty Output. + */ +export function setOutputMessagesAttribute(span: Span, parts: MessagePart[]): void { + const merged = mergeAdjacentTextParts(parts); + if (merged.length) { + span.setAttribute(GEN_AI_OUTPUT_MESSAGES, JSON.stringify([{ role: 'assistant', parts: merged }])); + } +} + +/** Flatten a `ContentUnion` instruction into the plain text `gen_ai.system_instructions` expects. */ +export function systemInstructionToText(systemInstruction: unknown): string | undefined { + const texts = contentUnionToMessages(systemInstruction as ContentUnion, 'system') + .flatMap(message => message.parts) + .map(part => (part.type === 'text' && typeof part.content === 'string' ? part.content : '')) + .filter(text => text.length > 0); + + return texts.length ? texts.join('\n') : undefined; } diff --git a/packages/server-utils/test/ai/lib/tracing/google-genai.test.ts b/packages/server-utils/test/ai/lib/tracing/google-genai.test.ts index 76df7fe57f22..1001a10ea766 100644 --- a/packages/server-utils/test/ai/lib/tracing/google-genai.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/google-genai.test.ts @@ -187,7 +187,7 @@ describe('instrumentGoogleGenAIClient chat config propagation', () => { expect(data[GEN_AI_TOOL_DEFINITIONS]).toBe('[{"name":"getWeather"}]'); expect(data[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe('[{"type":"text","content":"You are a friendly robot."}]'); // The chat message stays as the only input message; the system instruction is split out above. - expect(data[GEN_AI_INPUT_MESSAGES]).toBe('[{"role":"user","content":"Tell me a joke"}]'); + expect(data[GEN_AI_INPUT_MESSAGES]).toBe('[{"role":"user","parts":[{"type":"text","content":"Tell me a joke"}]}]'); }); it('reports the chat config on chat.sendMessageStream() spans', async () => { diff --git a/packages/server-utils/test/ai/lib/utils/google-genai-utils.test.ts b/packages/server-utils/test/ai/lib/utils/google-genai-utils.test.ts index 15a63c86df68..1815f7348f26 100644 --- a/packages/server-utils/test/ai/lib/utils/google-genai-utils.test.ts +++ b/packages/server-utils/test/ai/lib/utils/google-genai-utils.test.ts @@ -1,65 +1,140 @@ import { describe, expect, it } from 'vitest'; import type { ContentListUnion } from '../../../../src/ai/google-genai/utils'; -import { contentUnionToMessages } from '../../../../src/ai/google-genai/utils'; +import { contentUnionToMessages, systemInstructionToText } from '../../../../src/ai/google-genai/utils'; -describe('convert google-genai messages to consistent message', () => { +describe('convert google-genai content to gen_ai messages', () => { it('converts strings to messages', () => { - expect(contentUnionToMessages('hello', 'system')).toStrictEqual([{ role: 'system', content: 'hello' }]); - expect(contentUnionToMessages('hello')).toStrictEqual([{ role: 'user', content: 'hello' }]); + expect(contentUnionToMessages('hello', 'system')).toStrictEqual([ + { role: 'system', parts: [{ type: 'text', content: 'hello' }] }, + ]); + expect(contentUnionToMessages('hello')).toStrictEqual([ + { role: 'user', parts: [{ type: 'text', content: 'hello' }] }, + ]); }); - it('converts arrays of strings to messages', () => { + it('collects an array of bare parts into a single message', () => { expect(contentUnionToMessages(['hello', 'goodbye'], 'system')).toStrictEqual([ - { role: 'system', content: 'hello' }, - { role: 'system', content: 'goodbye' }, - ]); - expect(contentUnionToMessages(['hello', 'goodbye'])).toStrictEqual([ - { role: 'user', content: 'hello' }, - { role: 'user', content: 'goodbye' }, + { + role: 'system', + parts: [ + { type: 'text', content: 'hello' }, + { type: 'text', content: 'goodbye' }, + ], + }, ]); }); - it('converts PartUnion to messages', () => { - expect(contentUnionToMessages(['hello', { parts: ['i am here', { text: 'goodbye' }] }], 'system')).toStrictEqual([ - { role: 'system', content: 'hello' }, - { role: 'system', parts: ['i am here', { text: 'goodbye' }] }, + it('keeps a multimodal turn as one message and redacts the binary payload', () => { + expect( + contentUnionToMessages([ + { text: 'What is in this image?' }, + { inlineData: { mimeType: 'image/png', data: 'iVBORw0KGgo=' } }, + ]), + ).toStrictEqual([ + { + role: 'user', + parts: [ + { type: 'text', content: 'What is in this image?' }, + { type: 'blob', mime_type: 'image/png' }, + ], + }, ]); + }); - expect(contentUnionToMessages(['hello', { parts: ['i am here', { text: 'goodbye' }] }])).toStrictEqual([ - { role: 'user', content: 'hello' }, - { role: 'user', parts: ['i am here', { text: 'goodbye' }] }, + it('maps the `model` role to `assistant`', () => { + expect( + contentUnionToMessages([ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi there' }] }, + ]), + ).toStrictEqual([ + { role: 'user', parts: [{ type: 'text', content: 'Hello' }] }, + { role: 'assistant', parts: [{ type: 'text', content: 'Hi there' }] }, ]); }); - it('converts ContentUnion to messages', () => { + it('converts function calls and function responses', () => { expect( - contentUnionToMessages( + contentUnionToMessages([ + { + role: 'model', + parts: [{ functionCall: { id: 'call-1', name: 'get_current_time', args: { timezone: 'Asia/Tokyo' } } }], + }, { - parts: ['hello', 'goodbye'], - role: 'agent', + role: 'user', + parts: [{ functionResponse: { id: 'call-1', name: 'get_current_time', response: { output: '10:00' } } }], }, - 'user', - ), - ).toStrictEqual([{ parts: ['hello', 'goodbye'], role: 'agent' }]); + ]), + ).toStrictEqual([ + { + role: 'assistant', + parts: [{ type: 'tool_call', id: 'call-1', name: 'get_current_time', arguments: '{"timezone":"Asia/Tokyo"}' }], + }, + { + role: 'user', + parts: [{ type: 'tool_call_response', id: 'call-1', name: 'get_current_time', result: '{"output":"10:00"}' }], + }, + ]); + }); + + it('marks thought parts as reasoning', () => { + expect( + contentUnionToMessages({ role: 'model', parts: [{ text: 'Let me think', thought: true }, { text: 'Done' }] }), + ).toStrictEqual([ + { + role: 'assistant', + parts: [ + { type: 'reasoning', content: 'Let me think' }, + { type: 'text', content: 'Done' }, + ], + }, + ]); + }); + + it('falls back to an object part for part kinds it does not know', () => { + expect(contentUnionToMessages([{ executableCode: { code: 'print(1)' } }])).toStrictEqual([ + { role: 'user', parts: [{ type: 'object', content: { executableCode: { code: 'print(1)' } } }] }, + ]); }); it('handles unexpected formats safely', () => { expect( - contentUnionToMessages( - [ - { - parts: ['hello', 'goodbye'], - role: 'agent', - }, - null, - 21345, - { data: 'this is content' }, - ] as unknown as ContentListUnion, - 'user', - ), + contentUnionToMessages([ + { parts: ['hello', 'goodbye'], role: 'agent' }, + null, + 21345, + { data: 'this is content' }, + ] as ContentListUnion), ).toStrictEqual([ - { parts: ['hello', 'goodbye'], role: 'agent' }, - { role: 'user', content: { data: 'this is content' } }, + { + role: 'agent', + parts: [ + { type: 'text', content: 'hello' }, + { type: 'text', content: 'goodbye' }, + ], + }, + { role: 'user', parts: [{ type: 'object', content: { data: 'this is content' } }] }, ]); }); }); + +describe('systemInstructionToText', () => { + it('reads a plain string instruction', () => { + expect(systemInstructionToText('You are a helpful assistant')).toBe('You are a helpful assistant'); + }); + + it('reads an instruction given as a Content object', () => { + expect(systemInstructionToText({ parts: [{ text: 'You are a helpful assistant' }] })).toBe( + 'You are a helpful assistant', + ); + expect(systemInstructionToText({ role: 'system', parts: [{ text: 'Be brief' }, { text: 'Be kind' }] })).toBe( + 'Be brief\nBe kind', + ); + }); + + it('returns undefined when there is no text to report', () => { + expect( + systemInstructionToText({ parts: [{ inlineData: { mimeType: 'image/png', data: 'AAA=' } }] }), + ).toBeUndefined(); + }); +});