From da0f4b9affbd92c8b0f055e0dd5bba5cbd054334 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 09:39:50 -0700 Subject: [PATCH 1/4] Feat: port UiWidget event rendering metadata from adk-python Adds the UiWidget data model, EventActions.renderUiWidgets, the Context.renderUiWidget() accessor with its duplicate-id guard, widget aggregation in mergeEventActions, and payload preservation across the snake_case/camelCase event transform. --- core/src/agents/context.ts | 22 ++++++++++++++++ core/src/common.ts | 1 + core/src/events/event.ts | 2 ++ core/src/events/event_actions.ts | 43 ++++++++++++++++++++++++++++++-- core/src/events/ui_widget.ts | 34 +++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 core/src/events/ui_widget.ts diff --git a/core/src/agents/context.ts b/core/src/agents/context.ts index a2d942f6f..c98d81645 100644 --- a/core/src/agents/context.ts +++ b/core/src/agents/context.ts @@ -10,6 +10,7 @@ import {AuthCredential} from '../auth/auth_credential.js'; import {AuthHandler} from '../auth/auth_handler.js'; import {AuthConfig} from '../auth/auth_tool.js'; import {createEventActions, EventActions} from '../events/event_actions.js'; +import {UiWidget} from '../events/ui_widget.js'; import {SearchMemoryResponse} from '../memory/base_memory_service.js'; import {State} from '../sessions/state.js'; import {ToolConfirmation} from '../tools/tool_confirmation.js'; @@ -182,4 +183,25 @@ export class Context extends ReadonlyContext { payload: payload, }); } + + /** + * Adds a UI widget to the current event's actions for the UI to render. + * + * UI widgets provide rendering metadata that the UI host uses to display + * rich interactive components (e.g. MCP App iframes) alongside agent + * responses. + * + * @param uiWidget The widget to render. + * @throws If a widget with the same id was already added to this event. + */ + renderUiWidget(uiWidget: UiWidget): void { + const uiWidgets = (this.eventActions.renderUiWidgets ??= []); + if (uiWidgets.some((widget) => widget.id === uiWidget.id)) { + throw new Error( + `UI widget with ID '${uiWidget.id}' already exists in the current` + + ' event actions.', + ); + } + uiWidgets.push(uiWidget); + } } diff --git a/core/src/common.ts b/core/src/common.ts index 23f628165..591c53892 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -160,6 +160,7 @@ export type { ToolConfirmationEvent, ToolResultEvent, } from './events/structured_events.js'; +export type {UiWidget} from './events/ui_widget.js'; export { BaseExampleProvider, isBaseExampleProvider, diff --git a/core/src/events/event.ts b/core/src/events/event.ts index 92387d8b1..f543e50fa 100644 --- a/core/src/events/event.ts +++ b/core/src/events/event.ts @@ -258,6 +258,7 @@ const PRESERVE_KEYS_CAMEL_CASE = [ 'actions.artifactDelta', 'actions.requestedAuthConfigs', 'actions.requestedToolConfirmations', + 'actions.renderUiWidgets.payload', 'actions.customMetadata', 'customMetadata', 'content.parts.functionCall.args', @@ -277,6 +278,7 @@ const PRESERVE_KEYS_SNAKE_CASE = [ 'actions.artifact_delta', 'actions.requested_auth_configs', 'actions.requested_tool_confirmations', + 'actions.render_ui_widgets.payload', 'actions.custom_metadata', 'custom_metadata', 'content.parts.function_call.args', diff --git a/core/src/events/event_actions.ts b/core/src/events/event_actions.ts index 0316ec290..da47bd4a0 100644 --- a/core/src/events/event_actions.ts +++ b/core/src/events/event_actions.ts @@ -7,6 +7,8 @@ import {AuthConfig} from '../auth/auth_tool.js'; import {ToolConfirmation} from '../tools/tool_confirmation.js'; +import {UiWidget} from './ui_widget.js'; + /** * Represents the actions attached to an event. */ @@ -56,6 +58,11 @@ export interface EventActions { * call id. */ requestedToolConfirmations: {[key: string]: ToolConfirmation}; + + /** + * UI widgets to be rendered by the UI host for this event. + */ + renderUiWidgets?: UiWidget[]; } /** @@ -65,8 +72,8 @@ export interface EventActions { * @param state - Optional partial {@link EventActions} whose properties * override the defaults. Dictionary fields (`stateDelta`, `artifactDelta`, * `requestedAuthConfigs`, `requestedToolConfirmations`) default to `{}`; - * scalar fields (`skipSummarization`, `transferToAgent`, `escalate`) default - * to `undefined`. + * scalar fields (`skipSummarization`, `transferToAgent`, `escalate`) and list + * fields (`renderUiWidgets`) default to `undefined`. * @returns A fully populated {@link EventActions} object. */ export function createEventActions( @@ -81,6 +88,27 @@ export function createEventActions( }; } +const RENDER_UI_WIDGETS_SNAKE_CASE_KEY = 'render_ui_widgets'; + +/** + * Reads UI widgets off a partial {@link EventActions}, accepting both the + * camelCase field and the snake_case spelling ADK Python writes on the wire. + * An empty camelCase list falls through to the snake_case key, matching + * `merge_parallel_function_response_events` in adk-python + * (`src/google/adk/flows/llm_flows/functions.py`). + */ +function readRenderUiWidgets( + source: Partial, +): UiWidget[] | undefined { + if (source.renderUiWidgets?.length) { + return source.renderUiWidgets; + } + const snakeCased = (source as Record)[ + RENDER_UI_WIDGETS_SNAKE_CASE_KEY + ]; + return Array.isArray(snakeCased) ? (snakeCased as UiWidget[]) : undefined; +} + /** * Merges a list of {@link EventActions} objects into a single * {@link EventActions} object. @@ -93,6 +121,9 @@ export function createEventActions( * 2. **Scalar fields** (`skipSummarization`, `transferToAgent`, `escalate`) — * last-writer-wins: the value from the last source that sets the field is * kept. + * 3. **List fields** (`renderUiWidgets`) — concatenated in source order. Both + * the camelCase key and the snake_case `render_ui_widgets` spelling are + * read on input; the result always uses the camelCase key. * * @param sources - Ordered list of partial {@link EventActions} to merge. * Falsy entries are silently skipped. @@ -129,6 +160,14 @@ export function mergeEventActions( ); } + const uiWidgets = readRenderUiWidgets(source); + if (uiWidgets?.length) { + result.renderUiWidgets = [ + ...(result.renderUiWidgets ?? []), + ...uiWidgets, + ]; + } + if (source.skipSummarization !== undefined) { result.skipSummarization = source.skipSummarization; } diff --git a/core/src/events/ui_widget.ts b/core/src/events/ui_widget.ts new file mode 100644 index 000000000..16d421b56 --- /dev/null +++ b/core/src/events/ui_widget.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Rendering metadata for a UI widget associated with an event. + * + * When present on an event's actions, the UI renders the widget using the + * specified provider's renderer component. + */ +export interface UiWidget { + /** The unique identifier of the UI widget. */ + id: string; + + /** + * Widget provider identifier. Determines which rendering strategy the UI + * uses. + * + * Known values: + * - `'mcp'`: MCP App iframe, rendered with the MCP Apps AppBridge. + */ + provider: string; + + /** + * Provider-specific data required for rendering. + * + * For the `'mcp'` provider the payload carries `resource_uri` (a `ui://...` + * URI), `tool` and `tool_args`. Keys are provider-defined and are never + * case-converted, so they cross the wire exactly as written. + */ + payload: Record; +} From bfe7c1ce13be177e376ab3555e47577cbcfaeaf5 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 09:45:57 -0700 Subject: [PATCH 2/4] Test: cover UiWidget actions, Context.renderUiWidget and payload preservation Adds a renderUiWidgets suite to event_actions_test, a new context_test mirroring the adk-python TestContextAddUiWidget cases, merge aggregation cases in functions_test, and payload round-trip cases in event_test. --- core/test/agents/context_test.ts | 82 ++++++++++++++++++++++ core/test/agents/functions_test.ts | 67 ++++++++++++++++++ core/test/events/event_actions_test.ts | 96 ++++++++++++++++++++++++++ core/test/events/event_test.ts | 61 ++++++++++++++++ 4 files changed, 306 insertions(+) create mode 100644 core/test/agents/context_test.ts diff --git a/core/test/agents/context_test.ts b/core/test/agents/context_test.ts new file mode 100644 index 000000000..f2dd971a7 --- /dev/null +++ b/core/test/agents/context_test.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Context, + createSession, + InMemorySessionService, + InvocationContext, + LlmAgent, + PluginManager, + UiWidget, +} from '@google/adk'; +import {describe, expect, it} from 'vitest'; + +function createContext(): Context { + const agent = new LlmAgent({name: 'test_agent', model: 'gemini-2.0-flash'}); + const session = createSession({ + id: 'test-session', + appName: 'test-app', + userId: 'test-user', + }); + + return new Context({ + invocationContext: new InvocationContext({ + invocationId: 'test-invocation', + agent, + session, + pluginManager: new PluginManager([]), + sessionService: new InMemorySessionService(), + }), + }); +} + +const widget1: UiWidget = { + id: 'w1', + provider: 'mcp', + payload: {resource_uri: 'ui://app/one'}, +}; +const widget2: UiWidget = { + id: 'w2', + provider: 'mcp', + payload: {resource_uri: 'ui://app/two'}, +}; + +describe('Context.renderUiWidget', () => { + it('appends the widget to the current event actions', () => { + const context = createContext(); + + context.renderUiWidget(widget1); + + expect(context.actions.renderUiWidgets).toHaveLength(1); + expect(context.actions.renderUiWidgets?.[0]).toBe(widget1); + }); + + it('keeps multiple widgets in call order', () => { + const context = createContext(); + + context.renderUiWidget(widget1); + context.renderUiWidget(widget2); + + expect(context.actions.renderUiWidgets).toEqual([widget1, widget2]); + }); + + it('rejects a duplicate widget id and leaves the list unchanged', () => { + const context = createContext(); + context.renderUiWidget(widget1); + + expect(() => + context.renderUiWidget({ + id: 'w1', + provider: 'custom', + payload: {other: true}, + }), + ).toThrowError( + "UI widget with ID 'w1' already exists in the current event actions.", + ); + expect(context.actions.renderUiWidgets).toEqual([widget1]); + }); +}); diff --git a/core/test/agents/functions_test.ts b/core/test/agents/functions_test.ts index cd39ced1c..02f779cdc 100644 --- a/core/test/agents/functions_test.ts +++ b/core/test/agents/functions_test.ts @@ -755,6 +755,73 @@ describe('mergeParallelFunctionResponseEvents', () => { const merged = mergeParallelFunctionResponseEvents([event]); expect(merged).toBe(event); }); + + it('should aggregate UI widgets from every merged event', () => { + const event1 = createEvent({ + invocationId: 'inv-1', + author: 'agent-1', + content: { + role: 'user', + parts: [ + {functionResponse: {name: 'tool1', response: {result: 1}, id: 'id1'}}, + ], + }, + actions: createEventActions({ + renderUiWidgets: [ + {id: 'widget_1', provider: 'mcp', payload: {resource_uri: 'ui://a'}}, + ], + }), + }); + const event2 = createEvent({ + invocationId: 'inv-1', + author: 'agent-1', + content: { + role: 'user', + parts: [ + {functionResponse: {name: 'tool2', response: {result: 2}, id: 'id2'}}, + ], + }, + actions: createEventActions({ + renderUiWidgets: [ + {id: 'widget_2', provider: 'mcp', payload: {resource_uri: 'ui://b'}}, + {id: 'widget_3', provider: 'custom', payload: {}}, + ], + }), + }); + + const merged = mergeParallelFunctionResponseEvents([event1, event2]); + + expect(merged.actions!.renderUiWidgets?.map((widget) => widget.id)).toEqual( + ['widget_1', 'widget_2', 'widget_3'], + ); + }); + + it('should leave renderUiWidgets undefined when no event has widgets', () => { + const event1 = createEvent({ + invocationId: 'inv-1', + author: 'agent-1', + content: { + role: 'user', + parts: [ + {functionResponse: {name: 'tool1', response: {result: 1}, id: 'id1'}}, + ], + }, + }); + const event2 = createEvent({ + invocationId: 'inv-1', + author: 'agent-1', + content: { + role: 'user', + parts: [ + {functionResponse: {name: 'tool2', response: {result: 2}, id: 'id2'}}, + ], + }, + }); + + const merged = mergeParallelFunctionResponseEvents([event1, event2]); + + expect(merged.actions!.renderUiWidgets).toBeUndefined(); + }); }); describe('findEventByFunctionCallId', () => { diff --git a/core/test/events/event_actions_test.ts b/core/test/events/event_actions_test.ts index 0b4c0f704..b91341dee 100644 --- a/core/test/events/event_actions_test.ts +++ b/core/test/events/event_actions_test.ts @@ -4,9 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {UiWidget} from '@google/adk'; import {describe, expect, it} from 'vitest'; import { createEventActions, + EventActions, mergeEventActions, } from '../../src/events/event_actions.js'; @@ -202,3 +204,97 @@ describe('mergeEventActions', () => { expect(result.stateDelta).toEqual({x: 1}); }); }); + +const widget1: UiWidget = { + id: 'widget_1', + provider: 'mcp', + payload: {resource_uri: 'ui://app/one'}, +}; +const widget2: UiWidget = { + id: 'widget_2', + provider: 'mcp', + payload: {resource_uri: 'ui://app/two'}, +}; +const widget3: UiWidget = { + id: 'widget_3', + provider: 'custom', + payload: {}, +}; + +/** + * Builds a merge source carrying the snake_case `render_ui_widgets` spelling + * that ADK Python writes on the wire. `Object.assign` keeps the result out of + * the excess-property check that rejects the key on a fresh object literal. + */ +function sourceWithSnakeCaseWidgets( + widgets: unknown, + overrides: Partial = {}, +): Partial { + return Object.assign({render_ui_widgets: widgets}, overrides); +} + +describe('EventActions renderUiWidgets', () => { + it('leaves renderUiWidgets undefined by default', () => { + expect(createEventActions().renderUiWidgets).toBeUndefined(); + }); + + it('applies a renderUiWidgets override', () => { + const actions = createEventActions({renderUiWidgets: [widget1]}); + expect(actions.renderUiWidgets).toEqual([widget1]); + }); + + it('concatenates widgets from multiple sources in source order', () => { + const result = mergeEventActions([ + createEventActions({renderUiWidgets: [widget1]}), + createEventActions({renderUiWidgets: [widget2, widget3]}), + ]); + expect(result.renderUiWidgets?.map((widget) => widget.id)).toEqual([ + 'widget_1', + 'widget_2', + 'widget_3', + ]); + }); + + it('reads the snake_case render_ui_widgets key alongside camelCase', () => { + const result = mergeEventActions([ + createEventActions({renderUiWidgets: [widget1]}), + sourceWithSnakeCaseWidgets([widget2]), + ]); + expect(result.renderUiWidgets).toEqual([widget1, widget2]); + }); + + it('falls through to snake_case when the camelCase list is empty', () => { + const result = mergeEventActions([ + sourceWithSnakeCaseWidgets([widget2], {renderUiWidgets: []}), + ]); + expect(result.renderUiWidgets).toEqual([widget2]); + }); + + it('ignores a non-array render_ui_widgets value', () => { + const result = mergeEventActions([ + sourceWithSnakeCaseWidgets('not-a-list'), + ]); + expect(result.renderUiWidgets).toBeUndefined(); + }); + + it('leaves renderUiWidgets undefined when no source has widgets', () => { + const result = mergeEventActions([ + createEventActions({stateDelta: {x: 1}}), + createEventActions(), + ]); + expect(result.renderUiWidgets).toBeUndefined(); + }); + + it('does not mutate the widget arrays of the sources or the target', () => { + const target = createEventActions({renderUiWidgets: [widget1]}); + const source = createEventActions({renderUiWidgets: [widget2]}); + + const result = mergeEventActions([source], target); + + expect(result.renderUiWidgets).toEqual([widget1, widget2]); + expect(result.renderUiWidgets).not.toBe(target.renderUiWidgets); + expect(result.renderUiWidgets).not.toBe(source.renderUiWidgets); + expect(target.renderUiWidgets).toEqual([widget1]); + expect(source.renderUiWidgets).toEqual([widget2]); + }); +}); diff --git a/core/test/events/event_test.ts b/core/test/events/event_test.ts index b429ae754..7b14838d1 100644 --- a/core/test/events/event_test.ts +++ b/core/test/events/event_test.ts @@ -339,6 +339,34 @@ describe('Event Utils', () => { NestedKey: 'value2', }); }); + + it('preserves UI widget payload keys during conversion to camelCase', () => { + const snakeEvent = { + id: '123', + invocation_id: 'inv1', + actions: { + render_ui_widgets: [ + { + id: 'widget_1', + provider: 'mcp', + payload: { + resource_uri: 'ui://app', + tool_args: {a: 1}, + tool: {input_schema: {some_field: 'x'}}, + }, + }, + ], + }, + }; + + const camelEvent = transformToCamelCaseEvent(snakeEvent); + + expect(camelEvent.actions?.renderUiWidgets?.[0].payload).toEqual({ + resource_uri: 'ui://app', + tool_args: {a: 1}, + tool: {input_schema: {some_field: 'x'}}, + }); + }); }); describe('transformToSnakeCaseEvent', () => { @@ -373,6 +401,39 @@ describe('Event Utils', () => { NestedKey: 'value2', }); }); + + it('preserves UI widget payload keys during conversion to snake_case', () => { + const camelEvent = createEvent({ + id: '123', + invocationId: 'inv1', + actions: createEventActions({ + renderUiWidgets: [ + { + id: 'widget_1', + provider: 'mcp', + payload: { + resource_uri: 'ui://app', + tool_args: {a: 1}, + tool: {input_schema: {some_field: 'x'}}, + }, + }, + ], + }), + }); + + const snakeEvent = transformToSnakeCaseEvent(camelEvent); + + const actions = snakeEvent.actions as Record; + expect(actions.renderUiWidgets).toBeUndefined(); + const widgets = actions.render_ui_widgets as Array< + Record + >; + expect(widgets[0].payload).toEqual({ + resource_uri: 'ui://app', + tool_args: {a: 1}, + tool: {input_schema: {some_field: 'x'}}, + }); + }); }); describe('generateClientFunctionCallId', () => { From 6581a48355b376cf78352f212995727a02dab43d Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 09:50:52 -0700 Subject: [PATCH 3/4] Test: use a mixed-case widget payload to pin both preserve-key entries An all-snake_case payload is a no-op under the camelCase-to-snake_case transform, so the fixture could not detect a missing preserve key in that direction. The payload now also carries MCP's own camelCase inputSchema, which makes each direction fail if its preserve key is dropped. --- core/test/events/event_test.ts | 38 ++++++++++++++++------------------ 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/core/test/events/event_test.ts b/core/test/events/event_test.ts index 7b14838d1..78f239a46 100644 --- a/core/test/events/event_test.ts +++ b/core/test/events/event_test.ts @@ -26,6 +26,18 @@ import { transformToSnakeCaseEvent, } from '../../src/events/event.js'; +/** + * A widget payload holding both key spellings at once: ADK writes + * `resource_uri`/`tool_args`, while the embedded raw MCP tool definition uses + * MCP's own camelCase `inputSchema`. Both must survive either conversion + * direction untouched, so this fixture detects a mangling in both. + */ +const MIXED_CASE_WIDGET_PAYLOAD = { + resource_uri: 'ui://app', + tool_args: {a: 1}, + tool: {inputSchema: {someField: 'x'}}, +}; + describe('Event Utils', () => { describe('createEvent', () => { it('creates an event with default values', () => { @@ -349,11 +361,7 @@ describe('Event Utils', () => { { id: 'widget_1', provider: 'mcp', - payload: { - resource_uri: 'ui://app', - tool_args: {a: 1}, - tool: {input_schema: {some_field: 'x'}}, - }, + payload: MIXED_CASE_WIDGET_PAYLOAD, }, ], }, @@ -361,11 +369,9 @@ describe('Event Utils', () => { const camelEvent = transformToCamelCaseEvent(snakeEvent); - expect(camelEvent.actions?.renderUiWidgets?.[0].payload).toEqual({ - resource_uri: 'ui://app', - tool_args: {a: 1}, - tool: {input_schema: {some_field: 'x'}}, - }); + expect(camelEvent.actions?.renderUiWidgets?.[0].payload).toEqual( + MIXED_CASE_WIDGET_PAYLOAD, + ); }); }); @@ -411,11 +417,7 @@ describe('Event Utils', () => { { id: 'widget_1', provider: 'mcp', - payload: { - resource_uri: 'ui://app', - tool_args: {a: 1}, - tool: {input_schema: {some_field: 'x'}}, - }, + payload: MIXED_CASE_WIDGET_PAYLOAD, }, ], }), @@ -428,11 +430,7 @@ describe('Event Utils', () => { const widgets = actions.render_ui_widgets as Array< Record >; - expect(widgets[0].payload).toEqual({ - resource_uri: 'ui://app', - tool_args: {a: 1}, - tool: {input_schema: {some_field: 'x'}}, - }); + expect(widgets[0].payload).toEqual(MIXED_CASE_WIDGET_PAYLOAD); }); }); From 276f4803119639d3df8014001a824a2b0406d8ac Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 10:28:23 -0700 Subject: [PATCH 4/4] Refactor: drop the unreachable snake_case render_ui_widgets fallback The readRenderUiWidgets helper existed to read a snake_case key that nothing can produce. mergeEventActions has one production caller, mergeParallelFunctionResponseEvents, which maps event.actions over in-process events typed as EventActions; the only snake_case wire boundary runs transformToCamelCaseEvent before an Event exists. The adk-python fallback it mirrored is dead there too: EventActions sets alias_generator=to_camel and the merge dumps with by_alias=True, so the dump key is always renderUiWidgets and the second pop only ever returns None. Verified against pydantic 2.13.4. Inlining the concatenation also removes both unchecked casts. Drops the three tests that only covered the deleted branch, and adds one for the empty-list guard that keeps the field undefined rather than []. --- core/src/events/event_actions.ts | 30 ++------------------ core/test/events/event_actions_test.ts | 38 ++++---------------------- 2 files changed, 8 insertions(+), 60 deletions(-) diff --git a/core/src/events/event_actions.ts b/core/src/events/event_actions.ts index da47bd4a0..d3569c277 100644 --- a/core/src/events/event_actions.ts +++ b/core/src/events/event_actions.ts @@ -88,27 +88,6 @@ export function createEventActions( }; } -const RENDER_UI_WIDGETS_SNAKE_CASE_KEY = 'render_ui_widgets'; - -/** - * Reads UI widgets off a partial {@link EventActions}, accepting both the - * camelCase field and the snake_case spelling ADK Python writes on the wire. - * An empty camelCase list falls through to the snake_case key, matching - * `merge_parallel_function_response_events` in adk-python - * (`src/google/adk/flows/llm_flows/functions.py`). - */ -function readRenderUiWidgets( - source: Partial, -): UiWidget[] | undefined { - if (source.renderUiWidgets?.length) { - return source.renderUiWidgets; - } - const snakeCased = (source as Record)[ - RENDER_UI_WIDGETS_SNAKE_CASE_KEY - ]; - return Array.isArray(snakeCased) ? (snakeCased as UiWidget[]) : undefined; -} - /** * Merges a list of {@link EventActions} objects into a single * {@link EventActions} object. @@ -121,9 +100,7 @@ function readRenderUiWidgets( * 2. **Scalar fields** (`skipSummarization`, `transferToAgent`, `escalate`) — * last-writer-wins: the value from the last source that sets the field is * kept. - * 3. **List fields** (`renderUiWidgets`) — concatenated in source order. Both - * the camelCase key and the snake_case `render_ui_widgets` spelling are - * read on input; the result always uses the camelCase key. + * 3. **List fields** (`renderUiWidgets`) — concatenated in source order. * * @param sources - Ordered list of partial {@link EventActions} to merge. * Falsy entries are silently skipped. @@ -160,11 +137,10 @@ export function mergeEventActions( ); } - const uiWidgets = readRenderUiWidgets(source); - if (uiWidgets?.length) { + if (source.renderUiWidgets?.length) { result.renderUiWidgets = [ ...(result.renderUiWidgets ?? []), - ...uiWidgets, + ...source.renderUiWidgets, ]; } diff --git a/core/test/events/event_actions_test.ts b/core/test/events/event_actions_test.ts index b91341dee..af35b2845 100644 --- a/core/test/events/event_actions_test.ts +++ b/core/test/events/event_actions_test.ts @@ -8,7 +8,6 @@ import {UiWidget} from '@google/adk'; import {describe, expect, it} from 'vitest'; import { createEventActions, - EventActions, mergeEventActions, } from '../../src/events/event_actions.js'; @@ -221,18 +220,6 @@ const widget3: UiWidget = { payload: {}, }; -/** - * Builds a merge source carrying the snake_case `render_ui_widgets` spelling - * that ADK Python writes on the wire. `Object.assign` keeps the result out of - * the excess-property check that rejects the key on a fresh object literal. - */ -function sourceWithSnakeCaseWidgets( - widgets: unknown, - overrides: Partial = {}, -): Partial { - return Object.assign({render_ui_widgets: widgets}, overrides); -} - describe('EventActions renderUiWidgets', () => { it('leaves renderUiWidgets undefined by default', () => { expect(createEventActions().renderUiWidgets).toBeUndefined(); @@ -255,32 +242,17 @@ describe('EventActions renderUiWidgets', () => { ]); }); - it('reads the snake_case render_ui_widgets key alongside camelCase', () => { - const result = mergeEventActions([ - createEventActions({renderUiWidgets: [widget1]}), - sourceWithSnakeCaseWidgets([widget2]), - ]); - expect(result.renderUiWidgets).toEqual([widget1, widget2]); - }); - - it('falls through to snake_case when the camelCase list is empty', () => { - const result = mergeEventActions([ - sourceWithSnakeCaseWidgets([widget2], {renderUiWidgets: []}), - ]); - expect(result.renderUiWidgets).toEqual([widget2]); - }); - - it('ignores a non-array render_ui_widgets value', () => { + it('leaves renderUiWidgets undefined when no source has widgets', () => { const result = mergeEventActions([ - sourceWithSnakeCaseWidgets('not-a-list'), + createEventActions({stateDelta: {x: 1}}), + createEventActions(), ]); expect(result.renderUiWidgets).toBeUndefined(); }); - it('leaves renderUiWidgets undefined when no source has widgets', () => { + it('leaves renderUiWidgets undefined when a source has an empty list', () => { const result = mergeEventActions([ - createEventActions({stateDelta: {x: 1}}), - createEventActions(), + createEventActions({renderUiWidgets: []}), ]); expect(result.renderUiWidgets).toBeUndefined(); });