Skip to content
Merged
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
92 changes: 82 additions & 10 deletions src/client/multitransport-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { withA2AVersion } from './service-parameters.js';
import { AgentCardSignatureVerifier } from '../signature.js';
import { LEGACY_HTTP_EXTENSION_HEADER } from '../compat/v0_3/constants.js';
import { HTTP_EXTENSION_HEADER } from '../constants.js';
import { PushNotificationNotSupportedError } from '../errors.js';
import { isLegacyVersion } from '../version_utils.js';
import { TaskPushNotificationConfig, Task, AgentCard, SendMessageResult } from '../index.js';
import {
CancelTaskRequest,
Expand Down Expand Up @@ -141,7 +144,7 @@ export class Client {
const beforeArgs: BeforeArgs<'sendMessageStream'> = {
input: { method, value: params },
agentCard: this.agentCard,
options: this.withVersionHeader(options),
options: this.withNormalizedHeaders(options),
};
const beforeResult = await this.interceptBefore(beforeArgs);

Expand Down Expand Up @@ -310,7 +313,7 @@ export class Client {
const beforeArgs: BeforeArgs<'resubscribeTask'> = {
input: { method, value: params },
agentCard: this.agentCard,
options: this.withVersionHeader(options),
options: this.withNormalizedHeaders(options),
};
const beforeResult = await this.interceptBefore(beforeArgs);

Expand Down Expand Up @@ -370,16 +373,85 @@ export class Client {
}

/**
* Ensures the A2A-Version header is present in the request's service parameters.
* Per §3.6.1: "Clients MUST send the A2A-Version header with each request."
* Normalizes outgoing service parameters so they match the wire version
* negotiated by the underlying transport.
*
* Two things happen here:
*
* 1. The `A2A-Version` header is injected (overriding any caller-supplied
* value) from `this.protocolVersion`. Per §3.6.1: "Clients MUST send the
* A2A-Version header with each request."
*
* 2. The extension header is rewritten to the spelling expected by the
* negotiated wire version. v0.3 used `X-A2A-Extensions`; v1.0 dropped the
* `X-` prefix and uses `A2A-Extensions`. Callers can use the
* {@link withA2AExtensions} helper (which always writes the v1.0
* spelling) without having to know which transport they ended up on; the
* orchestrator translates as needed.
*
* Header names are matched case-insensitively per RFC 7230 §3.2, mirroring
* the case-insensitive lookup the server performs on read. If multiple
* case variants of the same logical header are supplied, the exact
* canonical-cased key wins within its group; otherwise the last variant
* seen wins. When both the canonical and the legacy spellings are present
* (across groups), the canonical spelling wins and the alias is dropped,
* again mirroring server-side precedence. The value emitted on the wire
* is always under the exact canonical spelling for the negotiated wire
* version.
*/
private withVersionHeader(options: RequestOptions | undefined): RequestOptions {
private withNormalizedHeaders(options: RequestOptions | undefined): RequestOptions {
const serviceParameters = ServiceParameters.createFrom(
options?.serviceParameters,
withA2AVersion(this.protocolVersion)
);

const legacy = isLegacyVersion(this.protocolVersion);
const canonical = legacy ? LEGACY_HTTP_EXTENSION_HEADER : HTTP_EXTENSION_HEADER;
const alias = legacy ? HTTP_EXTENSION_HEADER : LEGACY_HTTP_EXTENSION_HEADER;
const canonicalLower = canonical.toLowerCase();
const aliasLower = alias.toLowerCase();

// Collect values from any case variant of either header, then rebuild
// the entry under the exact canonical spelling at the end. We snapshot
// the key list with `Object.keys(...)` before mutating so the iteration
// is well-defined even though we delete entries inside the loop.
let canonicalValue: string | undefined;
let exactCanonicalSeen = false;
let aliasValue: string | undefined;
let exactAliasSeen = false;

for (const key of Object.keys(serviceParameters)) {
const keyLower = key.toLowerCase();
if (keyLower === canonicalLower) {
// Within the canonical group: exact spelling wins; otherwise last wins.
if (key === canonical) {
canonicalValue = serviceParameters[key];
exactCanonicalSeen = true;
} else if (!exactCanonicalSeen) {
canonicalValue = serviceParameters[key];
}
delete serviceParameters[key];
} else if (keyLower === aliasLower) {
// Within the alias group: exact alias spelling wins; otherwise last wins.
if (key === alias) {
aliasValue = serviceParameters[key];
exactAliasSeen = true;
} else if (!exactAliasSeen) {
aliasValue = serviceParameters[key];
}
delete serviceParameters[key];
}
}

if (canonicalValue !== undefined) {
serviceParameters[canonical] = canonicalValue;
} else if (aliasValue !== undefined) {
serviceParameters[canonical] = aliasValue;
}

return {
...options,
serviceParameters: ServiceParameters.createFrom(
options?.serviceParameters,
withA2AVersion(this.protocolVersion)
),
serviceParameters,
};
}

Expand All @@ -394,7 +466,7 @@ export class Client {
const beforeArgs: BeforeArgs<K> = {
input: input,
agentCard: this.agentCard,
options: this.withVersionHeader(options),
options: this.withNormalizedHeaders(options),
};
const beforeResult = await this.interceptBefore(beforeArgs);

Expand Down
238 changes: 238 additions & 0 deletions test/client/multitransport-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import {
StreamResponse,
A2A_VERSION_HEADER,
A2A_PROTOCOL_VERSION,
HTTP_EXTENSION_HEADER,
} from '../../src/index.js';
import {
A2A_LEGACY_PROTOCOL_VERSION,
LEGACY_HTTP_EXTENSION_HEADER,
} from '../../src/compat/v0_3/constants.js';

/**
* Helper: the default RequestOptions that the Client injects when the caller
Expand Down Expand Up @@ -1451,4 +1456,237 @@ describe('Client', () => {
expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, defaultVersionOptions);
});
});

describe('Extension header normalization', () => {
const makeTask = (): Task => ({
id: '123',
contextId: 'ctx1',
status: {
state: TaskState.TASK_STATE_COMPLETED,
timestamp: undefined,
message: undefined,
},
artifacts: [],
history: [],
metadata: {},
});

const params = { tenant: '', id: '123', historyLength: 0 };

describe('on a v1.0 transport', () => {
beforeEach(() => {
transport.protocolVersion = A2A_PROTOCOL_VERSION;
client = new Client(transport, agentCard);
transport.getTask.mockResolvedValue(makeTask());
});

it('passes A2A-Extensions through unchanged', async () => {
await client.getTask(params, {
serviceParameters: { [HTTP_EXTENSION_HEADER]: 'ext1' },
});

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, {
serviceParameters: {
[A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION,
[HTTP_EXTENSION_HEADER]: 'ext1',
},
});
});

it('rewrites X-A2A-Extensions to the v1.0 spelling', async () => {
await client.getTask(params, {
serviceParameters: { [LEGACY_HTTP_EXTENSION_HEADER]: 'ext1' },
});

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, {
serviceParameters: {
[A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION,
[HTTP_EXTENSION_HEADER]: 'ext1',
},
});
});

it('prefers the v1.0 spelling when both are present', async () => {
await client.getTask(params, {
serviceParameters: {
[HTTP_EXTENSION_HEADER]: 'canonical',
[LEGACY_HTTP_EXTENSION_HEADER]: 'legacy',
},
});

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, {
serviceParameters: {
[A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION,
[HTTP_EXTENSION_HEADER]: 'canonical',
},
});
});

it('does not synthesize an extension header when none was provided', async () => {
await client.getTask(params);

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, defaultVersionOptions);
});
});

describe('on a legacy v0.3 transport', () => {
beforeEach(() => {
transport.protocolVersion = A2A_LEGACY_PROTOCOL_VERSION;
client = new Client(transport, agentCard);
transport.getTask.mockResolvedValue(makeTask());
});

it('rewrites A2A-Extensions to the legacy X-A2A-Extensions spelling', async () => {
await client.getTask(params, {
serviceParameters: { [HTTP_EXTENSION_HEADER]: 'ext1' },
});

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, {
serviceParameters: {
[A2A_VERSION_HEADER]: A2A_LEGACY_PROTOCOL_VERSION,
[LEGACY_HTTP_EXTENSION_HEADER]: 'ext1',
},
});
});

it('passes X-A2A-Extensions through unchanged', async () => {
await client.getTask(params, {
serviceParameters: { [LEGACY_HTTP_EXTENSION_HEADER]: 'ext1' },
});

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, {
serviceParameters: {
[A2A_VERSION_HEADER]: A2A_LEGACY_PROTOCOL_VERSION,
[LEGACY_HTTP_EXTENSION_HEADER]: 'ext1',
},
});
});

it('prefers the legacy spelling when both are present', async () => {
await client.getTask(params, {
serviceParameters: {
[HTTP_EXTENSION_HEADER]: 'canonical',
[LEGACY_HTTP_EXTENSION_HEADER]: 'legacy',
},
});

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, {
serviceParameters: {
[A2A_VERSION_HEADER]: A2A_LEGACY_PROTOCOL_VERSION,
[LEGACY_HTTP_EXTENSION_HEADER]: 'legacy',
},
});
});

it('does not synthesize an extension header when none was provided', async () => {
await client.getTask(params);

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, {
serviceParameters: { [A2A_VERSION_HEADER]: A2A_LEGACY_PROTOCOL_VERSION },
});
});
});

describe('with case variants', () => {
describe('on a v1.0 transport', () => {
beforeEach(() => {
transport.protocolVersion = A2A_PROTOCOL_VERSION;
client = new Client(transport, agentCard);
transport.getTask.mockResolvedValue(makeTask());
});

it('matches a lowercase canonical key case-insensitively', async () => {
await client.getTask(params, {
serviceParameters: { 'a2a-extensions': 'ext1' },
});

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, {
serviceParameters: {
[A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION,
[HTTP_EXTENSION_HEADER]: 'ext1',
},
});
});

it('matches a lowercase legacy alias case-insensitively', async () => {
await client.getTask(params, {
serviceParameters: { 'x-a2a-extensions': 'ext1' },
});

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, {
serviceParameters: {
[A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION,
[HTTP_EXTENSION_HEADER]: 'ext1',
},
});
});

it('prefers the exact canonical spelling when a lowercase variant is also present', async () => {
await client.getTask(params, {
serviceParameters: {
[HTTP_EXTENSION_HEADER]: 'exact',
'a2a-extensions': 'variant',
},
});

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, {
serviceParameters: {
[A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION,
[HTTP_EXTENSION_HEADER]: 'exact',
},
});
});

it('prefers the exact alias spelling when only alias variants are present', async () => {
await client.getTask(params, {
serviceParameters: {
[LEGACY_HTTP_EXTENSION_HEADER]: 'exact',
'x-a2a-extensions': 'variant',
},
});

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, {
serviceParameters: {
[A2A_VERSION_HEADER]: A2A_PROTOCOL_VERSION,
[HTTP_EXTENSION_HEADER]: 'exact',
},
});
});
});

describe('on a legacy v0.3 transport', () => {
beforeEach(() => {
transport.protocolVersion = A2A_LEGACY_PROTOCOL_VERSION;
client = new Client(transport, agentCard);
transport.getTask.mockResolvedValue(makeTask());
});

it('matches a lowercase v1.0 key and rewrites to the legacy spelling', async () => {
await client.getTask(params, {
serviceParameters: { 'a2a-extensions': 'ext1' },
});

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, {
serviceParameters: {
[A2A_VERSION_HEADER]: A2A_LEGACY_PROTOCOL_VERSION,
[LEGACY_HTTP_EXTENSION_HEADER]: 'ext1',
},
});
});

it('matches a lowercase legacy key case-insensitively', async () => {
await client.getTask(params, {
serviceParameters: { 'x-a2a-extensions': 'ext1' },
});

expect(transport.getTask).toHaveBeenCalledExactlyOnceWith(params, {
serviceParameters: {
[A2A_VERSION_HEADER]: A2A_LEGACY_PROTOCOL_VERSION,
[LEGACY_HTTP_EXTENSION_HEADER]: 'ext1',
},
});
});
});
});
});
});
Loading