Skip to content

Commit bc87f2c

Browse files
committed
fix: address A2A review findings
1 parent b539279 commit bc87f2c

File tree

7 files changed

+144
-21
lines changed

7 files changed

+144
-21
lines changed

examples/with-a2a-server/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ The returned card advertises the JSON-RPC endpoint via its `url` field:
9393
Send a JSON-RPC request to the agent:
9494

9595
```bash
96-
curl -X POST http://localhost:3141/a2a/support \
96+
curl -X POST http://localhost:3141/a2a/supportagent \
9797
-H "Content-Type: application/json" \
9898
-d '{
9999
"jsonrpc": "2.0",

packages/a2a-server/src/server.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,25 @@ describe("A2AServer", () => {
194194
).toBe("https://agents.example/a2a/support-agent");
195195
});
196196

197+
it("encodes reserved characters in A2A endpoint URLs without removing spaces", () => {
198+
const agentId = "support agent/ops?";
199+
const agent: StubAgent = {
200+
id: agentId,
201+
purpose: "Answer support questions",
202+
generateText: vi.fn(),
203+
streamText: vi.fn(),
204+
};
205+
206+
const server = createServer(agent);
207+
208+
expect(server.getAgentCard(agentId).url).toBe("/a2a/support%20agent%2Fops%3F");
209+
expect(
210+
server.getAgentCard(agentId, {
211+
requestUrl: "https://agents.example/.well-known/support%20agent%2Fops%3F/agent-card.json",
212+
}).url,
213+
).toBe("https://agents.example/a2a/support%20agent%2Fops%3F");
214+
});
215+
197216
it("streams incremental updates and completes the task", async () => {
198217
const streamText = vi.fn().mockImplementation(async () => ({
199218
text: Promise.resolve("Final response"),

packages/a2a-server/src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { VoltA2AError } from "./types";
3434
const DEFAULT_A2A_ROUTE_PREFIX = "/a2a";
3535

3636
function sanitizeSegment(segment: string): string {
37-
return segment.replace(/^\/+|\/+$|\s+/g, "");
37+
return encodeURIComponent(segment.replace(/^\/+|\/+$/g, ""));
3838
}
3939

4040
function buildA2AEndpointPath(serverId: string): string {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildA2AEndpointPath, buildAgentCardPath } from "./routes";
3+
4+
describe("A2A route helpers", () => {
5+
it("encodes reserved characters without removing internal spaces", () => {
6+
expect(buildA2AEndpointPath("support agent/ops?")).toBe("/a2a/support%20agent%2Fops%3F");
7+
expect(buildAgentCardPath("support agent/ops?")).toBe(
8+
"/.well-known/support%20agent%2Fops%3F/agent-card.json",
9+
);
10+
});
11+
});

packages/server-core/src/a2a/routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ export function buildA2AEndpointPath(serverId: string): string {
1010
}
1111

1212
function sanitizeSegment(segment: string): string {
13-
return segment.replace(/^\/+|\/+$|\s+/g, "");
13+
return encodeURIComponent(segment.replace(/^\/+|\/+$/g, ""));
1414
}

packages/server-hono/src/routes/a2a.routes.spec.ts

Lines changed: 110 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { A2AServerRegistry, type ServerProviderDeps, TriggerRegistry } from "@voltagent/core";
2+
import type { Logger } from "@voltagent/internal";
13
import * as serverCore from "@voltagent/server-core";
24
import { beforeEach, describe, expect, it, vi } from "vitest";
35
import { OpenAPIHono } from "../zod-openapi-compat";
@@ -26,45 +28,136 @@ vi.mock("@voltagent/a2a-server", async () => {
2628
};
2729
});
2830

29-
describe("A2A Routes", () => {
30-
let app: OpenAPIHono;
31-
const mockDeps = {
32-
a2a: {
33-
registry: {
34-
list: vi.fn().mockReturnValue([{ id: "server1" }]),
31+
function createMockA2ARegistry() {
32+
const registry = new A2AServerRegistry();
33+
registry.register(
34+
{
35+
getMetadata() {
36+
return {
37+
id: "server1",
38+
name: "server1",
39+
version: "1.0.0",
40+
};
41+
},
42+
},
43+
{
44+
agentRegistry: {
45+
getAgent() {
46+
return undefined;
47+
},
48+
getAllAgents() {
49+
return [];
50+
},
51+
},
52+
},
53+
);
54+
return registry;
55+
}
56+
57+
function createMockDeps(): ServerProviderDeps {
58+
return {
59+
agentRegistry: {
60+
getAgent() {
61+
return undefined;
62+
},
63+
getAllAgents() {
64+
return [];
65+
},
66+
getAgentCount() {
67+
return 0;
68+
},
69+
removeAgent() {
70+
return false;
71+
},
72+
registerAgent() {},
73+
getGlobalVoltOpsClient() {
74+
return undefined;
75+
},
76+
getGlobalLogger() {
77+
return undefined;
3578
},
3679
},
37-
} as any;
38-
const mockLogger = {
80+
workflowRegistry: {
81+
getWorkflow() {
82+
return undefined;
83+
},
84+
getWorkflowsForApi() {
85+
return [];
86+
},
87+
getWorkflowDetailForApi() {
88+
return undefined;
89+
},
90+
getWorkflowCount() {
91+
return 0;
92+
},
93+
getAllWorkflowIds() {
94+
return [];
95+
},
96+
on() {},
97+
off() {},
98+
activeExecutions: new Map(),
99+
async resumeSuspendedWorkflow() {
100+
return undefined;
101+
},
102+
},
103+
triggerRegistry: new TriggerRegistry(),
104+
a2a: {
105+
registry: createMockA2ARegistry(),
106+
},
107+
};
108+
}
109+
110+
function createMockLogger(): Logger {
111+
const logger: Logger = {
39112
trace: vi.fn(),
40113
debug: vi.fn(),
41114
info: vi.fn(),
42115
warn: vi.fn(),
43116
error: vi.fn(),
44-
} as any;
117+
fatal: vi.fn(),
118+
child: vi.fn(() => logger),
119+
};
120+
121+
return logger;
122+
}
123+
124+
describe("A2A Routes", () => {
125+
let app: InstanceType<typeof OpenAPIHono>;
126+
let mockDeps: ServerProviderDeps;
127+
let mockLogger: Logger;
45128

46129
beforeEach(() => {
47130
app = new OpenAPIHono();
48-
registerA2ARoutes(app as any, mockDeps, mockLogger);
131+
mockDeps = createMockDeps();
132+
mockLogger = createMockLogger();
133+
registerA2ARoutes(app, mockDeps, mockLogger);
49134
vi.clearAllMocks();
50135
});
51136

52137
it("passes the request URL when resolving the agent card", async () => {
53-
vi.mocked(serverCore.resolveAgentCard).mockReturnValue({
138+
const card = {
54139
name: "agent",
55140
description: "desc",
56-
} as any);
141+
url: "https://agents.example/a2a/server1",
142+
version: "1.0.0",
143+
capabilities: {
144+
streaming: true,
145+
pushNotifications: false,
146+
stateTransitionHistory: false,
147+
},
148+
defaultInputModes: ["text"],
149+
defaultOutputModes: ["text"],
150+
skills: [],
151+
};
152+
vi.mocked(serverCore.resolveAgentCard).mockReturnValue(card);
57153

58154
const requestUrl = "http://agents.example/.well-known/server1/agent-card.json";
59155
const response = await app.request(requestUrl);
60156

61157
expect(response.status).toBe(200);
62-
expect(await response.json()).toEqual({
63-
name: "agent",
64-
description: "desc",
65-
});
158+
expect(await response.json()).toEqual(card);
66159
expect(serverCore.resolveAgentCard).toHaveBeenCalledWith(
67-
mockDeps.a2a.registry,
160+
mockDeps.a2a?.registry,
68161
"server1",
69162
"server1",
70163
{ requestUrl },

website/docs/agents/a2a/a2a-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,6 @@ The test sends a `message/send`, streams a `message/stream`, and exercises `task
223223

224224
## Troubleshooting checklist
225225

226-
- **404 for discovery card**: ensure the agent ID you request matches the key inside `VoltAgent({ agents: { ... } })`.
226+
- **404 for discovery card**: ensure the `serverId` in `/.well-known/{serverId}/agent-card.json` matches `A2AServer({ id })`, or the normalized `name` when `id` is omitted. It does not come from `VoltAgent({ agents: { ... } })` or the `a2aServers` map key.
227227
- **Unexpected JSON in SSE**: confirm you are stripping the `\x1E` prefix before parsing the JSON payload.
228228
- **Cancellation not propagating**: verify you call `tasks/cancel` with the task ID from the stream and that your TaskStore preserves the `activeCancellations` set.

0 commit comments

Comments
 (0)