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
35 changes: 25 additions & 10 deletions samples/client/angular/projects/contact/src/app/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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',
});

Expand Down Expand Up @@ -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 || (Array.isArray(responseData) ? responseData : []);
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) {
Expand All @@ -122,9 +132,14 @@ export class Client {
response: Response,
messages: Types.ServerToClientMessage[]
): Promise<void> {
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 || (Array.isArray(responseData) ? responseData : []);
const newMessages = this.processParts(parts);
messages.push(...newMessages);
}

Expand Down
80 changes: 60 additions & 20 deletions samples/client/angular/projects/contact/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
Expand All @@ -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) => {
Expand Down
2 changes: 2 additions & 0 deletions samples/client/angular/projects/orchestrator/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,23 @@ import { Injectable } from '@angular/core';
providedIn: 'root',
})
export class A2aServiceImpl implements A2aService {
private contextId?: string;

async sendMessage(parts: Part[], signal?: AbortSignal): Promise<SendMessageSuccessResponse> {
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 || data.result?.contextId) {
this.contextId = data.contextId || data.result?.contextId;
}
return data;
}

Expand Down
35 changes: 25 additions & 10 deletions samples/client/angular/projects/restaurant/src/app/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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',
});

Expand Down Expand Up @@ -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 || (Array.isArray(responseData) ? responseData : []);
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) {
Expand All @@ -122,9 +132,14 @@ export class Client {
response: Response,
messages: Types.ServerToClientMessage[]
): Promise<void> {
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 || (Array.isArray(responseData) ? responseData : []);
const newMessages = this.processParts(parts);
messages.push(...newMessages);
}

Expand Down
Loading
Loading