Skip to content

Commit 8c57002

Browse files
guglielmo-sanishymkogemini-code-assist[bot]
authored
feat: Add support for extension headers on client side (#227)
# Description This PR implements the support for extensions on client side Fixes #170 🦕 Release-As: 0.3.6 --------- Co-authored-by: Ivan Shymko <ishymko@google.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 7250d32 commit 8c57002

File tree

6 files changed

+104
-9
lines changed

6 files changed

+104
-9
lines changed

src/client/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,9 @@ export {
2525
ClientCallInput,
2626
ClientCallResult,
2727
} from './interceptors.js';
28+
export {
29+
ServiceParameters,
30+
ServiceParametersUpdate,
31+
withA2AExtensions,
32+
} from './service-parameters.js';
2833
export { ClientCallContext, ContextUpdate, ClientCallContextKey } from './context.js';

src/client/multitransport-client.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
ClientCallResult,
2020
ClientCallInput,
2121
} from './interceptors.js';
22+
import { ServiceParameters } from './service-parameters.js';
2223
import { Transport } from './transports/transport.js';
2324

2425
export interface ClientConfig {
@@ -51,7 +52,11 @@ export interface RequestOptions {
5152
*/
5253
signal?: AbortSignal;
5354

54-
// TODO: propagate extensions
55+
/**
56+
* A key-value map for passing horizontally applicable context or parameters.
57+
* All parameters are passed to the server via underlying transports (e.g. In JsonRPC via Headers).
58+
*/
59+
serviceParameters?: ServiceParameters;
5560

5661
/**
5762
* Arbitrary data available to interceptors and transport implementation.

src/client/service-parameters.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { HTTP_EXTENSION_HEADER } from '../constants.js';
2+
3+
export type ServiceParameters = Record<string, string>;
4+
5+
export type ServiceParametersUpdate = (parameters: ServiceParameters) => void;
6+
7+
export const ServiceParameters = {
8+
create(...updates: ServiceParametersUpdate[]): ServiceParameters {
9+
return ServiceParameters.createFrom(undefined, ...updates);
10+
},
11+
12+
createFrom: (
13+
serviceParameters: ServiceParameters | undefined,
14+
...updates: ServiceParametersUpdate[]
15+
): ServiceParameters => {
16+
const result = serviceParameters ? { ...serviceParameters } : {};
17+
for (const update of updates) {
18+
update(result);
19+
}
20+
return result;
21+
},
22+
};
23+
24+
export function withA2AExtensions(...extensions: string[]): ServiceParametersUpdate {
25+
return (parameters: ServiceParameters) => {
26+
parameters[HTTP_EXTENSION_HEADER] = extensions.join(',');
27+
};
28+
}

src/client/transports/json_rpc_transport.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ export class JsonRpcTransport implements Transport {
197197
id: requestId,
198198
};
199199

200-
const httpResponse = await this._fetchRpc(rpcRequest, 'application/json', options?.signal);
200+
const httpResponse = await this._fetchRpc(rpcRequest, 'application/json', options);
201201

202202
if (!httpResponse.ok) {
203203
let errorBodyText = '(empty or non-JSON response)';
@@ -237,16 +237,17 @@ export class JsonRpcTransport implements Transport {
237237
private async _fetchRpc(
238238
rpcRequest: JSONRPCRequest,
239239
acceptHeader: string = 'application/json',
240-
signal?: AbortSignal
240+
options?: RequestOptions
241241
): Promise<Response> {
242242
const requestInit: RequestInit = {
243243
method: 'POST',
244244
headers: {
245+
...options?.serviceParameters,
245246
'Content-Type': 'application/json',
246247
Accept: acceptHeader,
247248
},
248249
body: JSON.stringify(rpcRequest),
249-
signal,
250+
signal: options?.signal,
250251
};
251252
return this._fetch(this.endpoint, requestInit);
252253
}
@@ -264,7 +265,7 @@ export class JsonRpcTransport implements Transport {
264265
id: clientRequestId,
265266
};
266267

267-
const response = await this._fetchRpc(rpcRequest, 'text/event-stream', options?.signal);
268+
const response = await this._fetchRpc(rpcRequest, 'text/event-stream', options);
268269

269270
if (!response.ok) {
270271
let errorBody = '';
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { JsonRpcTransport } from '../../../src/client/transports/json_rpc_transport.js';
2+
import { expect } from 'chai';
3+
import sinon from 'sinon';
4+
import { describe, it, beforeEach } from 'mocha';
5+
import { MessageSendParams, TextPart } from '../../../src/types.js';
6+
import { RequestOptions } from '../../../src/client/multitransport-client.js';
7+
import { HTTP_EXTENSION_HEADER } from '../../../src/constants.js';
8+
import { ServiceParameters, withA2AExtensions } from '../../../src/client/service-parameters.js';
9+
10+
describe('JsonRpcTransport', () => {
11+
let transport: JsonRpcTransport;
12+
let mockFetch: sinon.SinonStubbedFunction<typeof fetch>;
13+
const endpoint = 'https://test.endpoint/api';
14+
15+
beforeEach(() => {
16+
mockFetch = sinon.stub();
17+
transport = new JsonRpcTransport({
18+
endpoint,
19+
fetchImpl: mockFetch,
20+
});
21+
});
22+
23+
describe('sendMessage', () => {
24+
it('should correctly add the extension headers', async () => {
25+
const messageParams: MessageSendParams = {
26+
message: {
27+
kind: 'message',
28+
messageId: 'test-msg-1',
29+
role: 'user',
30+
parts: [
31+
{
32+
kind: 'text',
33+
text: 'Hello, agent!',
34+
} as TextPart,
35+
],
36+
},
37+
};
38+
39+
const expectedExtensions = 'extension1,extension2';
40+
const serviceParameters = ServiceParameters.create(withA2AExtensions(expectedExtensions));
41+
const options: RequestOptions = {
42+
serviceParameters,
43+
};
44+
45+
mockFetch.resolves(
46+
new Response(JSON.stringify({ jsonrpc: '2.0', result: {}, id: 1 }), {
47+
status: 200,
48+
})
49+
);
50+
await transport.sendMessage(messageParams, options);
51+
const fetchArgs = mockFetch.firstCall.args[1];
52+
const headers = fetchArgs.headers;
53+
expect((headers as any)[HTTP_EXTENSION_HEADER]).to.deep.equal(expectedExtensions);
54+
});
55+
});
56+
});

test/server/a2a_express_app.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { A2AExpressApp } from '../../src/server/express/a2a_express_app.js';
88
import { A2ARequestHandler } from '../../src/server/request_handler/a2a_request_handler.js';
99
import { JsonRpcTransportHandler } from '../../src/server/transports/jsonrpc_transport_handler.js';
1010
import { AgentCard, JSONRPCSuccessResponse, JSONRPCErrorResponse } from '../../src/index.js';
11-
import { AGENT_CARD_PATH } from '../../src/constants.js';
11+
import { AGENT_CARD_PATH, HTTP_EXTENSION_HEADER } from '../../src/constants.js';
1212
import { A2AError } from '../../src/server/error.js';
1313
import { ServerCallContext } from '../../src/server/context.js';
1414
import { User, UnauthenticatedUser } from '../../src/server/authentication/user.js';
@@ -262,7 +262,7 @@ describe('A2AExpressApp', () => {
262262

263263
await request(expressApp)
264264
.post('/')
265-
.set('X-A2A-Extensions', uriExtensionsValues)
265+
.set(HTTP_EXTENSION_HEADER, uriExtensionsValues)
266266
.set('Not-Relevant-Header', 'unused-value')
267267
.send(requestBody)
268268
.expect(200);
@@ -294,12 +294,12 @@ describe('A2AExpressApp', () => {
294294
});
295295
const response = await request(expressApp)
296296
.post('/')
297-
.set('X-A2A-Extensions', uriExtensionsValues)
297+
.set(HTTP_EXTENSION_HEADER, uriExtensionsValues)
298298
.set('Not-Relevant-Header', 'unused-value')
299299
.send(requestBody)
300300
.expect(200);
301301

302-
expect(response.get('X-A2A-Extensions')).to.equal('activated-extension');
302+
expect(response.get(HTTP_EXTENSION_HEADER)).to.equal('activated-extension');
303303
});
304304
});
305305

0 commit comments

Comments
 (0)