Skip to content

Commit 45ee2a5

Browse files
committed
feat: add contextId support to A2A request and response payloads across samples
Currently, only the rizzcharts sample support multi-turn conversation with contextId: - server.ts: https://github.com/google/A2UI/blob/d50db0a75d9f8c4f144dc73bb058940fbfd7090d/samples/client/angular/projects/rizzcharts/src/server.ts#L61 - a2a_service.ts: https://github.com/google/A2UI/blob/d50db0a75d9f8c4f144dc73bb058940fbfd7090d/samples/client/angular/projects/rizzcharts/src/services/a2a_service.ts#L39 Validation steps: - Start the rizzcharts agent: `cd samples/agent/adk/rizzcharts && uv run . --port=10002` - Start the rizzcharts Angular client: `cd samples/client/angular/projects && npm start -- rizzcharts` - Send the first query and get the contextId from the response: `contextId=$(curl -X POST -H "Content-Type: application/json" -d '{"parts": [{"kind": "text", "text": "Show me sales data for Q4"}]}' http://localhost:4200/a2a | jq -r .result.contextId)` - Send the second query with the same contextId: `curl -X POST -H "Content-Type: application/json" -d "{\"parts\": [{\"kind\": \"text\", \"text\": \"Plot this as a pie chart\"}], \"context_id\": \"$contextId\"}" http://localhost:4200/a2a | jq` - Confirm A2UI messages are generated However, other samples don't have the contextId set correctly. A simple verification for the contact_lookup sample. - Start the contact_lookup agent: `cd samples/agent/adk/contact_lookup && uv run . --port=10003` - Start the contact_lookup angular client: `cd samples/client/angular/projects && npm start -- contact` - Send the first query: `curl -X POST -H "Content-Type: application/json" -d '{"query": "Who is Alex Jordan?"}' http://localhost:4200/a2a | jq` - Confirm no `contextId` is returned This commit adds contextId support to A2A request and response payloads across samples. Verification: - Unit tests passed: `/projects/a2ui/samples/client/angular$ npx ng test orchestrator --watch=false` - Contact_lookup sample has contextId returned and propagated: - Start the contact_lookup agent: `cd samples/agent/adk/contact_lookup && uv run . --port=10003` - Start the contact_lookup angular client: `cd samples/client/angular/projects && npm start -- contact` - Send the first query and get the contextId from the response: `contextId=$(curl -X POST -H "Content-Type: application/json" -d '{"query": "Who is Alex Jordan?"}' http://localhost:4200/a2a | jq -r .contextId)` - Send the second query with the same contextId: `curl -X POST -H "Content-Type: application/json" -d "{\"query\": \"show me his contact card\", \"contextId\": \"$contextId\"}" http://localhost:4200/a2a | jq` - Confirm a successful response is returned with Alex's contact info. - Restaurant_finder sample has contextId returned and propagated: - Start the restaurant_finder agent: `cd samples/agent/adk/restaurant_finder && uv run . --port=10004` - Start the restaurant_finder angular client: `cd samples/client/angular/projects && npm start -- restaurant` - Send the first query and get the contextId from the response: `contextId=$(curl -X POST -H "Content-Type: application/json" -d '{"query": "Find Chinese restaurants in New York"}' http://localhost:4200/a2a | jq -r .contextId)` - Send the second query with the same contextId: `curl -X POST -H "Content-Type: application/json" -d "{\"query\": \"Show those restaurants again\", \"contextId\": \"$contextId\"}" http://localhost:4200/a2a | jq` - Confirm a successful response is returned with the Chinese restaurant list. - component_gallery sample has contextId returned and propagated: - Start the component_gallery agent: `cd samples/agent/adk/component_gallery && uv run . --port=10005` - Start the component_gallery lit client: `cd samples/client/lit/component_gallery && npm run dev` - Send the first query and get the contextId from the response: `contextId=$(curl -X POST -H "Content-Type: application/json" -d '{"event": {"type": "some_ui_event_or_query"}}' http://localhost:5173/a2a | jq -r .contextId)` - Send the second query with the same contextId: `curl -X POST -H "Content-Type: application/json" -d "{\"event\": {\"type\": \"follow_up_event\"}, \"contextId\": \"$contextId\"}" http://localhost:5173/a2a | jq` - Confirm a successful response is returned with the follow up event. - custom_comonent_example sample - Start the custom-components-example agent: `cd samples/agent/adk/custom-components-example && uv run . --port=10004` - Start the custom-components-example lit client: `cd samples/client/lit/custom-components-example && npm run dev` - Define the inline catalogs in client capabilities: `a2ui_capabilities='{"a2uiClientCapabilities":{"inlineCatalogs":[{"components":{"OrgChart":{"type":"object","properties":{"chain":{"type":"object","properties":{"path":{"type":"string"},"literalArray":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string"},"name":{"type":"string"}},"required":["title","name"]}}}},"action":{"type":"object","properties":{"name":{"type":"string"},"context":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"value":{"type":"object","properties":{"path":{"type":"string"},"literalString":{"type":"string"},"literalNumber":{"type":"number"},"literalBoolean":{"type":"boolean"}}}},"required":["key","value"]}}},"required":["name"]}},"required":["chain"]},"McpApp":{"type":"object","properties":{"resourceUri":{"type":"string"},"htmlContent":{"type":"string"},"height":{"type":"number"},"allowedTools":{"type":"array","items":{"type":"string"}}}},"WebFrame":{"type":"object","properties":{"url":{"type":"string"},"html":{"type":"string"},"height":{"type":"number"},"interactionMode":{"type":"string","enum":["readOnly","interactive"]},"allowedEvents":{"type":"array","items":{"type":"string"}}}}}}]}}'` - Send the first query and get the contextId from the response: `contextId=$(curl -X POST -H "Content-Type: application/json" -d "{\"event\":{\"request\":\"Alex Jordan\",\"metadata\": $a2ui_capabilities}}" http://localhost:5173/a2a | jq -r .contextId)` - Send the second query with the same contextId: `curl -X POST -H "Content-Type: application/json" -d "{\"event\":{\"userAction\":{\"surfaceId\":\"contact-card\",\"name\":\"ACTION: view_location\",\"sourceComponentId\":\"location-button\",\"context\":{\"contactId\":\"1\"}},\"metadata\": $a2ui_capabilities},\"contextId\":\"$contextId\"}" http://localhost:5173/a2a | jq` - Confirm a successful response is returned with the floor plan. - orchestrator sample - Start the restaurant_finder agent: `cd samples/agent/adk/restaurant_finder && uv run . --port=10003` - Start the contact_lookup agent: `cd samples/agent/adk/contact_lookup && uv run . --port=10004` - Start the rizzcharts agent: `cd samples/agent/adk/rizzcharts && uv run . --port=10005` - Start the orchestrator agent: `cd samples/agent/adk/orchestrator && uv run . --port=10002 --subagent_urls=http://localhost:10003 --subagent_urls=http://localhost:10004 --subagent_urls=http://localhost:10005` - Start the orchestrator angular client: `cd samples/client/angular/projects && npm start -- orchestrator` - Send the first query and get the contextId from the response: `contextId=$(curl -X POST -H "Content-Type: application/json" -d "{\"parts\": [{\"kind\": \"text\", \"text\": \"Who is Alex Jordan?\"}]}" http://localhost:4200/a2a | jq -r .result.contextId)` - Send the second query with the same contextId: `curl -X POST -H "Content-Type: application/json" -d "{\"parts\": [{\"kind\": \"text\", \"text\": \"show me his contact card\"}], \"contextId\": \"$contextId\"}" http://localhost:4200/a2a | jq` - Confirm a successful response is returned with Alex's contact info.
1 parent 29f58e8 commit 45ee2a5

15 files changed

Lines changed: 474 additions & 86 deletions

File tree

samples/client/angular/projects/contact/src/app/client.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Injectable, inject, signal } from '@angular/core';
2121
@Injectable({ providedIn: 'root' })
2222
export class Client {
2323
private processor = inject(MessageProcessor);
24+
private contextId?: string;
2425

2526
readonly isLoading = signal(false);
2627

@@ -43,8 +44,13 @@ export class Client {
4344
// Clear surfaces at the start of a new request
4445
this.processor.clearSurfaces();
4546

47+
const isString = typeof request === 'string';
48+
const bodyData = isString
49+
? { query: request, contextId: this.contextId }
50+
: { event: request, contextId: this.contextId };
51+
4652
const response = await fetch('/a2a', {
47-
body: JSON.stringify(request as Types.A2UIClientEventMessage),
53+
body: JSON.stringify(bodyData),
4854
method: 'POST',
4955
});
5056

@@ -96,18 +102,22 @@ export class Client {
96102
if (line.startsWith("data: ")) {
97103
const jsonStr = line.slice(6);
98104
try {
99-
const data = JSON.parse(jsonStr) as A2AServerPayload;
100-
console.log(`[client] [${now.toFixed(2)}ms] Received SSE data:`, data);
105+
const responseData = JSON.parse(jsonStr);
106+
console.log(`[client] [${now.toFixed(2)}ms] Received SSE data:`, responseData);
101107

102-
if ('error' in data) {
103-
throw new Error(data.error);
108+
if (responseData.error) {
109+
throw new Error(responseData.error);
104110
} else {
111+
if (responseData.contextId) {
112+
this.contextId = responseData.contextId;
113+
}
114+
const parts = responseData.parts || [];
105115
console.log(
106-
`[client] [${performance.now().toFixed(2)}ms] Scheduling processing for ${data.length} parts`
116+
`[client] [${performance.now().toFixed(2)}ms] Scheduling processing for ${parts.length} parts`
107117
);
108118
// Use a microtask to ensure we don't block the stream reader
109119
await Promise.resolve();
110-
const newMessages = this.processParts(data as any[]);
120+
const newMessages = this.processParts(parts);
111121
messages.push(...newMessages);
112122
}
113123
} catch (e) {
@@ -122,9 +132,14 @@ export class Client {
122132
response: Response,
123133
messages: Types.ServerToClientMessage[]
124134
): Promise<void> {
125-
const data = (await response.json()) as any[];
126-
console.log(`[client] Received JSON response:`, data);
127-
const newMessages = this.processParts(data);
135+
const responseData = await response.json();
136+
console.log(`[client] Received JSON response:`, responseData);
137+
138+
if (responseData.contextId) {
139+
this.contextId = responseData.contextId;
140+
}
141+
const parts = responseData.parts || [];
142+
const newMessages = this.processParts(parts);
128143
messages.push(...newMessages);
129144
}
130145

samples/client/angular/projects/contact/src/server.ts

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -51,25 +51,58 @@ app.post('/a2a', (req, res) => {
5151
let sendParams: MessageSendParams;
5252

5353
if (isJson(originalBody)) {
54-
console.log('[a2a-middleware] Received JSON UI event:', originalBody);
55-
56-
const clientEvent = JSON.parse(originalBody);
57-
sendParams = {
58-
message: {
59-
messageId: uuidv4(),
60-
role: 'user',
61-
parts: [
62-
{
63-
kind: 'data',
64-
data: clientEvent,
65-
metadata: { 'mimeType': 'application/json+a2ui' },
66-
} as Part,
67-
],
68-
kind: 'message',
69-
},
70-
};
54+
const requestData = JSON.parse(originalBody);
55+
const contextId = requestData.contextId;
56+
57+
if (requestData.event) {
58+
console.log('[a2a-middleware] Received JSON UI event:', requestData.event);
59+
sendParams = {
60+
message: {
61+
messageId: uuidv4(),
62+
contextId,
63+
role: 'user',
64+
parts: [
65+
{
66+
kind: 'data',
67+
data: requestData.event,
68+
metadata: { 'mimeType': 'application/json+a2ui' },
69+
} as Part,
70+
],
71+
kind: 'message',
72+
},
73+
};
74+
} else if (requestData.query) {
75+
console.log('[a2a-middleware] Received text query:', requestData.query);
76+
sendParams = {
77+
message: {
78+
messageId: uuidv4(),
79+
contextId,
80+
role: 'user',
81+
parts: [{ kind: 'text', text: requestData.query }],
82+
kind: 'message',
83+
},
84+
};
85+
} else {
86+
// Fallback for legacy JSON event where the body is the event itself
87+
console.log('[a2a-middleware] Received legacy JSON event:', originalBody);
88+
sendParams = {
89+
message: {
90+
messageId: uuidv4(),
91+
contextId,
92+
role: 'user',
93+
parts: [
94+
{
95+
kind: 'data',
96+
data: requestData,
97+
metadata: { 'mimeType': 'application/json+a2ui' },
98+
} as Part,
99+
],
100+
kind: 'message',
101+
},
102+
};
103+
}
71104
} else {
72-
console.log('[a2a-middleware] Received text query:', originalBody);
105+
console.log('[a2a-middleware] Received plain text query:', originalBody);
73106
sendParams = {
74107
message: {
75108
messageId: uuidv4(),
@@ -121,7 +154,11 @@ async function handleStreamingResponse(client: A2AClient, sendParams: MessageSen
121154

122155
if (parts.length > 0) {
123156
console.log(`[server] Streaming ${parts.length} parts to client`);
124-
res.write(`data: ${JSON.stringify(parts)}\n\n`);
157+
const responseData = {
158+
parts,
159+
contextId: (event as any).contextId || (event as any).status?.message?.contextId
160+
};
161+
res.write(`data: ${JSON.stringify(responseData)}\n\n`);
125162
}
126163
}
127164
res.end();
@@ -140,7 +177,10 @@ async function handleNonStreamingResponse(client: A2AClient, sendParams: Message
140177
}
141178

142179
const result = (response as SendMessageSuccessResponse).result as Task;
143-
res.json(result.kind === 'task' ? result.status.message?.parts || [] : []);
180+
res.json({
181+
parts: result.kind === 'task' ? result.status.message?.parts || [] : [],
182+
contextId: result.contextId
183+
});
144184
}
145185

146186
app.use((req, res, next) => {

samples/client/angular/projects/orchestrator/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,12 @@ app.post('/a2a', (req, res) => {
5252
console.log('[a2a-middleware] Received data:', data);
5353

5454
const parts: Part[] = data['parts'];
55+
const contextId: string | undefined = data['contextId'];
5556

5657
const sendParams: MessageSendParams = {
5758
message: {
5859
messageId: uuidv4(),
60+
contextId,
5961
role: 'user',
6062
parts,
6163
kind: 'message',
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { TestBed } from '@angular/core/testing';
18+
import { A2aServiceImpl } from './a2a-service-impl';
19+
20+
describe('A2aServiceImpl', () => {
21+
let service: A2aServiceImpl;
22+
23+
beforeEach(() => {
24+
TestBed.configureTestingModule({
25+
providers: [A2aServiceImpl],
26+
});
27+
service = TestBed.inject(A2aServiceImpl);
28+
});
29+
30+
it('should be created', () => {
31+
expect(service).toBeTruthy();
32+
});
33+
34+
it('should send contextId in request after receiving it from server', async () => {
35+
// Mock first response to return a contextId
36+
const mockResponse1 = {
37+
contextId: 'test-session-123',
38+
parts: [],
39+
};
40+
41+
const fetchSpy = spyOn(globalThis, 'fetch').and.returnValue(
42+
Promise.resolve({
43+
ok: true,
44+
json: () => Promise.resolve(mockResponse1),
45+
} as Response)
46+
);
47+
48+
// First call should NOT send contextId (it doesn't have it yet)
49+
await service.sendMessage([]);
50+
51+
let lastCall = fetchSpy.calls.mostRecent();
52+
let body = JSON.parse(lastCall.args[1]!.body as string);
53+
expect(body.contextId).toBeUndefined();
54+
55+
// Mock second response (just to complete the call)
56+
const mockResponse2 = {
57+
parts: [],
58+
};
59+
fetchSpy.and.returnValue(
60+
Promise.resolve({
61+
ok: true,
62+
json: () => Promise.resolve(mockResponse2),
63+
} as Response)
64+
);
65+
66+
// Second call SHOULD send contextId
67+
await service.sendMessage([]);
68+
69+
lastCall = fetchSpy.calls.mostRecent();
70+
body = JSON.parse(lastCall.args[1]!.body as string);
71+
expect(body.contextId).toBe('test-session-123');
72+
});
73+
74+
it('should update contextId from data.result.contextId if contextId is missing', async () => {
75+
const mockResponse = {
76+
result: {
77+
contextId: 'test-session-456',
78+
},
79+
parts: [],
80+
};
81+
82+
const fetchSpy = spyOn(globalThis, 'fetch').and.returnValue(
83+
Promise.resolve({
84+
ok: true,
85+
json: () => Promise.resolve(mockResponse),
86+
} as Response)
87+
);
88+
89+
await service.sendMessage([]);
90+
91+
// Call again to see if it sends it
92+
fetchSpy.and.returnValue(
93+
Promise.resolve({
94+
ok: true,
95+
json: () => Promise.resolve({}),
96+
} as Response)
97+
);
98+
99+
await service.sendMessage([]);
100+
101+
const lastCall = fetchSpy.calls.mostRecent();
102+
const body = JSON.parse(lastCall.args[1]!.body as string);
103+
expect(body.contextId).toBe('test-session-456');
104+
});
105+
});

samples/client/angular/projects/orchestrator/src/services/a2a-service-impl.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,25 @@ import { Injectable } from '@angular/core';
2222
providedIn: 'root',
2323
})
2424
export class A2aServiceImpl implements A2aService {
25+
private contextId?: string;
2526

2627
async sendMessage(parts: Part[], signal?: AbortSignal): Promise<SendMessageSuccessResponse> {
2728
const response = await fetch('/a2a', {
28-
body: JSON.stringify({ parts: parts }),
29+
body: JSON.stringify({
30+
parts: parts,
31+
contextId: this.contextId
32+
}),
2933
method: 'POST',
3034
signal,
3135
});
3236

3337
if (response.ok) {
3438
const data = await response.json();
39+
if (data.contextId) {
40+
this.contextId = data.contextId;
41+
} else if (data.result?.contextId) { // fallback if it's there
42+
this.contextId = data.result.contextId;
43+
}
3544
return data;
3645
}
3746

samples/client/angular/projects/restaurant/src/app/client.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { inject, Injectable, signal } from '@angular/core';
2121
@Injectable({ providedIn: 'root' })
2222
export class Client {
2323
private processor = inject(MessageProcessor);
24+
private contextId?: string;
2425

2526
readonly isLoading = signal(false);
2627

@@ -43,8 +44,13 @@ export class Client {
4344
// Clear surfaces at the start of a new request
4445
this.processor.clearSurfaces();
4546

47+
const isString = typeof request === 'string';
48+
const bodyData = isString
49+
? { query: request, contextId: this.contextId }
50+
: { event: request, contextId: this.contextId };
51+
4652
const response = await fetch('/a2a', {
47-
body: JSON.stringify(request as Types.A2UIClientEventMessage),
53+
body: JSON.stringify(bodyData),
4854
method: 'POST',
4955
});
5056

@@ -96,18 +102,22 @@ export class Client {
96102
if (line.startsWith('data: ')) {
97103
const jsonStr = line.slice(6);
98104
try {
99-
const data = JSON.parse(jsonStr) as A2AServerPayload;
100-
console.log(`[client] [${now.toFixed(2)}ms] Received SSE data:`, data);
105+
const responseData = JSON.parse(jsonStr);
106+
console.log(`[client] [${now.toFixed(2)}ms] Received SSE data:`, responseData);
101107

102-
if ('error' in data) {
103-
throw new Error(data.error);
108+
if (responseData.error) {
109+
throw new Error(responseData.error);
104110
} else {
111+
if (responseData.contextId) {
112+
this.contextId = responseData.contextId;
113+
}
114+
const parts = responseData.parts || [];
105115
console.log(
106-
`[client] [${performance.now().toFixed(2)}ms] Scheduling processing for ${data.length} parts`
116+
`[client] [${performance.now().toFixed(2)}ms] Scheduling processing for ${parts.length} parts`
107117
);
108118
// Use a microtask to ensure we don't block the stream reader
109119
await Promise.resolve();
110-
const newMessages = this.processParts(data as any[]);
120+
const newMessages = this.processParts(parts);
111121
messages.push(...newMessages);
112122
}
113123
} catch (e) {
@@ -122,9 +132,14 @@ export class Client {
122132
response: Response,
123133
messages: Types.ServerToClientMessage[]
124134
): Promise<void> {
125-
const data = (await response.json()) as any[];
126-
console.log(`[client] Received JSON response:`, data);
127-
const newMessages = this.processParts(data);
135+
const responseData = await response.json();
136+
console.log(`[client] Received JSON response:`, responseData);
137+
138+
if (responseData.contextId) {
139+
this.contextId = responseData.contextId;
140+
}
141+
const parts = responseData.parts || [];
142+
const newMessages = this.processParts(parts);
128143
messages.push(...newMessages);
129144
}
130145

0 commit comments

Comments
 (0)