diff --git a/core/src/agents/processors/content_processor_utils.ts b/core/src/agents/processors/content_processor_utils.ts index 34ec60c7..6f7b4f0c 100644 --- a/core/src/agents/processors/content_processor_utils.ts +++ b/core/src/agents/processors/content_processor_utils.ts @@ -206,17 +206,19 @@ function convertForeignEvent(event: Event): Event { text: `[${event.author}] said: ${part.text}`, }); } else if (part.functionCall) { - const argsText = safeStringify(part.functionCall.args); content.parts?.push({ - text: `[${event.author}] called tool \`${part.functionCall.name}\` with parameters: ${argsText}`, + text: `[${event.author}] called tool \`${part.functionCall.name}\` with parameters: ${safeStringify( + part.functionCall.args, + )}`, }); } else if (part.functionResponse) { - const responseText = safeStringify(part.functionResponse.response); content.parts?.push({ - text: `[${event.author}] tool \`${part.functionResponse.name}\` returned result: ${responseText}`, + text: `[${event.author}] tool \`${part.functionResponse.name}\` returned result: ${safeStringify( + part.functionResponse.response, + )}`, }); } else { - content.parts?.push(part); + content.parts?.push(cloneDeep(part)); } } @@ -254,15 +256,17 @@ function convertForeignEvent(event: Event): Event { * 2. All non-function_response parts will be appended to the part list of * the initial function_response event. */ -function mergeFunctionResponseEvents(events: Event[]): Event { +export function mergeFunctionResponseEvents(events: Event[]): Event { if (events.length === 0) { throw new Error('Cannot merge an empty list of events.'); } - const mergedEvent = createEvent(events[0]); - const partsInMergedEvent = mergedEvent.content?.parts || []; - - if (partsInMergedEvent.length === 0) { + const mergedEvent = createEvent({ + ...events[0], + content: events[0].content ? cloneDeep(events[0].content) : undefined, + }); + const partsInMergedEvent = mergedEvent.content?.parts; + if (!partsInMergedEvent || partsInMergedEvent.length === 0) { throw new Error('There should be at least one function_response part.'); } @@ -279,17 +283,19 @@ function mergeFunctionResponseEvents(events: Event[]): Event { throw new Error('There should be at least one function_response part.'); } for (const part of event.content.parts) { - if (part.functionResponse && part.functionResponse.id) { - const functionCallId = part.functionResponse.id; + const clonedPart = cloneDeep(part); + if (clonedPart.functionResponse && clonedPart.functionResponse.id) { + const functionCallId = clonedPart.functionResponse.id; if (functionCallId in partIndicesInMergedEvent) { - partsInMergedEvent[partIndicesInMergedEvent[functionCallId]] = part; + partsInMergedEvent[partIndicesInMergedEvent[functionCallId]] = + clonedPart; } else { - partsInMergedEvent.push(part); + partsInMergedEvent.push(clonedPart); partIndicesInMergedEvent[functionCallId] = partsInMergedEvent.length - 1; } } else { - partsInMergedEvent.push(part); + partsInMergedEvent.push(clonedPart); } } } @@ -313,7 +319,7 @@ function rearrangeEventsForLatestFunctionResponse(events: Event[]): Event[] { return events; } - let functionResponsesIds = new Set( + const functionResponsesIds = new Set( functionResponses .filter((response): response is {id: string} => !!response.id) .map((response) => response.id), @@ -334,7 +340,8 @@ function rearrangeEventsForLatestFunctionResponse(events: Event[]): Event[] { } // Look for corresponding function call event reversely. - let functionCallEventIdx = -1; + let match: {eventIdx: number; responseIds: Set} | undefined; + for (let idx = events.length - 2; idx >= 0; idx--) { const event = events[idx]; const functionCalls = getFunctionCalls(event); @@ -342,9 +349,9 @@ function rearrangeEventsForLatestFunctionResponse(events: Event[]): Event[] { continue; } + let matchedInEvent = false; for (const functionCall of functionCalls) { if (functionCall.id && functionResponsesIds.has(functionCall.id)) { - functionCallEventIdx = idx; const functionCallIds = new Set( functionCalls.map((fc) => fc.id).filter((id): id is string => !!id), ); @@ -364,16 +371,17 @@ function rearrangeEventsForLatestFunctionResponse(events: Event[]): Event[] { ` ids provided: ${Array.from(functionResponsesIds).join(', ')}`, ); } - // Expand the function call events to collect all function responses - // from the function call event to the last response event. - // TODO - b/425992518: bad practice, state can mutated multiple times. - functionResponsesIds = functionCallIds; + match = {eventIdx: idx, responseIds: functionCallIds}; + matchedInEvent = true; break; } } + if (matchedInEvent) { + break; + } } - if (functionCallEventIdx === -1) { + if (!match) { throw new Error( `No function call event found for function responses ids: ${Array.from( functionResponsesIds, @@ -384,13 +392,14 @@ function rearrangeEventsForLatestFunctionResponse(events: Event[]): Event[] { // Collect all function response events between the function call event // and the last function response event const functionResponseEvents: Event[] = []; - for (let idx = functionCallEventIdx + 1; idx < events.length - 1; idx++) { + const activeResponses = match.responseIds; + for (let idx = match.eventIdx + 1; idx < events.length - 1; idx++) { const event = events[idx]; const responses = getFunctionResponses(event); if ( responses && responses.some( - (response) => response.id && functionResponsesIds.has(response.id), + (response) => response.id && activeResponses.has(response.id), ) ) { functionResponseEvents.push(event); @@ -398,7 +407,7 @@ function rearrangeEventsForLatestFunctionResponse(events: Event[]): Event[] { } functionResponseEvents.push(events[events.length - 1]); - const resultEvents = events.slice(0, functionCallEventIdx + 1); + const resultEvents = events.slice(0, match.eventIdx + 1); resultEvents.push(mergeFunctionResponseEvents(functionResponseEvents)); return resultEvents; @@ -468,7 +477,7 @@ function rearrangeEventsForAsyncFunctionResponsesInHistory( } if (functionResponseEventsIndices.size === 1) { - const [responseIndex] = [...functionResponseEventsIndices]; + const [responseIndex] = Array.from(functionResponseEventsIndices); resultEvents.push(events[responseIndex]); } else { const indicesArray = Array.from(functionResponseEventsIndices).sort( diff --git a/core/test/agents/processors/content_processor_utils_test.ts b/core/test/agents/processors/content_processor_utils_test.ts index 6fbfb99b..5c5b0b6b 100644 --- a/core/test/agents/processors/content_processor_utils_test.ts +++ b/core/test/agents/processors/content_processor_utils_test.ts @@ -4,9 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {createEvent} from '@google/adk'; +import {CompactedEvent, createEvent} from '@google/adk'; +import {Content} from '@google/genai'; import {describe, expect, it} from 'vitest'; -import {getContents} from '../../../src/agents/processors/content_processor_utils.js'; +import { + getContents, + getCurrentTurnContents, + mergeFunctionResponseEvents, +} from '../../../src/agents/processors/content_processor_utils.js'; describe('getContents', () => { it('should handle object responses in convertForeignEvent', () => { @@ -98,4 +103,990 @@ describe('getContents', () => { // It should fall back to String(obj) which is usually [object Object] for plain objects. expect(textPart?.text).toContain('[object Object]'); }); + + it('should rearrange basic function call and response events correctly', () => { + const e0 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [{text: 'hello'}], + }, + }); + const e1 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'tool1', + id: 'id1', + args: {}, + }, + }, + ], + }, + }); + const e2 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [{text: 'intermediate user message'}], + }, + }); + const e3 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool1', + id: 'id1', + response: {result: 'success'}, + }, + }, + ], + }, + }); + + const contents = getContents([e0, e1, e2, e3], 'my_agent'); + + // Expected output order: e0 (user input), e1 (function call), merged response (e3 response part) + // Note that intermediate user input (e2) between call and response is discarded. + expect(contents).toHaveLength(3); + expect(contents[0].parts?.[0].text).toBe('hello'); + expect(contents[1].parts?.[0].functionCall?.id).toBe('id1'); + expect(contents[2].parts?.[0].functionResponse?.id).toBe('id1'); + }); + + it('should avoid multiple mutations/overwrites and process multiple function calls safely', () => { + const e0 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [{text: 'hello'}], + }, + }); + const e1 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'tool1', + id: 'id1', + args: {}, + }, + }, + ], + }, + }); + const e2 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'tool1', + id: 'id1', + args: {}, + }, + }, + { + functionCall: { + name: 'tool2', + id: 'id2', + args: {}, + }, + }, + ], + }, + }); + const e3 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool2', + id: 'id2', + response: {result: 'success'}, + }, + }, + ], + }, + }); + + // We expect it to match the latest event e2 (which has id1 and id2) for the response id2, + // and terminate the loop immediately. If it didn't terminate, it would continue back to e1, + // matching id1 (due to mutated state), causing a subset error or wrong rearrangement index. + const contents = getContents([e0, e1, e2, e3], 'my_agent'); + expect(contents).toHaveLength(4); + expect(contents[0].parts?.[0].text).toBe('hello'); + expect(contents[1].parts?.[0].functionCall?.id).toBe('id1'); + // e2 has two function calls: + expect(contents[2].parts?.[0].functionCall?.id).toBe('id1'); + expect(contents[2].parts?.[1].functionCall?.id).toBe('id2'); + // e3 is merged/rearranged after e2: + expect(contents[3].parts?.[0].functionResponse?.id).toBe('id2'); + }); + + it('should throw an error when last responses do not match the expected subset criteria of function calls', () => { + const e0 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [{text: 'hello'}], + }, + }); + const e1 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'tool1', + id: 'id1', + args: {}, + }, + }, + ], + }, + }); + const e2 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + // response with id2, but the call event e1 only has id1 + functionResponse: { + name: 'tool2', + id: 'id2', + response: {result: 'success'}, + }, + }, + ], + }, + }); + + expect(() => getContents([e0, e1, e2], 'my_agent')).toThrowError( + 'No function call event found for function responses ids: id2', + ); + }); + + it('should throw subset error when response is for an id from a call event, but contains other unexpected ids', () => { + // Actually, the subset error occurs when functionResponsesIds is NOT a subset of functionCallIds. + // e.g. the last event has responses for id1 and id2, but the matched call event only has id1. + const e0 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'tool1', + id: 'id1', + args: {}, + }, + }, + ], + }, + }); + const e0_5 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [{text: 'hello'}], + }, + }); + const e1 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool1', + id: 'id1', + response: {result: 'success'}, + }, + }, + { + functionResponse: { + name: 'tool2', + id: 'id2', + response: {result: 'success'}, + }, + }, + ], + }, + }); + + expect(() => getContents([e0, e0_5, e1], 'my_agent')).toThrowError( + 'Last response event should only contain the responses for the function calls in the same function call event.', + ); + }); + + it('should handle empty events list gracefully', () => { + const contents = getContents([], 'my_agent'); + expect(contents).toEqual([]); + }); + + it('should collect and merge intermediate response events for parallel function calls', () => { + const e0 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'tool1', + id: 'id1', + args: {}, + }, + }, + { + functionCall: { + name: 'tool2', + id: 'id2', + args: {}, + }, + }, + ], + }, + }); + const e1 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool1', + id: 'id1', + response: {result: 'success1'}, + }, + }, + ], + }, + }); + const e2 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool2', + id: 'id2', + response: {result: 'success2'}, + }, + }, + ], + }, + }); + + const contents = getContents([e0, e1, e2], 'my_agent'); + expect(contents).toHaveLength(2); + expect(contents[0].parts?.[0].functionCall?.id).toBe('id1'); + expect(contents[0].parts?.[1].functionCall?.id).toBe('id2'); + + // e1 and e2 should be merged: + expect(contents[1].parts?.[0].functionResponse?.id).toBe('id1'); + expect(contents[1].parts?.[0].functionResponse?.response).toEqual({ + result: 'success1', + }); + expect(contents[1].parts?.[1].functionResponse?.id).toBe('id2'); + expect(contents[1].parts?.[1].functionResponse?.response).toEqual({ + result: 'success2', + }); + }); + + it('should not mutate input events content', () => { + const e0 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'tool1', + id: 'id1', + args: {}, + }, + }, + { + functionCall: { + name: 'tool2', + id: 'id2', + args: {}, + }, + }, + ], + }, + }); + const e1 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool1', + id: 'id1', + response: {result: 'success1'}, + }, + }, + ], + }, + }); + const e2 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool2', + id: 'id2', + response: {result: 'success2'}, + }, + }, + ], + }, + }); + + const originalE1PartsLength = e1.content?.parts?.length; + + getContents([e0, e1, e2], 'my_agent'); + + expect(e1.content?.parts?.length).toBe(originalE1PartsLength); + }); + + it('should convert CompactedEvent correctly', () => { + const compactedEvent = { + isCompacted: true, + author: 'user', + compactedContent: 'synthesized summary', + timestamp: 12345, + invocationId: 'inv1', + branch: 'main', + } as unknown as CompactedEvent; + + const contents = getContents([compactedEvent], 'my_agent'); + expect(contents).toHaveLength(1); + expect(contents[0].role).toBe('user'); + expect(contents[0].parts?.[0].text).toContain( + '[Previous Context Summary]:\nsynthesized summary', + ); + }); + + it('should skip rearranging when the second latest event contains the corresponding function calls', () => { + const e0 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [{text: 'hello'}], + }, + }); + const e1 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'tool1', + id: 'id1', + args: {}, + }, + }, + ], + }, + }); + const e2 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool1', + id: 'id1', + response: {result: 'success'}, + }, + }, + ], + }, + }); + + const contents = getContents([e0, e1, e2], 'my_agent'); + expect(contents).toHaveLength(3); + expect(contents[0].parts?.[0].text).toBe('hello'); + expect(contents[1].parts?.[0].functionCall?.id).toBe('id1'); + expect(contents[2].parts?.[0].functionResponse?.id).toBe('id1'); + }); + + it('should handle string arguments in convertForeignEvent', () => { + const event = createEvent({ + author: 'other_agent', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'transfer_to_agent', + args: 'plain_string_args' as unknown as Record, + }, + }, + ], + }, + }); + + const contents = getContents([event], 'current_agent'); + const textPart = contents[0].parts?.find((p) => + p.text?.includes('transfer_to_agent'), + ); + expect(textPart).toBeDefined(); + expect(textPart?.text).toContain('plain_string_args'); + }); + + it('should handle plain text parts in convertForeignEvent', () => { + const event = createEvent({ + author: 'other_agent', + content: { + role: 'model', + parts: [ + { + text: 'hello from other agent', + }, + ], + }, + }); + + const contents = getContents([event], 'current_agent'); + expect(contents).toHaveLength(1); + expect(contents[0].parts).toHaveLength(2); + expect(contents[0].parts?.[1].text).toBe( + '[other_agent] said: hello from other agent', + ); + }); + + it('should replace function responses with the same id and append non-function-response parts during merge', () => { + const e0 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [ + {functionCall: {name: 'tool1', id: 'id1', args: {}}}, + {functionCall: {name: 'tool2', id: 'id2', args: {}}}, + ], + }, + }); + const e1 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool1', + id: 'id1', + response: {result: 'initial'}, + }, + }, + ], + }, + }); + const e2 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool1', + id: 'id1', + response: {result: 'updated'}, + }, + }, + { + functionResponse: { + name: 'tool2', + id: 'id2', + response: {result: 'success2'}, + }, + }, + {text: 'some extra message'}, + ], + }, + }); + + const contents = getContents([e0, e1, e2], 'my_agent'); + expect(contents).toHaveLength(2); + const mergedResponseParts = contents[1].parts; + expect(mergedResponseParts).toHaveLength(3); + expect(mergedResponseParts?.[0].functionResponse?.response).toEqual({ + result: 'updated', + }); + expect(mergedResponseParts?.[1].functionResponse?.response).toEqual({ + result: 'success2', + }); + expect(mergedResponseParts?.[2].text).toBe('some extra message'); + }); + + it('should merge multiple function responses in history when size > 1', () => { + const e0 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [ + {functionCall: {name: 'tool1', id: 'id1', args: {}}}, + {functionCall: {name: 'tool2', id: 'id2', args: {}}}, + ], + }, + }); + const e1 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool1', + id: 'id1', + response: {result: 'res1'}, + }, + }, + ], + }, + }); + const e2 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool2', + id: 'id2', + response: {result: 'res2'}, + }, + }, + ], + }, + }); + const e3 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [{text: 'hello'}], + }, + }); + + const contents = getContents([e0, e1, e2, e3], 'my_agent'); + expect(contents).toHaveLength(3); + expect(contents[0].parts?.[0].functionCall?.id).toBe('id1'); + expect(contents[1].parts).toHaveLength(2); + expect(contents[1].parts?.[0].functionResponse?.id).toBe('id1'); + expect(contents[1].parts?.[1].functionResponse?.id).toBe('id2'); + expect(contents[2].parts?.[0].text).toBe('hello'); + }); + + it('should skip mapping function response event when response id is missing', () => { + const e0 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [{functionCall: {name: 'tool1', id: 'id1', args: {}}}], + }, + }); + const e1 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool1', + response: {result: 'res1'}, + }, + }, + ], + }, + }); + const e2 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [{text: 'hello'}], + }, + }); + + const contents = getContents([e0, e1, e2], 'my_agent'); + expect(contents).toHaveLength(2); + expect(contents[0].parts?.[0].functionCall?.id).toBe('id1'); + expect(contents[1].parts?.[0].text).toBe('hello'); + }); + + it('should handle empty agentName in getContents', () => { + const event = createEvent({ + author: 'other_agent', + content: { + role: 'model', + parts: [{text: 'hello'}], + }, + }); + const contents = getContents([event], ''); + expect(contents).toHaveLength(1); + expect(contents[0].parts?.[0].text).toBe('hello'); + }); + + describe('getCurrentTurnContents', () => { + it('should return empty list when no events are provided', () => { + const contents = getCurrentTurnContents([], 'my_agent'); + expect(contents).toEqual([]); + }); + + it('should slice events from the last user or foreign agent event', () => { + const e0 = createEvent({ + author: 'user', + content: {role: 'user', parts: [{text: 'hello'}]}, + }); + const e1 = createEvent({ + author: 'my_agent', + content: {role: 'model', parts: [{text: 'hi'}]}, + }); + const e2 = createEvent({ + author: 'user', + content: {role: 'user', parts: [{text: 'how are you?'}]}, + }); + const e3 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [{functionCall: {name: 'tool1', id: 'id1', args: {}}}], + }, + }); + + const contents = getCurrentTurnContents([e0, e1, e2, e3], 'my_agent'); + expect(contents).toHaveLength(2); + expect(contents[0].parts?.[0].text).toBe('how are you?'); + expect(contents[1].parts?.[0].functionCall?.id).toBe('id1'); + }); + + it('should return empty list if no user or foreign agent starts a turn', () => { + const e0 = createEvent({ + author: 'my_agent', + content: {role: 'model', parts: [{text: 'hello'}]}, + }); + const contents = getCurrentTurnContents([e0], 'my_agent'); + expect(contents).toEqual([]); + }); + + it('should handle empty agentName in getCurrentTurnContents', () => { + const e0 = createEvent({ + author: 'other_agent', + content: {role: 'model', parts: [{text: 'hello'}]}, + }); + const contents = getCurrentTurnContents([e0], ''); + expect(contents).toEqual([]); + }); + }); + + it('should handle media parts in convertForeignEvent', () => { + const event = createEvent({ + author: 'other_agent', + content: { + role: 'model', + parts: [ + { + inlineData: { + mimeType: 'image/png', + data: 'base64data', + }, + }, + ], + }, + }); + + const contents = getContents([event], 'current_agent'); + expect(contents).toHaveLength(1); + expect(contents[0].parts).toHaveLength(2); // 'For context:' part and the inlineData part + expect(contents[0].parts?.[1]).toEqual({ + inlineData: { + mimeType: 'image/png', + data: 'base64data', + }, + }); + }); + + it('should not mutate original event media parts in convertForeignEvent', () => { + const event = createEvent({ + author: 'other_agent', + content: { + role: 'model', + parts: [ + { + inlineData: { + mimeType: 'image/png', + data: 'base64data', + }, + }, + ], + }, + }); + + const contents = getContents([event], 'current_agent'); + + // Mutate the returned content + if (contents[0].parts?.[1]?.inlineData) { + contents[0].parts[1].inlineData.data = 'mutated'; + } + + // Check if original event was mutated + expect(event.content?.parts?.[0]?.inlineData?.data).toBe('base64data'); + }); + + describe('mergeFunctionResponseEvents', () => { + it('should throw an error when merging empty list of events', () => { + expect(() => mergeFunctionResponseEvents([])).toThrowError( + 'Cannot merge an empty list of events.', + ); + }); + + it('should throw an error when first event has no parts', () => { + const e0 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [], + }, + }); + expect(() => mergeFunctionResponseEvents([e0])).toThrowError( + 'There should be at least one function_response part.', + ); + }); + + it('should throw an error when first event has no content', () => { + const e0 = createEvent({ + author: 'user', + }); + expect(() => mergeFunctionResponseEvents([e0])).toThrowError( + 'There should be at least one function_response part.', + ); + }); + + it('should throw an error when subsequent event has no content or parts', () => { + const e0 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool1', + id: 'id1', + response: {result: 'success'}, + }, + }, + ], + }, + }); + const e1 = createEvent({ + author: 'user', + }); + expect(() => mergeFunctionResponseEvents([e0, e1])).toThrowError( + 'There should be at least one function_response part.', + ); + }); + + it('should not mutate subsequent events when merging', () => { + const e0 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool1', + id: 'id1', + response: {result: 'initial'}, + }, + }, + ], + }, + }); + const e1 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'tool2', + id: 'id2', + response: {result: 'success2'}, + }, + }, + ], + }, + }); + + const merged = mergeFunctionResponseEvents([e0, e1]); + + // Mutate the merged event parts + if (merged.content?.parts?.[0]?.functionResponse) { + merged.content.parts[0].functionResponse.response = { + result: 'mutated0', + }; + } + if (merged.content?.parts?.[1]?.functionResponse) { + merged.content.parts[1].functionResponse.response = { + result: 'mutated1', + }; + } + + // Check if e0 was mutated + expect(e0.content?.parts?.[0]?.functionResponse?.response).toEqual({ + result: 'initial', + }); + + // Check if e1 was mutated + expect(e1.content?.parts?.[0]?.functionResponse?.response).toEqual({ + result: 'success2', + }); + }); + }); + + it('should skip tool confirmation events in getContents', () => { + const e0 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'adk_request_confirmation', + args: {}, + }, + }, + ], + }, + }); + const contents = getContents([e0], 'my_agent'); + expect(contents).toEqual([]); + }); + + it('should skip auth events in getContents', () => { + const e0 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'adk_request_credential', + args: {}, + }, + }, + ], + }, + }); + const contents = getContents([e0], 'my_agent'); + expect(contents).toEqual([]); + }); + + it('should handle events with no parts in isAuthEvent and isToolConfirmationEvent', () => { + const event = createEvent({ + author: 'my_agent', + content: { + role: 'user', + }, + }); + const contents = getContents([event], 'my_agent'); + expect(contents).toHaveLength(1); + expect(contents[0].role).toBe('user'); + }); + + it('should return input event in convertForeignEvent if content or parts length is 0', () => { + const event = createEvent({ + author: 'other_agent', + content: { + role: 'model', + parts: [], + }, + }); + const contents = getContents([event], 'current_agent'); + expect(contents).toHaveLength(1); + }); + + it('should handle event without parts in isToolConfirmationEvent', () => { + const e0 = createEvent({ + author: 'my_agent', + content: { + role: 'model', + }, + }); + const contents = getContents([e0], 'my_agent'); + expect(contents).toHaveLength(1); + expect(contents[0].role).toBe('model'); + expect(contents[0].parts).toBeUndefined(); + }); + + it('should skip events with no role in getContents', () => { + const e0 = createEvent({ + author: 'user', + content: { + parts: [{text: 'hello'}], + } as unknown as Content, + }); + const contents = getContents([e0], 'my_agent'); + expect(contents).toEqual([]); + }); + + it('should skip events with empty first part text in getContents', () => { + const e0 = createEvent({ + author: 'user', + content: { + role: 'user', + parts: [{text: ''}], + }, + }); + const contents = getContents([e0], 'my_agent'); + expect(contents).toEqual([]); + }); + + it('should skip events from non-matching branch in getContents', () => { + const e0 = createEvent({ + author: 'user', + branch: 'main.agentB', + content: { + role: 'user', + parts: [{text: 'hello'}], + }, + }); + const contents = getContents([e0], 'my_agent', 'main.agentA'); + expect(contents).toEqual([]); + }); + + it('should not skip events from matching branch in getContents', () => { + const e0 = createEvent({ + author: 'user', + branch: 'main.agentA', + content: { + role: 'user', + parts: [{text: 'hello'}], + }, + }); + const contents = getContents([e0], 'my_agent', 'main.agentA.subAgent'); + expect(contents).toHaveLength(1); + expect(contents[0].parts?.[0].text).toBe('hello'); + }); }); diff --git a/tests/integration/a2a/basic/a2a_agent_test.ts b/tests/integration/a2a/basic/a2a_agent_test.ts index 8cda6f07..5e0f82c6 100644 --- a/tests/integration/a2a/basic/a2a_agent_test.ts +++ b/tests/integration/a2a/basic/a2a_agent_test.ts @@ -19,9 +19,10 @@ describe('A2A: Remote Agent Basic', () => { server = new AdkTsApiServer({ agentsDir: path.join(__dirname, 'remote_a2a/'), a2a: true, + startFailureTimeout: 60000, }); await server.start(); - }); + }, 60000); afterAll(async () => { await server.stop(); diff --git a/tests/integration/a2a/input_required/input_required_test.ts b/tests/integration/a2a/input_required/input_required_test.ts index 1b60747b..33231d20 100644 --- a/tests/integration/a2a/input_required/input_required_test.ts +++ b/tests/integration/a2a/input_required/input_required_test.ts @@ -10,7 +10,7 @@ import * as path from 'node:path'; import {afterAll, beforeAll, describe, expect, it} from 'vitest'; import {AdkTsApiServer} from '../../test_api_server.js'; -const TEST_TIMEOUT = 30000; +const TEST_TIMEOUT = 60000; describe('A2A: RemoteAgent InputRequired', () => { let server: AdkTsApiServer; diff --git a/tests/integration/test_api_server.ts b/tests/integration/test_api_server.ts index c8ad1c9e..8b6337d4 100644 --- a/tests/integration/test_api_server.ts +++ b/tests/integration/test_api_server.ts @@ -22,7 +22,7 @@ export interface TestApiServerParams { serveDebugUI?: boolean; } -const DEFAULT_TIMEOUT = 10000; +const DEFAULT_TIMEOUT = 60000; /** * ADK API server for testing via the CLI. This is useful for integration tests