Skip to content

Commit 0931163

Browse files
committed
feat: update push notification system to support message payloads and refine v0.3 compatibility wire version resolution
1 parent d926a66 commit 0931163

14 files changed

Lines changed: 468 additions & 166 deletions

src/compat/v0_3/README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ Webhooks registered over v0.3 transports must receive the v0.3-shaped HTTP body,
5252

5353
This is implemented by two pieces working together:
5454

55-
1. **`PushNotificationStore` captures the wire version.** The `InMemoryPushNotificationStore` (and any conforming implementation) reads `context.requestedVersion` on `save()` and persists it alongside the config as a `StoredPushNotificationConfig { config, wireVersion }`. When the same task later emits events, `load()` returns the wire version next to each config.
55+
1. **`PushNotificationStore` captures the wire version.** The `InMemoryPushNotificationStore` (and any conforming implementation) reads `context.requestedVersion` on `save()` and persists it alongside the config as a `StoredPushNotificationConfig { config, wireVersion }`. The wire version is surfaced via the optional `loadWithMetadata` read method.
5656

57-
2. **`DefaultPushNotificationSender` routes per wire version.** The sender resolves a `PushNotificationSerializer` per stored entry using the persisted wire version. It always registers `V1PushNotificationSerializer` under `'1.0'` and falls back to it (with a one-time warning per unknown version) when no serializer is registered for the entry's version.
57+
2. **`DefaultPushNotificationSender` routes per wire version.** The sender prefers `loadWithMetadata` when the store implements it, otherwise falls back to `load` and defaults every entry to wire version `'0.3'` per spec §3.6.2's absent-header rule. It always registers `V1PushNotificationSerializer` under `'1.0'` and falls back to it (with a one-time warning per unknown version) when no serializer is registered for the entry's version.
5858

5959
### Enabling v0.3 push delivery
6060

@@ -71,3 +71,13 @@ const sender = createLegacyAwarePushNotificationSender(store);
7171
```
7272

7373
v1.0-registered webhooks continue to receive the canonical `StreamResponse` body with `application/a2a+json`; v0.3-registered webhooks receive the bare-event JSON with `application/json`. Custom serializers (or overrides for the built-in `'0.3'` / `'1.0'` entries) can be supplied via the `serializers` option; user-supplied entries take precedence.
74+
75+
### Caveat for custom `PushNotificationStore` implementations
76+
77+
The `PushNotificationStore.loadWithMetadata` method is optional. The SDK's `InMemoryPushNotificationStore` implements it; **custom store implementations that omit it cause the sender to default every stored config to wire version `'0.3'`** (per spec §3.6.2). The implications:
78+
79+
- **v0.3-only deployments**: no concern — the default matches your transports.
80+
- **v1.0-only deployments**: also no concern unless you also opt into this compat layer (which you have no reason to do).
81+
- **Mixed v0.3 + v1.0 deployments backed by a custom store + the compat layer**: v1.0-registered webhooks will silently receive v0.3 bodies. Implement `loadWithMetadata` on your custom store (mirror `InMemoryPushNotificationStore`'s 3-line implementation) to preserve the originating wire version per config.
82+
83+
This compat layer (and therefore the caveat above) is opt-in and will be retired once the ecosystem has migrated to v1.0.

src/compat/v0_3/server/push_notification/index.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,21 @@ import {
1414
type DefaultPushNotificationSenderOptions,
1515
} from '../../../../server/push_notification/default_push_notification_sender.js';
1616
import type { PushNotificationStore } from '../../../../server/push_notification/push_notification_store.js';
17-
import { A2A_LEGACY_PROTOCOL_VERSION } from '../../../../constants.js';
17+
import { ProtocolVersion } from '../../../../constants.js';
1818
import { V03PushNotificationSerializer } from './v03_push_notification_serializer.js';
1919

2020
export { V03PushNotificationSerializer };
2121

2222
/**
2323
* Constructs a {@link DefaultPushNotificationSender} with the v0.3
24-
* serializer pre-registered under the legacy version key
25-
* ({@link A2A_LEGACY_PROTOCOL_VERSION}, i.e. `'0.3'`).
24+
* serializer pre-registered under {@link ProtocolVersion.V0_3} (`'0.3'`).
2625
*
2726
* Webhooks registered over v0.3 transports (e.g. legacy gRPC, legacy
2827
* JSON-RPC, legacy REST) carry their wire version through the
29-
* {@link PushNotificationStore} and are dispatched with the v0.3-shaped
30-
* body + `application/json` content type. Webhooks registered over the
31-
* canonical v1.0 transports continue to use the built-in v1.0 serializer.
28+
* {@link PushNotificationStore} (when it implements `loadWithMetadata`)
29+
* and are dispatched with the v0.3-shaped body + `application/json`
30+
* content type. Webhooks registered over the canonical v1.0 transports
31+
* continue to use the built-in v1.0 serializer.
3232
*
3333
* Callers can override the pre-registered v0.3 entry — or add serializers
3434
* for other versions — by supplying their own `serializers` map in
@@ -41,7 +41,7 @@ export function createLegacyAwarePushNotificationSender(
4141
return new DefaultPushNotificationSender(pushNotificationStore, {
4242
...options,
4343
serializers: {
44-
[A2A_LEGACY_PROTOCOL_VERSION]: new V03PushNotificationSerializer(),
44+
[ProtocolVersion.V0_3]: new V03PushNotificationSerializer(),
4545
...(options.serializers ?? {}),
4646
},
4747
});

src/compat/v0_3/server/push_notification/v03_push_notification_serializer.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,36 @@ import {
44
} from '../../../../server/push_notification/push_notification_serializer.js';
55
import { StreamResponse } from '../../../../index.js';
66
import { LEGACY_JSON_CONTENT_TYPE } from '../../constants.js';
7-
import { toCompatTask } from '../../translate/tasks.js';
87
import {
8+
toCompatTask,
99
toCompatTaskArtifactUpdateEvent,
1010
toCompatTaskStatusUpdateEvent,
1111
} from '../../translate/tasks.js';
12+
import { toCompatMessage } from '../../translate/messages.js';
1213

1314
/**
1415
* The v0.3 push notification serializer.
1516
*
1617
* Per the v0.3 spec example (§9.5), the push-notification HTTP body is the
17-
* **bare event object** (a v0.3 JSON `Task`, `TaskStatusUpdateEvent`, or
18-
* `TaskArtifactUpdateEvent` discriminated by its `kind` field) with
19-
* `Content-Type: application/json`. Notably:
18+
* **bare event object** (a v0.3 JSON `Task`, `Message`,
19+
* `TaskStatusUpdateEvent`, or `TaskArtifactUpdateEvent` discriminated by
20+
* its `kind` field) with `Content-Type: application/json`. Notably:
2021
*
2122
* - It is **not** wrapped in a `StreamResponse` discriminator (no outer
22-
* `task` / `statusUpdate` / `artifactUpdate` key) — that wrapper is a
23-
* v1.0 addition (§4.3.3).
23+
* `task` / `message` / `statusUpdate` / `artifactUpdate` key) — that
24+
* wrapper is a v1.0 addition (§4.3.3).
2425
* - It is **not** wrapped in a JSON-RPC envelope (no `jsonrpc`, `id`,
2526
* `result`) — push notifications are unsolicited and have no in-flight
2627
* request to correlate against; the JSON-RPC envelope only appears on
2728
* the streaming (SSE) path in v0.3.
2829
*
2930
* The canonical {@link StreamResponse} payload is translated to the v0.3
3031
* JSON shape via the per-case `toCompat*` translators in
31-
* `compat/v0_3/translate/tasks.ts`, which also set the legacy `kind`
32-
* discriminator (`'task'`, `'status-update'`, or `'artifact-update'`) the
32+
* `compat/v0_3/translate/`, which set the legacy `kind` discriminator
33+
* (`'task'`, `'message'`, `'status-update'`, or `'artifact-update'`) the
3334
* v0.3 schema requires.
3435
*
35-
* `message` payloads are rejected (consistent with v1.0 behavior); push
36-
* notifications are defined for task / status / artifact events only.
36+
* All four payload variants are handled per spec §4.3.3.
3737
*/
3838
export class V03PushNotificationSerializer implements PushNotificationSerializer {
3939
serialize(streamResponse: StreamResponse): SerializedPushNotification {
@@ -47,14 +47,15 @@ export class V03PushNotificationSerializer implements PushNotificationSerializer
4747
case 'task':
4848
legacyEvent = toCompatTask(payload.value);
4949
break;
50+
case 'message':
51+
legacyEvent = toCompatMessage(payload.value);
52+
break;
5053
case 'statusUpdate':
5154
legacyEvent = toCompatTaskStatusUpdateEvent(payload.value);
5255
break;
5356
case 'artifactUpdate':
5457
legacyEvent = toCompatTaskArtifactUpdateEvent(payload.value);
5558
break;
56-
case 'message':
57-
throw new Error('Push notification should not be sent for message payload.');
5859
default: {
5960
// Exhaustive check: keeps this switch in sync with the StreamResponse
6061
// payload union at compile time.

src/constants.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,26 @@ export const A2A_PROTOCOL_VERSION = '1.0';
3737
*/
3838
export const A2A_LEGACY_PROTOCOL_VERSION = '0.3';
3939

40+
/**
41+
* Known A2A protocol wire versions.
42+
*
43+
* The string values match the canonical `Major.Minor` form transmitted in
44+
* the `A2A-Version` HTTP header (§3.6.1) and stored on
45+
* `ServerCallContext.requestedVersion`. Used as typed keys in version-keyed
46+
* registries such as `DefaultPushNotificationSenderOptions.serializers`.
47+
*
48+
* Enum values are hard-coded string literals (TypeScript requires enum
49+
* initializers to be constants); the matching exported string constants
50+
* {@link A2A_PROTOCOL_VERSION} and {@link A2A_LEGACY_PROTOCOL_VERSION}
51+
* remain the canonical sources of truth. The enum is interchangeable with
52+
* those constants and with free-form `string` versions arriving over the
53+
* wire.
54+
*/
55+
export enum ProtocolVersion {
56+
V0_3 = '0.3',
57+
V1_0 = '1.0',
58+
}
59+
4060
/**
4161
* The JSON content type per §9.1.
4262
* JSON-RPC requests MUST use this content type.

src/server/push_notification/default_push_notification_sender.ts

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { TaskPushNotificationConfig, StreamResponse } from '../../index.js';
2-
import { A2A_PROTOCOL_VERSION } from '../../constants.js';
2+
import {
3+
A2A_LEGACY_PROTOCOL_VERSION,
4+
A2A_PROTOCOL_VERSION,
5+
ProtocolVersion,
6+
} from '../../constants.js';
37
import { ServerCallContext } from '../context.js';
48
import { PushNotificationSender } from './push_notification_sender.js';
59
import { PushNotificationStore, StoredPushNotificationConfig } from './push_notification_store.js';
@@ -20,10 +24,12 @@ export interface DefaultPushNotificationSenderOptions {
2024
*/
2125
tokenHeaderName?: string;
2226
/**
23-
* Per-wire-version serializers. Keys are A2A wire versions (e.g. `'1.0'`,
24-
* `'0.3'`); values are the {@link PushNotificationSerializer}
25-
* implementations that produce the HTTP body and content type for
26-
* notifications going out to webhooks registered over that wire version.
27+
* Per-wire-version push-notification serializers. Keys are A2A wire
28+
* versions ({@link ProtocolVersion.V1_0} = `'1.0'`,
29+
* {@link ProtocolVersion.V0_3} = `'0.3'`); values are the
30+
* {@link PushNotificationSerializer} implementations that produce the
31+
* HTTP body and content type for notifications going out to webhooks
32+
* registered over that wire version.
2733
*
2834
* The sender always registers a built-in `'1.0'` serializer
2935
* ({@link V1PushNotificationSerializer}) at construction time; entries
@@ -34,8 +40,12 @@ export interface DefaultPushNotificationSenderOptions {
3440
* When a stored config carries a wire version with no registered
3541
* serializer, the sender logs a warning and falls back to the `'1.0'`
3642
* serializer for that dispatch.
43+
*
44+
* The typed key set (`ProtocolVersion`) is a developer affordance; the
45+
* underlying registry accepts any string at runtime to remain forward
46+
* compatible with future or custom wire versions.
3747
*/
38-
serializers?: Record<string, PushNotificationSerializer>;
48+
serializers?: Partial<Record<ProtocolVersion, PushNotificationSerializer>>;
3949
}
4050

4151
export class DefaultPushNotificationSender implements PushNotificationSender {
@@ -65,22 +75,33 @@ export class DefaultPushNotificationSender implements PushNotificationSender {
6575
// serializer for testing or alternative encodings).
6676
const builtinV1 = new V1PushNotificationSerializer();
6777
this.serializers = new Map<string, PushNotificationSerializer>([
68-
[A2A_PROTOCOL_VERSION, builtinV1],
78+
[ProtocolVersion.V1_0, builtinV1],
6979
]);
7080
if (options.serializers) {
7181
for (const [version, serializer] of Object.entries(options.serializers)) {
72-
this.serializers.set(version, serializer);
82+
if (serializer) {
83+
this.serializers.set(version, serializer);
84+
}
7385
}
7486
}
7587
// Cache the v1.0 serializer for unknown-version fallback. We resolve
7688
// this from the registry (not the local `builtinV1`) so a user who
7789
// overrode '1.0' has their custom serializer used for fallback too.
78-
this.fallbackSerializer = this.serializers.get(A2A_PROTOCOL_VERSION) ?? builtinV1;
90+
this.fallbackSerializer = this.serializers.get(ProtocolVersion.V1_0) ?? builtinV1;
7991
}
8092

8193
async send(streamResponse: StreamResponse, context: ServerCallContext): Promise<void> {
8294
const taskId = this._getTaskId(streamResponse);
83-
const storedConfigs = await this.pushNotificationStore.load(taskId, context);
95+
// Stand-alone Messages (the message-only stream pattern in §3.1.2 with
96+
// no task association) cannot have a registered push config — skip the
97+
// store round-trip. This also keeps the dispatch silent when the
98+
// request handler forwards a bare Message event for which no task
99+
// exists.
100+
if (!taskId) {
101+
return;
102+
}
103+
104+
const storedConfigs = await this._loadStoredConfigs(taskId, context);
84105
if (!storedConfigs || storedConfigs.length === 0) {
85106
return;
86107
}
@@ -114,6 +135,17 @@ export class DefaultPushNotificationSender implements PushNotificationSender {
114135
});
115136
}
116137

138+
/**
139+
* Returns the task id associated with a {@link StreamResponse}.
140+
*
141+
* Per spec §4.3.3 all four payload variants (`task`, `message`,
142+
* `statusUpdate`, `artifactUpdate`) are valid push-notification payloads.
143+
* For task / status / artifact events the task id is always present.
144+
* For message events the task id is present iff the message is bound to
145+
* an existing task (§3.4.2); stand-alone messages from the message-only
146+
* stream pattern carry an empty `taskId`, in which case there can be no
147+
* registered push config and the sender simply skips dispatch.
148+
*/
117149
private _getTaskId(streamResponse: StreamResponse): string {
118150
const payload = streamResponse.payload;
119151
if (!payload) {
@@ -124,9 +156,8 @@ export class DefaultPushNotificationSender implements PushNotificationSender {
124156
return payload.value.id;
125157
case 'statusUpdate':
126158
case 'artifactUpdate':
127-
return payload.value.taskId;
128159
case 'message':
129-
throw new Error('Push notification should not be sent for message payload.');
160+
return payload.value.taskId;
130161
default: {
131162
// Exhaustive check: if a new $case is added to the StreamResponse union
132163
// without updating this switch, TypeScript will report a compile error here.
@@ -136,6 +167,28 @@ export class DefaultPushNotificationSender implements PushNotificationSender {
136167
}
137168
}
138169

170+
/**
171+
* Resolves stored configs from the {@link PushNotificationStore},
172+
* preferring the wire-version-aware {@link PushNotificationStore.loadWithMetadata}
173+
* when available.
174+
*
175+
* Stores that only implement the canonical {@link PushNotificationStore.load}
176+
* method are silently lifted into the wrapped shape by tagging every
177+
* entry with {@link A2A_LEGACY_PROTOCOL_VERSION} (`'0.3'`) per spec
178+
* §3.6.2's absent-header default. See `src/compat/v0_3/README.md` for
179+
* the implication on mixed-version deployments backed by custom stores.
180+
*/
181+
private async _loadStoredConfigs(
182+
taskId: string,
183+
context: ServerCallContext
184+
): Promise<StoredPushNotificationConfig[]> {
185+
if (this.pushNotificationStore.loadWithMetadata) {
186+
return await this.pushNotificationStore.loadWithMetadata(taskId, context);
187+
}
188+
const plain = await this.pushNotificationStore.load(taskId, context);
189+
return plain.map((config) => ({ config, wireVersion: A2A_LEGACY_PROTOCOL_VERSION }));
190+
}
191+
139192
/**
140193
* Resolves the serializer registered for the given wire version.
141194
*

src/server/push_notification/push_notification_serializer.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ export interface PushNotificationSerializer {
2828
* Serializes a {@link StreamResponse} into the HTTP body + content type
2929
* for one push-notification dispatch.
3030
*
31-
* Implementations MUST reject `message` payloads (push notifications are
32-
* defined for `task` / `statusUpdate` / `artifactUpdate` only, per
33-
* §4.3.3). Throwing from this method aborts the dispatch and is logged
34-
* by the sender; it does NOT propagate to the event loop or the caller.
31+
* Implementations MUST handle all four `StreamResponse` payload variants
32+
* (`task`, `message`, `statusUpdate`, `artifactUpdate`) per spec §4.3.3.
33+
* Any error thrown from this method aborts the dispatch and is logged by
34+
* the sender; it does NOT propagate to the event loop or the caller.
3535
*/
3636
serialize(streamResponse: StreamResponse): SerializedPushNotification;
3737
}

0 commit comments

Comments
 (0)