@renseiai/agentfactory-server 0.8.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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/dist/src/a2a-server.d.ts +88 -0
  4. package/dist/src/a2a-server.d.ts.map +1 -0
  5. package/dist/src/a2a-server.integration.test.d.ts +9 -0
  6. package/dist/src/a2a-server.integration.test.d.ts.map +1 -0
  7. package/dist/src/a2a-server.integration.test.js +397 -0
  8. package/dist/src/a2a-server.js +235 -0
  9. package/dist/src/a2a-server.test.d.ts +2 -0
  10. package/dist/src/a2a-server.test.d.ts.map +1 -0
  11. package/dist/src/a2a-server.test.js +311 -0
  12. package/dist/src/a2a-types.d.ts +125 -0
  13. package/dist/src/a2a-types.d.ts.map +1 -0
  14. package/dist/src/a2a-types.js +8 -0
  15. package/dist/src/agent-tracking.d.ts +201 -0
  16. package/dist/src/agent-tracking.d.ts.map +1 -0
  17. package/dist/src/agent-tracking.js +349 -0
  18. package/dist/src/env-validation.d.ts +65 -0
  19. package/dist/src/env-validation.d.ts.map +1 -0
  20. package/dist/src/env-validation.js +134 -0
  21. package/dist/src/governor-dedup.d.ts +15 -0
  22. package/dist/src/governor-dedup.d.ts.map +1 -0
  23. package/dist/src/governor-dedup.js +31 -0
  24. package/dist/src/governor-event-bus.d.ts +54 -0
  25. package/dist/src/governor-event-bus.d.ts.map +1 -0
  26. package/dist/src/governor-event-bus.js +152 -0
  27. package/dist/src/governor-storage.d.ts +28 -0
  28. package/dist/src/governor-storage.d.ts.map +1 -0
  29. package/dist/src/governor-storage.js +52 -0
  30. package/dist/src/index.d.ts +26 -0
  31. package/dist/src/index.d.ts.map +1 -0
  32. package/dist/src/index.js +50 -0
  33. package/dist/src/issue-lock.d.ts +129 -0
  34. package/dist/src/issue-lock.d.ts.map +1 -0
  35. package/dist/src/issue-lock.js +508 -0
  36. package/dist/src/logger.d.ts +76 -0
  37. package/dist/src/logger.d.ts.map +1 -0
  38. package/dist/src/logger.js +218 -0
  39. package/dist/src/orphan-cleanup.d.ts +64 -0
  40. package/dist/src/orphan-cleanup.d.ts.map +1 -0
  41. package/dist/src/orphan-cleanup.js +369 -0
  42. package/dist/src/pending-prompts.d.ts +67 -0
  43. package/dist/src/pending-prompts.d.ts.map +1 -0
  44. package/dist/src/pending-prompts.js +176 -0
  45. package/dist/src/processing-state-storage.d.ts +38 -0
  46. package/dist/src/processing-state-storage.d.ts.map +1 -0
  47. package/dist/src/processing-state-storage.js +61 -0
  48. package/dist/src/quota-tracker.d.ts +62 -0
  49. package/dist/src/quota-tracker.d.ts.map +1 -0
  50. package/dist/src/quota-tracker.js +155 -0
  51. package/dist/src/rate-limit.d.ts +111 -0
  52. package/dist/src/rate-limit.d.ts.map +1 -0
  53. package/dist/src/rate-limit.js +171 -0
  54. package/dist/src/redis-circuit-breaker.d.ts +67 -0
  55. package/dist/src/redis-circuit-breaker.d.ts.map +1 -0
  56. package/dist/src/redis-circuit-breaker.js +290 -0
  57. package/dist/src/redis-rate-limiter.d.ts +51 -0
  58. package/dist/src/redis-rate-limiter.d.ts.map +1 -0
  59. package/dist/src/redis-rate-limiter.js +168 -0
  60. package/dist/src/redis.d.ts +146 -0
  61. package/dist/src/redis.d.ts.map +1 -0
  62. package/dist/src/redis.js +343 -0
  63. package/dist/src/session-hash.d.ts +48 -0
  64. package/dist/src/session-hash.d.ts.map +1 -0
  65. package/dist/src/session-hash.js +80 -0
  66. package/dist/src/session-storage.d.ts +166 -0
  67. package/dist/src/session-storage.d.ts.map +1 -0
  68. package/dist/src/session-storage.js +397 -0
  69. package/dist/src/token-storage.d.ts +118 -0
  70. package/dist/src/token-storage.d.ts.map +1 -0
  71. package/dist/src/token-storage.js +263 -0
  72. package/dist/src/types.d.ts +11 -0
  73. package/dist/src/types.d.ts.map +1 -0
  74. package/dist/src/types.js +7 -0
  75. package/dist/src/webhook-idempotency.d.ts +44 -0
  76. package/dist/src/webhook-idempotency.d.ts.map +1 -0
  77. package/dist/src/webhook-idempotency.js +148 -0
  78. package/dist/src/work-queue.d.ts +120 -0
  79. package/dist/src/work-queue.d.ts.map +1 -0
  80. package/dist/src/work-queue.js +384 -0
  81. package/dist/src/worker-auth.d.ts +29 -0
  82. package/dist/src/worker-auth.d.ts.map +1 -0
  83. package/dist/src/worker-auth.js +49 -0
  84. package/dist/src/worker-storage.d.ts +108 -0
  85. package/dist/src/worker-storage.d.ts.map +1 -0
  86. package/dist/src/worker-storage.js +295 -0
  87. package/dist/src/workflow-state-integration.test.d.ts +2 -0
  88. package/dist/src/workflow-state-integration.test.d.ts.map +1 -0
  89. package/dist/src/workflow-state-integration.test.js +342 -0
  90. package/dist/src/workflow-state.test.d.ts +2 -0
  91. package/dist/src/workflow-state.test.d.ts.map +1 -0
  92. package/dist/src/workflow-state.test.js +113 -0
  93. package/package.json +72 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Rensei AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @renseiai/agentfactory-server
2
+
3
+ Redis-backed infrastructure for [AgentFactory](https://github.com/renseiai/agentfactory). Provides work queue, session storage, worker pool management, issue locking, and webhook idempotency.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @renseiai/agentfactory-server
9
+ ```
10
+
11
+ Requires a Redis instance (works with Redis, Upstash, Vercel KV, or any Redis-compatible store).
12
+
13
+ ## What It Provides
14
+
15
+ | Component | Description |
16
+ |-----------|-------------|
17
+ | **WorkQueue** | Priority queue with atomic claim/release (sorted sets) |
18
+ | **SessionStorage** | Key-value session state (status, cost, timestamps) |
19
+ | **WorkerStorage** | Worker registration, heartbeat, capacity tracking |
20
+ | **IssueLock** | Per-issue mutex with pending queue |
21
+ | **AgentTracking** | QA attempt counts, agent-worked history |
22
+ | **WebhookIdempotency** | Dedup webhook deliveries with TTL |
23
+ | **TokenStorage** | OAuth token storage and retrieval |
24
+ | **RateLimit** | Token bucket rate limiting |
25
+ | **WorkerAuth** | API key verification for workers |
26
+ | **RedisEventBus** | Governor event bus backed by Redis Streams (consumer groups, MAXLEN trim) |
27
+ | **RedisEventDeduplicator** | Governor event dedup using SETNX with TTL |
28
+ | **RedisOverrideStorage** | Governor human override state (HOLD, PRIORITY) |
29
+ | **RedisProcessingStateStorage** | Top-of-funnel phase tracking (research, backlog-creation) |
30
+
31
+ ## Quick Start
32
+
33
+ ```typescript
34
+ import { createRedisClient, enqueueWork, claimWork } from '@renseiai/agentfactory-server'
35
+
36
+ // Redis client (auto-reads REDIS_URL from env)
37
+ const redis = createRedisClient()
38
+
39
+ // Enqueue a work item with priority
40
+ await enqueueWork({
41
+ issueId: 'issue-uuid',
42
+ identifier: 'PROJ-123',
43
+ workType: 'development',
44
+ priority: 2,
45
+ prompt: 'Implement the login feature...',
46
+ })
47
+
48
+ // Worker claims next item
49
+ const work = await claimWork('worker-1')
50
+ if (work) {
51
+ console.log(`Claimed: ${work.identifier} [${work.workType}]`)
52
+ }
53
+ ```
54
+
55
+ ## Environment Variables
56
+
57
+ | Variable | Required | Description |
58
+ |----------|----------|-------------|
59
+ | `REDIS_URL` | Yes | Redis connection URL (e.g., `redis://localhost:6379`) |
60
+
61
+ ## Related Packages
62
+
63
+ | Package | Description |
64
+ |---------|-------------|
65
+ | [@renseiai/agentfactory](https://www.npmjs.com/package/@renseiai/agentfactory) | Core orchestrator |
66
+ | [@renseiai/agentfactory-cli](https://www.npmjs.com/package/@renseiai/agentfactory-cli) | CLI tools (worker, orchestrator) |
67
+ | [@renseiai/agentfactory-nextjs](https://www.npmjs.com/package/@renseiai/agentfactory-nextjs) | Next.js webhook server |
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,88 @@
1
+ /**
2
+ * A2A Server Handlers
3
+ *
4
+ * Framework-agnostic utilities for exposing AgentFactory fleet capabilities
5
+ * via the Agent-to-Agent (A2A) protocol. Provides AgentCard generation,
6
+ * JSON-RPC request routing, and SSE event formatting.
7
+ *
8
+ * Consuming applications wire these handlers into their own HTTP server
9
+ * (Express, Hono, Next.js, etc.) — this module has no framework dependency.
10
+ */
11
+ import type { A2aAgentCard, A2aAuthScheme, A2aMessage, A2aSkill, A2aTask, A2aTaskEvent, JsonRpcRequest, JsonRpcResponse } from './a2a-types.js';
12
+ /** Configuration for building an AgentCard */
13
+ export interface A2aServerConfig {
14
+ /** Human-readable agent name */
15
+ name: string;
16
+ /** Short description of the agent's purpose */
17
+ description: string;
18
+ /** Base URL where A2A endpoints are exposed */
19
+ url: string;
20
+ /** Semantic version of the agent (defaults to '1.0.0') */
21
+ version?: string;
22
+ /** Explicit skill list; when omitted skills are derived from AgentWorkType */
23
+ skills?: A2aSkill[];
24
+ /** Whether the agent supports SSE streaming (defaults to false) */
25
+ streaming?: boolean;
26
+ /** Authentication schemes the endpoint accepts */
27
+ authSchemes?: A2aAuthScheme[];
28
+ }
29
+ /**
30
+ * Build an A2A AgentCard from the supplied configuration.
31
+ *
32
+ * If no explicit skills are provided the card is populated with skills
33
+ * derived from every known {@link AgentWorkType}.
34
+ *
35
+ * @param config - Server configuration
36
+ * @returns A fully-formed AgentCard
37
+ */
38
+ export declare function buildAgentCard(config: A2aServerConfig): A2aAgentCard;
39
+ /** Callbacks that the consuming application must supply */
40
+ export interface A2aHandlerOptions {
41
+ /** Handle an incoming message, optionally targeting an existing task */
42
+ onSendMessage: (message: A2aMessage, taskId?: string) => Promise<A2aTask>;
43
+ /** Retrieve an existing task by ID */
44
+ onGetTask: (taskId: string) => Promise<A2aTask | null>;
45
+ /** Cancel an existing task by ID */
46
+ onCancelTask: (taskId: string) => Promise<A2aTask | null>;
47
+ /**
48
+ * Verify the Authorization header.
49
+ * Defaults to Bearer-token verification via {@link verifyApiKey}.
50
+ */
51
+ verifyAuth?: (authHeader: string | undefined) => boolean;
52
+ }
53
+ /**
54
+ * A framework-agnostic function that accepts a parsed JSON-RPC request
55
+ * (plus an optional Authorization header) and returns a JSON-RPC response.
56
+ */
57
+ export type A2aRequestHandler = (request: JsonRpcRequest, authHeader?: string) => Promise<JsonRpcResponse>;
58
+ /**
59
+ * Create a framework-agnostic A2A request handler.
60
+ *
61
+ * The returned function processes a single JSON-RPC request and returns
62
+ * the corresponding response. The consuming application is responsible
63
+ * for HTTP parsing, serialisation, and transport.
64
+ *
65
+ * Supported methods:
66
+ * - `message/send` — send a message (optionally to an existing task)
67
+ * - `tasks/get` — retrieve a task by ID
68
+ * - `tasks/cancel` — cancel a task by ID
69
+ *
70
+ * @param options - Callbacks for task lifecycle and (optional) auth
71
+ * @returns An async handler function
72
+ */
73
+ export declare function createA2aRequestHandler(options: A2aHandlerOptions): A2aRequestHandler;
74
+ /**
75
+ * Format an A2A task event as a Server-Sent Events (SSE) message.
76
+ *
77
+ * The output follows the SSE text/event-stream format:
78
+ * ```
79
+ * event: <type>
80
+ * data: <JSON payload>
81
+ *
82
+ * ```
83
+ *
84
+ * @param event - The task event to format
85
+ * @returns A string ready to write to an SSE response stream
86
+ */
87
+ export declare function formatSseEvent(event: A2aTaskEvent): string;
88
+ //# sourceMappingURL=a2a-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"a2a-server.d.ts","sourceRoot":"","sources":["../../src/a2a-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EACV,YAAY,EACZ,aAAa,EACb,UAAU,EACV,QAAQ,EACR,OAAO,EACP,YAAY,EACZ,cAAc,EACd,eAAe,EAChB,MAAM,gBAAgB,CAAA;AAMvB,8CAA8C;AAC9C,MAAM,WAAW,eAAe;IAC9B,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAA;IACZ,+CAA+C;IAC/C,WAAW,EAAE,MAAM,CAAA;IACnB,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAA;IACX,0DAA0D;IAC1D,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,8EAA8E;IAC9E,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAA;IACnB,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,kDAAkD;IAClD,WAAW,CAAC,EAAE,aAAa,EAAE,CAAA;CAC9B;AA2ED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,eAAe,GAAG,YAAY,CAmBpE;AAMD,2DAA2D;AAC3D,MAAM,WAAW,iBAAiB;IAChC,wEAAwE;IACxE,aAAa,EAAE,CAAC,OAAO,EAAE,UAAU,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IACzE,sCAAsC;IACtC,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;IACtD,oCAAoC;IACpC,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAA;IACzD;;;OAGG;IACH,UAAU,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,KAAK,OAAO,CAAA;CACzD;AAED;;;GAGG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAC9B,OAAO,EAAE,cAAc,EACvB,UAAU,CAAC,EAAE,MAAM,KAChB,OAAO,CAAC,eAAe,CAAC,CAAA;AAwC7B;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,iBAAiB,GAAG,iBAAiB,CAoErF;AAMD;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAG1D"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Integration tests for the A2A Server
3
+ *
4
+ * Wires the A2A request handler to mock task callbacks and verifies
5
+ * end-to-end JSON-RPC handling, AgentCard generation, SSE formatting,
6
+ * and auth integration.
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=a2a-server.integration.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"a2a-server.integration.test.d.ts","sourceRoot":"","sources":["../../src/a2a-server.integration.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Integration tests for the A2A Server
3
+ *
4
+ * Wires the A2A request handler to mock task callbacks and verifies
5
+ * end-to-end JSON-RPC handling, AgentCard generation, SSE formatting,
6
+ * and auth integration.
7
+ */
8
+ import { describe, it, expect, vi } from 'vitest';
9
+ import { buildAgentCard, createA2aRequestHandler, formatSseEvent, } from './a2a-server.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Helpers
12
+ // ---------------------------------------------------------------------------
13
+ function makeRequest(method, params, id = 'req-1') {
14
+ return { jsonrpc: '2.0', id, method, params };
15
+ }
16
+ function makeMessage(text, role = 'user') {
17
+ return { role, parts: [{ type: 'text', text }] };
18
+ }
19
+ function makeTask(overrides = {}) {
20
+ return {
21
+ id: 'task-1',
22
+ status: 'submitted',
23
+ messages: [],
24
+ artifacts: [],
25
+ ...overrides,
26
+ };
27
+ }
28
+ // ---------------------------------------------------------------------------
29
+ // 1. Full message/send -> task lifecycle
30
+ // ---------------------------------------------------------------------------
31
+ describe('A2A Server — full task lifecycle', () => {
32
+ it('creates a task via message/send, retrieves it, and cancels it', async () => {
33
+ // In-memory task store
34
+ const tasks = new Map();
35
+ const onSendMessage = vi.fn(async (message, _taskId) => {
36
+ const task = {
37
+ id: 'task-lifecycle-1',
38
+ status: 'working',
39
+ messages: [message],
40
+ artifacts: [],
41
+ };
42
+ tasks.set(task.id, task);
43
+ return task;
44
+ });
45
+ const onGetTask = vi.fn(async (taskId) => {
46
+ return tasks.get(taskId) ?? null;
47
+ });
48
+ const onCancelTask = vi.fn(async (taskId) => {
49
+ const task = tasks.get(taskId);
50
+ if (!task)
51
+ return null;
52
+ task.status = 'canceled';
53
+ return task;
54
+ });
55
+ const handler = createA2aRequestHandler({
56
+ onSendMessage,
57
+ onGetTask,
58
+ onCancelTask,
59
+ verifyAuth: () => true,
60
+ });
61
+ // Step 1: Send message
62
+ const sendRes = await handler(makeRequest('message/send', { message: makeMessage('Build the feature') }));
63
+ expect(sendRes.error).toBeUndefined();
64
+ const createdTask = sendRes.result;
65
+ expect(createdTask.id).toBe('task-lifecycle-1');
66
+ expect(createdTask.status).toBe('working');
67
+ expect(createdTask.messages).toHaveLength(1);
68
+ expect(createdTask.messages[0].parts[0]).toMatchObject({ type: 'text', text: 'Build the feature' });
69
+ expect(onSendMessage).toHaveBeenCalledWith(makeMessage('Build the feature'), undefined);
70
+ // Step 2: Get task
71
+ const getRes = await handler(makeRequest('tasks/get', { taskId: 'task-lifecycle-1' }));
72
+ expect(getRes.error).toBeUndefined();
73
+ const retrievedTask = getRes.result;
74
+ expect(retrievedTask.id).toBe('task-lifecycle-1');
75
+ expect(retrievedTask.status).toBe('working');
76
+ expect(onGetTask).toHaveBeenCalledWith('task-lifecycle-1');
77
+ // Step 3: Cancel task
78
+ const cancelRes = await handler(makeRequest('tasks/cancel', { taskId: 'task-lifecycle-1' }));
79
+ expect(cancelRes.error).toBeUndefined();
80
+ const canceledTask = cancelRes.result;
81
+ expect(canceledTask.id).toBe('task-lifecycle-1');
82
+ expect(canceledTask.status).toBe('canceled');
83
+ expect(onCancelTask).toHaveBeenCalledWith('task-lifecycle-1');
84
+ });
85
+ it('returns -32001 when getting a non-existent task', async () => {
86
+ const handler = createA2aRequestHandler({
87
+ onSendMessage: vi.fn(async () => makeTask()),
88
+ onGetTask: vi.fn(async () => null),
89
+ onCancelTask: vi.fn(async () => null),
90
+ verifyAuth: () => true,
91
+ });
92
+ const res = await handler(makeRequest('tasks/get', { taskId: 'nonexistent' }));
93
+ expect(res.error?.code).toBe(-32001);
94
+ expect(res.error?.message).toBe('Task not found');
95
+ });
96
+ it('returns -32001 when canceling a non-existent task', async () => {
97
+ const handler = createA2aRequestHandler({
98
+ onSendMessage: vi.fn(async () => makeTask()),
99
+ onGetTask: vi.fn(async () => null),
100
+ onCancelTask: vi.fn(async () => null),
101
+ verifyAuth: () => true,
102
+ });
103
+ const res = await handler(makeRequest('tasks/cancel', { taskId: 'nonexistent' }));
104
+ expect(res.error?.code).toBe(-32001);
105
+ expect(res.error?.message).toBe('Task not found');
106
+ });
107
+ it('passes taskId to onSendMessage for follow-up messages', async () => {
108
+ const onSendMessage = vi.fn(async () => makeTask({ id: 'task-followup', status: 'working' }));
109
+ const handler = createA2aRequestHandler({
110
+ onSendMessage,
111
+ onGetTask: vi.fn(async () => null),
112
+ onCancelTask: vi.fn(async () => null),
113
+ verifyAuth: () => true,
114
+ });
115
+ await handler(makeRequest('message/send', {
116
+ message: makeMessage('Continue working'),
117
+ taskId: 'task-followup',
118
+ }));
119
+ expect(onSendMessage).toHaveBeenCalledWith(makeMessage('Continue working'), 'task-followup');
120
+ });
121
+ it('returns -32603 when onSendMessage throws an error', async () => {
122
+ const handler = createA2aRequestHandler({
123
+ onSendMessage: vi.fn(async () => { throw new Error('Queue overflow'); }),
124
+ onGetTask: vi.fn(async () => null),
125
+ onCancelTask: vi.fn(async () => null),
126
+ verifyAuth: () => true,
127
+ });
128
+ const res = await handler(makeRequest('message/send', { message: makeMessage('hello') }));
129
+ expect(res.error?.code).toBe(-32603);
130
+ expect(res.error?.message).toBe('Queue overflow');
131
+ });
132
+ it('returns -32601 for unknown methods', async () => {
133
+ const handler = createA2aRequestHandler({
134
+ onSendMessage: vi.fn(async () => makeTask()),
135
+ onGetTask: vi.fn(async () => null),
136
+ onCancelTask: vi.fn(async () => null),
137
+ verifyAuth: () => true,
138
+ });
139
+ const res = await handler(makeRequest('tasks/unknown'));
140
+ expect(res.error?.code).toBe(-32601);
141
+ expect(res.error?.message).toContain('Method not found');
142
+ expect(res.error?.message).toContain('tasks/unknown');
143
+ });
144
+ it('always returns JSON-RPC 2.0 version and echoes request id', async () => {
145
+ const handler = createA2aRequestHandler({
146
+ onSendMessage: vi.fn(async () => makeTask()),
147
+ onGetTask: vi.fn(async () => makeTask()),
148
+ onCancelTask: vi.fn(async () => null),
149
+ verifyAuth: () => true,
150
+ });
151
+ const res = await handler(makeRequest('tasks/get', { taskId: 'task-1' }, 'my-req-42'));
152
+ expect(res.jsonrpc).toBe('2.0');
153
+ expect(res.id).toBe('my-req-42');
154
+ });
155
+ });
156
+ // ---------------------------------------------------------------------------
157
+ // 2. AgentCard generation end-to-end
158
+ // ---------------------------------------------------------------------------
159
+ describe('A2A Server — AgentCard generation', () => {
160
+ it('builds a complete agent card with all fields', () => {
161
+ const config = {
162
+ name: 'FleetCoordinator',
163
+ description: 'Coordinates work across multiple agents',
164
+ url: 'https://fleet.example.com/a2a',
165
+ version: '2.1.0',
166
+ streaming: true,
167
+ authSchemes: [
168
+ { type: 'http', scheme: 'bearer' },
169
+ { type: 'apiKey', in: 'header', name: 'x-api-key' },
170
+ ],
171
+ };
172
+ const card = buildAgentCard(config);
173
+ expect(card.name).toBe('FleetCoordinator');
174
+ expect(card.description).toBe('Coordinates work across multiple agents');
175
+ expect(card.url).toBe('https://fleet.example.com/a2a');
176
+ expect(card.version).toBe('2.1.0');
177
+ expect(card.capabilities.streaming).toBe(true);
178
+ expect(card.capabilities.pushNotifications).toBe(false);
179
+ expect(card.capabilities.stateTransitionHistory).toBe(false);
180
+ expect(card.authentication).toEqual([
181
+ { type: 'http', scheme: 'bearer' },
182
+ { type: 'apiKey', in: 'header', name: 'x-api-key' },
183
+ ]);
184
+ expect(card.defaultInputContentTypes).toEqual(['text/plain']);
185
+ expect(card.defaultOutputContentTypes).toEqual(['text/plain']);
186
+ });
187
+ it('auto-generates skills from work types when none provided', () => {
188
+ const card = buildAgentCard({
189
+ name: 'Worker',
190
+ description: 'Generic worker',
191
+ url: 'https://worker.example.com/a2a',
192
+ });
193
+ expect(card.skills.length).toBeGreaterThan(0);
194
+ const skillIds = card.skills.map(s => s.id);
195
+ expect(skillIds).toContain('code-development');
196
+ expect(skillIds).toContain('quality-assurance');
197
+ expect(skillIds).toContain('research-analysis');
198
+ expect(skillIds).toContain('backlog-creation');
199
+ expect(skillIds).toContain('inflight-work');
200
+ expect(skillIds).toContain('acceptance-review');
201
+ expect(skillIds).toContain('refinement');
202
+ expect(skillIds).toContain('coordination');
203
+ expect(skillIds).toContain('qa-coordination');
204
+ expect(skillIds).toContain('acceptance-coordination');
205
+ // Each skill should have an id, name, and description
206
+ for (const skill of card.skills) {
207
+ expect(skill.id).toBeTruthy();
208
+ expect(skill.name).toBeTruthy();
209
+ expect(skill.description).toBeTruthy();
210
+ }
211
+ });
212
+ it('uses explicit skills when provided', () => {
213
+ const card = buildAgentCard({
214
+ name: 'Specialist',
215
+ description: 'A specialist agent',
216
+ url: 'https://specialist.example.com/a2a',
217
+ skills: [
218
+ {
219
+ id: 'data-analysis',
220
+ name: 'Data Analysis',
221
+ description: 'Analyze data sets and produce reports',
222
+ tags: ['analysis'],
223
+ },
224
+ ],
225
+ });
226
+ expect(card.skills).toHaveLength(1);
227
+ expect(card.skills[0].id).toBe('data-analysis');
228
+ expect(card.skills[0].name).toBe('Data Analysis');
229
+ expect(card.skills[0].tags).toEqual(['analysis']);
230
+ });
231
+ it('defaults version to 1.0.0 and streaming to false', () => {
232
+ const card = buildAgentCard({
233
+ name: 'Default',
234
+ description: 'Defaults test',
235
+ url: 'https://default.example.com/a2a',
236
+ });
237
+ expect(card.version).toBe('1.0.0');
238
+ expect(card.capabilities.streaming).toBe(false);
239
+ });
240
+ it('omits authentication when no authSchemes provided', () => {
241
+ const card = buildAgentCard({
242
+ name: 'NoAuth',
243
+ description: 'No auth agent',
244
+ url: 'https://noauth.example.com/a2a',
245
+ });
246
+ expect(card.authentication).toBeUndefined();
247
+ });
248
+ });
249
+ // ---------------------------------------------------------------------------
250
+ // 3. SSE event formatting round-trip
251
+ // ---------------------------------------------------------------------------
252
+ describe('A2A Server — SSE formatting round-trip', () => {
253
+ it('formats and parses TaskStatusUpdate correctly', () => {
254
+ const event = {
255
+ type: 'TaskStatusUpdate',
256
+ taskId: 'task-rt-1',
257
+ status: 'working',
258
+ message: makeMessage('Processing your request...', 'agent'),
259
+ final: false,
260
+ };
261
+ const formatted = formatSseEvent(event);
262
+ // Parse the SSE output
263
+ const lines = formatted.split('\n');
264
+ expect(lines[0]).toBe('event: TaskStatusUpdate');
265
+ const dataLine = lines[1];
266
+ expect(dataLine.startsWith('data: ')).toBe(true);
267
+ const parsed = JSON.parse(dataLine.slice(6));
268
+ expect(parsed.type).toBe('TaskStatusUpdate');
269
+ expect(parsed.taskId).toBe('task-rt-1');
270
+ expect(parsed.status).toBe('working');
271
+ expect(parsed.message.role).toBe('agent');
272
+ expect(parsed.message.parts[0].text).toBe('Processing your request...');
273
+ expect(parsed.final).toBe(false);
274
+ // Ends with double newline
275
+ expect(formatted.endsWith('\n\n')).toBe(true);
276
+ });
277
+ it('formats and parses TaskArtifactUpdate correctly', () => {
278
+ const event = {
279
+ type: 'TaskArtifactUpdate',
280
+ taskId: 'task-rt-2',
281
+ artifact: {
282
+ name: 'output.json',
283
+ parts: [{ type: 'data', data: { result: 42, status: 'ok' } }],
284
+ },
285
+ };
286
+ const formatted = formatSseEvent(event);
287
+ const lines = formatted.split('\n');
288
+ expect(lines[0]).toBe('event: TaskArtifactUpdate');
289
+ const parsed = JSON.parse(lines[1].slice(6));
290
+ expect(parsed.type).toBe('TaskArtifactUpdate');
291
+ expect(parsed.taskId).toBe('task-rt-2');
292
+ expect(parsed.artifact.name).toBe('output.json');
293
+ expect(parsed.artifact.parts[0].data).toEqual({ result: 42, status: 'ok' });
294
+ });
295
+ it('produces valid SSE format with event and data lines separated by double newline', () => {
296
+ const event = {
297
+ type: 'TaskStatusUpdate',
298
+ taskId: 'task-fmt',
299
+ status: 'completed',
300
+ final: true,
301
+ };
302
+ const formatted = formatSseEvent(event);
303
+ // Should match the exact SSE spec: event line, data line, empty line
304
+ const pattern = /^event: \w+\ndata: .+\n\n$/;
305
+ expect(formatted).toMatch(pattern);
306
+ });
307
+ it('round-trips a completed status event with final: true', () => {
308
+ const original = {
309
+ type: 'TaskStatusUpdate',
310
+ taskId: 'task-complete',
311
+ status: 'completed',
312
+ message: makeMessage('All done!', 'agent'),
313
+ final: true,
314
+ };
315
+ const formatted = formatSseEvent(original);
316
+ const dataLine = formatted.split('\n')[1];
317
+ const roundTripped = JSON.parse(dataLine.slice(6));
318
+ expect(roundTripped.type).toBe(original.type);
319
+ expect(roundTripped.taskId).toBe(original.taskId);
320
+ expect(roundTripped.status).toBe(original.status);
321
+ expect(roundTripped.final).toBe(original.final);
322
+ expect(roundTripped.message).toEqual(original.message);
323
+ });
324
+ });
325
+ // ---------------------------------------------------------------------------
326
+ // 4. Auth integration
327
+ // ---------------------------------------------------------------------------
328
+ describe('A2A Server — auth integration', () => {
329
+ function createAuthHandler(validTokens) {
330
+ const verifyAuth = (authHeader) => {
331
+ if (!authHeader)
332
+ return false;
333
+ const token = authHeader.startsWith('Bearer ')
334
+ ? authHeader.slice(7)
335
+ : authHeader;
336
+ return validTokens.includes(token);
337
+ };
338
+ return createA2aRequestHandler({
339
+ onSendMessage: vi.fn(async () => makeTask()),
340
+ onGetTask: vi.fn(async () => makeTask()),
341
+ onCancelTask: vi.fn(async () => makeTask({ status: 'canceled' })),
342
+ verifyAuth,
343
+ });
344
+ }
345
+ it('allows requests with a valid Bearer token', async () => {
346
+ const handler = createAuthHandler(['valid-token-123']);
347
+ const res = await handler(makeRequest('tasks/get', { taskId: 'task-1' }), 'Bearer valid-token-123');
348
+ expect(res.error).toBeUndefined();
349
+ expect(res.result).toBeDefined();
350
+ });
351
+ it('allows requests with a valid raw API key', async () => {
352
+ const handler = createAuthHandler(['raw-api-key-456']);
353
+ const res = await handler(makeRequest('tasks/get', { taskId: 'task-1' }), 'raw-api-key-456');
354
+ expect(res.error).toBeUndefined();
355
+ expect(res.result).toBeDefined();
356
+ });
357
+ it('rejects requests with an invalid token', async () => {
358
+ const handler = createAuthHandler(['valid-token-123']);
359
+ const res = await handler(makeRequest('tasks/get', { taskId: 'task-1' }), 'Bearer wrong-token');
360
+ expect(res.error?.code).toBe(-32000);
361
+ expect(res.error?.message).toBe('Unauthorized');
362
+ expect(res.result).toBeUndefined();
363
+ });
364
+ it('rejects requests with no auth header', async () => {
365
+ const handler = createAuthHandler(['valid-token-123']);
366
+ const res = await handler(makeRequest('tasks/get', { taskId: 'task-1' }));
367
+ expect(res.error?.code).toBe(-32000);
368
+ expect(res.error?.message).toBe('Unauthorized');
369
+ });
370
+ it('rejects requests with empty auth header', async () => {
371
+ const handler = createAuthHandler(['valid-token-123']);
372
+ const res = await handler(makeRequest('tasks/get', { taskId: 'task-1' }), '');
373
+ expect(res.error?.code).toBe(-32000);
374
+ expect(res.error?.message).toBe('Unauthorized');
375
+ });
376
+ it('allows different methods when auth passes', async () => {
377
+ const handler = createAuthHandler(['super-secret']);
378
+ // message/send
379
+ const sendRes = await handler(makeRequest('message/send', { message: makeMessage('hi') }), 'Bearer super-secret');
380
+ expect(sendRes.error).toBeUndefined();
381
+ // tasks/get
382
+ const getRes = await handler(makeRequest('tasks/get', { taskId: 'task-1' }), 'Bearer super-secret');
383
+ expect(getRes.error).toBeUndefined();
384
+ // tasks/cancel
385
+ const cancelRes = await handler(makeRequest('tasks/cancel', { taskId: 'task-1' }), 'Bearer super-secret');
386
+ expect(cancelRes.error).toBeUndefined();
387
+ });
388
+ it('blocks all methods when auth fails', async () => {
389
+ const handler = createAuthHandler(['good-key']);
390
+ const sendRes = await handler(makeRequest('message/send', { message: makeMessage('hi') }), 'Bearer bad-key');
391
+ expect(sendRes.error?.code).toBe(-32000);
392
+ const getRes = await handler(makeRequest('tasks/get', { taskId: 'task-1' }), 'Bearer bad-key');
393
+ expect(getRes.error?.code).toBe(-32000);
394
+ const cancelRes = await handler(makeRequest('tasks/cancel', { taskId: 'task-1' }), 'Bearer bad-key');
395
+ expect(cancelRes.error?.code).toBe(-32000);
396
+ });
397
+ });