@galdor/a2a 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +107 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +241 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +89 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +321 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.ts +236 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +148 -0
- package/dist/types.js.map +1 -0
- package/package.json +36 -0
- package/src/a2a.test.ts +267 -0
- package/src/client.ts +298 -0
- package/src/index.ts +54 -0
- package/src/server.ts +408 -0
- package/src/types.ts +297 -0
package/dist/types.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types, constants, message helpers and errors for the Agent-to-Agent
|
|
3
|
+
* (A2A) protocol carried as JSON-RPC 2.0 over HTTP.
|
|
4
|
+
*
|
|
5
|
+
* Field names are camelCase and serialize directly to the on-wire JSON. Data
|
|
6
|
+
* shapes are plain interfaces; failures are represented as {@link Error}
|
|
7
|
+
* subclasses ({@link A2AError}, {@link RPCError}).
|
|
8
|
+
*
|
|
9
|
+
* Scope notes for this implementation:
|
|
10
|
+
* - {@link TaskState} is the A2A-spec six-value lifecycle:
|
|
11
|
+
* submitted | working | input-required | completed | failed | canceled.
|
|
12
|
+
* - {@link TaskStatus} carries the state plus an optional status `message`
|
|
13
|
+
* (e.g. an input-required prompt or a failure message) and a `timestamp`.
|
|
14
|
+
* - A task's full message log lives in {@link Task.history}; non-message
|
|
15
|
+
* outputs live in {@link Task.artifacts}.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Well-known location of an agent's card. The A2A spec mandates this exact
|
|
19
|
+
* suffix; clients discover an agent at `<baseURL>/.well-known/agent.json`.
|
|
20
|
+
*/
|
|
21
|
+
export const AGENT_CARD_PATH = "/.well-known/agent.json";
|
|
22
|
+
/** A2A protocol revision this implementation targets. */
|
|
23
|
+
export const PROTOCOL_VERSION = "0.1";
|
|
24
|
+
/** JSON-RPC method name for creating or continuing a task. */
|
|
25
|
+
export const METHOD_TASKS_SEND = "tasks/send";
|
|
26
|
+
/** JSON-RPC method name for fetching a task's current state. */
|
|
27
|
+
export const METHOD_TASKS_GET = "tasks/get";
|
|
28
|
+
// JSON-RPC 2.0 standard error codes.
|
|
29
|
+
/** Malformed JSON in the request body. */
|
|
30
|
+
export const ERR_PARSE_ERROR = -32700;
|
|
31
|
+
/** Request envelope is not a valid JSON-RPC request. */
|
|
32
|
+
export const ERR_INVALID_REQUEST = -32600;
|
|
33
|
+
/** Requested method is not implemented. */
|
|
34
|
+
export const ERR_METHOD_NOT_FOUND = -32601;
|
|
35
|
+
/** Method params are missing or invalid. */
|
|
36
|
+
export const ERR_INVALID_PARAMS = -32602;
|
|
37
|
+
/** Unexpected server-side failure. */
|
|
38
|
+
export const ERR_INTERNAL_ERROR = -32603;
|
|
39
|
+
// A2A-specific error codes defined by the protocol.
|
|
40
|
+
/** No task exists for the supplied id. */
|
|
41
|
+
export const ERR_TASK_NOT_FOUND = -32001;
|
|
42
|
+
/** Task cannot accept the request in its current state (e.g. terminal). */
|
|
43
|
+
export const ERR_INVALID_TASK_STATE = -32002;
|
|
44
|
+
/**
|
|
45
|
+
* Builds a text {@link Part} from a string.
|
|
46
|
+
*
|
|
47
|
+
* @param s - The text content.
|
|
48
|
+
* @returns A part with `type` `"text"`.
|
|
49
|
+
*/
|
|
50
|
+
export function textPart(s) {
|
|
51
|
+
return { type: "text", text: s };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Builds a user-role {@link TaskMessage} carrying a single text part.
|
|
55
|
+
*
|
|
56
|
+
* @param s - The user's text.
|
|
57
|
+
* @returns A message with role `"user"`.
|
|
58
|
+
* @example
|
|
59
|
+
* client.sendTask(userText("What is the weather?"));
|
|
60
|
+
*/
|
|
61
|
+
export function userText(s) {
|
|
62
|
+
return { role: "user", parts: [textPart(s)] };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Builds an agent-role {@link TaskMessage} carrying a single text part; the
|
|
66
|
+
* agent-side counterpart of {@link userText}.
|
|
67
|
+
*
|
|
68
|
+
* @param s - The agent's text.
|
|
69
|
+
* @returns A message with role `"agent"`.
|
|
70
|
+
*/
|
|
71
|
+
export function agentText(s) {
|
|
72
|
+
return { role: "agent", parts: [textPart(s)] };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Concatenates the text of every text part in a message, joined by newlines.
|
|
76
|
+
* Non-text parts are skipped.
|
|
77
|
+
*
|
|
78
|
+
* @param m - The message to flatten.
|
|
79
|
+
* @returns The combined text, or an empty string if the message has no text
|
|
80
|
+
* parts.
|
|
81
|
+
*/
|
|
82
|
+
export function messageText(m) {
|
|
83
|
+
let out = "";
|
|
84
|
+
for (const p of m.parts) {
|
|
85
|
+
if (p.type === "text") {
|
|
86
|
+
if (out !== "")
|
|
87
|
+
out += "\n";
|
|
88
|
+
out += p.text ?? "";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Appends a message to a task's history, mutating the task in place. Stamps the
|
|
95
|
+
* status timestamp when it isn't set yet.
|
|
96
|
+
*
|
|
97
|
+
* @param task - The task whose history to extend.
|
|
98
|
+
* @param m - The message to append.
|
|
99
|
+
*/
|
|
100
|
+
export function appendMessage(task, m) {
|
|
101
|
+
if (task.history === undefined)
|
|
102
|
+
task.history = [];
|
|
103
|
+
task.history.push(m);
|
|
104
|
+
if (!task.status.timestamp)
|
|
105
|
+
task.status.timestamp = new Date().toISOString();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Reports whether a task state is terminal (no further work will occur).
|
|
109
|
+
*
|
|
110
|
+
* @param state - The state to test.
|
|
111
|
+
* @returns `true` for `"completed"`, `"failed"`, or `"canceled"`.
|
|
112
|
+
*/
|
|
113
|
+
export function isTerminalState(state) {
|
|
114
|
+
return state === "completed" || state === "failed" || state === "canceled";
|
|
115
|
+
}
|
|
116
|
+
/** Base error for transport and protocol failures raised by this library. */
|
|
117
|
+
export class A2AError extends Error {
|
|
118
|
+
name = "A2AError";
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Error carrying a JSON-RPC error envelope returned by a remote agent.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* try {
|
|
125
|
+
* await client.getTask("missing");
|
|
126
|
+
* } catch (e) {
|
|
127
|
+
* if (e instanceof RPCError) console.error(e.code, e.message);
|
|
128
|
+
* }
|
|
129
|
+
*/
|
|
130
|
+
export class RPCError extends A2AError {
|
|
131
|
+
name = "RPCError";
|
|
132
|
+
/** The JSON-RPC numeric error code. */
|
|
133
|
+
code;
|
|
134
|
+
/** Optional implementation-defined error detail. */
|
|
135
|
+
data;
|
|
136
|
+
/**
|
|
137
|
+
* @param code - JSON-RPC error code.
|
|
138
|
+
* @param message - Human-readable error message.
|
|
139
|
+
* @param data - Optional structured error detail.
|
|
140
|
+
*/
|
|
141
|
+
constructor(code, message, data) {
|
|
142
|
+
super(`a2a: JSON-RPC ${code}: ${message}`);
|
|
143
|
+
this.code = code;
|
|
144
|
+
if (data !== undefined)
|
|
145
|
+
this.data = data;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@galdor/a2a",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "galdor-bun a2a: Google Agent-to-Agent (A2A) protocol — JSON-RPC 2.0 over HTTP client + server.",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Yasser Rosas",
|
|
8
|
+
"email": "yassros16@gmail.com"
|
|
9
|
+
},
|
|
10
|
+
"license": "Apache-2.0",
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"module": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"bun": "./src/index.ts",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.build.json"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22.5",
|
|
31
|
+
"bun": ">=1.3"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@galdor/core": "0.3.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/a2a.test.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Client } from "./client.ts";
|
|
3
|
+
import { type Handler, handlerFunc, newServer } from "./server.ts";
|
|
4
|
+
import {
|
|
5
|
+
AGENT_CARD_PATH,
|
|
6
|
+
type AgentCard,
|
|
7
|
+
agentText,
|
|
8
|
+
appendMessage,
|
|
9
|
+
ERR_PARSE_ERROR,
|
|
10
|
+
messageText,
|
|
11
|
+
type Task,
|
|
12
|
+
} from "./types.ts";
|
|
13
|
+
|
|
14
|
+
let listening: ReturnType<typeof Bun.serve> | undefined;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
listening?.stop(true);
|
|
18
|
+
listening = undefined;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** start spins up an A2A Server on an ephemeral port and returns a Client. */
|
|
22
|
+
function start(handler: Handler): Client {
|
|
23
|
+
const card: AgentCard = {
|
|
24
|
+
name: "test-agent",
|
|
25
|
+
description: "test fixture",
|
|
26
|
+
url: "",
|
|
27
|
+
version: "0.1",
|
|
28
|
+
capabilities: {},
|
|
29
|
+
skills: [{ id: "echo", name: "Echo", description: "Repeats whatever the user said" }],
|
|
30
|
+
};
|
|
31
|
+
const server = newServer(card, handler);
|
|
32
|
+
listening = Bun.serve({ port: 0, fetch: server.fetch });
|
|
33
|
+
return new Client(`http://localhost:${listening.port}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** echo replays the latest user message back as an agent turn, then completes. */
|
|
37
|
+
const echo = handlerFunc(async (task: Task) => {
|
|
38
|
+
let said = "";
|
|
39
|
+
for (const m of task.history ?? []) {
|
|
40
|
+
if (m.role === "user") said = messageText(m);
|
|
41
|
+
}
|
|
42
|
+
appendMessage(task, agentText(`echo: ${said}`));
|
|
43
|
+
task.status.state = "completed";
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("a2a", () => {
|
|
47
|
+
test("fetchAgentCard returns the published card", async () => {
|
|
48
|
+
const client = start(echo);
|
|
49
|
+
const card = await client.fetchAgentCard();
|
|
50
|
+
expect(card.name).toBe("test-agent");
|
|
51
|
+
expect(card.version).toBe("0.1");
|
|
52
|
+
expect(card.skills).toHaveLength(1);
|
|
53
|
+
expect(card.skills[0]?.id).toBe("echo");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("sendTask runs the handler and returns the completed task", async () => {
|
|
57
|
+
const client = start(echo);
|
|
58
|
+
const task = await client.sendTask({ role: "user", parts: [{ type: "text", text: "hello" }] });
|
|
59
|
+
|
|
60
|
+
expect(task.id).not.toBe("");
|
|
61
|
+
expect(task.status.state).toBe("completed");
|
|
62
|
+
// Log holds the user turn + the echoed agent turn.
|
|
63
|
+
expect(task.history).toHaveLength(2);
|
|
64
|
+
const agentTurn = task.history![1];
|
|
65
|
+
expect(agentTurn?.role).toBe("agent");
|
|
66
|
+
expect(messageText(agentTurn!)).toContain("echo: hello");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("getTask retrieves a task by id", async () => {
|
|
70
|
+
const client = start(echo);
|
|
71
|
+
const created = await client.sendTask({
|
|
72
|
+
role: "user",
|
|
73
|
+
parts: [{ type: "text", text: "hi" }],
|
|
74
|
+
});
|
|
75
|
+
const got = await client.getTask(created.id);
|
|
76
|
+
expect(got.id).toBe(created.id);
|
|
77
|
+
expect(got.status.state).toBe("completed");
|
|
78
|
+
expect(messageText(got.history![1]!)).toContain("echo: hi");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("handler errors mark the task failed with the message in status.message", async () => {
|
|
82
|
+
const client = start(
|
|
83
|
+
handlerFunc(async () => {
|
|
84
|
+
throw new Error("planet exploded");
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
// SendTask succeeds at the protocol level; the failure is in the status.
|
|
88
|
+
const task = await client.sendTask({ role: "user", parts: [{ type: "text", text: "oops" }] });
|
|
89
|
+
expect(task.status.state).toBe("failed");
|
|
90
|
+
expect(task.status.message).toBeDefined();
|
|
91
|
+
expect(messageText(task.status.message!)).toContain("planet exploded");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("server auto-completes when the handler forgets the terminal state", async () => {
|
|
95
|
+
const client = start(
|
|
96
|
+
handlerFunc(async (task: Task) => {
|
|
97
|
+
appendMessage(task, agentText("done but forgot the flag"));
|
|
98
|
+
// Intentionally leaves state at "working".
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
const task = await client.sendTask({ role: "user", parts: [{ type: "text", text: "hi" }] });
|
|
102
|
+
expect(task.status.state).toBe("completed");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("input-required is preserved (not coerced to completed) and the task can be continued", async () => {
|
|
106
|
+
// The handler asks for more input on the first turn, then completes on the second.
|
|
107
|
+
const client = start(
|
|
108
|
+
handlerFunc(async (task: Task) => {
|
|
109
|
+
const userTurns = (task.history ?? []).filter((m) => m.role === "user").length;
|
|
110
|
+
if (userTurns < 2) {
|
|
111
|
+
appendMessage(task, agentText("What is your name?"));
|
|
112
|
+
task.status = { state: "input-required", message: agentText("What is your name?") };
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
appendMessage(task, agentText("Hello!"));
|
|
116
|
+
task.status.state = "completed";
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
const first = await client.sendTask({ role: "user", parts: [{ type: "text", text: "hi" }] });
|
|
120
|
+
expect(first.status.state).toBe("input-required");
|
|
121
|
+
// Continue the same task by reusing its id (via the taskId option).
|
|
122
|
+
const second = await client.sendTask(
|
|
123
|
+
{ role: "user", parts: [{ type: "text", text: "Ada" }] },
|
|
124
|
+
{ taskId: first.id },
|
|
125
|
+
);
|
|
126
|
+
expect(second.status.state).toBe("completed");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a handler can attach artifacts to the task", async () => {
|
|
130
|
+
const client = start(
|
|
131
|
+
handlerFunc(async (task: Task) => {
|
|
132
|
+
task.artifacts = [{ name: "result.json", parts: [{ type: "text", text: '{"ok":true}' }] }];
|
|
133
|
+
task.status.state = "completed";
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
const task = await client.sendTask({ role: "user", parts: [{ type: "text", text: "go" }] });
|
|
137
|
+
expect(task.artifacts).toHaveLength(1);
|
|
138
|
+
expect(task.artifacts![0]?.name).toBe("result.json");
|
|
139
|
+
expect(messageText({ role: "agent", parts: task.artifacts![0]!.parts })).toContain('"ok":true');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("getTask on an unknown id rejects", async () => {
|
|
143
|
+
const client = start(echo);
|
|
144
|
+
await expect(client.getTask("ghost")).rejects.toThrow();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("getTask truncates the log to historyLength", async () => {
|
|
148
|
+
const client = start(
|
|
149
|
+
handlerFunc(async (task: Task) => {
|
|
150
|
+
appendMessage(task, agentText("a"));
|
|
151
|
+
appendMessage(task, agentText("b"));
|
|
152
|
+
appendMessage(task, agentText("c"));
|
|
153
|
+
task.status.state = "completed";
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
const created = await client.sendTask({ role: "user", parts: [{ type: "text", text: "go" }] });
|
|
157
|
+
// Full log = 1 user + 3 agent = 4 messages.
|
|
158
|
+
const got = await client.getTask(created.id, 2);
|
|
159
|
+
expect(got.history).toHaveLength(2);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("server rejects an over-cap request body", async () => {
|
|
163
|
+
const client = start(echo);
|
|
164
|
+
// A text part larger than the 4 MiB request cap.
|
|
165
|
+
const big = "y".repeat((4 << 20) + 1024);
|
|
166
|
+
await expect(
|
|
167
|
+
client.sendTask({ role: "user", parts: [{ type: "text", text: big }] }),
|
|
168
|
+
).rejects.toThrow();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** A minimal Agent Card fixture for the protection tests. */
|
|
173
|
+
function fixtureCard(name = "test-agent"): AgentCard {
|
|
174
|
+
return { name, description: "fixture", url: "", version: "0.1", capabilities: {}, skills: [] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe("a2a protections", () => {
|
|
178
|
+
let server: ReturnType<typeof Bun.serve> | undefined;
|
|
179
|
+
|
|
180
|
+
afterEach(() => {
|
|
181
|
+
server?.stop(true);
|
|
182
|
+
server = undefined;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("client refuses a cross-host redirect (SSRF guard)", async () => {
|
|
186
|
+
server = Bun.serve({
|
|
187
|
+
port: 0,
|
|
188
|
+
fetch: () =>
|
|
189
|
+
new Response(null, { status: 302, headers: { Location: "http://example.com/evil" } }),
|
|
190
|
+
});
|
|
191
|
+
const client = new Client(`http://localhost:${server.port}`);
|
|
192
|
+
await expect(client.fetchAgentCard()).rejects.toThrow(/cross-host/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("client follows a same-host redirect", async () => {
|
|
196
|
+
server = Bun.serve({
|
|
197
|
+
port: 0,
|
|
198
|
+
fetch: (req) => {
|
|
199
|
+
const url = new URL(req.url);
|
|
200
|
+
if (url.pathname === AGENT_CARD_PATH) {
|
|
201
|
+
return new Response(null, { status: 302, headers: { Location: "/card" } });
|
|
202
|
+
}
|
|
203
|
+
return new Response(JSON.stringify(fixtureCard("redirected")), {
|
|
204
|
+
headers: { "Content-Type": "application/json" },
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
const client = new Client(`http://localhost:${server.port}`);
|
|
209
|
+
const card = await client.fetchAgentCard();
|
|
210
|
+
expect(card.name).toBe("redirected");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("client rejects an over-cap response body", async () => {
|
|
214
|
+
// One byte past the 4 MiB response cap.
|
|
215
|
+
const big = "x".repeat((4 << 20) + 1);
|
|
216
|
+
server = Bun.serve({ port: 0, fetch: () => new Response(big) });
|
|
217
|
+
const client = new Client(`http://localhost:${server.port}`);
|
|
218
|
+
await expect(client.fetchAgentCard()).rejects.toThrow(/exceeds/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("client honors a caller signal alongside the default deadline", async () => {
|
|
222
|
+
server = Bun.serve({
|
|
223
|
+
port: 0,
|
|
224
|
+
fetch: async () => {
|
|
225
|
+
await Bun.sleep(2000);
|
|
226
|
+
return new Response("{}");
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
const client = new Client(`http://localhost:${server.port}`);
|
|
230
|
+
// The caller's 50ms signal must fire even though it is combined with the
|
|
231
|
+
// 60s default deadline.
|
|
232
|
+
await expect(client.fetchAgentCard(AbortSignal.timeout(50))).rejects.toThrow();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("server rejects an over-cap streamed request without content-length", async () => {
|
|
236
|
+
const a2a = newServer(fixtureCard(), echo);
|
|
237
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
238
|
+
start(controller) {
|
|
239
|
+
controller.enqueue(new Uint8Array((4 << 20) + 1));
|
|
240
|
+
controller.close();
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
const req = new Request("http://localhost/rpc", {
|
|
244
|
+
method: "POST",
|
|
245
|
+
body: stream,
|
|
246
|
+
duplex: "half",
|
|
247
|
+
} as RequestInit & { duplex: "half" });
|
|
248
|
+
const resp = await a2a.fetch(req);
|
|
249
|
+
const reply = (await resp.json()) as { error?: { code: number } };
|
|
250
|
+
expect(reply.error?.code).toBe(ERR_PARSE_ERROR);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("a custom fetch is used for requests (HTTP transport customization)", async () => {
|
|
254
|
+
const seen: string[] = [];
|
|
255
|
+
const card: AgentCard = { name: "custom", description: "d", url: "http://agent.invalid", version: "1", capabilities: {}, skills: [] };
|
|
256
|
+
const customFetch = async (url: string): Promise<Response> => {
|
|
257
|
+
seen.push(url);
|
|
258
|
+
return new Response(JSON.stringify(card), { status: 200, headers: { "content-type": "application/json" } });
|
|
259
|
+
};
|
|
260
|
+
// timeoutMs: 0 disables the built-in deadline so a caller-supplied client
|
|
261
|
+
// has full control (it could run longer than the default 60s).
|
|
262
|
+
const client = new Client("http://agent.invalid", { fetch: customFetch, timeoutMs: 0 });
|
|
263
|
+
const got = await client.fetchAgentCard();
|
|
264
|
+
expect(got.name).toBe("custom");
|
|
265
|
+
expect(seen).toEqual([`http://agent.invalid${AGENT_CARD_PATH}`]);
|
|
266
|
+
});
|
|
267
|
+
});
|