From ba136abbbe3e780f7efebf286dc9e6a4bf079b65 Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Fri, 3 Apr 2026 22:40:43 +0000 Subject: [PATCH 1/2] 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. --- .../projects/contact/src/app/client.ts | 35 ++++-- .../angular/projects/contact/src/server.ts | 80 +++++++++---- .../projects/orchestrator/src/server.ts | 2 + .../src/services/a2a-service-impl.spec.ts | 105 ++++++++++++++++++ .../src/services/a2a-service-impl.ts | 11 +- .../projects/restaurant/src/app/client.ts | 35 ++++-- .../angular/projects/restaurant/src/server.ts | 80 +++++++++---- .../angular/projects/rizzcharts/src/server.ts | 2 +- .../rizzcharts/src/services/a2a_service.ts | 8 +- .../client/lit/component_gallery/client.ts | 20 +++- .../lit/component_gallery/middleware/a2a.ts | 12 +- .../lit/custom-components-example/client.ts | 31 ++++-- .../middleware/a2a.ts | 25 ++++- samples/client/lit/shell/client.ts | 10 ++ 14 files changed, 370 insertions(+), 86 deletions(-) create mode 100644 samples/client/angular/projects/orchestrator/src/services/a2a-service-impl.spec.ts diff --git a/samples/client/angular/projects/contact/src/app/client.ts b/samples/client/angular/projects/contact/src/app/client.ts index 15a9dc43a..576af0495 100644 --- a/samples/client/angular/projects/contact/src/app/client.ts +++ b/samples/client/angular/projects/contact/src/app/client.ts @@ -21,6 +21,7 @@ import { Injectable, inject, signal } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class Client { private processor = inject(MessageProcessor); + private contextId?: string; readonly isLoading = signal(false); @@ -43,8 +44,13 @@ export class Client { // Clear surfaces at the start of a new request this.processor.clearSurfaces(); + const isString = typeof request === 'string'; + const bodyData = isString + ? { query: request, contextId: this.contextId } + : { event: request, contextId: this.contextId }; + const response = await fetch('/a2a', { - body: JSON.stringify(request as Types.A2UIClientEventMessage), + body: JSON.stringify(bodyData), method: 'POST', }); @@ -96,18 +102,22 @@ export class Client { if (line.startsWith("data: ")) { const jsonStr = line.slice(6); try { - const data = JSON.parse(jsonStr) as A2AServerPayload; - console.log(`[client] [${now.toFixed(2)}ms] Received SSE data:`, data); + const responseData = JSON.parse(jsonStr); + console.log(`[client] [${now.toFixed(2)}ms] Received SSE data:`, responseData); - if ('error' in data) { - throw new Error(data.error); + if (responseData.error) { + throw new Error(responseData.error); } else { + if (responseData.contextId) { + this.contextId = responseData.contextId; + } + const parts = responseData.parts || []; console.log( - `[client] [${performance.now().toFixed(2)}ms] Scheduling processing for ${data.length} parts` + `[client] [${performance.now().toFixed(2)}ms] Scheduling processing for ${parts.length} parts` ); // Use a microtask to ensure we don't block the stream reader await Promise.resolve(); - const newMessages = this.processParts(data as any[]); + const newMessages = this.processParts(parts); messages.push(...newMessages); } } catch (e) { @@ -122,9 +132,14 @@ export class Client { response: Response, messages: Types.ServerToClientMessage[] ): Promise { - const data = (await response.json()) as any[]; - console.log(`[client] Received JSON response:`, data); - const newMessages = this.processParts(data); + const responseData = await response.json(); + console.log(`[client] Received JSON response:`, responseData); + + if (responseData.contextId) { + this.contextId = responseData.contextId; + } + const parts = responseData.parts || []; + const newMessages = this.processParts(parts); messages.push(...newMessages); } diff --git a/samples/client/angular/projects/contact/src/server.ts b/samples/client/angular/projects/contact/src/server.ts index 0d6ee3d28..675ee34ea 100644 --- a/samples/client/angular/projects/contact/src/server.ts +++ b/samples/client/angular/projects/contact/src/server.ts @@ -51,25 +51,58 @@ app.post('/a2a', (req, res) => { let sendParams: MessageSendParams; if (isJson(originalBody)) { - console.log('[a2a-middleware] Received JSON UI event:', originalBody); - - const clientEvent = JSON.parse(originalBody); - sendParams = { - message: { - messageId: uuidv4(), - role: 'user', - parts: [ - { - kind: 'data', - data: clientEvent, - metadata: { 'mimeType': 'application/json+a2ui' }, - } as Part, - ], - kind: 'message', - }, - }; + const requestData = JSON.parse(originalBody); + const contextId = requestData.contextId; + + if (requestData.event) { + console.log('[a2a-middleware] Received JSON UI event:', requestData.event); + sendParams = { + message: { + messageId: uuidv4(), + contextId, + role: 'user', + parts: [ + { + kind: 'data', + data: requestData.event, + metadata: { 'mimeType': 'application/json+a2ui' }, + } as Part, + ], + kind: 'message', + }, + }; + } else if (requestData.query) { + console.log('[a2a-middleware] Received text query:', requestData.query); + sendParams = { + message: { + messageId: uuidv4(), + contextId, + role: 'user', + parts: [{ kind: 'text', text: requestData.query }], + kind: 'message', + }, + }; + } else { + // Fallback for legacy JSON event where the body is the event itself + console.log('[a2a-middleware] Received legacy JSON event:', originalBody); + sendParams = { + message: { + messageId: uuidv4(), + contextId, + role: 'user', + parts: [ + { + kind: 'data', + data: requestData, + metadata: { 'mimeType': 'application/json+a2ui' }, + } as Part, + ], + kind: 'message', + }, + }; + } } else { - console.log('[a2a-middleware] Received text query:', originalBody); + console.log('[a2a-middleware] Received plain text query:', originalBody); sendParams = { message: { messageId: uuidv4(), @@ -121,7 +154,11 @@ async function handleStreamingResponse(client: A2AClient, sendParams: MessageSen if (parts.length > 0) { console.log(`[server] Streaming ${parts.length} parts to client`); - res.write(`data: ${JSON.stringify(parts)}\n\n`); + const responseData = { + parts, + contextId: (event as any).contextId || (event as any).status?.message?.contextId + }; + res.write(`data: ${JSON.stringify(responseData)}\n\n`); } } res.end(); @@ -140,7 +177,10 @@ async function handleNonStreamingResponse(client: A2AClient, sendParams: Message } const result = (response as SendMessageSuccessResponse).result as Task; - res.json(result.kind === 'task' ? result.status.message?.parts || [] : []); + res.json({ + parts: result.kind === 'task' ? result.status.message?.parts || [] : [], + contextId: result.contextId + }); } app.use((req, res, next) => { diff --git a/samples/client/angular/projects/orchestrator/src/server.ts b/samples/client/angular/projects/orchestrator/src/server.ts index 8db1068e8..1aacd931f 100644 --- a/samples/client/angular/projects/orchestrator/src/server.ts +++ b/samples/client/angular/projects/orchestrator/src/server.ts @@ -52,10 +52,12 @@ app.post('/a2a', (req, res) => { console.log('[a2a-middleware] Received data:', data); const parts: Part[] = data['parts']; + const contextId: string | undefined = data['contextId']; const sendParams: MessageSendParams = { message: { messageId: uuidv4(), + contextId, role: 'user', parts, kind: 'message', diff --git a/samples/client/angular/projects/orchestrator/src/services/a2a-service-impl.spec.ts b/samples/client/angular/projects/orchestrator/src/services/a2a-service-impl.spec.ts new file mode 100644 index 000000000..691e63755 --- /dev/null +++ b/samples/client/angular/projects/orchestrator/src/services/a2a-service-impl.spec.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { A2aServiceImpl } from './a2a-service-impl'; + +describe('A2aServiceImpl', () => { + let service: A2aServiceImpl; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [A2aServiceImpl], + }); + service = TestBed.inject(A2aServiceImpl); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should send contextId in request after receiving it from server', async () => { + // Mock first response to return a contextId + const mockResponse1 = { + contextId: 'test-session-123', + parts: [], + }; + + const fetchSpy = spyOn(globalThis, 'fetch').and.returnValue( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse1), + } as Response) + ); + + // First call should NOT send contextId (it doesn't have it yet) + await service.sendMessage([]); + + let lastCall = fetchSpy.calls.mostRecent(); + let body = JSON.parse(lastCall.args[1]!.body as string); + expect(body.contextId).toBeUndefined(); + + // Mock second response (just to complete the call) + const mockResponse2 = { + parts: [], + }; + fetchSpy.and.returnValue( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse2), + } as Response) + ); + + // Second call SHOULD send contextId + await service.sendMessage([]); + + lastCall = fetchSpy.calls.mostRecent(); + body = JSON.parse(lastCall.args[1]!.body as string); + expect(body.contextId).toBe('test-session-123'); + }); + + it('should update contextId from data.result.contextId if contextId is missing', async () => { + const mockResponse = { + result: { + contextId: 'test-session-456', + }, + parts: [], + }; + + const fetchSpy = spyOn(globalThis, 'fetch').and.returnValue( + Promise.resolve({ + ok: true, + json: () => Promise.resolve(mockResponse), + } as Response) + ); + + await service.sendMessage([]); + + // Call again to see if it sends it + fetchSpy.and.returnValue( + Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + } as Response) + ); + + await service.sendMessage([]); + + const lastCall = fetchSpy.calls.mostRecent(); + const body = JSON.parse(lastCall.args[1]!.body as string); + expect(body.contextId).toBe('test-session-456'); + }); +}); diff --git a/samples/client/angular/projects/orchestrator/src/services/a2a-service-impl.ts b/samples/client/angular/projects/orchestrator/src/services/a2a-service-impl.ts index 55ac70497..b12ba03fd 100644 --- a/samples/client/angular/projects/orchestrator/src/services/a2a-service-impl.ts +++ b/samples/client/angular/projects/orchestrator/src/services/a2a-service-impl.ts @@ -22,16 +22,25 @@ import { Injectable } from '@angular/core'; providedIn: 'root', }) export class A2aServiceImpl implements A2aService { + private contextId?: string; async sendMessage(parts: Part[], signal?: AbortSignal): Promise { const response = await fetch('/a2a', { - body: JSON.stringify({ parts: parts }), + body: JSON.stringify({ + parts: parts, + contextId: this.contextId + }), method: 'POST', signal, }); if (response.ok) { const data = await response.json(); + if (data.contextId) { + this.contextId = data.contextId; + } else if (data.result?.contextId) { // fallback if it's there + this.contextId = data.result.contextId; + } return data; } diff --git a/samples/client/angular/projects/restaurant/src/app/client.ts b/samples/client/angular/projects/restaurant/src/app/client.ts index 6784f7286..34d82cd3c 100644 --- a/samples/client/angular/projects/restaurant/src/app/client.ts +++ b/samples/client/angular/projects/restaurant/src/app/client.ts @@ -21,6 +21,7 @@ import { inject, Injectable, signal } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class Client { private processor = inject(MessageProcessor); + private contextId?: string; readonly isLoading = signal(false); @@ -43,8 +44,13 @@ export class Client { // Clear surfaces at the start of a new request this.processor.clearSurfaces(); + const isString = typeof request === 'string'; + const bodyData = isString + ? { query: request, contextId: this.contextId } + : { event: request, contextId: this.contextId }; + const response = await fetch('/a2a', { - body: JSON.stringify(request as Types.A2UIClientEventMessage), + body: JSON.stringify(bodyData), method: 'POST', }); @@ -96,18 +102,22 @@ export class Client { if (line.startsWith('data: ')) { const jsonStr = line.slice(6); try { - const data = JSON.parse(jsonStr) as A2AServerPayload; - console.log(`[client] [${now.toFixed(2)}ms] Received SSE data:`, data); + const responseData = JSON.parse(jsonStr); + console.log(`[client] [${now.toFixed(2)}ms] Received SSE data:`, responseData); - if ('error' in data) { - throw new Error(data.error); + if (responseData.error) { + throw new Error(responseData.error); } else { + if (responseData.contextId) { + this.contextId = responseData.contextId; + } + const parts = responseData.parts || []; console.log( - `[client] [${performance.now().toFixed(2)}ms] Scheduling processing for ${data.length} parts` + `[client] [${performance.now().toFixed(2)}ms] Scheduling processing for ${parts.length} parts` ); // Use a microtask to ensure we don't block the stream reader await Promise.resolve(); - const newMessages = this.processParts(data as any[]); + const newMessages = this.processParts(parts); messages.push(...newMessages); } } catch (e) { @@ -122,9 +132,14 @@ export class Client { response: Response, messages: Types.ServerToClientMessage[] ): Promise { - const data = (await response.json()) as any[]; - console.log(`[client] Received JSON response:`, data); - const newMessages = this.processParts(data); + const responseData = await response.json(); + console.log(`[client] Received JSON response:`, responseData); + + if (responseData.contextId) { + this.contextId = responseData.contextId; + } + const parts = responseData.parts || []; + const newMessages = this.processParts(parts); messages.push(...newMessages); } diff --git a/samples/client/angular/projects/restaurant/src/server.ts b/samples/client/angular/projects/restaurant/src/server.ts index 86f92730a..7185f9d99 100644 --- a/samples/client/angular/projects/restaurant/src/server.ts +++ b/samples/client/angular/projects/restaurant/src/server.ts @@ -51,25 +51,58 @@ app.post('/a2a', (req, res) => { let sendParams: MessageSendParams; if (isJson(originalBody)) { - console.log('[a2a-middleware] Received JSON UI event:', originalBody); - - const clientEvent = JSON.parse(originalBody); - sendParams = { - message: { - messageId: uuidv4(), - role: 'user', - parts: [ - { - kind: 'data', - data: clientEvent, - metadata: { 'mimeType': 'application/json+a2ui' }, - } as Part, - ], - kind: 'message', - }, - }; + const requestData = JSON.parse(originalBody); + const contextId = requestData.contextId; + + if (requestData.event) { + console.log('[a2a-middleware] Received JSON UI event:', requestData.event); + sendParams = { + message: { + messageId: uuidv4(), + contextId, + role: 'user', + parts: [ + { + kind: 'data', + data: requestData.event, + metadata: { 'mimeType': 'application/json+a2ui' }, + } as Part, + ], + kind: 'message', + }, + }; + } else if (requestData.query) { + console.log('[a2a-middleware] Received text query:', requestData.query); + sendParams = { + message: { + messageId: uuidv4(), + contextId, + role: 'user', + parts: [{ kind: 'text', text: requestData.query }], + kind: 'message', + }, + }; + } else { + // Fallback for legacy JSON event + console.log('[a2a-middleware] Received legacy JSON event:', originalBody); + sendParams = { + message: { + messageId: uuidv4(), + contextId, + role: 'user', + parts: [ + { + kind: 'data', + data: requestData, + metadata: { 'mimeType': 'application/json+a2ui' }, + } as Part, + ], + kind: 'message', + }, + }; + } } else { - console.log('[a2a-middleware] Received text query:', originalBody); + console.log('[a2a-middleware] Received plain text query:', originalBody); sendParams = { message: { messageId: uuidv4(), @@ -122,7 +155,11 @@ async function handleStreamingResponse(client: A2AClient, sendParams: MessageSen if (parts.length > 0) { console.log(`[server] Streaming ${parts.length} parts to client`); console.log(`[server] Streaming parts: ${JSON.stringify(parts)}`); - res.write(`data: ${JSON.stringify(parts)}\n\n`); + const responseData = { + parts, + contextId: (event as any).contextId || (event as any).status?.message?.contextId + }; + res.write(`data: ${JSON.stringify(responseData)}\n\n`); } } res.end(); @@ -141,7 +178,10 @@ async function handleNonStreamingResponse(client: A2AClient, sendParams: Message } const result = (response as SendMessageSuccessResponse).result as Task; - res.json(result.kind === 'task' ? result.status.message?.parts || [] : []); + res.json({ + parts: result.kind === 'task' ? result.status.message?.parts || [] : [], + contextId: result.contextId + }); } app.use((req, res, next) => { diff --git a/samples/client/angular/projects/rizzcharts/src/server.ts b/samples/client/angular/projects/rizzcharts/src/server.ts index 5ad4046fd..5bf454a1e 100644 --- a/samples/client/angular/projects/rizzcharts/src/server.ts +++ b/samples/client/angular/projects/rizzcharts/src/server.ts @@ -53,7 +53,7 @@ app.post('/a2a', (req, res) => { const parts: Part[] = data['parts']; const metadata: Record = data['metadata']; - const contextId: string | undefined = data['context_id']; + const contextId: string | undefined = data['contextId']; const sendParams: MessageSendParams = { message: { diff --git a/samples/client/angular/projects/rizzcharts/src/services/a2a_service.ts b/samples/client/angular/projects/rizzcharts/src/services/a2a_service.ts index 8a37a3696..78b8064f9 100644 --- a/samples/client/angular/projects/rizzcharts/src/services/a2a_service.ts +++ b/samples/client/angular/projects/rizzcharts/src/services/a2a_service.ts @@ -36,16 +36,16 @@ export class A2aService implements A2aServiceInterface { "supportedCatalogIds": currentCatalogUris } }, - 'context_id': this.contextId + 'contextId': this.contextId }), method: 'POST', signal, }); if (response.ok) { - const json = await response.json() as SendMessageSuccessResponse & { context_id?: string }; - if (json.context_id) { - this.contextId = json.context_id; + const json = await response.json() as SendMessageSuccessResponse & { contextId?: string }; + if (json.contextId) { + this.contextId = json.contextId; } return json; } diff --git a/samples/client/lit/component_gallery/client.ts b/samples/client/lit/component_gallery/client.ts index e4343cdce..c76dcf5be 100644 --- a/samples/client/lit/component_gallery/client.ts +++ b/samples/client/lit/component_gallery/client.ts @@ -34,6 +34,8 @@ import { componentRegistry } from "@a2ui/lit/ui"; export class A2UIClient { #ready: Promise = Promise.resolve(); + #contextId?: string; + get ready() { return this.#ready; } @@ -50,17 +52,25 @@ export class A2UIClient { }; const response = await fetch("/a2a", { - body: JSON.stringify(finalMessage), + body: JSON.stringify({ + event: finalMessage, + contextId: this.#contextId + }), method: "POST", }); if (response.ok) { - const data = (await response.json()) as A2AServerPayload; + const responseData = await response.json(); + if (responseData.contextId) { + this.#contextId = responseData.contextId; + } + const parts = responseData.parts || responseData; const messages: v0_8.Types.ServerToClientMessage[] = []; - if ("error" in data) { - throw new Error(data.error); + if ("error" in parts) { + throw new Error((parts as any).error); } else { - for (const item of data) { + const items = Array.isArray(parts) ? parts : [parts]; + for (const item of items) { if (item.kind === "text") continue; messages.push(item.data); } diff --git a/samples/client/lit/component_gallery/middleware/a2a.ts b/samples/client/lit/component_gallery/middleware/a2a.ts index d5a05fd21..fbba480a9 100644 --- a/samples/client/lit/component_gallery/middleware/a2a.ts +++ b/samples/client/lit/component_gallery/middleware/a2a.ts @@ -83,10 +83,14 @@ export const plugin = (): Plugin => { originalBody ); - const clientEvent = JSON.parse(originalBody); + const requestData = JSON.parse(originalBody); + const contextId = requestData.contextId; + const clientEvent = requestData.event || requestData; + sendParams = { message: { messageId: uuidv4(), + contextId, role: "user", parts: [ { @@ -132,7 +136,11 @@ export const plugin = (): Plugin => { if (result.kind === "task") { res.statusCode = 200; res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify(result.status.message?.parts)); + const responseData = { + parts: result.status.message?.parts || [], + contextId: result.contextId + }; + res.end(JSON.stringify(responseData)); return; } } diff --git a/samples/client/lit/custom-components-example/client.ts b/samples/client/lit/custom-components-example/client.ts index 3228e4969..ccd31027d 100644 --- a/samples/client/lit/custom-components-example/client.ts +++ b/samples/client/lit/custom-components-example/client.ts @@ -34,6 +34,8 @@ import { componentRegistry } from "@a2ui/lit/ui"; export class A2UIClient { #ready: Promise = Promise.resolve(); + #contextId?: string; + get ready() { return this.#ready; } @@ -53,7 +55,10 @@ export class A2UIClient { }; const response = await fetch("/a2a", { - body: JSON.stringify(finalMessage), + body: JSON.stringify({ + event: finalMessage, + contextId: this.#contextId + }), method: "POST", }); @@ -83,11 +88,15 @@ export class A2UIClient { if (line.startsWith("data: ")) { const jsonStr = line.replace(/^data:\s*/, ""); try { - const parsed = JSON.parse(jsonStr); - if ("error" in parsed) { - throw new Error(parsed.error); + const responseData = JSON.parse(jsonStr); + if (responseData.error) { + throw new Error(responseData.error); } else { - const chunkMessages = this.#extractMessages(parsed); + if (responseData.contextId) { + this.#contextId = responseData.contextId; + } + const parts = responseData.parts || responseData; + const chunkMessages = this.#extractMessages(parts); if (chunkMessages.length > 0) { messages.push(...chunkMessages); onChunk?.(chunkMessages); @@ -102,11 +111,15 @@ export class A2UIClient { return messages; } - const data = (await response.json()) as any; - if (data && typeof data === 'object' && "error" in data) { - throw new Error(data.error); + const responseData = (await response.json()) as any; + if (responseData && typeof responseData === 'object' && "error" in responseData) { + throw new Error(responseData.error); } else { - const extracted = this.#extractMessages(data); + if (responseData.contextId) { + this.#contextId = responseData.contextId; + } + const parts = responseData.parts || responseData; + const extracted = this.#extractMessages(parts); messages.push(...extracted); if (messages.length > 0) { onChunk?.(messages); diff --git a/samples/client/lit/custom-components-example/middleware/a2a.ts b/samples/client/lit/custom-components-example/middleware/a2a.ts index e0f38eddd..17f515ec5 100644 --- a/samples/client/lit/custom-components-example/middleware/a2a.ts +++ b/samples/client/lit/custom-components-example/middleware/a2a.ts @@ -84,10 +84,14 @@ export const plugin = (): Plugin => { originalBody ); - const clientEvent = JSON.parse(originalBody); + const requestData = JSON.parse(originalBody); + const contextId = requestData.contextId; + const clientEvent = requestData.event || requestData; // fallback if it's old format + sendParams = { message: { messageId: uuidv4(), + contextId, role: "user", parts: [ { @@ -131,10 +135,19 @@ export const plugin = (): Plugin => { for await (const chunk of stream) { // A2AClient unpacks the JSON-RPC, so chunk is an A2AStreamEventData + let parts: Part[] = []; if (chunk.kind === "status-update" && chunk.status.message?.parts) { - res.write(`data: ${JSON.stringify(chunk.status.message.parts)}\n\n`); + parts = chunk.status.message.parts; } else if (chunk.kind === "message" && chunk.parts) { - res.write(`data: ${JSON.stringify(chunk.parts)}\n\n`); + parts = chunk.parts; + } + + if (parts.length > 0) { + const responseData = { + parts, + contextId: (chunk as any).contextId || (chunk as any).status?.message?.contextId + }; + res.write(`data: ${JSON.stringify(responseData)}\n\n`); } } res.end(); @@ -149,7 +162,11 @@ export const plugin = (): Plugin => { const result = (response as SendMessageSuccessResponse).result as Task; res.statusCode = 200; res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify(result.kind === "task" ? result.status.message?.parts || [] : [])); + const responseData = { + parts: result.kind === "task" ? result.status.message?.parts || [] : [], + contextId: result.contextId + }; + res.end(JSON.stringify(responseData)); } } } catch (e: any) { diff --git a/samples/client/lit/shell/client.ts b/samples/client/lit/shell/client.ts index 165da4df4..d84bea3a7 100644 --- a/samples/client/lit/shell/client.ts +++ b/samples/client/lit/shell/client.ts @@ -23,6 +23,7 @@ const A2UI_MIME_TYPE = "application/json+a2ui"; export class A2UIClient { #serverUrl: string; #client: A2AClient | null = null; + #contextId?: string; constructor(serverUrl: string = "") { this.#serverUrl = serverUrl; @@ -86,6 +87,7 @@ export class A2UIClient { const response = await client.sendMessage({ message: { messageId: crypto.randomUUID(), + contextId: this.#contextId, role: "user", parts: parts, kind: "message", @@ -97,6 +99,14 @@ export class A2UIClient { } const result = (response as SendMessageSuccessResponse).result as Task; + + // Extract contextId + if (result.contextId) { + this.#contextId = result.contextId; + } else if ("contextId" in response && response.contextId) { + this.#contextId = response.contextId as string; + } + if (result.kind === "task" && result.status.message?.parts) { const messages: v0_8.Types.ServerToClientMessage[] = []; for (const part of result.status.message.parts) { From b9680819a32fc432209e453a4b98393e2976e3b7 Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Mon, 6 Apr 2026 18:13:28 +0000 Subject: [PATCH 2/2] Address review comments --- samples/client/angular/projects/contact/src/app/client.ts | 4 ++-- .../projects/orchestrator/src/services/a2a-service-impl.ts | 6 ++---- .../client/angular/projects/restaurant/src/app/client.ts | 4 ++-- .../angular/projects/rizzcharts/src/services/a2a_service.ts | 4 ++-- samples/client/lit/component_gallery/client.ts | 6 +++--- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/samples/client/angular/projects/contact/src/app/client.ts b/samples/client/angular/projects/contact/src/app/client.ts index 576af0495..34da78996 100644 --- a/samples/client/angular/projects/contact/src/app/client.ts +++ b/samples/client/angular/projects/contact/src/app/client.ts @@ -111,7 +111,7 @@ export class Client { if (responseData.contextId) { this.contextId = responseData.contextId; } - const parts = responseData.parts || []; + const parts = responseData.parts || (Array.isArray(responseData) ? responseData : []); console.log( `[client] [${performance.now().toFixed(2)}ms] Scheduling processing for ${parts.length} parts` ); @@ -138,7 +138,7 @@ export class Client { if (responseData.contextId) { this.contextId = responseData.contextId; } - const parts = responseData.parts || []; + const parts = responseData.parts || (Array.isArray(responseData) ? responseData : []); const newMessages = this.processParts(parts); messages.push(...newMessages); } diff --git a/samples/client/angular/projects/orchestrator/src/services/a2a-service-impl.ts b/samples/client/angular/projects/orchestrator/src/services/a2a-service-impl.ts index b12ba03fd..4e7eae67c 100644 --- a/samples/client/angular/projects/orchestrator/src/services/a2a-service-impl.ts +++ b/samples/client/angular/projects/orchestrator/src/services/a2a-service-impl.ts @@ -36,10 +36,8 @@ export class A2aServiceImpl implements A2aService { if (response.ok) { const data = await response.json(); - if (data.contextId) { - this.contextId = data.contextId; - } else if (data.result?.contextId) { // fallback if it's there - this.contextId = data.result.contextId; + if (data.contextId || data.result?.contextId) { + this.contextId = data.contextId || data.result?.contextId; } return data; } diff --git a/samples/client/angular/projects/restaurant/src/app/client.ts b/samples/client/angular/projects/restaurant/src/app/client.ts index 34d82cd3c..85abed510 100644 --- a/samples/client/angular/projects/restaurant/src/app/client.ts +++ b/samples/client/angular/projects/restaurant/src/app/client.ts @@ -111,7 +111,7 @@ export class Client { if (responseData.contextId) { this.contextId = responseData.contextId; } - const parts = responseData.parts || []; + const parts = responseData.parts || (Array.isArray(responseData) ? responseData : []); console.log( `[client] [${performance.now().toFixed(2)}ms] Scheduling processing for ${parts.length} parts` ); @@ -138,7 +138,7 @@ export class Client { if (responseData.contextId) { this.contextId = responseData.contextId; } - const parts = responseData.parts || []; + const parts = responseData.parts || (Array.isArray(responseData) ? responseData : []); const newMessages = this.processParts(parts); messages.push(...newMessages); } diff --git a/samples/client/angular/projects/rizzcharts/src/services/a2a_service.ts b/samples/client/angular/projects/rizzcharts/src/services/a2a_service.ts index 78b8064f9..1f501d259 100644 --- a/samples/client/angular/projects/rizzcharts/src/services/a2a_service.ts +++ b/samples/client/angular/projects/rizzcharts/src/services/a2a_service.ts @@ -44,8 +44,8 @@ export class A2aService implements A2aServiceInterface { if (response.ok) { const json = await response.json() as SendMessageSuccessResponse & { contextId?: string }; - if (json.contextId) { - this.contextId = json.contextId; + if (json.contextId || json.result?.contextId) { + this.contextId = json.contextId || json.result?.contextId; } return json; } diff --git a/samples/client/lit/component_gallery/client.ts b/samples/client/lit/component_gallery/client.ts index c76dcf5be..935f0ae13 100644 --- a/samples/client/lit/component_gallery/client.ts +++ b/samples/client/lit/component_gallery/client.ts @@ -64,10 +64,10 @@ export class A2UIClient { if (responseData.contextId) { this.#contextId = responseData.contextId; } - const parts = responseData.parts || responseData; + const parts = Array.isArray(responseData) ? responseData : (responseData.parts || []); const messages: v0_8.Types.ServerToClientMessage[] = []; - if ("error" in parts) { - throw new Error((parts as any).error); + if (responseData.error) { + throw new Error(responseData.error); } else { const items = Array.isArray(parts) ? parts : [parts]; for (const item of items) {