Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions core/src/agents/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}
1 change: 1 addition & 0 deletions core/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export type {
ToolConfirmationEvent,
ToolResultEvent,
} from './events/structured_events.js';
export type {UiWidget} from './events/ui_widget.js';
export {
BaseExampleProvider,
isBaseExampleProvider,
Expand Down
2 changes: 2 additions & 0 deletions core/src/events/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
19 changes: 17 additions & 2 deletions core/src/events/event_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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[];
}

/**
Expand All @@ -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(
Expand All @@ -93,6 +100,7 @@ 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.
*
* @param sources - Ordered list of partial {@link EventActions} to merge.
* Falsy entries are silently skipped.
Expand Down Expand Up @@ -129,6 +137,13 @@ export function mergeEventActions(
);
}

if (source.renderUiWidgets?.length) {
result.renderUiWidgets = [
...(result.renderUiWidgets ?? []),
...source.renderUiWidgets,
];
}

if (source.skipSummarization !== undefined) {
result.skipSummarization = source.skipSummarization;
}
Expand Down
34 changes: 34 additions & 0 deletions core/src/events/ui_widget.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}
82 changes: 82 additions & 0 deletions core/test/agents/context_test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
67 changes: 67 additions & 0 deletions core/test/agents/functions_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
68 changes: 68 additions & 0 deletions core/test/events/event_actions_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {UiWidget} from '@google/adk';
import {describe, expect, it} from 'vitest';
import {
createEventActions,
Expand Down Expand Up @@ -202,3 +203,70 @@ 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: {},
};

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('leaves renderUiWidgets undefined when no source has widgets', () => {
const result = mergeEventActions([
createEventActions({stateDelta: {x: 1}}),
createEventActions(),
]);
expect(result.renderUiWidgets).toBeUndefined();
});

it('leaves renderUiWidgets undefined when a source has an empty list', () => {
const result = mergeEventActions([
createEventActions({renderUiWidgets: []}),
]);
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]);
});
});
Loading
Loading