From e0dc8061121c4553728e764513a7d9eaa16b02c9 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Thu, 13 Nov 2025 09:48:14 +0100 Subject: [PATCH 01/10] refactor: Simplify architecture to use QueuingRequestHandler as single A2A interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove AMQPStreamTaskStore and AMQPEventBus classes - Make QueuingRequestHandler self-contained with event publishing and task projection - Add WorkerEventBus for worker processes to publish events - Simplify AMQPAgentBackend to only manage connection and work queue - Fix naming inconsistency: rename QueueingRequestHandler to QueuingRequestHandler - Change QueuingRequestHandler to use private EventEmitter instead of extending it - Add proper queue cleanup in integration test teardown - Fix Vitest 4 compatibility issues (vi.mocked, timeout syntax) All 65 unit tests and 3 integration tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- REFACTORING-NOTES.md | 119 +++ src/examples/http-server.ts | 18 +- src/examples/worker.ts | 6 +- src/index.ts | 11 +- src/lib/AMQPAgentBackend.ts | 154 +--- src/lib/AMQPEventBus.ts | 382 --------- src/lib/AMQPStreamTaskStore.ts | 543 ------------- src/lib/QueueingRequestHandler.ts | 372 --------- src/lib/QueuingRequestHandler.ts | 737 ++++++++++++++++++ src/lib/WorkerEventBus.ts | 178 +++++ src/lib/types.ts | 3 - tests/integration/e2e-happy-path.test.ts | 65 +- tests/unit/AMQPAgentBackend.test.ts | 72 +- tests/unit/AMQPConnection.test.ts | 4 +- tests/unit/AMQPEventBus.test.ts | 396 ---------- tests/unit/AMQPStreamTaskStore.test.ts | 641 --------------- tests/unit/AMQPWorkQueue.test.ts | 8 +- ....test.ts => QueuingRequestHandler.test.ts} | 200 ++--- 18 files changed, 1185 insertions(+), 2724 deletions(-) create mode 100644 REFACTORING-NOTES.md delete mode 100644 src/lib/AMQPEventBus.ts delete mode 100644 src/lib/AMQPStreamTaskStore.ts delete mode 100644 src/lib/QueueingRequestHandler.ts create mode 100644 src/lib/QueuingRequestHandler.ts create mode 100644 src/lib/WorkerEventBus.ts delete mode 100644 tests/unit/AMQPEventBus.test.ts delete mode 100644 tests/unit/AMQPStreamTaskStore.test.ts rename tests/unit/{QueueingRequestHandler.test.ts => QueuingRequestHandler.test.ts} (55%) diff --git a/REFACTORING-NOTES.md b/REFACTORING-NOTES.md new file mode 100644 index 0000000..c587af4 --- /dev/null +++ b/REFACTORING-NOTES.md @@ -0,0 +1,119 @@ +# Refactoring Notes: Stateless EventBus Design + +## Current State (Partially Implemented) + +**Date**: 2025-01-10 + +### Overview +Working on refactoring `AMQPEventBus` from a stateful (task-scoped) design to a stateless (shared) design. This will simplify the architecture and reduce the number of EventBus instances. + +### Intended Architecture + +#### Before (Task-Scoped EventBus) +``` +AMQPAgentBackend + ├─ AMQPEventBusFactory + │ └─ Creates new AMQPEventBus per task (with taskId, contextId) + └─ Each EventBus instance tracks its own task +``` + +#### After (Stateless Shared EventBus) +``` +AMQPAgentBackend + ├─ Single shared AMQPEventBus (no taskId/contextId) + └─ TaskScopedEventBus wrapper provides task context + └─ Passes taskId, contextId on each publish() call +``` + +### Changes Made So Far + +1. ✅ **Removed sequence tracking** (completed, all tests pass) + - Removed `EventMetadata.sequence` field + - Removed `eventSequence` counter from AMQPEventBus + - Removed `sequenceMap` from AMQPAgentBackend + - Removed `getSequence()` from TaskScopedEventBus + +2. 🔶 **Made AMQPEventBus stateless** (partially done, currently broken) + - Changed constructor: `constructor(connection: AMQPConnection)` - no taskId/contextId + - Removed `extends EventEmitter` from AMQPEventBus + - Changed `publishForTask()` to `publish()` with (event, taskId, contextId) parameters + - Made it async: `async publish(...): Promise` + - Updated TaskScopedEventBus.publish() to call new signature + +### What's Broken (Known Issues) + +1. **AMQPEventBusFactory** still tries to create EventBus with old signature: + ```typescript + create(taskId: string, contextId?: string): AMQPEventBus { + return new AMQPEventBus(this.connection, taskId, contextId); // ❌ Wrong signature + } + ``` + +2. **Consumer Mode** (`startConsuming`, `stopConsuming`) references removed fields: + - References `this.taskId` and `this.contextId` which no longer exist + - Calls `this.emit()` but class no longer extends EventEmitter + - Used in legacy code path (probably can be removed entirely) + +3. **Unit Tests** instantiate EventBus with old signature: + ```typescript + new AMQPEventBus(mockConnection, taskId, contextId) // ❌ Wrong signature + ``` + +### TODO: Complete the Refactoring + +#### Option A: Minimal Fix (Keep Factory Pattern) +1. Fix AMQPEventBusFactory.create() to use new signature: + ```typescript + create(taskId: string, contextId?: string): AMQPEventBus { + return new AMQPEventBus(this.connection); // ✅ Correct + } + ``` + - Note: taskId/contextId parameters become unused (can be removed later) + +2. Remove consumer mode entirely (dead code since TaskStore handles consumption) + - Remove `startConsuming()` method + - Remove `stopConsuming()` method + - Remove `isInConsumerMode()` method + - Remove consumer mode fields: `isConsumerMode`, `consumer`, `consumerTag`, `streamQueueName` + +3. Update all unit tests: + ```typescript + new AMQPEventBus(mockConnection) // ✅ Correct + ``` + +#### Option B: Full Refactor (Remove Factory) +1. Remove AMQPEventBusFactory entirely +2. AMQPAgentBackend creates one shared EventBus directly +3. createEventBus() just returns TaskScopedEventBus wrappers +4. Remove all consumer mode code +5. Update all tests + +### Why This Design? + +#### Benefits of Stateless EventBus +1. **Fewer instances**: One shared EventBus instead of N task-scoped instances +2. **Simpler lifecycle**: No need to track/cleanup EventBus per task +3. **Clearer separation**: EventBus = AMQP publishing, TaskScopedEventBus = task context +4. **Already done for TaskStore**: TaskStore uses shared EventEmitter pattern successfully + +#### Event Consumption Strategy +- ✅ TaskStore handles AMQP consumption (single stream consumer) +- ✅ TaskStore emits to Node EventEmitter +- ✅ SSE clients subscribe to TaskStore events (fast in-memory) +- ❌ EventBus consumer mode no longer needed + +### Next Steps + +Choose Option A or B, then: +1. Fix the broken code +2. Update all tests +3. Run integration tests +4. Remove this document once complete + +### Related Files + +- `src/lib/AMQPEventBus.ts` - Main EventBus implementation +- `src/lib/TaskScopedEventBus.ts` - Wrapper that adds task context +- `src/lib/AMQPAgentBackend.ts` - Creates shared EventBus +- `src/lib/AMQPStreamTaskStore.ts` - Consumes events via EventEmitter +- `tests/unit/AMQPEventBus.test.ts` - Unit tests (need updating) diff --git a/src/examples/http-server.ts b/src/examples/http-server.ts index b8f04e3..7f7f884 100644 --- a/src/examples/http-server.ts +++ b/src/examples/http-server.ts @@ -1,7 +1,7 @@ /** * HTTP Server Example (Using A2A SDK) - Queuing Architecture * - * This server uses the A2A SDK's A2AExpressApp with a custom QueueingRequestHandler: + * This server uses the A2A SDK's A2AExpressApp with a custom QueuingRequestHandler: * 1. Accepts incoming A2A requests via HTTP (handled by SDK) * 2. Creates initial Task event and publishes to exchange * 3. Enqueues task to work queue for worker processing @@ -20,7 +20,7 @@ import { AgentCard } from "@a2a-js/sdk"; import { A2AExpressApp } from "@a2a-js/sdk/server/express"; import express from "express"; -import { AMQPAgentBackend, QueueingRequestHandler } from "../index.js"; +import { AMQPAgentBackend, QueuingRequestHandler } from "../index.js"; // AMQP configuration const AMQP_URL = process.env.AMQP_URL || "amqp://localhost:5672"; @@ -54,16 +54,20 @@ async function main() { console.log("AMQP backend initialized"); - // Log projection stats - const stats = backend.taskStore.getProjectionStats(); - console.log(`TaskStore projection: ${stats.taskCount} tasks loaded into memory`); - // Create queuing request handler - const requestHandler = new QueueingRequestHandler( + const requestHandler = new QueuingRequestHandler( agentCard, backend ); + // Initialize request handler (sets up event stream) + await requestHandler.initialize(); + console.log("Request handler initialized"); + + // Log projection stats + const stats = requestHandler.getProjectionStats(); + console.log(`Event projection: ${stats.taskCount} tasks loaded into memory`); + // Create A2A Express app const a2aExpressApp = new A2AExpressApp(requestHandler); diff --git a/src/examples/worker.ts b/src/examples/worker.ts index 28e7515..eb7f774 100644 --- a/src/examples/worker.ts +++ b/src/examples/worker.ts @@ -18,7 +18,7 @@ import { RequestContext, type ExecutionEventBus, } from "@a2a-js/sdk/server"; -import { AMQPAgentBackend, type QueuedTaskMessage } from "../index.js"; +import { AMQPAgentBackend, WorkerEventBus, type QueuedTaskMessage } from "../index.js"; // AMQP configuration const AMQP_URL = process.env.AMQP_URL || "amqp://localhost:5672"; @@ -176,8 +176,8 @@ async function main() { requestContext.referenceTasks ); - // Create EventBus for this task (publisher mode) - const eventBus = backend.createEventBus(taskId, contextId); + // Create WorkerEventBus for this task to publish events + const eventBus = new WorkerEventBus(backend.amqpConnection, taskId, contextId); // Execute the task await executor.execute(reqContext, eventBus); diff --git a/src/index.ts b/src/index.ts index 55fda01..c17b94c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ /** - * a2a-amqp - AMQP EventBus adapter for A2A protocol with LavinMQ streams support + * a2a-amqp - AMQP backend for A2A protocol with LavinMQ streams support * - * This library provides an AMQP-backed implementation of the A2A ExecutionEventBus - * interface, enabling event sourcing and distributed task processing for A2A agents. + * This library provides a simplified AMQP-backed A2A request handler + * that enables event sourcing and distributed task processing for A2A agents. */ // Simplified interface (recommended for most users) @@ -10,13 +10,12 @@ export { AMQPAgentBackend, type AMQPAgentBackendConfig, } from "./lib/AMQPAgentBackend.js"; -export { QueueingRequestHandler } from "./lib/QueueingRequestHandler.js"; +export { QueuingRequestHandler } from "./lib/QueuingRequestHandler.js"; // Core classes (for advanced use cases) export { AMQPConnection } from "./lib/AMQPConnection.js"; -export { AMQPEventBus, AMQPEventBusFactory } from "./lib/AMQPEventBus.js"; -export { AMQPStreamTaskStore } from "./lib/AMQPStreamTaskStore.js"; export { AMQPWorkQueue } from "./lib/AMQPWorkQueue.js"; +export { WorkerEventBus } from "./lib/WorkerEventBus.js"; // Types and interfaces export type { diff --git a/src/lib/AMQPAgentBackend.ts b/src/lib/AMQPAgentBackend.ts index 93d99e9..fb5134b 100644 --- a/src/lib/AMQPAgentBackend.ts +++ b/src/lib/AMQPAgentBackend.ts @@ -1,14 +1,11 @@ /** * Simplified AMQP backend for A2A agents * - * Provides a simple interface to set up AMQP-backed TaskStore and EventBus + * Provides a simple interface to set up AMQP connection and work queue * with minimal configuration. */ -import type { ExecutionEventBus, ExecutionEventBusManager } from "@a2a-js/sdk/server"; import { AMQPConnection } from "./AMQPConnection.js"; -import { AMQPEventBus, AMQPEventBusFactory } from "./AMQPEventBus.js"; -import { AMQPStreamTaskStore } from "./AMQPStreamTaskStore.js"; import { AMQPWorkQueue } from "./AMQPWorkQueue.js"; import type { AMQPConfig, Logger } from "./types.js"; import { ConsoleLogger } from "./types.js"; @@ -103,108 +100,21 @@ export interface AMQPAgentBackendConfig { }; } -/** - * Event bus manager implementation for AMQP - */ -export class AMQPEventBusManager implements ExecutionEventBusManager { - private factory: AMQPEventBusFactory; - private busMap = new Map(); - - constructor(factory: AMQPEventBusFactory) { - this.factory = factory; - } - - /** - * Create or get EventBus for a task with explicit contextId - * @param taskId - Task identifier - * @param contextId - Context identifier for grouping related tasks - */ - createOrGetByTaskIdWithContext(taskId: string, contextId: string): ExecutionEventBus { - if (!this.busMap.has(taskId)) { - const bus = this.factory.create(taskId, contextId); - this.busMap.set(taskId, bus); - } - return this.busMap.get(taskId)!; - } - - /** - * Create or get EventBus for a task - * The contextId will be extracted from the Task event when it's published - * @param taskId - Task identifier - */ - createOrGetByTaskId(taskId: string): ExecutionEventBus { - if (!this.busMap.has(taskId)) { - // Create EventBus without contextId - it will be set when Task is published - const bus = this.factory.create(taskId); - this.busMap.set(taskId, bus); - } - return this.busMap.get(taskId)!; - } - - /** - * Get contextId for a given taskId - * @param taskId - Task identifier - * @returns Context ID or null if task not found or contextId not yet set - */ - getContextIdByTaskId(taskId: string): string | null | undefined { - const bus = this.busMap.get(taskId); - return bus?.getContextId(); - } - - getByTaskId(taskId: string): ExecutionEventBus | undefined { - return this.busMap.get(taskId); - } - - cleanupByTaskId(taskId: string): void { - const bus = this.busMap.get(taskId); - if (bus) { - // Remove all event listeners to prevent memory leaks - bus.removeAllListeners(); - this.busMap.delete(taskId); - } - } - - /** - * Get all active task IDs - */ - getAllTaskIds(): string[] { - return Array.from(this.busMap.keys()); - } - - /** - * Cleanup all event buses - */ - cleanupAll(): void { - for (const taskId of this.getAllTaskIds()) { - this.cleanupByTaskId(taskId); - } - } -} - /** * AMQP Agent Backend * * Simplified interface for setting up AMQP-backed A2A agent infrastructure. - * Manages connection, task store, and event bus components. + * Manages connection and work queue components. */ export class AMQPAgentBackend { private connection: AMQPConnection; - private taskStoreInstance: AMQPStreamTaskStore; - private eventBusFactory: AMQPEventBusFactory; - private eventBusManagerInstance: AMQPEventBusManager; private workQueueInstance: AMQPWorkQueue; private constructor( connection: AMQPConnection, - taskStore: AMQPStreamTaskStore, - eventBusFactory: AMQPEventBusFactory, - eventBusManager: AMQPEventBusManager, workQueue: AMQPWorkQueue, ) { this.connection = connection; - this.taskStoreInstance = taskStore; - this.eventBusFactory = eventBusFactory; - this.eventBusManagerInstance = eventBusManager; this.workQueueInstance = workQueue; } @@ -221,12 +131,8 @@ export class AMQPAgentBackend { * agentName: "my-agent" * }); * - * const requestHandler = new DefaultRequestHandler( - * agentCard, - * backend.taskStore, - * executor, - * backend.eventBusManager - * ); + * const requestHandler = new QueuingRequestHandler(agentCard, backend); + * await requestHandler.initialize(); * ``` */ static async create(config: AMQPAgentBackendConfig): Promise { @@ -288,56 +194,17 @@ export class AMQPAgentBackend { // Set connection name for better visibility in AMQP broker await connection.connect(); - // Create task store - const taskQueueName = config.taskQueueName || `${config.agentName}.tasks`; - const taskStore = new AMQPStreamTaskStore(connection, taskQueueName); - await taskStore.initialize(); - // Create work queue const workQueueName = config.workQueueName || `${config.agentName}.work-queue`; const workQueue = new AMQPWorkQueue(connection, workQueueName); await workQueue.initialize(); - // Create event bus factory and manager - // Pass taskStore so contextId can be extracted from tasks - const eventBusFactory = new AMQPEventBusFactory(connection); - const eventBusManager = new AMQPEventBusManager(eventBusFactory); - return new AMQPAgentBackend( connection, - taskStore, - eventBusFactory, - eventBusManager, workQueue ); } - /** - * Get the task store - */ - get taskStore(): AMQPStreamTaskStore { - return this.taskStoreInstance; - } - - /** - * Create a task-scoped EventBus for a specific task - * This is a convenience method that delegates to eventBusManager - * - * @param taskId - Task identifier - * @param contextId - Context identifier for grouping related tasks - * @returns ExecutionEventBus scoped to this task - */ - createEventBus(taskId: string, contextId: string): ExecutionEventBus { - return this.eventBusManagerInstance.createOrGetByTaskIdWithContext(taskId, contextId); - } - - /** - * Get the event bus manager - */ - get eventBusManager(): AMQPEventBusManager { - return this.eventBusManagerInstance; - } - /** * Get the AMQP connection (for advanced use cases) */ @@ -345,13 +212,6 @@ export class AMQPAgentBackend { return this.connection; } - /** - * Get the event bus factory (for advanced use cases) - */ - get eventBusFactoryInstance(): AMQPEventBusFactory { - return this.eventBusFactory; - } - /** * Get the work queue */ @@ -363,12 +223,6 @@ export class AMQPAgentBackend { * Close all connections and clean up resources */ async close(): Promise { - // Cleanup all event bus instances - this.eventBusManagerInstance.cleanupAll(); - - // Close task store - await this.taskStoreInstance.close(); - // Close work queue await this.workQueueInstance.close(); diff --git a/src/lib/AMQPEventBus.ts b/src/lib/AMQPEventBus.ts deleted file mode 100644 index 402a0a8..0000000 --- a/src/lib/AMQPEventBus.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { Task, Message, TaskStatusUpdateEvent, TaskArtifactUpdateEvent } from "@a2a-js/sdk"; -import { ExecutionEventBus } from "@a2a-js/sdk/server"; -import { EventEmitter } from "events"; -import type { AMQPConnection } from "./AMQPConnection.js"; -import type { A2AEvent, EventMetadata, PublishOptions, Logger } from "./types.js"; - -/** - * AMQP-backed implementation of A2A ExecutionEventBus - * Publishes events to LavinMQ streams tagged by taskId and contextId - */ -export class AMQPEventBus extends EventEmitter implements ExecutionEventBus { - private connection: AMQPConnection; - private taskId: string; - private contextId: string | null; // Set when Task is published - private logger: Logger; - private isFinished = false; - - // Consumer mode state - private isConsumerMode = false; - private consumer: any = null; - private consumerTag: string | null = null; - private streamQueueName: string | null = null; - - constructor( - connection: AMQPConnection, - taskId: string, - contextId?: string - ) { - super(); - this.connection = connection; - this.taskId = taskId; - this.contextId = contextId || null; - this.logger = connection.getConfig().logger; - - // Add default error handler to prevent unhandled rejection crashes - this.on('error', (error) => { - this.logger.error(`[EventBus ${this.taskId}] Error:`, error); - }); - } - - /** - * Publish an event to the AMQP stream - * Events are tagged with taskId and contextId for filtering - */ - publish( - event: Task | Message | TaskStatusUpdateEvent | TaskArtifactUpdateEvent - ): void { - if (this.isFinished) { - this.logger.warn( - `Attempted to publish event after EventBus finished for task ${this.taskId}` - ); - return; - } - - try { - // If publishing a Task event, extract and set contextId - if (event.kind === "task") { - if (!event.contextId) { - throw new Error(`Task event for ${this.taskId} is missing contextId`); - } - if (this.contextId === null) { - this.contextId = event.contextId; - this.logger.debug(`Set contextId to ${event.contextId} for task ${this.taskId}`); - } else if (event.contextId !== this.contextId) { - this.logger.warn( - `Task contextId mismatch: expected ${this.contextId}, got ${event.contextId} for task ${this.taskId}` - ); - } - } - - // Ensure contextId is set before publishing non-Task events - if (this.contextId === null) { - throw new Error( - `Cannot publish ${event.kind} event for task ${this.taskId}: contextId not set. Task event must be published first.` - ); - } - - // Create metadata for the event - const metadata: EventMetadata = { - taskId: this.taskId, - contextId: this.contextId, - kind: event.kind, - timestamp: new Date().toISOString(), - }; - - // Determine routing key based on event type - const routingKey = this.getRoutingKey(event); - - // Publish to AMQP (async, but don't wait) - this.publishToAMQP(event, metadata, { routingKey }).catch(error => { - this.logger.error(`Failed to publish event for task ${this.taskId}:`, error); - this.emit('error', error); - }); - - // Emit event to local listeners - this.emit('event', event); - } catch (error) { - this.logger.error(`Failed to publish event for task ${this.taskId}:`, error); - this.emit('error', error); - } - } - - /** - * Signal that no more events will be published - * This is called when task execution completes - */ - finished(): void { - if (this.isFinished) { - return; - } - - this.isFinished = true; - this.emit('finished', undefined); - - this.logger.debug(`EventBus finished for task ${this.taskId}`); - } - - /** - * Publish event to AMQP with metadata - */ - private async publishToAMQP( - event: A2AEvent, - metadata: EventMetadata, - options: PublishOptions = {} - ): Promise { - const channel = await this.connection.getPublishChannel(); - const exchange = this.connection.getExchange(); - const config = this.connection.getConfig(); - - // Build message payload - const payload = { - event, - metadata, - }; - - // Determine if message should be persistent - const persistent = options.persistent ?? config.publishing.persistent; - - // Build message properties with headers for filtering - const properties = { - deliveryMode: persistent ? 2 : 1, // 2 = persistent, 1 = non-persistent - priority: options.priority ?? 0, - timestamp: new Date(), - messageId: `${metadata.taskId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - headers: { - taskId: metadata.taskId, - contextId: event.contextId, - eventKind: event.kind, - ...options.headers, - }, - }; - - // Add TTL if configured - if (config.publishing.messageTtl && config.publishing.messageTtl > 0) { - Object.assign(properties, { - expiration: config.publishing.messageTtl.toString(), - }); - } - - // Serialize and publish - const body = JSON.stringify(payload); - const routingKey = options.routingKey || this.getDefaultRoutingKey(); - - // Publish (automatically waits for confirmation if confirmMode is enabled) - await channel.basicPublish(exchange, routingKey, body, properties); - } - - /** - * Generate routing key based on event type - * Pattern: a2a.{contextId}.{taskId}.{eventKind} - */ - private getRoutingKey(event: A2AEvent): string { - const kind = event.kind; - - // Map A2A event kinds to routing keys - switch (kind) { - case "task": - return `a2a.${this.contextId}.${this.taskId}.task`; - case "message": - return `a2a.${this.contextId}.${this.taskId}.message`; - case "status-update": - return `a2a.${this.contextId}.${this.taskId}.status`; - case "artifact-update": - return `a2a.${this.contextId}.${this.taskId}.artifact`; - default: - return `a2a.${this.contextId}.${this.taskId}.event`; - } - } - - /** - * Get default routing key for unknown event types - */ - private getDefaultRoutingKey(): string { - return `a2a.${this.contextId}.${this.taskId}.event`; - } - - /** - * Get the task ID this EventBus is associated with - */ - getTaskId(): string { - return this.taskId; - } - - /** - * Get the context ID this EventBus is associated with - * Returns null if contextId hasn't been set yet (no Task published) - */ - getContextId(): string | null { - return this.contextId; - } - - /** - * Check if this EventBus has finished - */ - isEventBusFinished(): boolean { - return this.isFinished; - } - - /** - * Start consuming events from the AMQP stream for this task - * This enables "consumer mode" where the EventBus reads from the stream - * and emits events locally for SSE streaming in the HTTP server - * - * @param streamQueueName - Name of the stream queue to consume from - * @param options - Consumption options - */ - async startConsuming( - streamQueueName: string, - options?: { - /** - * Where to start reading from the stream - * - 'last': Start from current position (only new events) - * - 'first': Start from beginning (all historical events) - * @default 'last' - */ - offset?: 'first' | 'last'; - } - ): Promise { - if (this.isConsumerMode) { - this.logger.warn(`EventBus for task ${this.taskId} already in consumer mode`); - return; - } - - this.isConsumerMode = true; - this.streamQueueName = streamQueueName; - const offset = options?.offset || 'last'; - - try { - const channel = await this.connection.getConsumeChannel(); - this.consumerTag = `eventbus-consumer-${this.taskId}-${Date.now()}`; - - this.logger.debug(`Starting consumer for task ${this.taskId} (offset: ${offset})`); - - // Start consuming from stream - this.consumer = await channel.basicConsume( - streamQueueName, - { - noAck: false, // Stream consumers MUST acknowledge messages - tag: this.consumerTag, - args: { - 'x-stream-offset': offset, // 'first' or 'last' - }, - }, - async (msg) => { - try { - const body = msg.bodyToString(); - if (!body) { - msg.ack(); - return; - } - - const payload = JSON.parse(body); - const headers = msg.properties.headers || {}; - - // Filter: only emit events for this task - if (headers.taskId !== this.taskId) { - msg.ack(); - return; - } - - // Extract event and metadata - const event = payload.event as A2AEvent; - const metadata = payload.metadata as EventMetadata; - - // Set contextId if not already set - if (!this.contextId && metadata.contextId) { - this.contextId = metadata.contextId; - } - - // Emit event locally for SSE consumers - this.emit('event', event); - - // Handle finished state - if ( - event.kind === 'status-update' && event.final - ) { - this.logger.debug(`Task ${this.taskId} reached final status`); - this.isFinished = true; - this.emit('finished', undefined); - } - - // Acknowledge message - msg.ack(); - } catch (error) { - this.logger.error(`Error processing consumed event:`, error); - this.emit('error', error); - // Acknowledge even on error to avoid blocking - msg.ack(); - } - } - ); - - this.logger.info(`Consumer mode started for task ${this.taskId}`); - } catch (error) { - this.logger.error(`Failed to start consuming:`, error); - this.isConsumerMode = false; - throw error; - } - } - - /** - * Stop consuming from the AMQP stream - * Cancels the consumer and exits consumer mode - */ - async stopConsuming(): Promise { - if (!this.isConsumerMode) { - return; - } - - try { - if (this.consumer && this.consumerTag) { - await this.consumer.cancel(); - this.logger.debug(`Consumer ${this.consumerTag} cancelled`); - } - - this.isConsumerMode = false; - this.consumer = null; - this.consumerTag = null; - this.streamQueueName = null; - - this.logger.info(`Consumer mode stopped for task ${this.taskId}`); - } catch (error) { - this.logger.error(`Error stopping consumer:`, error); - throw error; - } - } - - /** - * Check if EventBus is in consumer mode - */ - isInConsumerMode(): boolean { - return this.isConsumerMode; - } -} - -/** - * Factory for creating AMQPEventBus instances - */ -export class AMQPEventBusFactory { - private connection: AMQPConnection; - - constructor(connection: AMQPConnection) { - this.connection = connection; - } - - /** - * Create a new EventBus for a specific task - * The contextId will be set when the first Task event is published - * @param taskId - Task identifier - * @param contextId - Optional context identifier (will be extracted from Task if not provided) - */ - create(taskId: string, contextId?: string): AMQPEventBus { - return new AMQPEventBus(this.connection, taskId, contextId); - } - - /** - * Get the underlying AMQP connection - */ - getConnection(): AMQPConnection { - return this.connection; - } -} diff --git a/src/lib/AMQPStreamTaskStore.ts b/src/lib/AMQPStreamTaskStore.ts deleted file mode 100644 index 10668e5..0000000 --- a/src/lib/AMQPStreamTaskStore.ts +++ /dev/null @@ -1,543 +0,0 @@ - -import { EventEmitter } from "events"; -import { TaskStore } from "@a2a-js/sdk/server"; -import type { AMQPConnection } from "./AMQPConnection.js"; -import type { A2AEvent, ConsumeOptions, AMQPEventMessage, Logger } from "./types.js"; -import { Task } from "@a2a-js/sdk"; - -/** - * TaskStore implementation that reads task state from AMQP streams - * Uses event sourcing pattern - task state is derived by replaying events - * Extends EventEmitter to allow SSE clients to subscribe to task events - */ -export class AMQPStreamTaskStore extends EventEmitter implements TaskStore { - private connection: AMQPConnection; - private queueName: string; - private logger: Logger; - - // In-memory task projection (read model) - private tasks: Map = new Map(); - private consumer: any = null; - private consumerTag: string | null = null; - private isInitialized = false; - - constructor(connection: AMQPConnection, queueName?: string) { - super(); - this.connection = connection; - this.queueName = queueName || "a2a.taskstore.queue"; - this.logger = connection.getConfig().logger; - } - - /** - * Get the queue name used by this task store - */ - getQueueName(): string { - return this.queueName; - } - - /** - * Initialize the task store by setting up the stream queue - */ - async initialize(): Promise { - const channel = await this.connection.getConsumeChannel(); - const exchange = this.connection.getExchange(); - const config = this.connection.getConfig(); - - // Create stream queue arguments for LavinMQ - const queueArgs: Record = { - "x-queue-type": "stream", - }; - - // Convert maxAge to seconds for LavinMQ (it expects seconds, not duration strings) - if (config.stream.maxAge) { - const maxAgeSeconds = this.parseDurationToSeconds(config.stream.maxAge); - if (maxAgeSeconds) { - queueArgs["x-max-age"] = `${maxAgeSeconds}s`; - } - } - - if (config.stream.maxLengthBytes) { - queueArgs["x-max-length-bytes"] = config.stream.maxLengthBytes; - } - - // Declare stream queue - await channel.queueDeclare( - this.queueName, - { durable: true }, // Streams must be durable - queueArgs - ); - - // Bind to exchange with specific routing keys to capture event messages only - // Pattern: a2a.{contextId}.{taskId}.{eventType} - // This excludes work queue messages which use "a2a.work.*" pattern - const eventRoutingPatterns = [ - "a2a.*.*.task", // Task creation events - "a2a.*.*.message", // Message events - "a2a.*.*.status", // Status update events - "a2a.*.*.artifact", // Artifact update events - "a2a.*.*.event", // Generic events - ]; - - for (const pattern of eventRoutingPatterns) { - await channel.queueBind(this.queueName, exchange, pattern); - } - - this.logger.info(`TaskStore queue '${this.queueName}' initialized with event bindings`); - - // Start persistent consumer to maintain in-memory projection - await this.startProjectionConsumer(); - this.isInitialized = true; - } - - /** - * Start persistent consumer to maintain in-memory task projection - * Reads from beginning of stream and continues to consume new events - */ - private async startProjectionConsumer(): Promise { - const channel = await this.connection.getConsumeChannel(); - this.consumerTag = `taskstore-projection-${Date.now()}`; - - this.logger.info("Starting TaskStore projection consumer (reading from start)..."); - - this.consumer = await channel.basicConsume( - this.queueName, - { - noAck: false, // Stream consumers MUST acknowledge messages - tag: this.consumerTag, - args: { - "x-stream-offset": "first", // Read from beginning - }, - }, - async (msg) => { - try { - const body = msg.bodyToString(); - if (!body) { - msg.ack(); - return; - } - - const payload: AMQPEventMessage = JSON.parse(body); - - // Validate payload has event - if (!payload || !payload.event) { - msg.ack(); - return; - } - - const event = payload.event; - const metadata = payload.metadata; - - // Update in-memory projection - this.applyEventToProjection(event, metadata.taskId); - - // Acknowledge message - msg.ack(); - } catch (error) { - this.logger.error("Error processing event in projection:", error); - // Acknowledge even on error to avoid blocking the stream - msg.ack(); - } - } - ); - - this.logger.info("TaskStore projection consumer started"); - } - - /** - * Apply an event to the in-memory task projection - */ - private applyEventToProjection(event: A2AEvent, taskId: string): void { - if (!event || !event.kind) { - return; - } - - switch (event.kind) { - case "task": - // Initial task creation - if (event.id === taskId) { - this.tasks.set(taskId, event); - this.logger.debug(`Projected task ${taskId}: created`); - } - break; - - case "status-update": - // Update task status - if (event.taskId === taskId) { - const task = this.tasks.get(taskId); - if (task) { - this.tasks.set(taskId, { - ...task, - status: event.status, - }); - this.logger.debug(`Projected task ${taskId}: status -> ${event.status.state}`); - } - } - break; - - case "artifact-update": - // Add or update artifact - if (event.taskId === taskId) { - const task = this.tasks.get(taskId); - if (task) { - const artifacts = task.artifacts || []; - const existingIndex = artifacts.findIndex( - (a) => a.artifactId === event.artifact.artifactId - ); - - if (existingIndex >= 0) { - artifacts[existingIndex] = event.artifact; - } else { - artifacts.push(event.artifact); - } - - this.tasks.set(taskId, { - ...task, - artifacts, - }); - this.logger.debug(`Projected task ${taskId}: artifact ${event.artifact.artifactId}`); - } - } - break; - - case "message": - // Add message to history - if (event.messageId) { - const task = this.tasks.get(taskId); - if (task) { - const history = task.history || []; - // Only add if not already in history - if (!history.some((m) => m.messageId === event.messageId)) { - history.push(event); - this.tasks.set(taskId, { - ...task, - history, - }); - this.logger.debug(`Projected task ${taskId}: message added to history`); - } - } - } - break; - } - - // Emit event for SSE clients to subscribe to - // Clients will filter by taskId - this.emit('task-event', { taskId, event }); - } - - /** - * Load a task by ID - * Returns task from in-memory projection (instant!) - */ - async load(taskId: string): Promise { - return this.tasks.get(taskId); - } - - /** - * Save a task - * In the event sourcing pattern, this is typically not used as updates - * happen through publishing events via the EventBus. - * However, we implement it for compatibility. - */ - async save(task: Task): Promise { - // In event sourcing, we don't save tasks directly - // Updates happen by publishing new events via EventBus - this.logger.warn( - "save() called on AMQPStreamTaskStore. " + - "In event sourcing pattern, use EventBus to publish events instead. " + - "This operation is a no-op." - ); - // No-op - task state is managed through events - } - - /** - * List all tasks (optionally filtered by context) - * Note: This can be expensive for large event streams - * This is NOT part of the TaskStore interface but provided as utility - */ - async listTasks(contextId?: string): Promise { - try { - // Fetch all events, optionally filtered by contextId - const events = await this.fetchEvents({ contextId }); - - // Group events by taskId - const eventsByTask = this.groupEventsByTaskId(events); - - // Reconstruct each task from its events - const tasks: Task[] = []; - for (const [taskId, taskEvents] of eventsByTask.entries()) { - const task = this.replayEvents(taskEvents, taskId); - if (task) { - tasks.push(task); - } - } - - return tasks; - } catch (error) { - this.logger.error("Error listing tasks:", error); - throw error; - } - } - - /** - * Fetch events from stream with optional filters - */ - private async fetchEvents(options: ConsumeOptions = {}): Promise { - const channel = await this.connection.getConsumeChannel(); - const events: A2AEvent[] = []; - - return new Promise((resolve, reject) => { - let messageCount = 0; - const limit = options.limit || 1000; // Default limit to prevent unbounded reads - const consumerTag = `taskstore-${Date.now()}`; - let consumer: any; - let timeoutHandle: NodeJS.Timeout | null = null; - - // Cleanup function to ensure timeout is always cleared - const cleanup = async (error?: Error) => { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - timeoutHandle = null; - } - - try { - if (consumer) { - await consumer.cancel(); - } - } catch (cleanupError) { - this.logger.error("Error during consumer cleanup:", cleanupError); - } - - if (error) { - reject(error); - } else { - resolve(events); - } - }; - - // Timeout to stop consuming after reasonable period - timeoutHandle = setTimeout(() => { - cleanup(); - }, 5000); // 5 second timeout - - // Consume from the stream starting from the beginning - channel.basicConsume( - this.queueName, - { - noAck: false, - tag: consumerTag, - args: { - // Read from the beginning of the stream - "x-stream-offset": "first", - }, - }, - async (msg) => { - try { - // Parse message - const body = msg.bodyToString(); - if (!body) { - msg.ack(); - return; - } - - const payload: AMQPEventMessage = JSON.parse(body); - - // Validate payload has event - if (!payload || !payload.event) { - this.logger.warn("Skipping message with missing event payload"); - msg.ack(); - return; - } - - // Apply filters - const headers = msg.properties.headers || {}; - - if (options.taskId && headers.taskId !== options.taskId) { - msg.ack(); - return; - } - - if (options.contextId && headers.contextId !== options.contextId) { - msg.ack(); - return; - } - - // Add event to results - events.push(payload.event); - messageCount++; - - // Acknowledge message - msg.ack(); - - // Check if we've reached the limit - if (messageCount >= limit) { - await cleanup(); - } - } catch (error) { - this.logger.error("Error processing message:", error); - msg.nack(false, false); // Don't requeue - } - } - ).then((c) => { - consumer = c; - }).catch((error) => { - cleanup(error as Error); - }); - }); - } - - /** - * Group events by taskId - */ - private groupEventsByTaskId(events: A2AEvent[]): Map { - const grouped = new Map(); - - for (const event of events) { - let taskId: string | undefined; - - // Extract taskId based on event type - if (event.kind === "task") { - taskId = event.id; - } else if (event.kind === "status-update" || event.kind === "artifact-update") { - taskId = event.taskId; - } else if (event.kind === "message") { - // Messages might have taskId, use it if available - taskId = event.taskId; - } - - if (taskId) { - if (!grouped.has(taskId)) { - grouped.set(taskId, []); - } - grouped.get(taskId)?.push(event); - } - } - - return grouped; - } - - /** - * Replay events to reconstruct task state - * Events are processed in order to build up the current task state - */ - private replayEvents(events: A2AEvent[], taskId: string): Task | undefined { - let task: Task | undefined; - - for (const event of events) { - // Skip undefined or null events - if (!event || !event.kind) { - this.logger.warn("Skipping invalid event in replay"); - continue; - } - - switch (event.kind) { - case "task": - // Initial task creation - if (event.id === taskId) { - task = event; - } - break; - - case "status-update": - // Update task status - if (task && event.taskId === taskId) { - task = { - ...task, - status: event.status, - }; - } - break; - - case "artifact-update": - // Add or update artifact - if (task && event.taskId === taskId) { - const artifacts = task.artifacts || []; - const existingIndex = artifacts.findIndex( - (a) => a.artifactId === event.artifact.artifactId - ); - - if (existingIndex >= 0) { - artifacts[existingIndex] = event.artifact; - } else { - artifacts.push(event.artifact); - } - - task = { - ...task, - artifacts, - }; - } - break; - - case "message": - // Messages might be added to task history - // The A2A protocol handles this at a higher level - break; - } - } - - return task; - } - - /** - * Parse duration string (e.g., "7d", "24h", "60m", "3600s") to seconds - */ - private parseDurationToSeconds(duration: string): number | null { - const match = duration.match(/^(\d+)([smhd])$/); - if (!match || !match[1] || !match[2]) { - this.logger.warn(`Invalid duration format: ${duration}`); - return null; - } - - const value = parseInt(match[1], 10); - const unit = match[2]; - - switch (unit) { - case 's': - return value; - case 'm': - return value * 60; - case 'h': - return value * 60 * 60; - case 'd': - return value * 60 * 60 * 24; - default: - return null; - } - } - - /** - * Get statistics about the in-memory projection - */ - getProjectionStats(): { taskCount: number; isInitialized: boolean } { - return { - taskCount: this.tasks.size, - isInitialized: this.isInitialized, - }; - } - - /** - * Close and cleanup resources - */ - async close(): Promise { - // Stop projection consumer - if (this.consumer && this.consumerTag) { - try { - await this.consumer.cancel(); - this.logger.info(`TaskStore projection consumer ${this.consumerTag} stopped`); - } catch (error: any) { - // Connection might already be closed during shutdown, which is fine - if (error.message?.includes("Connection closed") || error.message?.includes("closed")) { - this.logger.debug("Consumer already stopped (connection closed)"); - } else { - this.logger.error("Error stopping projection consumer:", error); - } - } - } - - // Clear in-memory projection - this.tasks.clear(); - this.isInitialized = false; - - this.logger.info("TaskStore closed"); - } -} diff --git a/src/lib/QueueingRequestHandler.ts b/src/lib/QueueingRequestHandler.ts deleted file mode 100644 index 5eb06ec..0000000 --- a/src/lib/QueueingRequestHandler.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * Queuing Request Handler - * - * Custom A2A request handler that enqueues tasks to AMQP work queue - * instead of executing them immediately. Workers process tasks asynchronously. - */ - -import type { - AgentCard, - Message, - Task, - TaskStatusUpdateEvent, - TaskArtifactUpdateEvent, - MessageSendParams, - TaskQueryParams, - TaskIdParams, - TaskPushNotificationConfig, -} from "@a2a-js/sdk"; -import type { AMQPAgentBackend } from "./AMQPAgentBackend.js"; -import type { AMQPWorkQueue } from "./AMQPWorkQueue.js"; -import type { QueuedTaskMessage } from "./QueuedTaskMessage.js"; -import { EventTaskStore } from "./types.js"; - -type A2AEvent = Message | Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent; - - -/** - * Request handler that enqueues tasks to AMQP work queue - */ -export class QueueingRequestHandler { - private agentCard: AgentCard; - private taskStore: EventTaskStore; - private backend: AMQPAgentBackend; - private workQueue: AMQPWorkQueue; - - constructor( - agentCard: AgentCard, - backend: AMQPAgentBackend - ) { - this.agentCard = agentCard; - this.backend = backend; - this.taskStore = backend.taskStore; - this.workQueue = backend.workQueue; - } - - /** - * Get agent card - */ - async getAgentCard(): Promise { - return this.agentCard; - } - - /** - * Get authenticated/extended agent card - */ - async getAuthenticatedExtendedAgentCard(): Promise { - return this.agentCard; - } - - /** - * Send a message (non-streaming) - * Creates task, publishes to exchange, enqueues to work queue, returns immediately - */ - async sendMessage(params: MessageSendParams): Promise { - const message = params.message; - - // Generate task ID and context ID - const taskId = `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const contextId = message.contextId || taskId; - - // Create initial Task event - const task: Task = { - kind: "task", - id: taskId, - contextId: contextId, - status: { - state: "submitted", - timestamp: new Date().toISOString(), - }, - history: [message], - artifacts: [], - }; - - // Save task to store - await this.taskStore.save(task); - - // Publish Task event to exchange (routes to stream + work queue) - const eventBus = this.backend.createEventBus(taskId, contextId); - eventBus.publish(task); - - // Enqueue to work queue for worker processing - const queuedMessage: QueuedTaskMessage = { - taskId, - contextId, - requestContext: { - userMessage: message, - task, - referenceTasks: [], - }, - configuration: { - blocking: false, - }, - metadata: { - enqueuedAt: new Date().toISOString(), - priority: 0, - retryCount: 0, - }, - }; - - await this.workQueue.enqueue(queuedMessage); - - // Return task immediately (worker will process asynchronously) - return task; - } - - /** - * Send a message with streaming response - * Creates task, enqueues to work queue, and streams events from TaskStore EventEmitter - */ - async *sendMessageStream( - params: MessageSendParams - ): AsyncGenerator { - const message = params.message; - - // Generate task ID and context ID - const taskId = `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const contextId = message.contextId || taskId; - - // Create initial Task event - const task: Task = { - kind: "task", - id: taskId, - contextId: contextId, - status: { - state: "submitted", - timestamp: new Date().toISOString(), - }, - history: [message], - artifacts: [], - }; - - // Save task to store - await this.taskStore.save(task); - - // Set up event queue for streaming - const eventQueue: A2AEvent[] = []; - let resolvePromise: (() => void) | null = null; - let finished = false; - - // Event handler that filters events for this specific taskId - const eventHandler = ({ taskId: eventTaskId, event }: { taskId: string, event: A2AEvent }) => { - // Only process events for this task - if (eventTaskId === taskId) { - eventQueue.push(event); - - // Check if task is finished - if (event.kind === 'status-update' && (event as TaskStatusUpdateEvent).final) { - finished = true; - } - - if (resolvePromise) { - resolvePromise(); - resolvePromise = null; - } - } - }; - - // Subscribe to TaskStore events - this.taskStore.on('task-event', eventHandler); - - try { - // Publish Task event to exchange using task-scoped bus - const publishBus = this.backend.createEventBus(taskId, contextId); - publishBus.publish(task); - - // Enqueue to work queue for worker processing - const queuedMessage: QueuedTaskMessage = { - taskId, - contextId, - requestContext: { - userMessage: message, - task, - referenceTasks: [], - }, - configuration: { - blocking: false, - }, - metadata: { - enqueuedAt: new Date().toISOString(), - priority: 0, - retryCount: 0, - }, - }; - - await this.workQueue.enqueue(queuedMessage); - - // Yield initial task - yield task; - - // Stream events from TaskStore EventEmitter - while (!finished) { - // Yield all queued events - while (eventQueue.length > 0) { - const event = eventQueue.shift()!; - yield event; - } - - // Wait for more events - if (!finished) { - await new Promise((resolve) => { - resolvePromise = resolve; - }); - } - } - } finally { - // Cleanup event listener - (this.taskStore as any).off('task-event', eventHandler); - } - } - - /** - * Get task by ID - */ - async getTask(params: TaskQueryParams): Promise { - const task = await this.taskStore.load(params.id); - if (!task) { - throw new Error(`Task not found: ${params.id}`); - } - return task; - } - - /** - * Cancel a task - */ - async cancelTask(params: TaskIdParams): Promise { - const task = await this.taskStore.load(params.id); - if (!task) { - throw new Error(`Task not found: ${params.id}`); - } - - // Update task status to canceled - task.status = { - state: "canceled", - timestamp: new Date().toISOString(), - }; - - await this.taskStore.save(task); - - // Publish cancel event - const eventBus = this.backend.createEventBus(params.id, task.contextId); - eventBus.publish({ - kind: "status-update", - taskId: params.id, - contextId: task.contextId, - status: task.status, - final: true, - }); - - return task; - } - - /** - * Set push notification config (not implemented) - */ - async setTaskPushNotificationConfig( - params: TaskPushNotificationConfig - ): Promise { - throw new Error("Push notifications not implemented"); - } - - /** - * Get push notification config (not implemented) - */ - async getTaskPushNotificationConfig( - params: TaskIdParams - ): Promise { - throw new Error("Push notifications not implemented"); - } - - /** - * List push notification configs (not implemented) - */ - async listTaskPushNotificationConfigs( - params: any - ): Promise { - throw new Error("Push notifications not implemented"); - } - - /** - * Delete push notification config (not implemented) - */ - async deleteTaskPushNotificationConfig(params: any): Promise { - throw new Error("Push notifications not implemented"); - } - - /** - * Resubscribe to task events (streaming) - * Filters to only yield Task, TaskStatusUpdateEvent, and TaskArtifactUpdateEvent - */ - async *resubscribe( - params: TaskIdParams - ): AsyncGenerator { - const task = await this.taskStore.load(params.id); - if (!task) { - throw new Error(`Task not found: ${params.id}`); - } - - // Yield task first (current state from memory) - yield task; - - // Check if task is already finished - const isFinished = task.status.state === 'completed' || - task.status.state === 'failed' || - task.status.state === 'canceled'; - - if (isFinished) { - // Task is already finished, no need to listen for more events - return; - } - - // Set up event queue for streaming new events - const eventQueue: A2AEvent[] = []; - let resolvePromise: (() => void) | null = null; - let finished = false; - - // Event handler that filters events for this specific taskId - const eventHandler = ({ taskId: eventTaskId, event }: { taskId: string, event: A2AEvent }) => { - // Only process events for this task - if (eventTaskId === params.id) { - eventQueue.push(event); - - // Check if task is finished - if (event.kind === 'status-update' && (event as TaskStatusUpdateEvent).final) { - finished = true; - } - - if (resolvePromise) { - resolvePromise(); - resolvePromise = null; - } - } - }; - - // Subscribe to TaskStore events - this.taskStore.on('task-event', eventHandler); - - try { - while (!finished) { - while (eventQueue.length > 0) { - const event = eventQueue.shift()!; - // Only yield Task, status updates, and artifact updates (not Message) - if ( - event.kind === "task" || - event.kind === "status-update" || - event.kind === "artifact-update" - ) { - yield event as Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent; - } - } - - if (!finished) { - await new Promise((resolve) => { - resolvePromise = resolve; - }); - } - } - } finally { - // Cleanup event listener - (this.taskStore as any).off('task-event', eventHandler); - } - } -} diff --git a/src/lib/QueuingRequestHandler.ts b/src/lib/QueuingRequestHandler.ts new file mode 100644 index 0000000..f2b2273 --- /dev/null +++ b/src/lib/QueuingRequestHandler.ts @@ -0,0 +1,737 @@ +/** + * Queuing Request Handler + * + * Self-contained A2A request handler that: + * - Publishes events directly to AMQP (no separate EventBus) + * - Maintains in-memory task cache from stream (no separate TaskStore) + * - Enqueues tasks to AMQP work queue for worker processing + * - Provides SSE streaming via internal EventEmitter + */ + +import { EventEmitter } from "events"; +import type { + AgentCard, + Message, + Task, + TaskStatusUpdateEvent, + TaskArtifactUpdateEvent, + MessageSendParams, + TaskQueryParams, + TaskIdParams, + TaskPushNotificationConfig, +} from "@a2a-js/sdk"; +import type { AMQPAgentBackend } from "./AMQPAgentBackend.js"; +import type { AMQPConnection } from "./AMQPConnection.js"; +import type { AMQPWorkQueue } from "./AMQPWorkQueue.js"; +import type { QueuedTaskMessage } from "./QueuedTaskMessage.js"; +import type { A2AEvent, EventMetadata, AMQPEventMessage, Logger } from "./types.js"; + +/** + * Request handler that enqueues tasks to AMQP work queue + * Combines functionality of EventBus (publishing) and TaskStore (consuming + projection) + */ +export class QueuingRequestHandler { + private agentCard: AgentCard; + private connection: AMQPConnection; + private workQueue: AMQPWorkQueue; + private logger: Logger; + + // In-memory task projection (from TaskStore) + private tasks: Map = new Map(); + + // Stream consumer state (from TaskStore) + private streamQueueName: string; + private consumer: any = null; + private consumerTag: string | null = null; + private isInitialized = false; + + // Internal event emitter for streaming + private eventEmitter: EventEmitter = new EventEmitter(); + + constructor( + agentCard: AgentCard, + backend: AMQPAgentBackend + ) { + this.agentCard = agentCard; + this.connection = backend.amqpConnection; + this.workQueue = backend.workQueue; + this.logger = this.connection.getConfig().logger; + this.streamQueueName = `${agentCard.name}.events`; + + // Add error handler to prevent unhandled rejections + this.eventEmitter.on('error', (error) => { + this.logger.error('[QueuingRequestHandler] Error:', error); + }); + } + + /** + * Initialize the request handler + * Sets up event stream queue and consumer for maintaining in-memory task cache + */ + async initialize(): Promise { + if (this.isInitialized) { + return; + } + + const channel = await this.connection.getConsumeChannel(); + const exchange = this.connection.getExchange(); + const config = this.connection.getConfig(); + + // Create stream queue arguments for LavinMQ + const queueArgs: Record = { + "x-queue-type": "stream", + }; + + // Convert maxAge to seconds for LavinMQ + if (config.stream.maxAge) { + const maxAgeSeconds = this.parseDurationToSeconds(config.stream.maxAge); + if (maxAgeSeconds) { + queueArgs["x-max-age"] = `${maxAgeSeconds}s`; + } + } + + if (config.stream.maxLengthBytes) { + queueArgs["x-max-length-bytes"] = config.stream.maxLengthBytes; + } + + // Declare stream queue + await channel.queueDeclare( + this.streamQueueName, + { durable: true }, // Streams must be durable + queueArgs + ); + + // Bind to exchange with routing keys to capture event messages + // Pattern: a2a.{contextId}.{taskId}.{eventType} + const eventRoutingPatterns = [ + "a2a.*.*.task", // Task creation events + "a2a.*.*.message", // Message events + "a2a.*.*.status", // Status update events + "a2a.*.*.artifact", // Artifact update events + "a2a.*.*.event", // Generic events + ]; + + for (const pattern of eventRoutingPatterns) { + await channel.queueBind(this.streamQueueName, exchange, pattern); + } + + this.logger.info(`Event stream queue '${this.streamQueueName}' initialized`); + + // Start persistent consumer to maintain in-memory projection + await this.startProjectionConsumer(); + this.isInitialized = true; + } + + /** + * Start persistent consumer to maintain in-memory task projection + * Reads from beginning of stream and continues to consume new events + */ + private async startProjectionConsumer(): Promise { + const channel = await this.connection.getConsumeChannel(); + this.consumerTag = `queueing-handler-${Date.now()}`; + + this.logger.info("Starting event projection consumer (reading from start)..."); + + this.consumer = await channel.basicConsume( + this.streamQueueName, + { + noAck: false, // Stream consumers MUST acknowledge messages + tag: this.consumerTag, + args: { + "x-stream-offset": "first", // Read from beginning + }, + }, + async (msg) => { + try { + const body = msg.bodyToString(); + if (!body) { + msg.ack(); + return; + } + + const payload: AMQPEventMessage = JSON.parse(body); + + // Validate payload has event + if (!payload || !payload.event) { + msg.ack(); + return; + } + + const event = payload.event; + const metadata = payload.metadata; + + // Update in-memory projection + this.applyEventToProjection(event, metadata.taskId); + + // Acknowledge message + msg.ack(); + } catch (error) { + this.logger.error("Error processing event in projection:", error); + // Acknowledge even on error to avoid blocking the stream + msg.ack(); + } + } + ); + + this.logger.info("Event projection consumer started"); + } + + /** + * Apply an event to the in-memory task projection + */ + private applyEventToProjection(event: A2AEvent, taskId: string): void { + if (!event || !event.kind) { + return; + } + + switch (event.kind) { + case "task": + // Initial task creation + if (event.id === taskId) { + this.tasks.set(taskId, event); + this.logger.debug(`Projected task ${taskId}: created`); + } + break; + + case "status-update": + // Update task status + if (event.taskId === taskId) { + const task = this.tasks.get(taskId); + if (task) { + this.tasks.set(taskId, { + ...task, + status: event.status, + }); + this.logger.debug(`Projected task ${taskId}: status -> ${event.status.state}`); + } + } + break; + + case "artifact-update": + // Add or update artifact + if (event.taskId === taskId) { + const task = this.tasks.get(taskId); + if (task) { + const artifacts = task.artifacts || []; + const existingIndex = artifacts.findIndex( + (a) => a.artifactId === event.artifact.artifactId + ); + + if (existingIndex >= 0) { + artifacts[existingIndex] = event.artifact; + } else { + artifacts.push(event.artifact); + } + + this.tasks.set(taskId, { + ...task, + artifacts, + }); + this.logger.debug(`Projected task ${taskId}: artifact ${event.artifact.artifactId}`); + } + } + break; + + case "message": + // Add message to history + if (event.messageId) { + const task = this.tasks.get(taskId); + if (task) { + const history = task.history || []; + // Only add if not already in history + if (!history.some((m) => m.messageId === event.messageId)) { + history.push(event); + this.tasks.set(taskId, { + ...task, + history, + }); + this.logger.debug(`Projected task ${taskId}: message added to history`); + } + } + } + break; + } + + // Emit event for SSE clients to subscribe to + // Clients will filter by taskId + this.eventEmitter.emit('task-event', { taskId, event }); + } + + /** + * Publish an event directly to AMQP exchange + * Combines functionality from AMQPEventBus + */ + private async publishEvent( + event: Task | Message | TaskStatusUpdateEvent | TaskArtifactUpdateEvent, + taskId: string, + contextId: string + ): Promise { + try { + const channel = await this.connection.getPublishChannel(); + const exchange = this.connection.getExchange(); + const config = this.connection.getConfig(); + + // Create metadata for the event + const metadata: EventMetadata = { + taskId, + contextId, + kind: event.kind, + timestamp: new Date().toISOString(), + }; + + // Build message payload + const payload = { + event, + metadata, + }; + + // Determine routing key based on event type + const routingKey = this.getRoutingKey(event, contextId, taskId); + + // Build message properties with headers for filtering + const properties = { + deliveryMode: config.publishing.persistent ? 2 : 1, + priority: 0, + timestamp: new Date(), + messageId: `${taskId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + headers: { + taskId, + contextId, + eventKind: event.kind, + }, + }; + + // Add TTL if configured + if (config.publishing.messageTtl && config.publishing.messageTtl > 0) { + Object.assign(properties, { + expiration: config.publishing.messageTtl.toString(), + }); + } + + // Serialize and publish + const body = JSON.stringify(payload); + + // Publish (automatically waits for confirmation if confirmMode is enabled) + await channel.basicPublish(exchange, routingKey, body, properties); + } catch (error) { + this.logger.error(`Failed to publish event for task ${taskId}:`, error); + throw error; + } + } + + /** + * Generate routing key based on event type + * Pattern: a2a.{contextId}.{taskId}.{eventKind} + */ + private getRoutingKey(event: A2AEvent, contextId: string, taskId: string): string { + const kind = event.kind; + + // Map A2A event kinds to routing keys + switch (kind) { + case "task": + return `a2a.${contextId}.${taskId}.task`; + case "message": + return `a2a.${contextId}.${taskId}.message`; + case "status-update": + return `a2a.${contextId}.${taskId}.status`; + case "artifact-update": + return `a2a.${contextId}.${taskId}.artifact`; + default: + return `a2a.${contextId}.${taskId}.event`; + } + } + + /** + * Parse duration string (e.g., "7d", "24h", "60m", "3600s") to seconds + */ + private parseDurationToSeconds(duration: string): number | null { + const match = duration.match(/^(\d+)([smhd])$/); + if (!match || !match[1] || !match[2]) { + this.logger.warn(`Invalid duration format: ${duration}`); + return null; + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case 's': + return value; + case 'm': + return value * 60; + case 'h': + return value * 60 * 60; + case 'd': + return value * 60 * 60 * 24; + default: + return null; + } + } + + /** + * Get agent card + */ + async getAgentCard(): Promise { + return this.agentCard; + } + + /** + * Get authenticated/extended agent card + */ + async getAuthenticatedExtendedAgentCard(): Promise { + return this.agentCard; + } + + /** + * Send a message (non-streaming) + * Creates task, publishes to exchange, enqueues to work queue, returns immediately + */ + async sendMessage(params: MessageSendParams): Promise { + const message = params.message; + + // Generate task ID and context ID + const taskId = `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const contextId = message.contextId || taskId; + + // Create initial Task event + const task: Task = { + kind: "task", + id: taskId, + contextId: contextId, + status: { + state: "submitted", + timestamp: new Date().toISOString(), + }, + history: [message], + artifacts: [], + }; + + // Note: No need to save to store - the task will be added to the in-memory + // cache automatically when we publish it (it will be consumed back via the stream) + + // Publish Task event to exchange (routes to stream + work queue) + await this.publishEvent(task, taskId, contextId); + + // Enqueue to work queue for worker processing + const queuedMessage: QueuedTaskMessage = { + taskId, + contextId, + requestContext: { + userMessage: message, + task, + referenceTasks: [], + }, + configuration: { + blocking: false, + }, + metadata: { + enqueuedAt: new Date().toISOString(), + priority: 0, + retryCount: 0, + }, + }; + + await this.workQueue.enqueue(queuedMessage); + + // Return task immediately (worker will process asynchronously) + return task; + } + + /** + * Send a message with streaming response + * Creates task, enqueues to work queue, and streams events from internal EventEmitter + */ + async *sendMessageStream( + params: MessageSendParams + ): AsyncGenerator { + const message = params.message; + + // Generate task ID and context ID + const taskId = `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const contextId = message.contextId || taskId; + + // Create initial Task event + const task: Task = { + kind: "task", + id: taskId, + contextId: contextId, + status: { + state: "submitted", + timestamp: new Date().toISOString(), + }, + history: [message], + artifacts: [], + }; + + // Note: No need to save to store - the task will be added to the in-memory + // cache automatically when we publish it (it will be consumed back via the stream) + + // Set up event queue for streaming + const eventQueue: A2AEvent[] = []; + let resolvePromise: (() => void) | null = null; + let finished = false; + + // Event handler that filters events for this specific taskId + const eventHandler = ({ taskId: eventTaskId, event }: { taskId: string, event: A2AEvent }) => { + // Only process events for this task + if (eventTaskId === taskId) { + eventQueue.push(event); + + // Check if task is finished + if (event.kind === 'status-update' && (event as TaskStatusUpdateEvent).final) { + finished = true; + } + + if (resolvePromise) { + resolvePromise(); + resolvePromise = null; + } + } + }; + + // Subscribe to internal EventEmitter + this.eventEmitter.on('task-event', eventHandler); + + try { + // Publish Task event to exchange + await this.publishEvent(task, taskId, contextId); + + // Enqueue to work queue for worker processing + const queuedMessage: QueuedTaskMessage = { + taskId, + contextId, + requestContext: { + userMessage: message, + task, + referenceTasks: [], + }, + configuration: { + blocking: false, + }, + metadata: { + enqueuedAt: new Date().toISOString(), + priority: 0, + retryCount: 0, + }, + }; + + await this.workQueue.enqueue(queuedMessage); + + // Yield initial task + yield task; + + // Stream events from internal EventEmitter + while (!finished) { + // Yield all queued events + while (eventQueue.length > 0) { + const event = eventQueue.shift()!; + yield event; + } + + // Wait for more events + if (!finished) { + await new Promise((resolve) => { + resolvePromise = resolve; + }); + } + } + } finally { + // Cleanup event listener + this.eventEmitter.off('task-event', eventHandler); + } + } + + /** + * Get task by ID from in-memory cache + */ + async getTask(params: TaskQueryParams): Promise { + const task = this.tasks.get(params.id); + if (!task) { + throw new Error(`Task not found: ${params.id}`); + } + return task; + } + + /** + * Cancel a task + */ + async cancelTask(params: TaskIdParams): Promise { + const task = this.tasks.get(params.id); + if (!task) { + throw new Error(`Task not found: ${params.id}`); + } + + // Update task status to canceled + const updatedTask: Task = { + ...task, + status: { + state: "canceled", + timestamp: new Date().toISOString(), + }, + }; + + // Update in-memory cache + this.tasks.set(params.id, updatedTask); + + // Publish cancel event + await this.publishEvent({ + kind: "status-update", + taskId: params.id, + contextId: task.contextId, + status: updatedTask.status, + final: true, + }, params.id, task.contextId); + + return updatedTask; + } + + /** + * Set push notification config (not implemented) + */ + async setTaskPushNotificationConfig( + params: TaskPushNotificationConfig + ): Promise { + throw new Error("Push notifications not implemented"); + } + + /** + * Get push notification config (not implemented) + */ + async getTaskPushNotificationConfig( + params: TaskIdParams + ): Promise { + throw new Error("Push notifications not implemented"); + } + + /** + * List push notification configs (not implemented) + */ + async listTaskPushNotificationConfigs( + params: any + ): Promise { + throw new Error("Push notifications not implemented"); + } + + /** + * Delete push notification config (not implemented) + */ + async deleteTaskPushNotificationConfig(params: any): Promise { + throw new Error("Push notifications not implemented"); + } + + /** + * Resubscribe to task events (streaming) + * Filters to only yield Task, TaskStatusUpdateEvent, and TaskArtifactUpdateEvent + */ + async *resubscribe( + params: TaskIdParams + ): AsyncGenerator { + const task = this.tasks.get(params.id); + if (!task) { + throw new Error(`Task not found: ${params.id}`); + } + + // Yield task first (current state from memory) + yield task; + + // Check if task is already finished + const isFinished = task.status.state === 'completed' || + task.status.state === 'failed' || + task.status.state === 'canceled'; + + if (isFinished) { + // Task is already finished, no need to listen for more events + return; + } + + // Set up event queue for streaming new events + const eventQueue: A2AEvent[] = []; + let resolvePromise: (() => void) | null = null; + let finished = false; + + // Event handler that filters events for this specific taskId + const eventHandler = ({ taskId: eventTaskId, event }: { taskId: string, event: A2AEvent }) => { + // Only process events for this task + if (eventTaskId === params.id) { + eventQueue.push(event); + + // Check if task is finished + if (event.kind === 'status-update' && (event as TaskStatusUpdateEvent).final) { + finished = true; + } + + if (resolvePromise) { + resolvePromise(); + resolvePromise = null; + } + } + }; + + // Subscribe to internal EventEmitter + this.eventEmitter.on('task-event', eventHandler); + + try { + while (!finished) { + while (eventQueue.length > 0) { + const event = eventQueue.shift()!; + // Only yield Task, status updates, and artifact updates (not Message) + if ( + event.kind === "task" || + event.kind === "status-update" || + event.kind === "artifact-update" + ) { + yield event as Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent; + } + } + + if (!finished) { + await new Promise((resolve) => { + resolvePromise = resolve; + }); + } + } + } finally { + // Cleanup event listener + this.eventEmitter.off('task-event', eventHandler); + } + } + + /** + * Get statistics about the in-memory projection + */ + getProjectionStats(): { taskCount: number; isInitialized: boolean } { + return { + taskCount: this.tasks.size, + isInitialized: this.isInitialized, + }; + } + + /** + * Close and cleanup resources + */ + async close(): Promise { + // Stop projection consumer + if (this.consumer && this.consumerTag) { + try { + await this.consumer.cancel(); + this.logger.info(`Projection consumer ${this.consumerTag} stopped`); + } catch (error: any) { + // Connection might already be closed during shutdown, which is fine + if (error.message?.includes("Connection closed") || error.message?.includes("closed")) { + this.logger.debug("Consumer already stopped (connection closed)"); + } else { + this.logger.error("Error stopping projection consumer:", error); + } + } + } + + // Clear in-memory projection + this.tasks.clear(); + this.isInitialized = false; + + // Remove all event listeners + this.eventEmitter.removeAllListeners(); + + this.logger.info("QueuingRequestHandler closed"); + } +} diff --git a/src/lib/WorkerEventBus.ts b/src/lib/WorkerEventBus.ts new file mode 100644 index 0000000..82a2ff3 --- /dev/null +++ b/src/lib/WorkerEventBus.ts @@ -0,0 +1,178 @@ +/** + * WorkerEventBus - Simplified event bus for worker processes + * + * This is a minimal implementation of ExecutionEventBus for workers to publish + * task events (status updates, messages, artifacts) to AMQP. + */ + +import { EventEmitter } from "events"; +import { Task, Message, TaskStatusUpdateEvent, TaskArtifactUpdateEvent } from "@a2a-js/sdk"; +import { ExecutionEventBus } from "@a2a-js/sdk/server"; +import type { AMQPConnection } from "./AMQPConnection.js"; +import type { A2AEvent, EventMetadata, Logger } from "./types.js"; + +/** + * Simple event bus for workers to publish events to AMQP + * Workers don't consume events, only publish them + */ +export class WorkerEventBus extends EventEmitter implements ExecutionEventBus { + private connection: AMQPConnection; + private taskId: string; + private contextId: string; + private logger: Logger; + private isFinished = false; + + constructor( + connection: AMQPConnection, + taskId: string, + contextId: string + ) { + super(); + this.connection = connection; + this.taskId = taskId; + this.contextId = contextId; + this.logger = connection.getConfig().logger; + + // Add default error handler + this.on('error', (error) => { + this.logger.error(`[WorkerEventBus ${this.taskId}] Error:`, error); + }); + } + + /** + * Publish an event to the AMQP stream + */ + publish( + event: Task | Message | TaskStatusUpdateEvent | TaskArtifactUpdateEvent + ): void { + if (this.isFinished) { + this.logger.warn( + `Attempted to publish event after EventBus finished for task ${this.taskId}` + ); + return; + } + + try { + // Publish to AMQP (async, but don't wait) + this.publishToAMQP(event).catch(error => { + this.logger.error(`Failed to publish event for task ${this.taskId}:`, error); + this.emit('error', error); + }); + + // Emit event to local listeners + this.emit('event', event); + } catch (error) { + this.logger.error(`Failed to publish event for task ${this.taskId}:`, error); + this.emit('error', error); + } + } + + /** + * Signal that no more events will be published + */ + finished(): void { + if (this.isFinished) { + return; + } + + this.isFinished = true; + this.emit('finished', undefined); + + this.logger.debug(`WorkerEventBus finished for task ${this.taskId}`); + } + + /** + * Publish event to AMQP with metadata + */ + private async publishToAMQP(event: A2AEvent): Promise { + const channel = await this.connection.getPublishChannel(); + const exchange = this.connection.getExchange(); + const config = this.connection.getConfig(); + + // Create metadata for the event + const metadata: EventMetadata = { + taskId: this.taskId, + contextId: this.contextId, + kind: event.kind, + timestamp: new Date().toISOString(), + }; + + // Build message payload + const payload = { + event, + metadata, + }; + + // Determine routing key based on event type + const routingKey = this.getRoutingKey(event); + + // Build message properties with headers for filtering + const properties = { + deliveryMode: config.publishing.persistent ? 2 : 1, + priority: 0, + timestamp: new Date(), + messageId: `${this.taskId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + headers: { + taskId: this.taskId, + contextId: this.contextId, + eventKind: event.kind, + }, + }; + + // Add TTL if configured + if (config.publishing.messageTtl && config.publishing.messageTtl > 0) { + Object.assign(properties, { + expiration: config.publishing.messageTtl.toString(), + }); + } + + // Serialize and publish + const body = JSON.stringify(payload); + + // Publish (automatically waits for confirmation if confirmMode is enabled) + await channel.basicPublish(exchange, routingKey, body, properties); + } + + /** + * Generate routing key based on event type + * Pattern: a2a.{contextId}.{taskId}.{eventKind} + */ + private getRoutingKey(event: A2AEvent): string { + const kind = event.kind; + + // Map A2A event kinds to routing keys + switch (kind) { + case "task": + return `a2a.${this.contextId}.${this.taskId}.task`; + case "message": + return `a2a.${this.contextId}.${this.taskId}.message`; + case "status-update": + return `a2a.${this.contextId}.${this.taskId}.status`; + case "artifact-update": + return `a2a.${this.contextId}.${this.taskId}.artifact`; + default: + return `a2a.${this.contextId}.${this.taskId}.event`; + } + } + + /** + * Get the task ID this EventBus is associated with + */ + getTaskId(): string { + return this.taskId; + } + + /** + * Get the context ID this EventBus is associated with + */ + getContextId(): string { + return this.contextId; + } + + /** + * Check if this EventBus has finished + */ + isEventBusFinished(): boolean { + return this.isFinished; + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 60e2723..2d8445e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,5 +1,4 @@ import type { Task, Message, TaskStatusUpdateEvent, TaskArtifactUpdateEvent } from "@a2a-js/sdk"; -import { TaskStore } from "@a2a-js/sdk/server"; /** * Configuration for AMQP connection and event publishing @@ -265,5 +264,3 @@ export class SilentLogger implements Logger { warn(): void { } error(): void { } } - -export type EventTaskStore = TaskStore & import("events").EventEmitter; diff --git a/tests/integration/e2e-happy-path.test.ts b/tests/integration/e2e-happy-path.test.ts index 0a1a4fa..0da1261 100644 --- a/tests/integration/e2e-happy-path.test.ts +++ b/tests/integration/e2e-happy-path.test.ts @@ -18,7 +18,8 @@ import { } from "@a2a-js/sdk/server"; import { AMQPAgentBackend, - QueueingRequestHandler, + QueuingRequestHandler, + WorkerEventBus, type QueuedTaskMessage, } from "../../src/index.js"; @@ -124,7 +125,7 @@ class TestExecutor implements AgentExecutor { describe("E2E Happy Path", () => { let serverBackend: AMQPAgentBackend; let workerBackend: AMQPAgentBackend; - let requestHandler: QueueingRequestHandler; + let requestHandler: QueuingRequestHandler; let consumerTag: string; beforeAll(async () => { @@ -135,11 +136,14 @@ describe("E2E Happy Path", () => { }); // Create request handler - requestHandler = new QueueingRequestHandler( + requestHandler = new QueuingRequestHandler( agentCard, serverBackend ); + // Initialize request handler (sets up event stream) + await requestHandler.initialize(); + // Setup worker backend with silent logger to avoid duplicate projection logs const silentLogger = { info: () => {}, @@ -168,12 +172,13 @@ describe("E2E Happy Path", () => { requestContext.referenceTasks ); - const eventBus = workerBackend.createEventBus(taskId, contextId); + // Create WorkerEventBus for this task + const eventBus = new WorkerEventBus(workerBackend.amqpConnection, taskId, contextId); await executor.execute(reqContext, eventBus); } ); - }, 30000); + }); afterAll(async () => { // Stop worker consumer @@ -181,6 +186,26 @@ describe("E2E Happy Path", () => { await workerBackend.workQueue.stopConsumer(consumerTag); } + // Delete queues created during tests + try { + const channel = await serverBackend.amqpConnection.getConsumeChannel(); + + // Delete event stream queue + const eventQueueName = `${agentCard.name}.events`; + await channel.queueDelete(eventQueueName); + + // Delete work queue + const workQueueName = workerBackend.workQueue.getQueueName(); + await channel.queueDelete(workQueueName); + + console.log("Test queues deleted successfully"); + } catch (error: any) { + // Queue might not exist, which is fine for cleanup + if (!error.message?.includes("NOT_FOUND")) { + console.error("Error deleting test queues:", error); + } + } + // Close backends if (workerBackend) { await workerBackend.close(); @@ -188,9 +213,9 @@ describe("E2E Happy Path", () => { if (serverBackend) { await serverBackend.close(); } - }, 30000); + }); - it("should process a message through the full pipeline (non-streaming)", async () => { + it("should process a message through the full pipeline (non-streaming)", { timeout: 15000 }, async () => { // Create user message const userMessage: Message = { kind: "message", @@ -218,7 +243,7 @@ describe("E2E Happy Path", () => { expect(task.history[0]).toEqual(userMessage); // Wait for worker to process the task - // We'll poll the task store to check for completion + // We'll poll the request handler to check for completion const taskId = task.id; let completedTask: Task | null = null; const maxWaitTime = 10000; // 10 seconds @@ -226,10 +251,14 @@ describe("E2E Happy Path", () => { const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { - const currentTask = await serverBackend.taskStore.load(taskId); - if (currentTask && currentTask.status.state === "completed") { - completedTask = currentTask; - break; + try { + const currentTask = await requestHandler.getTask({ id: taskId }); + if (currentTask && currentTask.status.state === "completed") { + completedTask = currentTask; + break; + } + } catch (error) { + // Task not found yet, keep polling } await new Promise((resolve) => setTimeout(resolve, pollInterval)); } @@ -253,9 +282,9 @@ describe("E2E Happy Path", () => { expect(completedTask!.artifacts).toHaveLength(1); expect(completedTask!.artifacts[0].artifactId).toBe("test-artifact"); expect(completedTask!.artifacts[0].name).toBe("result.txt"); - }, 15000); + }); - it("should stream events through the full pipeline", async () => { + it("should stream events through the full pipeline", { timeout: 15000 }, async () => { // Create user message const userMessage: Message = { kind: "message", @@ -311,12 +340,12 @@ describe("E2E Happy Path", () => { // (Note: final status event may not be streamed due to timing - see issue with EventBus) await new Promise((resolve) => setTimeout(resolve, 100)); // Brief wait for final state const taskId = firstEvent.id; - const finalTask = await serverBackend.taskStore.load(taskId); + const finalTask = await requestHandler.getTask({ id: taskId }); expect(finalTask).toBeDefined(); expect(finalTask!.status.state).toBe("completed"); - }, 15000); + }); - it("should allow task retrieval by ID", async () => { + it("should allow task retrieval by ID", { timeout: 15000 }, async () => { // Create and send a message const userMessage: Message = { kind: "message", @@ -364,5 +393,5 @@ describe("E2E Happy Path", () => { expect(retrievedTask!.status.state).toBe("completed"); expect(retrievedTask!.history.length).toBeGreaterThan(1); expect(retrievedTask!.artifacts).toHaveLength(1); - }, 15000); + }); }); diff --git a/tests/unit/AMQPAgentBackend.test.ts b/tests/unit/AMQPAgentBackend.test.ts index b83eeb9..3c21fec 100644 --- a/tests/unit/AMQPAgentBackend.test.ts +++ b/tests/unit/AMQPAgentBackend.test.ts @@ -5,9 +5,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { AMQPAgentBackend } from "../../src/lib/AMQPAgentBackend.js"; import { AMQPConnection } from "../../src/lib/AMQPConnection.js"; -import { AMQPStreamTaskStore } from "../../src/lib/AMQPStreamTaskStore.js"; import { AMQPWorkQueue } from "../../src/lib/AMQPWorkQueue.js"; -import { AMQPEventBusFactory } from "../../src/lib/AMQPEventBus.js"; import { createMockLogger, createMockAMQPClient } from "../helpers/mocks.js"; // Mock the module that contains AMQPClient @@ -21,26 +19,6 @@ vi.mock("@cloudamqp/amqp-client", () => ({ }, })); -// Mock AMQPStreamTaskStore initialization -vi.mock("../../src/lib/AMQPStreamTaskStore.js", () => { - return { - AMQPStreamTaskStore: class MockAMQPStreamTaskStore { - public _connection: any; - public _queueName: string; - public initialize = vi.fn().mockResolvedValue(undefined); - public close = vi.fn().mockResolvedValue(undefined); - public load = vi.fn().mockResolvedValue(undefined); - public save = vi.fn().mockResolvedValue(undefined); - public getProjectionStats = vi.fn().mockReturnValue({ taskCount: 0, isInitialized: true }); - - constructor(connection: any, queueName: string) { - this._connection = connection; - this._queueName = queueName; - } - }, - }; -}); - // Mock AMQPWorkQueue initialization vi.mock("../../src/lib/AMQPWorkQueue.js", () => { return { @@ -126,30 +104,26 @@ describe("AMQPAgentBackend", () => { }); expect(backend).toBeDefined(); - expect(backend.taskStore).toBeDefined(); - expect(backend.createEventBus).toBeDefined(); + expect(backend.amqpConnection).toBeDefined(); expect(backend.workQueue).toBeDefined(); }); - it("should use custom queue names", async () => { + it("should use custom work queue name", async () => { const backend = await AMQPAgentBackend.create({ url: "amqp://localhost:5672", agentName: "test-agent", - taskQueueName: "custom-tasks", workQueueName: "custom-work", }); - expect((backend.taskStore as any)._queueName).toBe("custom-tasks"); expect(backend.workQueue.getQueueName()).toBe("custom-work"); }); - it("should use default queue names based on agentName", async () => { + it("should use default work queue name based on agentName", async () => { const backend = await AMQPAgentBackend.create({ url: "amqp://localhost:5672", agentName: "my-agent", }); - expect((backend.taskStore as any)._queueName).toBe("my-agent.tasks"); expect(backend.workQueue.getQueueName()).toBe("my-agent.work-queue"); }); @@ -177,30 +151,6 @@ describe("AMQPAgentBackend", () => { }); describe("getters", () => { - it("should expose taskStore", async () => { - const backend = await AMQPAgentBackend.create({ - url: "amqp://localhost:5672", - agentName: "test-agent", - }); - - expect(backend.taskStore).toBeDefined(); - }); - - it("should expose createEventBus method", async () => { - const backend = await AMQPAgentBackend.create({ - url: "amqp://localhost:5672", - agentName: "test-agent", - }); - - expect(backend.createEventBus).toBeDefined(); - expect(typeof backend.createEventBus).toBe("function"); - - // Test that it creates an EventBus - const eventBus = backend.createEventBus("task-1", "context-1"); - expect(eventBus).toBeDefined(); - expect(eventBus.publish).toBeDefined(); - }); - it("should expose amqpConnection", async () => { const backend = await AMQPAgentBackend.create({ url: "amqp://localhost:5672", @@ -210,15 +160,6 @@ describe("AMQPAgentBackend", () => { expect(backend.amqpConnection).toBeDefined(); }); - it("should expose eventBusFactoryInstance", async () => { - const backend = await AMQPAgentBackend.create({ - url: "amqp://localhost:5672", - agentName: "test-agent", - }); - - expect(backend.eventBusFactoryInstance).toBeDefined(); - }); - it("should expose workQueue", async () => { const backend = await AMQPAgentBackend.create({ url: "amqp://localhost:5672", @@ -236,15 +177,8 @@ describe("AMQPAgentBackend", () => { agentName: "test-agent", }); - // Create some event buses - backend.createEventBus("task-1", "context-1"); - backend.createEventBus("task-2", "context-2"); - await backend.close(); - // Task store should be closed - expect(backend.taskStore.close).toHaveBeenCalled(); - // Work queue should be closed expect(backend.workQueue.close).toHaveBeenCalled(); diff --git a/tests/unit/AMQPConnection.test.ts b/tests/unit/AMQPConnection.test.ts index bdb7daf..aefba0d 100644 --- a/tests/unit/AMQPConnection.test.ts +++ b/tests/unit/AMQPConnection.test.ts @@ -162,7 +162,7 @@ describe("AMQPConnection", () => { await connection.connect(); // Make close throw an error - vi.mocked(mockClient.close).mockRejectedValueOnce(new Error("Close failed")); + mockClient.close.mockRejectedValueOnce(new Error("Close failed")); // Should throw and log error await expect(connection.close()).rejects.toThrow("Close failed"); @@ -195,7 +195,7 @@ describe("AMQPConnection", () => { const connection = new AMQPConnection(config); // Make connection fail - vi.mocked(mockClient.connect).mockRejectedValueOnce(new Error("Connection failed")); + mockClient.connect.mockRejectedValueOnce(new Error("Connection failed")); await expect(connection.connect()).rejects.toThrow("Connection failed"); diff --git a/tests/unit/AMQPEventBus.test.ts b/tests/unit/AMQPEventBus.test.ts deleted file mode 100644 index b1462b6..0000000 --- a/tests/unit/AMQPEventBus.test.ts +++ /dev/null @@ -1,396 +0,0 @@ -/** - * Unit tests for AMQPEventBus - */ - -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { AMQPEventBus } from "../../src/lib/AMQPEventBus.js"; -import { createPartialMockConnection, createMockAMQPMessage } from "../helpers/mocks.js"; -import { createMockTask, createMockMessage, createMockStatusUpdate, createMockArtifactUpdate } from "../helpers/fixtures.js"; - -describe("AMQPEventBus", () => { - let mockConnection: ReturnType; - const taskId = "test-task-123"; - const contextId = "test-context-456"; - - beforeEach(() => { - mockConnection = createPartialMockConnection(); - }); - - describe("constructor", () => { - it("should create instance with taskId", () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - - expect(eventBus.getTaskId()).toBe(taskId); - expect(eventBus.getContextId()).toBeNull(); - }); - - it("should create instance with taskId and contextId", () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId, contextId); - - expect(eventBus.getTaskId()).toBe(taskId); - expect(eventBus.getContextId()).toBe(contextId); - }); - - it("should add default error handler", () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - - // Should not throw when error is emitted - expect(() => { - eventBus.emit("error", new Error("test error")); - }).not.toThrow(); - }); - }); - - describe("publisher mode - publish", () => { - it("should publish task event", async () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - const task = createMockTask({ id: taskId, contextId }); - const mockChannel = await mockConnection.getPublishChannel(); - - eventBus.publish(task); - - // Wait for async publish to complete - await new Promise(resolve => setTimeout(resolve, 10)); - - // Should publish to AMQP - expect(mockChannel.basicPublish).toHaveBeenCalledWith( - "test-exchange", - `a2a.${contextId}.${taskId}.task`, - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ - taskId, - contextId, - eventKind: "task", - }), - }) - ); - }); - - it("should set contextId from task event", () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - const task = createMockTask({ id: taskId, contextId }); - - expect(eventBus.getContextId()).toBeNull(); - - eventBus.publish(task); - - expect(eventBus.getContextId()).toBe(contextId); - }); - - it("should publish message event", async () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId, contextId); - const message = createMockMessage(); - const mockChannel = await mockConnection.getPublishChannel(); - - eventBus.publish(message); - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockChannel.basicPublish).toHaveBeenCalledWith( - "test-exchange", - `a2a.${contextId}.${taskId}.message`, - expect.any(String), - expect.any(Object) - ); - }); - - it("should publish status update event", async () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId, contextId); - const statusUpdate = createMockStatusUpdate(taskId, contextId, "working"); - const mockChannel = await mockConnection.getPublishChannel(); - - eventBus.publish(statusUpdate); - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockChannel.basicPublish).toHaveBeenCalledWith( - "test-exchange", - `a2a.${contextId}.${taskId}.status`, - expect.any(String), - expect.any(Object) - ); - }); - - it("should publish artifact update event", async () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId, contextId); - const artifactUpdate = createMockArtifactUpdate(taskId, contextId); - const mockChannel = await mockConnection.getPublishChannel(); - - eventBus.publish(artifactUpdate); - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockChannel.basicPublish).toHaveBeenCalledWith( - "test-exchange", - `a2a.${contextId}.${taskId}.artifact`, - expect.any(String), - expect.any(Object) - ); - }); - - it("should emit event locally", () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId, contextId); - const task = createMockTask({ id: taskId, contextId }); - const eventListener = vi.fn(); - - eventBus.on("event", eventListener); - eventBus.publish(task); - - expect(eventListener).toHaveBeenCalledWith(task); - }); - - it("should emit error if contextId not set for non-task events", () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - const message = createMockMessage(); - const errorListener = vi.fn(); - - eventBus.on("error", errorListener); - eventBus.publish(message); - - // Should emit error event - expect(errorListener).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining("contextId not set"), - }) - ); - }); - - it("should not publish after finished", () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId, contextId); - const message = createMockMessage(); - - eventBus.finished(); - eventBus.publish(message); - - // Should log warning but not throw - expect(mockConnection.getConfig().logger.warn).toHaveBeenCalledWith( - expect.stringContaining("after EventBus finished") - ); - }); - }); - - describe("consumer mode - startConsuming", () => { - it("should start consuming from stream", async () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - const mockChannel = await mockConnection.getConsumeChannel(); - - await eventBus.startConsuming("test-stream-queue", { offset: "first" }); - - expect(mockChannel.basicConsume).toHaveBeenCalledWith( - "test-stream-queue", - expect.objectContaining({ - noAck: false, - args: { "x-stream-offset": "first" }, - }), - expect.any(Function) - ); - - expect(eventBus.isInConsumerMode()).toBe(true); - }); - - it("should default to 'last' offset", async () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - const mockChannel = await mockConnection.getConsumeChannel(); - - await eventBus.startConsuming("test-stream-queue"); - - const [_, consumeCall] = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - expect(consumeCall!.args).toEqual({ "x-stream-offset": "last" }); - }); - - it("should filter events by taskId", async () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - const mockChannel = await mockConnection.getConsumeChannel(); - const eventListener = vi.fn(); - - await eventBus.startConsuming("test-stream-queue"); - eventBus.on("event", eventListener); - - // Get consumer handler - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - // Event for our taskId - should emit - const ourMessage = createMockAMQPMessage( - JSON.stringify({ - event: createMockMessage(), - metadata: { taskId, contextId, kind: "message" }, - }), - { taskId, contextId } - ); - await handler(ourMessage); - - // Event for different taskId - should not emit - const otherMessage = createMockAMQPMessage( - JSON.stringify({ - event: createMockMessage(), - metadata: { taskId: "other-task", contextId, kind: "message" }, - }), - { taskId: "other-task", contextId } - ); - await handler(otherMessage); - - expect(eventListener).toHaveBeenCalledTimes(1); - expect(ourMessage.ack).toHaveBeenCalled(); - expect(otherMessage.ack).toHaveBeenCalled(); // Still ACKs, just doesn't emit - }); - - it("should emit finished event on final status", async () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - const mockChannel = await mockConnection.getConsumeChannel(); - const finishedListener = vi.fn(); - - await eventBus.startConsuming("test-stream-queue"); - eventBus.on("finished", finishedListener); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - // Send final status update - const statusUpdate = createMockStatusUpdate(taskId, contextId, "completed", true); - const message = createMockAMQPMessage( - JSON.stringify({ - event: statusUpdate, - metadata: { taskId, contextId, kind: "status-update" }, - }), - { taskId, contextId } - ); - - await handler(message); - - expect(finishedListener).toHaveBeenCalled(); - expect(eventBus.isEventBusFinished()).toBe(true); - }); - - it("should set contextId from consumed events", async () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - const mockChannel = await mockConnection.getConsumeChannel(); - - expect(eventBus.getContextId()).toBeNull(); - - await eventBus.startConsuming("test-stream-queue"); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - const message = createMockAMQPMessage( - JSON.stringify({ - event: createMockMessage(), - metadata: { taskId, contextId, kind: "message" }, - }), - { taskId, contextId } - ); - - await handler(message); - - expect(eventBus.getContextId()).toBe(contextId); - }); - - it("should acknowledge messages even on errors", async () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - const mockChannel = await mockConnection.getConsumeChannel(); - - await eventBus.startConsuming("test-stream-queue"); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - // Send malformed message - const badMessage = createMockAMQPMessage("invalid json", { taskId }); - - await handler(badMessage); - - // Should still ACK to avoid blocking stream - expect(badMessage.ack).toHaveBeenCalled(); - }); - - it("should not start consuming if already in consumer mode", async () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - const mockChannel = await mockConnection.getConsumeChannel(); - - await eventBus.startConsuming("test-stream-queue"); - await eventBus.startConsuming("test-stream-queue"); - - // Should only call basicConsume once - expect(mockChannel.basicConsume).toHaveBeenCalledTimes(1); - }); - }); - - describe("consumer mode - stopConsuming", () => { - it("should stop consuming", async () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - const mockChannel = await mockConnection.getConsumeChannel(); - - await eventBus.startConsuming("test-stream-queue"); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const mockConsumer = await consumeCall[2]; // Returns consumer object - - await eventBus.stopConsuming(); - - expect(eventBus.isInConsumerMode()).toBe(false); - }); - - it("should be idempotent", async () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - - await eventBus.stopConsuming(); // Should not throw - await eventBus.stopConsuming(); // Should not throw - - expect(eventBus.isInConsumerMode()).toBe(false); - }); - }); - - describe("finished", () => { - it("should mark eventbus as finished", () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId, contextId); - - expect(eventBus.isEventBusFinished()).toBe(false); - - eventBus.finished(); - - expect(eventBus.isEventBusFinished()).toBe(true); - }); - - it("should emit finished event", () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId, contextId); - const finishedListener = vi.fn(); - - eventBus.on("finished", finishedListener); - eventBus.finished(); - - expect(finishedListener).toHaveBeenCalled(); - }); - - it("should be idempotent", () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId, contextId); - const finishedListener = vi.fn(); - - eventBus.on("finished", finishedListener); - eventBus.finished(); - eventBus.finished(); // Second call - - // Should only emit once - expect(finishedListener).toHaveBeenCalledTimes(1); - }); - }); - - describe("getTaskId and getContextId", () => { - it("should return taskId", () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - - expect(eventBus.getTaskId()).toBe(taskId); - }); - - it("should return contextId when set", () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId, contextId); - - expect(eventBus.getContextId()).toBe(contextId); - }); - - it("should return null when contextId not set", () => { - const eventBus = new AMQPEventBus(mockConnection as any, taskId); - - expect(eventBus.getContextId()).toBeNull(); - }); - }); - -}); diff --git a/tests/unit/AMQPStreamTaskStore.test.ts b/tests/unit/AMQPStreamTaskStore.test.ts deleted file mode 100644 index a01b4ce..0000000 --- a/tests/unit/AMQPStreamTaskStore.test.ts +++ /dev/null @@ -1,641 +0,0 @@ -/** - * Unit tests for AMQPStreamTaskStore - */ - -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { AMQPStreamTaskStore } from "../../src/lib/AMQPStreamTaskStore.js"; -import { createPartialMockConnection, createMockAMQPMessage } from "../helpers/mocks.js"; -import { createMockTask, createMockStatusUpdate, createMockArtifactUpdate, createMockMessage } from "../helpers/fixtures.js"; - -describe("AMQPStreamTaskStore", () => { - let mockConnection: ReturnType; - let taskStore: AMQPStreamTaskStore; - - beforeEach(() => { - mockConnection = createPartialMockConnection(); - taskStore = new AMQPStreamTaskStore(mockConnection as any, "test-queue"); - }); - - describe("initialization", () => { - it("should declare stream queue with correct arguments", async () => { - const mockChannel = await mockConnection.getPublishChannel(); - - await taskStore.initialize(); - - // Should declare queue as stream - expect(mockChannel.queueDeclare).toHaveBeenCalledWith( - "test-queue", - { durable: true }, - expect.objectContaining({ - "x-queue-type": "stream", - }) - ); - }); - - it("should bind to event routing patterns", async () => { - const mockChannel = await mockConnection.getPublishChannel(); - - await taskStore.initialize(); - - // Should bind to specific event patterns, not a2a.# - expect(mockChannel.queueBind).toHaveBeenCalledWith("test-queue", "test-exchange", "a2a.*.*.task"); - expect(mockChannel.queueBind).toHaveBeenCalledWith("test-queue", "test-exchange", "a2a.*.*.message"); - expect(mockChannel.queueBind).toHaveBeenCalledWith("test-queue", "test-exchange", "a2a.*.*.status"); - expect(mockChannel.queueBind).toHaveBeenCalledWith("test-queue", "test-exchange", "a2a.*.*.artifact"); - expect(mockChannel.queueBind).toHaveBeenCalledWith("test-queue", "test-exchange", "a2a.*.*.event"); - }); - - it("should start projection consumer", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - - await taskStore.initialize(); - - // Should start consuming from stream - expect(mockChannel.basicConsume).toHaveBeenCalledWith( - "test-queue", - expect.objectContaining({ - noAck: false, // Streams require ACK - args: { "x-stream-offset": "first" }, // Read from beginning - }), - expect.any(Function) - ); - }); - }); - - describe("in-memory projection", () => { - it("should start with empty projection", () => { - const stats = taskStore.getProjectionStats(); - - expect(stats.taskCount).toBe(0); - expect(stats.isInitialized).toBe(false); - }); - - it("should update projection when task event is received", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - // Get the consumer handler - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - // Simulate receiving a task event - const task = createMockTask(); - const message = createMockAMQPMessage( - JSON.stringify({ - event: task, - metadata: { - taskId: task.id, - contextId: task.contextId, - kind: "task", - timestamp: new Date().toISOString(), - }, - }), - { taskId: task.id, contextId: task.contextId } - ); - - await handler(message); - - // Projection should be updated - const loadedTask = await taskStore.load(task.id); - expect(loadedTask).toBeDefined(); - expect(loadedTask?.id).toBe(task.id); - expect(loadedTask?.status.state).toBe("submitted"); - }); - - it("should update task status in projection", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - // First, add a task - const task = createMockTask(); - const taskMessage = createMockAMQPMessage( - JSON.stringify({ - event: task, - metadata: { taskId: task.id, contextId: task.contextId, kind: "task", timestamp: new Date().toISOString() }, - }), - { taskId: task.id } - ); - await handler(taskMessage); - - // Then, update its status - const statusUpdate = createMockStatusUpdate(task.id, task.contextId, "working"); - const statusMessage = createMockAMQPMessage( - JSON.stringify({ - event: statusUpdate, - metadata: { taskId: task.id, contextId: task.contextId, kind: "status-update", timestamp: new Date().toISOString() }, - }), - { taskId: task.id } - ); - await handler(statusMessage); - - // Check updated status - const loadedTask = await taskStore.load(task.id); - expect(loadedTask?.status.state).toBe("working"); - }); - - it("should add artifacts to projection", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - // Add a task - const task = createMockTask(); - const taskMessage = createMockAMQPMessage( - JSON.stringify({ - event: task, - metadata: { taskId: task.id, contextId: task.contextId, kind: "task", timestamp: new Date().toISOString() }, - }), - { taskId: task.id } - ); - await handler(taskMessage); - - // Add an artifact - const artifactUpdate = createMockArtifactUpdate(task.id, task.contextId); - const artifactMessage = createMockAMQPMessage( - JSON.stringify({ - event: artifactUpdate, - metadata: { taskId: task.id, contextId: task.contextId, kind: "artifact-update", timestamp: new Date().toISOString() }, - }), - { taskId: task.id } - ); - await handler(artifactMessage); - - // Check artifact was added - const loadedTask = await taskStore.load(task.id); - expect(loadedTask?.artifacts).toHaveLength(1); - expect(loadedTask?.artifacts?.[0].artifactId).toBe("artifact-1"); - }); - - it("should handle missing event payload gracefully", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - // Simulate message with missing event - const badMessage = createMockAMQPMessage( - JSON.stringify({ metadata: { taskId: "test" } }), - {} - ); - - // Should not throw - await expect(handler(badMessage)).resolves.not.toThrow(); - - // Should ACK the message - expect(badMessage.ack).toHaveBeenCalled(); - }); - }); - - describe("load", () => { - it("should return undefined for non-existent task", async () => { - await taskStore.initialize(); - - const result = await taskStore.load("non-existent-task"); - - expect(result).toBeUndefined(); - }); - - it("should return task instantly from memory", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - // Add a task to projection - const task = createMockTask(); - const message = createMockAMQPMessage( - JSON.stringify({ - event: task, - metadata: { taskId: task.id, contextId: task.contextId, kind: "task", timestamp: new Date().toISOString() }, - }), - { taskId: task.id } - ); - await handler(message); - - // Load should be instant (no AMQP calls) - const loadedTask = await taskStore.load(task.id); - - expect(loadedTask).toBeDefined(); - expect(loadedTask?.id).toBe(task.id); - }); - }); - - describe("save", () => { - it("should log warning (no-op in event sourcing)", async () => { - const mockLogger = mockConnection.getConfig().logger; - await taskStore.initialize(); - - const task = createMockTask(); - await taskStore.save(task); - - // Should log warning about event sourcing - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining("save() called on AMQPStreamTaskStore") - ); - }); - }); - - describe("close", () => { - it("should stop consumer and clear projection", async () => { - await taskStore.initialize(); - - const stats = taskStore.getProjectionStats(); - expect(stats.isInitialized).toBe(true); - - await taskStore.close(); - - const afterStats = taskStore.getProjectionStats(); - expect(afterStats.isInitialized).toBe(false); - expect(afterStats.taskCount).toBe(0); - }); - }); - - describe("getProjectionStats", () => { - it("should return correct stats", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - // Add multiple tasks - for (let i = 0; i < 3; i++) { - const task = createMockTask({ id: `task-${i}` }); - const message = createMockAMQPMessage( - JSON.stringify({ - event: task, - metadata: { taskId: task.id, contextId: task.contextId, kind: "task", timestamp: new Date().toISOString() }, - }), - { taskId: task.id } - ); - await handler(message); - } - - const stats = taskStore.getProjectionStats(); - expect(stats.taskCount).toBe(3); - expect(stats.isInitialized).toBe(true); - }); - }); - - describe("applyEventToProjection - message events", () => { - it("should add message to task history", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - // Create task first - const task = createMockTask({ id: "task-123" }); - const taskMessage = createMockAMQPMessage( - JSON.stringify({ - event: task, - metadata: { taskId: task.id, contextId: task.contextId, kind: "task" }, - }), - { taskId: task.id } - ); - await handler(taskMessage); - - // Add message event - const message = createMockMessage({ messageId: "msg-456" }); - const messageEvent = createMockAMQPMessage( - JSON.stringify({ - event: message, - metadata: { taskId: task.id, contextId: task.contextId, kind: "message" }, - }), - { taskId: task.id } - ); - await handler(messageEvent); - - const loadedTask = await taskStore.load(task.id); - expect(loadedTask?.history).toHaveLength(2); // Original + new - expect(loadedTask?.history.some((m) => m.messageId === "msg-456")).toBe(true); - }); - - it("should not duplicate messages in history", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - const task = createMockTask({ id: "task-123" }); - const taskMessage = createMockAMQPMessage( - JSON.stringify({ - event: task, - metadata: { taskId: task.id, contextId: task.contextId, kind: "task" }, - }), - { taskId: task.id } - ); - await handler(taskMessage); - - // Add same message twice - const message = createMockMessage({ messageId: "msg-456" }); - const messageEvent1 = createMockAMQPMessage( - JSON.stringify({ - event: message, - metadata: { taskId: task.id, contextId: task.contextId, kind: "message" }, - }), - { taskId: task.id } - ); - await handler(messageEvent1); - await handler(messageEvent1); - - const loadedTask = await taskStore.load(task.id); - const msgCount = loadedTask?.history.filter((m) => m.messageId === "msg-456").length; - expect(msgCount).toBe(1); - }); - - it("should skip message events without messageId", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - const task = createMockTask({ id: "task-123" }); - await handler(createMockAMQPMessage(JSON.stringify({ - event: task, - metadata: { taskId: task.id, contextId: task.contextId, kind: "task" }, - }), { taskId: task.id })); - - const message = { ...createMockMessage(), messageId: undefined }; - await handler(createMockAMQPMessage(JSON.stringify({ - event: message, - metadata: { taskId: task.id, contextId: task.contextId, kind: "message" }, - }), { taskId: task.id })); - - const loadedTask = await taskStore.load(task.id); - expect(loadedTask?.history).toHaveLength(1); // Only original - }); - - it("should skip message events for non-existent tasks", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - const message = createMockMessage(); - await handler(createMockAMQPMessage(JSON.stringify({ - event: message, - metadata: { taskId: "nonexistent", contextId: "ctx", kind: "message" }, - }), { taskId: "nonexistent" })); - - const loadedTask = await taskStore.load("nonexistent"); - expect(loadedTask).toBeUndefined(); - }); - }); - - describe("applyEventToProjection - edge cases", () => { - it("should skip events without kind", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - const invalidEvent = createMockAMQPMessage( - JSON.stringify({ - event: { id: "task-123" }, // No kind - metadata: { taskId: "task-123", contextId: "ctx", kind: "task" }, - }), - { taskId: "task-123" } - ); - - await handler(invalidEvent); - - const loadedTask = await taskStore.load("task-123"); - expect(loadedTask).toBeUndefined(); - }); - - it("should update existing artifact", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - // Create task - const task = createMockTask({ id: "task-123" }); - await handler(createMockAMQPMessage(JSON.stringify({ - event: task, - metadata: { taskId: task.id, contextId: task.contextId, kind: "task" }, - }), { taskId: task.id })); - - // Add artifact - const artifact1 = createMockArtifactUpdate(task.id, task.contextId, "art-1"); - await handler(createMockAMQPMessage(JSON.stringify({ - event: artifact1, - metadata: { taskId: task.id, contextId: task.contextId, kind: "artifact-update" }, - }), { taskId: task.id })); - - // Update same artifact - const artifact2 = createMockArtifactUpdate(task.id, task.contextId, "art-1"); - artifact2.artifact.name = "updated-name.txt"; - await handler(createMockAMQPMessage(JSON.stringify({ - event: artifact2, - metadata: { taskId: task.id, contextId: task.contextId, kind: "artifact-update" }, - }), { taskId: task.id })); - - const loadedTask = await taskStore.load(task.id); - expect(loadedTask?.artifacts).toHaveLength(1); - expect(loadedTask?.artifacts?.[0].name).toBe("updated-name.txt"); - }); - - it("should skip status updates for non-existent tasks", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - const statusUpdate = createMockStatusUpdate("nonexistent", "ctx", "working"); - await handler(createMockAMQPMessage(JSON.stringify({ - event: statusUpdate, - metadata: { taskId: "nonexistent", contextId: "ctx", kind: "status-update" }, - }), { taskId: "nonexistent" })); - - const loadedTask = await taskStore.load("nonexistent"); - expect(loadedTask).toBeUndefined(); - }); - - it("should skip artifact updates for non-existent tasks", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - const artifactUpdate = createMockArtifactUpdate("nonexistent", "ctx"); - await handler(createMockAMQPMessage(JSON.stringify({ - event: artifactUpdate, - metadata: { taskId: "nonexistent", contextId: "ctx", kind: "artifact-update" }, - }), { taskId: "nonexistent" })); - - const loadedTask = await taskStore.load("nonexistent"); - expect(loadedTask).toBeUndefined(); - }); - }); - - describe("initialization - duration parsing", () => { - it("should parse seconds duration", async () => { - const config = mockConnection.getConfig(); - config.stream.maxAge = "3600s"; - - await taskStore.initialize(); - - const mockChannel = await mockConnection.getConsumeChannel(); - expect(mockChannel.queueDeclare).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - expect.objectContaining({ - "x-max-age": "3600s", - }) - ); - }); - - it("should parse minutes duration", async () => { - const config = mockConnection.getConfig(); - config.stream.maxAge = "60m"; - - await taskStore.initialize(); - - const mockChannel = await mockConnection.getConsumeChannel(); - expect(mockChannel.queueDeclare).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - expect.objectContaining({ - "x-max-age": "3600s", - }) - ); - }); - - it("should parse hours duration", async () => { - const config = mockConnection.getConfig(); - config.stream.maxAge = "24h"; - - await taskStore.initialize(); - - const mockChannel = await mockConnection.getConsumeChannel(); - expect(mockChannel.queueDeclare).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - expect.objectContaining({ - "x-max-age": "86400s", - }) - ); - }); - - it("should parse days duration", async () => { - const config = mockConnection.getConfig(); - config.stream.maxAge = "7d"; - - await taskStore.initialize(); - - const mockChannel = await mockConnection.getConsumeChannel(); - expect(mockChannel.queueDeclare).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - expect.objectContaining({ - "x-max-age": "604800s", - }) - ); - }); - - it("should handle invalid duration format", async () => { - const config = mockConnection.getConfig(); - config.stream.maxAge = "invalid"; - - await taskStore.initialize(); - - const mockChannel = await mockConnection.getConsumeChannel(); - const declareCall = vi.mocked(mockChannel.queueDeclare).mock.calls[0]; - const queueArgs = declareCall[2]; - - expect(queueArgs).not.toHaveProperty("x-max-age"); - }); - - it("should not set maxAge if not configured", async () => { - const config = mockConnection.getConfig(); - config.stream.maxAge = undefined; - - await taskStore.initialize(); - - const mockChannel = await mockConnection.getConsumeChannel(); - const declareCall = vi.mocked(mockChannel.queueDeclare).mock.calls[0]; - const queueArgs = declareCall[2]; - - expect(queueArgs).not.toHaveProperty("x-max-age"); - }); - - it("should set maxLengthBytes if configured", async () => { - const config = mockConnection.getConfig(); - config.stream.maxLengthBytes = 5000000; - - await taskStore.initialize(); - - const mockChannel = await mockConnection.getConsumeChannel(); - expect(mockChannel.queueDeclare).toHaveBeenCalledWith( - expect.any(String), - expect.any(Object), - expect.objectContaining({ - "x-max-length-bytes": 5000000, - }) - ); - }); - }); - - describe("error handling", () => { - it("should handle JSON parse errors in consumer", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - const badMessage = createMockAMQPMessage("invalid json", {}); - - await handler(badMessage); - - // Should ACK even on error - expect(badMessage.ack).toHaveBeenCalled(); - }); - - it("should handle empty message body", async () => { - const mockChannel = await mockConnection.getConsumeChannel(); - await taskStore.initialize(); - - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; - const handler = consumeCall[2]; - - const emptyMessage = createMockAMQPMessage("", {}); - - await handler(emptyMessage); - - expect(emptyMessage.ack).toHaveBeenCalled(); - }); - - it("should handle error when stopping consumer on close", async () => { - await taskStore.initialize(); - - // Mock consumer.cancel to throw error - const mockCancel = vi.fn().mockRejectedValue(new Error("Cancel failed")); - (taskStore as any).consumer = { cancel: mockCancel }; - (taskStore as any).consumerTag = "test-consumer"; - - await expect(taskStore.close()).resolves.not.toThrow(); - - const stats = taskStore.getProjectionStats(); - expect(stats.isInitialized).toBe(false); - expect(stats.taskCount).toBe(0); - }); - }); -}); diff --git a/tests/unit/AMQPWorkQueue.test.ts b/tests/unit/AMQPWorkQueue.test.ts index 9859b2a..fd9012a 100644 --- a/tests/unit/AMQPWorkQueue.test.ts +++ b/tests/unit/AMQPWorkQueue.test.ts @@ -135,7 +135,7 @@ describe("AMQPWorkQueue", () => { await workQueue.initialize(); const mockChannel = await mockConnection.getPublishChannel(); - vi.mocked(mockChannel.basicPublish).mockRejectedValueOnce(new Error("Publish failed")); + mockChannel.basicPublish.mockRejectedValueOnce(new Error("Publish failed")); const taskMessage = createMockQueuedTask(); @@ -184,7 +184,7 @@ describe("AMQPWorkQueue", () => { await workQueue.startConsumer(handler); // Get the consumer handler - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; + const consumeCall = mockChannel.basicConsume.mock.calls[0]; const messageHandler = consumeCall[2]; // Simulate receiving a message @@ -209,7 +209,7 @@ describe("AMQPWorkQueue", () => { await workQueue.startConsumer(handler); - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; + const consumeCall = mockChannel.basicConsume.mock.calls[0]; const messageHandler = consumeCall[2]; // Simulate empty message @@ -230,7 +230,7 @@ describe("AMQPWorkQueue", () => { await workQueue.startConsumer(handler); - const consumeCall = vi.mocked(mockChannel.basicConsume).mock.calls[0]; + const consumeCall = mockChannel.basicConsume.mock.calls[0]; const messageHandler = consumeCall[2]; const taskMessage = createMockQueuedTask(); diff --git a/tests/unit/QueueingRequestHandler.test.ts b/tests/unit/QueuingRequestHandler.test.ts similarity index 55% rename from tests/unit/QueueingRequestHandler.test.ts rename to tests/unit/QueuingRequestHandler.test.ts index 1981358..15b05b6 100644 --- a/tests/unit/QueueingRequestHandler.test.ts +++ b/tests/unit/QueuingRequestHandler.test.ts @@ -1,9 +1,9 @@ /** - * Unit tests for QueueingRequestHandler + * Unit tests for QueuingRequestHandler */ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { QueueingRequestHandler } from "../../src/lib/QueueingRequestHandler.js"; +import { QueuingRequestHandler } from "../../src/lib/QueuingRequestHandler.js"; import type { AMQPAgentBackend } from "../../src/lib/AMQPAgentBackend.js"; import type { TaskStore } from "@a2a-js/sdk/server"; import type { Task } from "@a2a-js/sdk"; @@ -16,63 +16,59 @@ import { createMockArtifactUpdate, } from "../helpers/fixtures.js"; -describe("QueueingRequestHandler", () => { - let handler: QueueingRequestHandler; +describe("QueuingRequestHandler", () => { + let handler: QueuingRequestHandler; let mockAgentCard: ReturnType; - let mockTaskStore: TaskStore; let mockBackend: AMQPAgentBackend; let mockWorkQueue: any; - let mockEventBus: any; + let mockConnection: any; beforeEach(() => { mockAgentCard = createMockAgentCard(); - // Mock TaskStore with EventEmitter capabilities - mockTaskStore = { - save: vi.fn().mockResolvedValue(undefined), - load: vi.fn().mockResolvedValue(null), - on: vi.fn(), - off: vi.fn(), - emit: vi.fn(), - } as unknown as TaskStore; - - // Mock EventBus with event emitter capabilities - mockEventBus = { - publish: vi.fn(), - publishForTask: vi.fn(), - on: vi.fn(), - off: vi.fn(), - emit: vi.fn(), - removeAllListeners: vi.fn(), - startConsuming: vi.fn().mockResolvedValue(undefined), - stopConsuming: vi.fn().mockResolvedValue(undefined), - getTaskId: vi.fn().mockReturnValue("test-task-id"), - getContextId: vi.fn().mockReturnValue("test-context-id"), + // Mock AMQPConnection + mockConnection = { + getConfig: vi.fn().mockReturnValue({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + stream: { + maxAge: "7d", + maxLengthBytes: 1000000, + }, + publishing: { + persistent: true, + messageTtl: 0, + }, + }), + getPublishChannel: vi.fn().mockResolvedValue({ + basicPublish: vi.fn().mockResolvedValue(undefined), + }), + getConsumeChannel: vi.fn().mockResolvedValue({ + queueDeclare: vi.fn().mockResolvedValue(undefined), + queueBind: vi.fn().mockResolvedValue(undefined), + basicConsume: vi.fn().mockResolvedValue({ + cancel: vi.fn().mockResolvedValue(undefined), + }), + }), + getExchange: vi.fn().mockReturnValue("test-exchange"), }; // Mock WorkQueue mockWorkQueue = { enqueue: vi.fn().mockResolvedValue(undefined), - connection: { - getConfig: vi.fn().mockReturnValue({ - logger: { - prefix: "[test-agent] ", - }, - }), - }, }; // Mock Backend mockBackend = { - createEventBus: vi.fn().mockReturnValue(mockEventBus), - eventBusFactoryInstance: { - create: vi.fn().mockReturnValue(mockEventBus), - }, - taskStore: mockTaskStore, + amqpConnection: mockConnection, workQueue: mockWorkQueue, } as unknown as AMQPAgentBackend; - handler = new QueueingRequestHandler( + handler = new QueuingRequestHandler( mockAgentCard, mockBackend ); @@ -95,7 +91,7 @@ describe("QueueingRequestHandler", () => { }); describe("sendMessage", () => { - it("should create task, save to store, publish to event bus, and enqueue to work queue", async () => { + it("should create task, publish to AMQP, and enqueue to work queue", async () => { const params = createMockMessageSendParams(); const task = await handler.sendMessage(params); @@ -113,12 +109,9 @@ describe("QueueingRequestHandler", () => { artifacts: [], }); - // Verify task was saved - expect(mockTaskStore.save).toHaveBeenCalledWith(task); - - // Verify EventBus was created and task published - expect(mockBackend.createEventBus).toHaveBeenCalledWith(task.id, task.contextId); - expect(mockEventBus.publish).toHaveBeenCalledWith(task); + // Verify event was published to AMQP + const publishChannel = await mockConnection.getPublishChannel(); + expect(publishChannel.basicPublish).toHaveBeenCalled(); // Verify task was enqueued to work queue expect(mockWorkQueue.enqueue).toHaveBeenCalledWith( @@ -165,7 +158,7 @@ describe("QueueingRequestHandler", () => { }); describe("sendMessageStream", () => { - it("should yield initial task and subscribe to TaskStore events", async () => { + it("should yield initial task and subscribe to events", async () => { const params = createMockMessageSendParams(); const generator = handler.sendMessageStream(params); @@ -177,11 +170,9 @@ describe("QueueingRequestHandler", () => { status: { state: "submitted" }, }); - // Verify task was saved - expect(mockTaskStore.save).toHaveBeenCalled(); - - // Verify TaskStore event listener was registered - expect(mockTaskStore.on).toHaveBeenCalledWith('task-event', expect.any(Function)); + // Verify event was published to AMQP + const publishChannel = await mockConnection.getPublishChannel(); + expect(publishChannel.basicPublish).toHaveBeenCalled(); // Verify task was enqueued expect(mockWorkQueue.enqueue).toHaveBeenCalled(); @@ -190,7 +181,7 @@ describe("QueueingRequestHandler", () => { await generator.return(); }); - it("should stream events from TaskStore", async () => { + it("should stream events", async () => { const params = createMockMessageSendParams(); const generator = handler.sendMessageStream(params); @@ -198,17 +189,6 @@ describe("QueueingRequestHandler", () => { // Get initial task const firstResult = await generator.next(); expect(firstResult.done).toBe(false); - const taskId = firstResult.value.id; - - // Verify TaskStore event listener was registered - expect(mockTaskStore.on).toHaveBeenCalledWith('task-event', expect.any(Function)); - - // Extract the event handler to verify it's a function - const onCalls = vi.mocked(mockTaskStore.on).mock.calls; - const taskEventCall = onCalls.find(call => call[0] === 'task-event'); - expect(taskEventCall).toBeDefined(); - const eventHandler = taskEventCall![1]; - expect(typeof eventHandler).toBe('function'); // Verify task was enqueued expect(mockWorkQueue.enqueue).toHaveBeenCalled(); @@ -216,27 +196,26 @@ describe("QueueingRequestHandler", () => { // Cleanup await generator.return(); - // Verify cleanup - expect(mockTaskStore.off).toHaveBeenCalledWith('task-event', expect.any(Function)); - // Note: Full streaming behavior with real events is tested in integration tests }); }); describe("getTask", () => { - it("should return task from store", async () => { - const task = createMockTask({ id: "task-123" }); - vi.mocked(mockTaskStore.load).mockResolvedValueOnce(task); + it("should return task from internal cache", async () => { + // Simulate task being in cache by first creating it + const params = createMockMessageSendParams(); + const task = await handler.sendMessage(params); - const result = await handler.getTask({ id: "task-123" }); + // Manually add task to handler's internal cache + // (In real usage, this happens via event consumption) + (handler as any).tasks.set(task.id, task); + + const result = await handler.getTask({ id: task.id }); expect(result).toEqual(task); - expect(mockTaskStore.load).toHaveBeenCalledWith("task-123"); }); it("should throw if task not found", async () => { - vi.mocked(mockTaskStore.load).mockResolvedValueOnce(null); - await expect(handler.getTask({ id: "nonexistent" })).rejects.toThrow( "Task not found: nonexistent" ); @@ -246,7 +225,9 @@ describe("QueueingRequestHandler", () => { describe("cancelTask", () => { it("should cancel task and publish status update", async () => { const task = createMockTask({ id: "task-123" }); - vi.mocked(mockTaskStore.load).mockResolvedValueOnce(task); + + // Add task to internal cache + (handler as any).tasks.set("task-123", task); const result = await handler.cancelTask({ id: "task-123" }); @@ -254,27 +235,12 @@ describe("QueueingRequestHandler", () => { expect(result.status.state).toBe("canceled"); expect(result.status.timestamp).toBeDefined(); - // Verify task was saved - expect(mockTaskStore.save).toHaveBeenCalledWith( - expect.objectContaining({ - id: "task-123", - status: { state: "canceled", timestamp: expect.any(String) }, - }) - ); - - // Verify status update was published - expect(mockEventBus.publish).toHaveBeenCalledWith({ - kind: "status-update", - taskId: "task-123", - contextId: task.contextId, - status: { state: "canceled", timestamp: expect.any(String) }, - final: true, - }); + // Verify status update was published to AMQP + const publishChannel = await mockConnection.getPublishChannel(); + expect(publishChannel.basicPublish).toHaveBeenCalled(); }); it("should throw if task not found", async () => { - vi.mocked(mockTaskStore.load).mockResolvedValueOnce(null); - await expect(handler.cancelTask({ id: "nonexistent" })).rejects.toThrow( "Task not found: nonexistent" ); @@ -308,9 +274,11 @@ describe("QueueingRequestHandler", () => { }); describe("resubscribe", () => { - it("should yield task and register event listener", async () => { + it("should yield task", async () => { const task = createMockTask({ id: "task-123" }); - vi.mocked(mockTaskStore.load).mockResolvedValueOnce(task); + + // Add task to internal cache + (handler as any).tasks.set("task-123", task); const generator = handler.resubscribe({ id: "task-123" }); @@ -319,52 +287,28 @@ describe("QueueingRequestHandler", () => { expect(firstResult.value).toEqual(task); expect(firstResult.done).toBe(false); - // Continue the generator to set up the event listener - const nextPromise = generator.next(); - - // Give generator time to register the listener - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify TaskStore event listener was registered - expect(mockTaskStore.on).toHaveBeenCalledWith('task-event', expect.any(Function)); - - // Extract the event handler to verify it's a function - const onCalls = vi.mocked(mockTaskStore.on).mock.calls; - const taskEventCall = onCalls.find(call => call[0] === 'task-event'); - expect(taskEventCall).toBeDefined(); - const eventHandler = taskEventCall![1]; - expect(typeof eventHandler).toBe('function'); - // Note: Full event filtering and streaming behavior is tested in integration tests - // Generator cleanup is not tested here as it would require emitting final events + await generator.return(); }); it("should throw if task not found", async () => { - vi.mocked(mockTaskStore.load).mockResolvedValueOnce(null); - const generator = handler.resubscribe({ id: "nonexistent" }); await expect(generator.next()).rejects.toThrow("Task not found: nonexistent"); }); - it("should subscribe to TaskStore events", async () => { - const task = createMockTask({ id: "task-123" }); - vi.mocked(mockTaskStore.load).mockResolvedValueOnce(task); + it("should subscribe to events for non-finished tasks", async () => { + const task = createMockTask({ id: "task-123", status: { state: "working" } }); + + // Add task to internal cache + (handler as any).tasks.set("task-123", task); const generator = handler.resubscribe({ id: "task-123" }); await generator.next(); // Get initial task - // Continue the generator in background to set up the event listener - const nextPromise = generator.next(); - - // Give generator time to register the listener - await new Promise(resolve => setTimeout(resolve, 50)); - - // Verify TaskStore event listener was registered - expect(mockTaskStore.on).toHaveBeenCalledWith('task-event', expect.any(Function)); - - // Note: Generator cleanup is not tested here as it would require emitting final events + // Note: Full streaming behavior is tested in integration tests + await generator.return(); }); }); }); From 9f7690ffeba29ff343a91a886efa11cf857102a6 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Thu, 13 Nov 2025 10:38:45 +0100 Subject: [PATCH 02/10] feat: Add Zod schema validation for QueuedTaskMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert QueuedTaskMessage from interface to Zod schema - Export QueuedTaskMessageSchema for runtime validation - Add zod as a dependency - Add comprehensive unit tests for schema validation - Use z.custom() for Message and Task types from @a2a-js/sdk - Maintain backward compatibility by exporting inferred type The schema enables runtime validation of work queue messages, providing better error handling and type safety. All 73 unit tests and 3 integration tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- bun.lock | 7 +- package.json | 3 +- src/index.ts | 1 + src/lib/QueuedTaskMessage.ts | 136 ++++++++++++++++----------- tests/unit/QueuedTaskMessage.test.ts | 103 ++++++++++++++++++++ 5 files changed, 192 insertions(+), 58 deletions(-) create mode 100644 tests/unit/QueuedTaskMessage.test.ts diff --git a/bun.lock b/bun.lock index 6d7c1e1..f740aa5 100644 --- a/bun.lock +++ b/bun.lock @@ -4,7 +4,8 @@ "": { "name": "a2a-amqp", "dependencies": { - "@cloudamqp/amqp-client": "^3.1.1", + "@cloudamqp/amqp-client": "^3.4.0", + "zod": "^4.1.12", }, "devDependencies": { "@types/bun": "latest", @@ -34,7 +35,7 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - "@cloudamqp/amqp-client": ["@cloudamqp/amqp-client@3.3.2", "", {}, "sha512-fNCrPCGXc2m1XWaLAaFIn+utWXc02NImEC8lxp5qfqLLqX1gV1kgS0oBVhLl+NA6sTR1SkM1ye0LJWGoczOayw=="], + "@cloudamqp/amqp-client": ["@cloudamqp/amqp-client@3.4.0", "", {}, "sha512-ie7IjTCByDuFRYGNt78W3ixgs2DNWoBl7ybXBO0aGf9lPaIs2yCJcEWqBFgxZEKb3pWIsBngBt44gPvnuXurXg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], @@ -420,6 +421,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], + "@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], diff --git a/package.json b/package.json index 41477b0..7971b37 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "author": "84codes", "license": "MIT", "dependencies": { - "@cloudamqp/amqp-client": "^3.1.1" + "@cloudamqp/amqp-client": "^3.4.0", + "zod": "^4.1.12" }, "devDependencies": { "@types/bun": "latest", diff --git a/src/index.ts b/src/index.ts index c17b94c..341a4a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,3 +28,4 @@ export type { ConsumeResult, } from "./lib/types.js"; export type { QueuedTaskMessage } from "./lib/QueuedTaskMessage.js"; +export { QueuedTaskMessageSchema } from "./lib/QueuedTaskMessage.js"; diff --git a/src/lib/QueuedTaskMessage.ts b/src/lib/QueuedTaskMessage.ts index 2a2278e..be35197 100644 --- a/src/lib/QueuedTaskMessage.ts +++ b/src/lib/QueuedTaskMessage.ts @@ -1,80 +1,106 @@ +import { z } from "zod"; import type { Task, Message } from "@a2a-js/sdk"; /** * Message format for queued tasks in the AMQP work queue * This is what gets serialized and sent from HTTP server to worker process */ -export interface QueuedTaskMessage { + +// Configuration schema for task execution +const QueuedTaskConfigurationSchema = z.object({ + /** + * Whether client expects blocking response + * (HTTP server uses this to determine streaming behavior) + */ + blocking: z.boolean().optional(), + + /** + * Push notification configuration + */ + pushNotificationConfig: z.any().optional(), +}).optional(); + +// Metadata schema for queue management +const QueuedTaskMetadataSchema = z.object({ + /** + * Timestamp when task was enqueued + */ + enqueuedAt: z.string(), + + /** + * Priority level (higher = more important) + * @default 0 + */ + priority: z.number().optional(), + + /** + * Number of times this task has been retried + * @default 0 + */ + retryCount: z.number().optional(), + + /** + * Original request ID for tracking + */ + requestId: z.string().optional(), +}); + +// Request context schema +const RequestContextSchema = z.object({ + /** + * The user message that triggered this task + */ + userMessage: z.custom((val) => { + return typeof val === "object" && val !== null && "kind" in val; + }), + + /** + * Existing task if continuing/resuming + */ + task: z.custom((val) => { + return typeof val === "object" && val !== null && "kind" in val && val.kind === "task"; + }).optional(), + + /** + * Referenced tasks for context + */ + referenceTasks: z.array(z.custom((val) => { + return typeof val === "object" && val !== null && "kind" in val && val.kind === "task"; + })).optional(), +}); + +/** + * Zod schema for QueuedTaskMessage + * Validates the message format for queued tasks in the AMQP work queue + */ +export const QueuedTaskMessageSchema = z.object({ /** * Unique task identifier */ - taskId: string; + taskId: z.string(), /** * Context identifier for grouping related tasks */ - contextId: string; + contextId: z.string(), /** * Request context information needed for execution */ - requestContext: { - /** - * The user message that triggered this task - */ - userMessage: Message; - - /** - * Existing task if continuing/resuming - */ - task?: Task; - - /** - * Referenced tasks for context - */ - referenceTasks?: Task[]; - }; + requestContext: RequestContextSchema, /** * Configuration for task execution */ - configuration?: { - /** - * Whether client expects blocking response - * (HTTP server uses this to determine streaming behavior) - */ - blocking?: boolean; - - /** - * Push notification configuration - */ - pushNotificationConfig?: any; - }; + configuration: QueuedTaskConfigurationSchema, /** * Metadata for queue management */ - metadata: { - /** - * Timestamp when task was enqueued - */ - enqueuedAt: string; - - /** - * Priority level (higher = more important) - * @default 0 - */ - priority?: number; - - /** - * Number of times this task has been retried - * @default 0 - */ - retryCount?: number; - - /** - * Original request ID for tracking - */ - requestId?: string; - }; -} + metadata: QueuedTaskMetadataSchema, +}); + +/** + * TypeScript type inferred from the Zod schema + */ +export type QueuedTaskMessage = z.infer; diff --git a/tests/unit/QueuedTaskMessage.test.ts b/tests/unit/QueuedTaskMessage.test.ts new file mode 100644 index 0000000..b23d597 --- /dev/null +++ b/tests/unit/QueuedTaskMessage.test.ts @@ -0,0 +1,103 @@ +/** + * Unit tests for QueuedTaskMessage Zod schema + */ + +import { describe, it, expect } from "vitest"; +import { QueuedTaskMessageSchema } from "../../src/lib/QueuedTaskMessage.js"; +import { createMockQueuedTask } from "../helpers/fixtures.js"; + +describe("QueuedTaskMessageSchema", () => { + describe("valid messages", () => { + it("should validate a valid queued task message", () => { + const validMessage = createMockQueuedTask(); + + const result = QueuedTaskMessageSchema.safeParse(validMessage); + + expect(result.success).toBe(true); + }); + + it("should validate with optional fields omitted", () => { + const validMessage = createMockQueuedTask(); + delete validMessage.configuration; + delete validMessage.metadata.priority; + delete validMessage.metadata.retryCount; + delete validMessage.metadata.requestId; + + const result = QueuedTaskMessageSchema.safeParse(validMessage); + + expect(result.success).toBe(true); + }); + }); + + describe("invalid messages", () => { + it("should reject message without taskId", () => { + const invalidMessage = createMockQueuedTask(); + delete (invalidMessage as any).taskId; + + const result = QueuedTaskMessageSchema.safeParse(invalidMessage); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toContain("taskId"); + } + }); + + it("should reject message without contextId", () => { + const invalidMessage = createMockQueuedTask(); + delete (invalidMessage as any).contextId; + + const result = QueuedTaskMessageSchema.safeParse(invalidMessage); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toContain("contextId"); + } + }); + + it("should reject message without metadata.enqueuedAt", () => { + const invalidMessage = createMockQueuedTask(); + delete (invalidMessage as any).metadata.enqueuedAt; + + const result = QueuedTaskMessageSchema.safeParse(invalidMessage); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toContain("enqueuedAt"); + } + }); + + it("should reject message with invalid priority type", () => { + const invalidMessage = createMockQueuedTask(); + (invalidMessage as any).metadata.priority = "high"; // Should be number + + const result = QueuedTaskMessageSchema.safeParse(invalidMessage); + + expect(result.success).toBe(false); + }); + }); + + describe("parsing and validation", () => { + it("should parse valid JSON string", () => { + const validMessage = createMockQueuedTask(); + const jsonString = JSON.stringify(validMessage); + const parsed = JSON.parse(jsonString); + + const result = QueuedTaskMessageSchema.safeParse(parsed); + + expect(result.success).toBe(true); + }); + + it("should provide typed data on successful validation", () => { + const validMessage = createMockQueuedTask(); + + const result = QueuedTaskMessageSchema.safeParse(validMessage); + + if (result.success) { + // Type should be inferred correctly + expect(result.data.taskId).toBe(validMessage.taskId); + expect(result.data.contextId).toBe(validMessage.contextId); + expect(result.data.metadata.enqueuedAt).toBe(validMessage.metadata.enqueuedAt); + } + }); + }); +}); From 3285e8a3ecf94c2145edca534effdc0d54e6b640 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Thu, 13 Nov 2025 14:05:16 +0100 Subject: [PATCH 03/10] refactor: Migrate AMQPWorkQueue to async generator pattern and update tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes to AMQPWorkQueue: - Replace callback-based consumer with async generator pattern - Make queueName required in constructor (no default) - Remove startConsumer/stopConsumer methods in favor of start() generator - Remove enqueue routingKey parameter (use instance property) - Remove close() method (cleanup handled by connection) - Store routingKey as instance property Test updates: - Update all unit tests to match new async generator API - Fix integration test to use generator pattern for worker - Add proper test isolation by running unit and integration tests separately - Improve integration test cleanup order (close backends first) - Remove queue deletion from cleanup (tests use unique timestamped names) - Add afterEach cleanup to QueuingRequestHandler unit tests Other improvements: - Update worker example to use async generator pattern - Fix QueuingRequestHandler projection consumer to use async iteration - Remove unused WorkerEventBus getter methods - Update package.json test script to run test:unit && test:integration All tests passing: 68 unit tests + 3 integration tests ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package.json | 2 +- src/examples/worker.ts | 58 +++--- src/lib/AMQPAgentBackend.ts | 3 - src/lib/AMQPWorkQueue.ts | 101 +++++------ src/lib/QueuingRequestHandler.ts | 60 ++++--- src/lib/WorkerEventBus.ts | 21 --- tests/integration/e2e-happy-path.test.ts | 89 ++++----- tests/unit/AMQPAgentBackend.test.ts | 13 +- tests/unit/AMQPWorkQueue.test.ts | 219 ++++++++++++----------- tests/unit/QueuingRequestHandler.test.ts | 16 +- 10 files changed, 279 insertions(+), 303 deletions(-) diff --git a/package.json b/package.json index 7971b37..e1bd0bf 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "prepare": "bun run build", "server": "bun run src/examples/http-server.ts", "worker": "bun run src/examples/worker.ts", - "test": "vitest run", + "test": "bun run test:unit && bun run test:integration", "test:unit": "vitest run --config vitest.config.ts", "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest watch --config vitest.config.ts", diff --git a/src/examples/worker.ts b/src/examples/worker.ts index eb7f774..3065007 100644 --- a/src/examples/worker.ts +++ b/src/examples/worker.ts @@ -161,38 +161,12 @@ async function main() { console.log(`[${WORKER_ID}] AMQP backend initialized, starting consumer...`); // Start consuming from work queue - const consumerTag = await workQueue.startConsumer(async (taskMessage: QueuedTaskMessage) => { - const { taskId, contextId, requestContext } = taskMessage; - - console.log(`[${WORKER_ID}] Received task ${taskId} from queue`); - - try { - // Create RequestContext - const reqContext = new RequestContext( - requestContext.userMessage, - taskId, - contextId, - requestContext.task, - requestContext.referenceTasks - ); - - // Create WorkerEventBus for this task to publish events - const eventBus = new WorkerEventBus(backend.amqpConnection, taskId, contextId); - - // Execute the task - await executor.execute(reqContext, eventBus); - } catch (error) { - console.error(`[${WORKER_ID}] Failed to process task ${taskId}:`, error); - // Error handling: task will be NACK'd and requeued by AMQPWorkQueue - throw error; - } - }); + const messages = await workQueue.start() console.log(` 🔧 A2A Worker is running! Worker ID: ${WORKER_ID} -Consumer Tag: ${consumerTag} Queue: ${workQueue.getQueueName()} Waiting for tasks from work queue... @@ -210,9 +184,6 @@ Press Ctrl+C to stop. console.log(`\n[${WORKER_ID}] Shutting down...`); try { - // Stop consuming - await workQueue.stopConsumer(consumerTag); - // Close backend await backend.close(); @@ -226,6 +197,33 @@ Press Ctrl+C to stop. process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); + + for await (const taskMessage of messages) { + const { taskId, contextId, requestContext } = taskMessage; + + console.log(`[${WORKER_ID}] Received task ${taskId} from queue`); + + try { + // Create RequestContext + const reqContext = new RequestContext( + requestContext.userMessage, + taskId, + contextId, + requestContext.task, + requestContext.referenceTasks + ); + + // Create WorkerEventBus for this task to publish events + const eventBus = new WorkerEventBus(backend.amqpConnection, taskId, contextId); + + // Execute the task + await executor.execute(reqContext, eventBus); + } catch (error) { + console.error(`[${WORKER_ID}] Failed to process task ${taskId}:`, error); + // Error handling: task will be NACK'd and requeued by AMQPWorkQueue + throw error; + } + } } // Run the worker diff --git a/src/lib/AMQPAgentBackend.ts b/src/lib/AMQPAgentBackend.ts index fb5134b..5fc9b66 100644 --- a/src/lib/AMQPAgentBackend.ts +++ b/src/lib/AMQPAgentBackend.ts @@ -223,9 +223,6 @@ export class AMQPAgentBackend { * Close all connections and clean up resources */ async close(): Promise { - // Close work queue - await this.workQueueInstance.close(); - // Close AMQP connection await this.connection.close(); } diff --git a/src/lib/AMQPWorkQueue.ts b/src/lib/AMQPWorkQueue.ts index 48cd438..5f46b9a 100644 --- a/src/lib/AMQPWorkQueue.ts +++ b/src/lib/AMQPWorkQueue.ts @@ -1,5 +1,5 @@ import type { AMQPConnection } from "./AMQPConnection.js"; -import type { QueuedTaskMessage } from "./QueuedTaskMessage.js"; +import { QueuedTaskMessageSchema, type QueuedTaskMessage } from "./QueuedTaskMessage.js"; import type { Logger } from "./types.js"; /** @@ -16,10 +16,11 @@ export class AMQPWorkQueue { private queueName: string; private logger: Logger; private isInitialized = false; + private routingKey = "a2a.work.task"; - constructor(connection: AMQPConnection, queueName?: string) { + constructor(connection: AMQPConnection, queueName: string) { this.connection = connection; - this.queueName = queueName || "a2a.work-queue"; + this.queueName = queueName; this.logger = connection.getConfig().logger; } @@ -64,15 +65,12 @@ export class AMQPWorkQueue { */ async enqueue( taskMessage: QueuedTaskMessage, - routingKey: string = "a2a.work.task" ): Promise { if (!this.isInitialized) { throw new Error("Work queue not initialized. Call initialize() first."); } const channel = await this.connection.getPublishChannel(); - const exchange = this.connection.getExchange(); - const config = this.connection.getConfig(); // Build message properties const properties = { @@ -92,7 +90,7 @@ export class AMQPWorkQueue { const body = JSON.stringify(taskMessage); try { - await channel.basicPublish(exchange, routingKey, body, properties); + await channel.basicPublish(this.exchange, this.routingKey, body, properties); this.logger.debug(`Task ${taskMessage.taskId} enqueued to work queue`); } catch (error) { this.logger.error(`Failed to enqueue task ${taskMessage.taskId}:`, error); @@ -103,13 +101,10 @@ export class AMQPWorkQueue { /** * Start consuming from the work queue * Sets up a competing consumer that processes one task at a time - * - * @param handler - Async function to process each task message - * @returns Consumer tag for cancellation + * Yields each task message to the caller for processing + * @returns messages - Async generator of queued task messages */ - async startConsumer( - handler: (taskMessage: QueuedTaskMessage) => Promise - ): Promise { + async* start(): AsyncGenerator { if (!this.isInitialized) { throw new Error("Work queue not initialized. Call initialize() first."); } @@ -117,59 +112,48 @@ export class AMQPWorkQueue { const channel = await this.connection.getConsumeChannel(); const consumerTag = `worker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - // Set prefetch to 1 for fair distribution (one task at a time) await channel.prefetch(1); this.logger.info(`Starting consumer '${consumerTag}' on queue '${this.queueName}'`); // Start consuming - await channel.basicConsume( + const consumer = await channel.basicConsume( this.queueName, { - noAck: false, // Manual ACK for reliability + noAck: false, // Manual ack after task processing. tag: consumerTag, }, - async (msg) => { - try { - // Parse message body - const body = msg.bodyToString(); - if (!body) { - this.logger.warn("Received empty message, nacking"); - msg.nack(false, false); // Don't requeue - return; - } - - const taskMessage: QueuedTaskMessage = JSON.parse(body); - this.logger.info(`Processing task ${taskMessage.taskId}`); - - // Call handler - await handler(taskMessage); - - // ACK on success - msg.ack(); - this.logger.info(`Task ${taskMessage.taskId} completed successfully`); - } catch (error) { - this.logger.error(`Error processing task:`, error); - - // NACK and requeue for retry - // TODO: Implement max retry count and dead letter queue - msg.nack(false, true); // Requeue for retry - } - } ); - return consumerTag; - } + for await (const msg of consumer.messages) { + try { + // Parse message body + const body = msg.bodyToString(); + if (!body) { + this.logger.warn("Received empty message, nacking"); + msg.nack(false, false); // Don't requeue + continue; + } - /** - * Stop consuming from the work queue - * - * @param consumerTag - The consumer tag returned from startConsumer() - */ - async stopConsumer(consumerTag: string): Promise { - const channel = await this.connection.getConsumeChannel(); - await channel.basicCancel(consumerTag); - this.logger.info(`Consumer '${consumerTag}' stopped`); + // Parse JSON and validate with Zod schema + const parsed = JSON.parse(body); + const taskMessage = QueuedTaskMessageSchema.parse(parsed); + this.logger.info(`Processing task ${taskMessage.taskId}`); + + // Call handler + yield taskMessage; + + // ACK on success + msg.ack(); + this.logger.info(`Task ${taskMessage.taskId} completed successfully`); + } catch (error) { + this.logger.error(`Error processing task:`, error); + + // NACK and requeue for retry + // TODO: Implement max retry count and dead letter queue + msg.nack(false, true); // Requeue for retry + } + } } /** @@ -186,12 +170,7 @@ export class AMQPWorkQueue { return this.isInitialized; } - /** - * Close and cleanup - */ - async close(): Promise { - // Channel cleanup is handled by AMQPConnection - this.isInitialized = false; - this.logger.info("Work queue closed"); + private get exchange(): string { + return this.connection.getExchange(); } } diff --git a/src/lib/QueuingRequestHandler.ts b/src/lib/QueuingRequestHandler.ts index f2b2273..e61a4c1 100644 --- a/src/lib/QueuingRequestHandler.ts +++ b/src/lib/QueuingRequestHandler.ts @@ -25,6 +25,7 @@ import type { AMQPConnection } from "./AMQPConnection.js"; import type { AMQPWorkQueue } from "./AMQPWorkQueue.js"; import type { QueuedTaskMessage } from "./QueuedTaskMessage.js"; import type { A2AEvent, EventMetadata, AMQPEventMessage, Logger } from "./types.js"; +import { AMQPGeneratorConsumer } from "@cloudamqp/amqp-client/amqp-consumer"; /** * Request handler that enqueues tasks to AMQP work queue @@ -41,7 +42,7 @@ export class QueuingRequestHandler { // Stream consumer state (from TaskStore) private streamQueueName: string; - private consumer: any = null; + private consumer: AMQPGeneratorConsumer | null = null; private consumerTag: string | null = null; private isInitialized = false; @@ -118,7 +119,8 @@ export class QueuingRequestHandler { this.logger.info(`Event stream queue '${this.streamQueueName}' initialized`); // Start persistent consumer to maintain in-memory projection - await this.startProjectionConsumer(); + // Run in background - don't await the message processing loop + this.startProjectionConsumer(); this.isInitialized = true; } @@ -141,39 +143,39 @@ export class QueuingRequestHandler { "x-stream-offset": "first", // Read from beginning }, }, - async (msg) => { - try { - const body = msg.bodyToString(); - if (!body) { - msg.ack(); - return; - } - - const payload: AMQPEventMessage = JSON.parse(body); - - // Validate payload has event - if (!payload || !payload.event) { - msg.ack(); - return; - } + ); - const event = payload.event; - const metadata = payload.metadata; + this.logger.info("Event projection consumer started"); + for await (const msg of this.consumer.messages) { + try { + const body = msg.bodyToString(); + if (!body) { + msg.ack(); + continue; + } - // Update in-memory projection - this.applyEventToProjection(event, metadata.taskId); + const payload: AMQPEventMessage = JSON.parse(body); - // Acknowledge message - msg.ack(); - } catch (error) { - this.logger.error("Error processing event in projection:", error); - // Acknowledge even on error to avoid blocking the stream + // Validate payload has event + if (!payload || !payload.event) { msg.ack(); + continue; } - } - ); - this.logger.info("Event projection consumer started"); + const event = payload.event; + const metadata = payload.metadata; + + // Update in-memory projection + this.applyEventToProjection(event, metadata.taskId); + + // Acknowledge message + msg.ack(); + } catch (error) { + this.logger.error("Error processing event in projection:", error); + // Acknowledge even on error to avoid blocking the stream + msg.ack(); + } + } } /** diff --git a/src/lib/WorkerEventBus.ts b/src/lib/WorkerEventBus.ts index 82a2ff3..5727fd9 100644 --- a/src/lib/WorkerEventBus.ts +++ b/src/lib/WorkerEventBus.ts @@ -154,25 +154,4 @@ export class WorkerEventBus extends EventEmitter implements ExecutionEventBus { return `a2a.${this.contextId}.${this.taskId}.event`; } } - - /** - * Get the task ID this EventBus is associated with - */ - getTaskId(): string { - return this.taskId; - } - - /** - * Get the context ID this EventBus is associated with - */ - getContextId(): string { - return this.contextId; - } - - /** - * Check if this EventBus has finished - */ - isEventBusFinished(): boolean { - return this.isFinished; - } } diff --git a/tests/integration/e2e-happy-path.test.ts b/tests/integration/e2e-happy-path.test.ts index 0da1261..4249b2c 100644 --- a/tests/integration/e2e-happy-path.test.ts +++ b/tests/integration/e2e-happy-path.test.ts @@ -126,7 +126,8 @@ describe("E2E Happy Path", () => { let serverBackend: AMQPAgentBackend; let workerBackend: AMQPAgentBackend; let requestHandler: QueuingRequestHandler; - let consumerTag: string; + let workerGenerator: AsyncGenerator | null = null; + let workQueueName: string; beforeAll(async () => { // Setup server backend @@ -158,61 +159,65 @@ describe("E2E Happy Path", () => { logger: silentLogger, }); - // Start worker consumer - const executor = new TestExecutor(); - consumerTag = await workerBackend.workQueue.startConsumer( - async (taskMessage: QueuedTaskMessage) => { - const { taskId, contextId, requestContext } = taskMessage; - - const reqContext = new RequestContext( - requestContext.userMessage, - taskId, - contextId, - requestContext.task, - requestContext.referenceTasks - ); - - // Create WorkerEventBus for this task - const eventBus = new WorkerEventBus(workerBackend.amqpConnection, taskId, contextId); - - await executor.execute(reqContext, eventBus); - } - ); - }); + // Initialize work queue + await workerBackend.workQueue.initialize(); - afterAll(async () => { - // Stop worker consumer - if (consumerTag && workerBackend?.workQueue) { - await workerBackend.workQueue.stopConsumer(consumerTag); - } + // Store queue name for cleanup (before starting consumer) + workQueueName = `${AGENT_NAME}.work-queue`; + + // Start worker consumer using async generator + const executor = new TestExecutor(); + workerGenerator = workerBackend.workQueue.start(); - // Delete queues created during tests - try { - const channel = await serverBackend.amqpConnection.getConsumeChannel(); + // Process messages in the background + (async () => { + try { + for await (const taskMessage of workerGenerator!) { + const { taskId, contextId, requestContext } = taskMessage; - // Delete event stream queue - const eventQueueName = `${agentCard.name}.events`; - await channel.queueDelete(eventQueueName); + const reqContext = new RequestContext( + requestContext.userMessage, + taskId, + contextId, + requestContext.task, + requestContext.referenceTasks + ); - // Delete work queue - const workQueueName = workerBackend.workQueue.getQueueName(); - await channel.queueDelete(workQueueName); + // Create WorkerEventBus for this task + const eventBus = new WorkerEventBus(workerBackend.amqpConnection, taskId, contextId); - console.log("Test queues deleted successfully"); - } catch (error: any) { - // Queue might not exist, which is fine for cleanup - if (!error.message?.includes("NOT_FOUND")) { - console.error("Error deleting test queues:", error); + await executor.execute(reqContext, eventBus); + } + } catch (error: any) { + // Generator was closed or consumer cancelled, which is expected during cleanup + if (!error?.message?.includes("Consumer cancelled")) { + console.error("Worker consumer error:", error); + } } + })(); + }); + + afterAll(async () => { + // Stop worker consumer and close backends + // Closing backends cleanly closes all consumers and channels + if (workerGenerator) { + workerGenerator.return(); + workerGenerator = null; } - // Close backends if (workerBackend) { await workerBackend.close(); } + if (requestHandler) { + await requestHandler.close(); + } if (serverBackend) { await serverBackend.close(); } + + // Note: We don't explicitly delete test queues here. + // Each test run uses a unique timestamp-based agent name, so queues don't conflict. + // AMQP broker will clean them up based on TTL/inactivity policies. }); it("should process a message through the full pipeline (non-streaming)", { timeout: 15000 }, async () => { diff --git a/tests/unit/AMQPAgentBackend.test.ts b/tests/unit/AMQPAgentBackend.test.ts index 3c21fec..cca98fa 100644 --- a/tests/unit/AMQPAgentBackend.test.ts +++ b/tests/unit/AMQPAgentBackend.test.ts @@ -26,16 +26,16 @@ vi.mock("../../src/lib/AMQPWorkQueue.js", () => { public _connection: any; public _queueName: string; public initialize = vi.fn().mockResolvedValue(undefined); - public close = vi.fn().mockResolvedValue(undefined); public enqueue = vi.fn().mockResolvedValue(undefined); - public startConsumer = vi.fn().mockResolvedValue("consumer-tag"); - public stopConsumer = vi.fn().mockResolvedValue(undefined); + public start = vi.fn().mockReturnValue((async function* () { + // Empty generator for mock + })()); public getQueueName = vi.fn(); public isReady = vi.fn().mockReturnValue(true); - constructor(connection: any, queueName?: string) { + constructor(connection: any, queueName: string) { this._connection = connection; - this._queueName = queueName || "default-queue"; + this._queueName = queueName; this.getQueueName.mockReturnValue(this._queueName); } }, @@ -179,9 +179,6 @@ describe("AMQPAgentBackend", () => { await backend.close(); - // Work queue should be closed - expect(backend.workQueue.close).toHaveBeenCalled(); - // Connection should be closed expect(mockClient.close).toHaveBeenCalled(); }); diff --git a/tests/unit/AMQPWorkQueue.test.ts b/tests/unit/AMQPWorkQueue.test.ts index fd9012a..7191aa6 100644 --- a/tests/unit/AMQPWorkQueue.test.ts +++ b/tests/unit/AMQPWorkQueue.test.ts @@ -15,18 +15,11 @@ describe("AMQPWorkQueue", () => { }); describe("constructor", () => { - it("should create instance with default queue name", () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); + it("should create instance with queue name", () => { + const workQueue = new AMQPWorkQueue(mockConnection as any, "test-queue"); - expect(workQueue.getQueueName()).toBe("a2a.work-queue"); expect(workQueue.isReady()).toBe(false); }); - - it("should create instance with custom queue name", () => { - const workQueue = new AMQPWorkQueue(mockConnection as any, "custom-queue"); - - expect(workQueue.getQueueName()).toBe("custom-queue"); - }); }); describe("initialize", () => { @@ -57,7 +50,7 @@ describe("AMQPWorkQueue", () => { }); it("should mark as initialized", async () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); + const workQueue = new AMQPWorkQueue(mockConnection as any, "test-queue"); expect(workQueue.isReady()).toBe(false); @@ -67,7 +60,7 @@ describe("AMQPWorkQueue", () => { }); it("should be idempotent", async () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); + const workQueue = new AMQPWorkQueue(mockConnection as any, "test-queue"); const mockChannel = await mockConnection.getConsumeChannel(); await workQueue.initialize(); @@ -80,7 +73,7 @@ describe("AMQPWorkQueue", () => { describe("enqueue", () => { it("should throw if not initialized", async () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); + const workQueue = new AMQPWorkQueue(mockConnection as any, "test-queue"); const taskMessage = createMockQueuedTask(); await expect(workQueue.enqueue(taskMessage)).rejects.toThrow( @@ -89,7 +82,7 @@ describe("AMQPWorkQueue", () => { }); it("should publish task message with correct properties", async () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); + const workQueue = new AMQPWorkQueue(mockConnection as any, "test-queue"); await workQueue.initialize(); const mockChannel = await mockConnection.getPublishChannel(); @@ -113,25 +106,8 @@ describe("AMQPWorkQueue", () => { ); }); - it("should use custom routing key", async () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); - await workQueue.initialize(); - - const mockChannel = await mockConnection.getPublishChannel(); - const taskMessage = createMockQueuedTask(); - - await workQueue.enqueue(taskMessage, "a2a.work.custom"); - - expect(mockChannel.basicPublish).toHaveBeenCalledWith( - "test-exchange", - "a2a.work.custom", - expect.any(String), - expect.any(Object) - ); - }); - it("should handle publish errors", async () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); + const workQueue = new AMQPWorkQueue(mockConnection as any, "test-queue"); await workQueue.initialize(); const mockChannel = await mockConnection.getPublishChannel(); @@ -143,12 +119,12 @@ describe("AMQPWorkQueue", () => { }); }); - describe("startConsumer", () => { + describe("start", () => { it("should throw if not initialized", async () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); - const handler = vi.fn(); + const workQueue = new AMQPWorkQueue(mockConnection as any, "test-queue"); - await expect(workQueue.startConsumer(handler)).rejects.toThrow( + const generator = workQueue.start(); + await expect(generator.next()).rejects.toThrow( "Work queue not initialized" ); }); @@ -158,9 +134,23 @@ describe("AMQPWorkQueue", () => { await workQueue.initialize(); const mockChannel = await mockConnection.getConsumeChannel(); - const handler = vi.fn().mockResolvedValue(undefined); - const consumerTag = await workQueue.startConsumer(handler); + // Mock consumer with async generator + const mockMessages = (async function* () { + // Empty generator for this test + })(); + + mockChannel.basicConsume.mockResolvedValueOnce({ + messages: mockMessages + }); + + const generator = workQueue.start(); + + // Start the generator (but don't wait for messages) + const promise = generator.next(); + + // Give it time to set up + await new Promise(resolve => setTimeout(resolve, 10)); expect(mockChannel.prefetch).toHaveBeenCalledWith(1); expect(mockChannel.basicConsume).toHaveBeenCalledWith( @@ -168,121 +158,136 @@ describe("AMQPWorkQueue", () => { expect.objectContaining({ noAck: false, tag: expect.stringContaining("worker-"), - }), - expect.any(Function) + }) ); - expect(consumerTag).toMatch(/^worker-/); }); - it("should process messages and call handler", async () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); + it("should yield messages from the generator", async () => { + const workQueue = new AMQPWorkQueue(mockConnection as any, "test-queue"); await workQueue.initialize(); const mockChannel = await mockConnection.getConsumeChannel(); - const handler = vi.fn().mockResolvedValue(undefined); - await workQueue.startConsumer(handler); - - // Get the consumer handler - const consumeCall = mockChannel.basicConsume.mock.calls[0]; - const messageHandler = consumeCall[2]; - - // Simulate receiving a message + // Simulate receiving messages const taskMessage = createMockQueuedTask(); const message = createMockAMQPMessage( JSON.stringify(taskMessage), { taskId: taskMessage.taskId } ); - await messageHandler(message); - - expect(handler).toHaveBeenCalledWith(taskMessage); + // Mock consumer with async generator - just yield one message then complete + const mockMessages = (async function* () { + yield message; + })(); + + mockChannel.basicConsume.mockResolvedValueOnce({ + messages: mockMessages + }); + + const generator = workQueue.start(); + const result = await Promise.race([ + generator.next(), + new Promise((_, reject) => setTimeout(() => reject(new Error("Generator timeout")), 1000)) + ]); + + expect(result.done).toBe(false); + expect(result.value).toMatchObject({ + taskId: taskMessage.taskId, + contextId: taskMessage.contextId + }); + + // Continue to allow ack to happen + await generator.next(); expect(message.ack).toHaveBeenCalled(); }); - it("should nack empty messages", async () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); + it("should nack empty messages and continue", async () => { + const workQueue = new AMQPWorkQueue(mockConnection as any, "test-queue"); await workQueue.initialize(); const mockChannel = await mockConnection.getConsumeChannel(); - const handler = vi.fn(); - await workQueue.startConsumer(handler); + // Simulate empty message followed by valid message + const emptyMessage = createMockAMQPMessage("", {}); + const taskMessage = createMockQueuedTask(); + const validMessage = createMockAMQPMessage( + JSON.stringify(taskMessage), + { taskId: taskMessage.taskId } + ); + + // Mock consumer with async generator - yield both messages then complete + const mockMessages = (async function* () { + yield emptyMessage; + yield validMessage; + })(); - const consumeCall = mockChannel.basicConsume.mock.calls[0]; - const messageHandler = consumeCall[2]; + mockChannel.basicConsume.mockResolvedValueOnce({ + messages: mockMessages + }); - // Simulate empty message - const message = createMockAMQPMessage("", {}); + const generator = workQueue.start(); - await messageHandler(message); + // First next() should skip empty message and return valid one + const result = await generator.next(); - expect(handler).not.toHaveBeenCalled(); - expect(message.nack).toHaveBeenCalledWith(false, false); // Don't requeue + expect(result.done).toBe(false); + expect(result.value).toMatchObject({ + taskId: taskMessage.taskId, + }); + expect(emptyMessage.nack).toHaveBeenCalledWith(false, false); // Don't requeue + + // Continue to allow ack to happen + await generator.next(); + expect(validMessage.ack).toHaveBeenCalled(); }); - it("should nack and requeue on handler error", async () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); + it("should nack and requeue on parse error", async () => { + const workQueue = new AMQPWorkQueue(mockConnection as any, "test-queue"); await workQueue.initialize(); const mockChannel = await mockConnection.getConsumeChannel(); - const handler = vi.fn().mockRejectedValue(new Error("Handler failed")); - - await workQueue.startConsumer(handler); - - const consumeCall = mockChannel.basicConsume.mock.calls[0]; - const messageHandler = consumeCall[2]; + // Simulate invalid JSON followed by valid message + const invalidMessage = createMockAMQPMessage( + "invalid json", + {} + ); const taskMessage = createMockQueuedTask(); - const message = createMockAMQPMessage( + const validMessage = createMockAMQPMessage( JSON.stringify(taskMessage), { taskId: taskMessage.taskId } ); - await messageHandler(message); + // Mock consumer with async generator - yield both messages then complete + const mockMessages = (async function* () { + yield invalidMessage; + yield validMessage; + })(); - expect(message.nack).toHaveBeenCalledWith(false, true); // Requeue for retry - expect(message.ack).not.toHaveBeenCalled(); - }); - }); + mockChannel.basicConsume.mockResolvedValueOnce({ + messages: mockMessages + }); - describe("stopConsumer", () => { - it("should cancel consumer by tag", async () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); - await workQueue.initialize(); + const generator = workQueue.start(); - const mockChannel = await mockConnection.getConsumeChannel(); - const handler = vi.fn(); - - const consumerTag = await workQueue.startConsumer(handler); - await workQueue.stopConsumer(consumerTag); - - expect(mockChannel.basicCancel).toHaveBeenCalledWith(consumerTag); - }); - }); - - describe("close", () => { - it("should mark as not initialized", async () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); - await workQueue.initialize(); + // Should skip invalid message and return valid one + const result = await generator.next(); - expect(workQueue.isReady()).toBe(true); - - await workQueue.close(); + expect(result.done).toBe(false); + expect(result.value).toMatchObject({ + taskId: taskMessage.taskId, + }); + expect(invalidMessage.nack).toHaveBeenCalledWith(false, true); // Requeue for retry - expect(workQueue.isReady()).toBe(false); + // Continue to allow ack to happen + await generator.next(); + expect(validMessage.ack).toHaveBeenCalled(); }); }); describe("getters", () => { - it("should return queue name", () => { - const workQueue = new AMQPWorkQueue(mockConnection as any, "my-queue"); - - expect(workQueue.getQueueName()).toBe("my-queue"); - }); - it("should return ready status", async () => { - const workQueue = new AMQPWorkQueue(mockConnection as any); + const workQueue = new AMQPWorkQueue(mockConnection as any, "test-queue"); expect(workQueue.isReady()).toBe(false); diff --git a/tests/unit/QueuingRequestHandler.test.ts b/tests/unit/QueuingRequestHandler.test.ts index 15b05b6..983d23f 100644 --- a/tests/unit/QueuingRequestHandler.test.ts +++ b/tests/unit/QueuingRequestHandler.test.ts @@ -2,7 +2,7 @@ * Unit tests for QueuingRequestHandler */ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { QueuingRequestHandler } from "../../src/lib/QueuingRequestHandler.js"; import type { AMQPAgentBackend } from "../../src/lib/AMQPAgentBackend.js"; import type { TaskStore } from "@a2a-js/sdk/server"; @@ -74,6 +74,20 @@ describe("QueuingRequestHandler", () => { ); }); + afterEach(async () => { + // Clean up handler if it was initialized + if (handler && typeof handler.close === 'function') { + try { + await handler.close(); + } catch (error) { + // Ignore errors during cleanup (handler might not be initialized) + } + } + + // Clear all mocks to prevent interference with other tests + vi.clearAllMocks(); + }); + describe("getAgentCard", () => { it("should return the agent card", async () => { const result = await handler.getAgentCard(); From c5e0e2097d8084f44d0829b68658db936d8c7c10 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Thu, 13 Nov 2025 14:13:29 +0100 Subject: [PATCH 04/10] fix: Improve shutdown handling and update documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fixes: - Fix worker shutdown to close async generator before closing backend - Fix server shutdown to close requestHandler (projection consumer) first - Prevents "Consumer cancelled by the server" errors during shutdown Code cleanup: - Remove unnecessary workQueue null check in worker (always initialized) - Add missing semicolon for consistency Documentation: - Update README to match current simplified API - Remove references to old callback-based consumer API - Remove references to removed classes (EventBusManager, TaskStore) - Update worker example to show async generator pattern - Update configuration docs to reflect actual options - Add graceful shutdown details All examples and documentation now match the actual codebase API. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 86 +++++++++++++++++++++++-------------- src/examples/http-server.ts | 7 +++ src/examples/worker.ts | 11 ++--- 3 files changed, 66 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 52fcf18..ed4dd36 100644 --- a/README.md +++ b/README.md @@ -54,23 +54,21 @@ docker run -d -p 5672:5672 -p 15672:15672 cloudamqp/lavinmq:latest ### 1. HTTP Server (enqueues tasks) ```typescript -import { AMQPAgentBackend, QueueingRequestHandler } from "@84codes/a2a-amqp"; +import { AMQPAgentBackend, QueuingRequestHandler } from "@84codes/a2a-amqp"; import { A2AExpressApp } from "@a2a-js/sdk/server/express"; import express from "express"; +// Create AMQP backend const backend = await AMQPAgentBackend.create({ url: "amqp://localhost:5672", agentName: "my-agent", - enableWorkQueue: true, // Enable task queuing }); -const requestHandler = new QueueingRequestHandler( - agentCard, - backend.taskStore, - backend.eventBusManager, - backend.workQueue -); +// Create request handler (handles task queuing + event projection) +const requestHandler = new QueuingRequestHandler(agentCard, backend); +await requestHandler.initialize(); +// Setup Express with A2A routes const app = express(); new A2AExpressApp(requestHandler).setupRoutes(app, "/"); app.listen(3000); @@ -79,23 +77,27 @@ app.listen(3000); ### 2. Worker Process (processes tasks) ```typescript -import { AMQPAgentBackend } from "@84codes/a2a-amqp"; +import { AMQPAgentBackend, WorkerEventBus } from "@84codes/a2a-amqp"; import { AgentExecutor, RequestContext } from "@a2a-js/sdk/server"; +// Create backend with same agent name as server const backend = await AMQPAgentBackend.create({ url: "amqp://localhost:5672", agentName: "my-agent", - enableWorkQueue: true, }); +// Initialize work queue +await backend.workQueue.initialize(); + class MyExecutor implements AgentExecutor { async execute(context: RequestContext, eventBus: ExecutionEventBus) { // Your long-running task logic here - // Events are automatically published to AMQP stream eventBus.publish({ kind: "status-update", taskId: context.taskId, - status: { state: "working" }, + contextId: context.contextId, + status: { state: "working", timestamp: new Date().toISOString() }, + final: false, }); // ... do work ... @@ -103,7 +105,8 @@ class MyExecutor implements AgentExecutor { eventBus.publish({ kind: "status-update", taskId: context.taskId, - status: { state: "completed" }, + contextId: context.contextId, + status: { state: "completed", timestamp: new Date().toISOString() }, final: true, }); eventBus.finished(); @@ -111,21 +114,28 @@ class MyExecutor implements AgentExecutor { } const executor = new MyExecutor(); -await backend.workQueue.startConsumer(async (taskMsg) => { + +// Start consuming with async generator pattern +const messages = backend.workQueue.start(); + +for await (const taskMessage of messages) { + const { taskId, contextId, requestContext } = taskMessage; + + // Create request context const context = new RequestContext( - taskMsg.requestContext.userMessage, - taskMsg.taskId, - taskMsg.contextId + requestContext.userMessage, + taskId, + contextId, + requestContext.task, + requestContext.referenceTasks ); - const eventBus = backend.eventBusManager.createOrGetByTaskIdWithContext( - taskMsg.taskId, - taskMsg.contextId - ); + // Create event bus for publishing task events + const eventBus = new WorkerEventBus(backend.amqpConnection, taskId, contextId); + // Execute task await executor.execute(context, eventBus); - backend.eventBusManager.cleanupByTaskId(taskMsg.taskId); -}); +} ``` ### 3. Scale horizontally @@ -145,11 +155,11 @@ bun run worker # Add more workers for higher throughput - **Work Queue**: Distribute tasks across multiple worker processes - **Event Sourcing**: All task events stored in AMQP streams for replay -- **Task Store**: In-memory task projection with automatic recovery from streams +- **In-Memory Projection**: Fast task lookups with automatic recovery from streams - **SSE Streaming**: Automatic streaming of task events back to clients - **Horizontal Scaling**: Add more workers to increase throughput -- **Fault Tolerance**: Task replay from event streams on crashes -- **Type-Safe**: Full TypeScript support +- **Graceful Shutdown**: Clean consumer and connection handling +- **Type-Safe**: Full TypeScript support with Zod validation ## Configuration @@ -157,13 +167,21 @@ bun run worker # Add more workers for higher throughput interface AMQPAgentBackendConfig { url: string; // AMQP broker URL agentName: string; // Agent identifier - enableWorkQueue?: boolean; // Enable task queuing (default: false) streamRetention?: string; // Event retention (default: "7d") streamMaxBytes?: number; // Max stream size (default: 1GB) - workQueueName?: string; // Custom queue name - taskQueueName?: string; // Custom task store name + workQueueName?: string; // Custom work queue name exchangeName?: string; // Custom exchange name logger?: Logger; // Custom logger + connection?: { + heartbeat?: number; // Heartbeat interval in seconds + reconnectDelay?: number; // Reconnection delay in ms + maxReconnectAttempts?: number;// Max reconnection attempts + }; + publishing?: { + persistent?: boolean; // Persistent messages (default: true) + confirmMode?: boolean; // Publisher confirms (default: true) + messageTtl?: number; // Message TTL in ms (0 = no expiration) + }; } ``` @@ -171,7 +189,7 @@ interface AMQPAgentBackendConfig { See complete working examples: -- `src/examples/http-server-sdk.ts` - HTTP server with queuing +- `src/examples/http-server.ts` - HTTP server with queuing - `src/examples/worker.ts` - Worker process ```bash @@ -188,9 +206,11 @@ curl -X POST http://localhost:3000/ \ ## Testing ```bash -bun test # Run all tests -bun run test:unit # Unit tests only -bun run test:watch # Watch mode +bun test # Run all tests (unit + integration) +bun run test:unit # Unit tests only +bun run test:integration # Integration tests only +bun run test:watch # Watch mode +bun run test:coverage # With coverage ``` ## License diff --git a/src/examples/http-server.ts b/src/examples/http-server.ts index 7f7f884..1f97b0c 100644 --- a/src/examples/http-server.ts +++ b/src/examples/http-server.ts @@ -155,7 +155,14 @@ Make sure to run worker.ts in another process! process.on("SIGINT", async () => { console.log("\nShutting down..."); server.close(); + + // Close request handler first (stops projection consumer) + await requestHandler.close(); + + // Then close backend await backend.close(); + + console.log("Server stopped gracefully"); process.exit(0); }); } diff --git a/src/examples/worker.ts b/src/examples/worker.ts index 3065007..1f32b2c 100644 --- a/src/examples/worker.ts +++ b/src/examples/worker.ts @@ -151,17 +151,13 @@ async function main() { agentName: AGENT_NAME, }); - if (!backend.workQueue) { - throw new Error("Work queue not initialized"); - } - const workQueue = backend.workQueue; const executor = new GreetingExecutor(); console.log(`[${WORKER_ID}] AMQP backend initialized, starting consumer...`); // Start consuming from work queue - const messages = await workQueue.start() + const messages = workQueue.start(); console.log(` 🔧 A2A Worker is running! @@ -184,6 +180,11 @@ Press Ctrl+C to stop. console.log(`\n[${WORKER_ID}] Shutting down...`); try { + // Close generator first to stop consuming messages + if (messages) { + await messages.return(); + } + // Close backend await backend.close(); From c4a6e7a00c1b3b3913a3b95d4afa6d76069704b2 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Thu, 13 Nov 2025 14:15:22 +0100 Subject: [PATCH 05/10] fix: Add proper queue cleanup in integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Integration tests were leaving queues behind in LavinMQ broker Solution: Add proper cleanup after closing all connections: 1. Close all backends and consumers first 2. Create fresh AMQP connection for cleanup 3. Delete both test queues (event stream + work queue) 4. Close cleanup connection Each test run creates uniquely named queues (timestamp-based), so they were accumulating in the broker without explicit deletion. Now properly cleans up: - Event stream queue: "Test Agent.events" - Work queue: "test-agent-{timestamp}.work-queue" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/integration/e2e-happy-path.test.ts | 34 +++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/tests/integration/e2e-happy-path.test.ts b/tests/integration/e2e-happy-path.test.ts index 4249b2c..988184f 100644 --- a/tests/integration/e2e-happy-path.test.ts +++ b/tests/integration/e2e-happy-path.test.ts @@ -215,9 +215,37 @@ describe("E2E Happy Path", () => { await serverBackend.close(); } - // Note: We don't explicitly delete test queues here. - // Each test run uses a unique timestamp-based agent name, so queues don't conflict. - // AMQP broker will clean them up based on TTL/inactivity policies. + // Delete test queues after all connections are closed + // Create a fresh connection just for cleanup + try { + const { AMQPClient } = await import("@cloudamqp/amqp-client"); + const cleanupClient = new AMQPClient(AMQP_URL); + await cleanupClient.connect(); + const cleanupChannel = await cleanupClient.channel(); + + // Delete event stream queue + const eventQueueName = `${agentCard.name}.events`; + try { + await cleanupChannel.queueDelete(eventQueueName); + } catch (error: any) { + if (!error.message?.includes("NOT_FOUND")) { + console.error(`Failed to delete event queue: ${error.message}`); + } + } + + // Delete work queue + try { + await cleanupChannel.queueDelete(workQueueName); + } catch (error: any) { + if (!error.message?.includes("NOT_FOUND")) { + console.error(`Failed to delete work queue: ${error.message}`); + } + } + + await cleanupClient.close(); + } catch (error) { + console.error("Error during queue cleanup:", error); + } }); it("should process a message through the full pipeline (non-streaming)", { timeout: 15000 }, async () => { From ac61ba568cc344e70fdb245e3e5f805fb04344e9 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Thu, 13 Nov 2025 14:28:38 +0100 Subject: [PATCH 06/10] refactor: Simplify routing key bindings to use catch-all pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: 5 specific routing patterns - a2a.*.*.task - a2a.*.*.message - a2a.*.*.status - a2a.*.*.artifact - a2a.*.*.event After: Single catch-all pattern - a2a.# Benefits: - Simpler code (1 binding instead of 5) - Less broker overhead - Easier to extend (new event types need no code changes) - Functionally equivalent (QueuingRequestHandler consumes all event types) The fine-grained patterns only add value if different consumers need different event types. Since we have one consumer that needs everything, the catch-all pattern is cleaner. Publisher confirms remain: - Default: enabled (confirmMode: true) - Configurable via AMQPAgentBackendConfig.publishing.confirmMode - Provides guaranteed delivery at cost of latency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/lib/QueuingRequestHandler.ts | 16 +++------------- tests/integration/e2e-happy-path.test.ts | 16 ++++++++-------- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/src/lib/QueuingRequestHandler.ts b/src/lib/QueuingRequestHandler.ts index e61a4c1..2f5d060 100644 --- a/src/lib/QueuingRequestHandler.ts +++ b/src/lib/QueuingRequestHandler.ts @@ -102,19 +102,9 @@ export class QueuingRequestHandler { queueArgs ); - // Bind to exchange with routing keys to capture event messages - // Pattern: a2a.{contextId}.{taskId}.{eventType} - const eventRoutingPatterns = [ - "a2a.*.*.task", // Task creation events - "a2a.*.*.message", // Message events - "a2a.*.*.status", // Status update events - "a2a.*.*.artifact", // Artifact update events - "a2a.*.*.event", // Generic events - ]; - - for (const pattern of eventRoutingPatterns) { - await channel.queueBind(this.streamQueueName, exchange, pattern); - } + // Bind to exchange with catch-all routing key to capture all A2A events + // Pattern: a2a.# matches a2a.{contextId}.{taskId}.{eventType} + await channel.queueBind(this.streamQueueName, exchange, "a2a.#"); this.logger.info(`Event stream queue '${this.streamQueueName}' initialized`); diff --git a/tests/integration/e2e-happy-path.test.ts b/tests/integration/e2e-happy-path.test.ts index 988184f..0cf4b54 100644 --- a/tests/integration/e2e-happy-path.test.ts +++ b/tests/integration/e2e-happy-path.test.ts @@ -147,10 +147,10 @@ describe("E2E Happy Path", () => { // Setup worker backend with silent logger to avoid duplicate projection logs const silentLogger = { - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, + info: () => { }, + warn: () => { }, + error: () => { }, + debug: () => { }, }; workerBackend = await AMQPAgentBackend.create({ @@ -422,9 +422,9 @@ describe("E2E Happy Path", () => { // Verify task structure expect(retrievedTask).toBeDefined(); - expect(retrievedTask!.id).toBe(taskId); - expect(retrievedTask!.status.state).toBe("completed"); - expect(retrievedTask!.history.length).toBeGreaterThan(1); - expect(retrievedTask!.artifacts).toHaveLength(1); + expect(retrievedTask.id).toBe(taskId); + expect(retrievedTask.status.state).toBe("completed"); + expect(retrievedTask.history.length).toBeGreaterThan(1); + expect(retrievedTask.artifacts).toHaveLength(1); }); }); From a41f8f6ca605eae45e4f3d9065639b945b5acd2f Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Thu, 13 Nov 2025 14:40:49 +0100 Subject: [PATCH 07/10] fix: Add TypeScript strict null checks to test files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tests/**/* to tsconfig includes for type checking - Remove rootDir constraint to allow checking test files - Fix null safety issues in e2e tests with ! assertions - Fix optional property access in unit tests - Add proper type casts for vitest mock methods - Complete AgentCard fixture with all required properties - Add prefix type to mock logger 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/helpers/fixtures.ts | 9 ++++++ tests/helpers/mocks.ts | 37 ++++++++++++------------ tests/integration/e2e-happy-path.test.ts | 20 ++++++------- tests/unit/AMQPConnection.test.ts | 6 ++-- tests/unit/AMQPWorkQueue.test.ts | 12 ++++---- tests/unit/QueuedTaskMessage.test.ts | 6 ++-- tsconfig.json | 3 +- 7 files changed, 51 insertions(+), 42 deletions(-) diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts index 7a1cbcf..13632a2 100644 --- a/tests/helpers/fixtures.ts +++ b/tests/helpers/fixtures.ts @@ -150,6 +150,15 @@ export function createMockAgentCard(overrides?: Partial): AgentCard { name: "Test Agent", description: "A test agent for unit testing", url: "https://test.example.com/agent", + protocolVersion: "1.0.0", + version: "1.0.0", + defaultInputModes: ["text/plain"], + defaultOutputModes: ["text/plain"], + skills: [], + capabilities: { + streaming: true, + pushNotifications: false, + }, ...overrides, }; } diff --git a/tests/helpers/mocks.ts b/tests/helpers/mocks.ts index 3ff8e33..e99b735 100644 --- a/tests/helpers/mocks.ts +++ b/tests/helpers/mocks.ts @@ -17,6 +17,7 @@ export function createMockLogger() { warn: vi.fn(), error: vi.fn(), } as Logger & { + prefix: string; debug: Mock; info: Mock; warn: Mock; @@ -29,21 +30,21 @@ export function createMockLogger() { */ export function createMockChannel() { return { - queueDeclare: vi.fn().mockResolvedValue(undefined) as Mock, - queueBind: vi.fn().mockResolvedValue(undefined) as Mock, - basicPublish: vi.fn().mockResolvedValue(undefined) as Mock, + queueDeclare: vi.fn().mockResolvedValue(undefined), + queueBind: vi.fn().mockResolvedValue(undefined), + basicPublish: vi.fn().mockResolvedValue(undefined), basicConsume: vi.fn().mockImplementation(async (_queue, _options, handler) => { // Return a mock consumer return { cancel: vi.fn().mockResolvedValue(undefined), handler, }; - }) as Mock, - close: vi.fn().mockResolvedValue(undefined) as Mock, - confirmSelect: vi.fn().mockResolvedValue(undefined) as Mock, - prefetch: vi.fn().mockResolvedValue(undefined) as Mock, - exchangeDeclare: vi.fn().mockResolvedValue(undefined) as Mock, - basicCancel: vi.fn().mockResolvedValue(undefined) as Mock, + }), + close: vi.fn().mockResolvedValue(undefined), + confirmSelect: vi.fn().mockResolvedValue(undefined), + prefetch: vi.fn().mockResolvedValue(undefined), + exchangeDeclare: vi.fn().mockResolvedValue(undefined), + basicCancel: vi.fn().mockResolvedValue(undefined), }; } @@ -54,9 +55,9 @@ export function createMockAMQPClient() { const channel = createMockChannel(); return { - connect: vi.fn().mockResolvedValue(undefined) as Mock, - channel: vi.fn().mockResolvedValue(channel) as Mock, - close: vi.fn().mockResolvedValue(undefined) as Mock, + connect: vi.fn().mockResolvedValue(undefined), + channel: vi.fn().mockResolvedValue(channel), + close: vi.fn().mockResolvedValue(undefined), closed: false, onerror: null as any, // Error handler property _mockChannel: channel, // Expose for testing @@ -87,12 +88,12 @@ export function createMockAMQPMessage(body: string, headers: Record redelivered: false, // AMQPMessage methods - bodyToString: vi.fn().mockReturnValue(body) as Mock, - bodyString: vi.fn().mockReturnValue(body) as Mock, - ack: vi.fn().mockResolvedValue(undefined) as Mock, - nack: vi.fn().mockResolvedValue(undefined) as Mock, - reject: vi.fn().mockResolvedValue(undefined) as Mock, - cancelConsumer: vi.fn().mockResolvedValue({}) as Mock, + bodyToString: vi.fn().mockReturnValue(body), + bodyString: vi.fn().mockReturnValue(body), + ack: vi.fn().mockResolvedValue(undefined), + nack: vi.fn().mockResolvedValue(undefined), + reject: vi.fn().mockResolvedValue(undefined), + cancelConsumer: vi.fn().mockResolvedValue({}), }; } diff --git a/tests/integration/e2e-happy-path.test.ts b/tests/integration/e2e-happy-path.test.ts index 0cf4b54..40e6738 100644 --- a/tests/integration/e2e-happy-path.test.ts +++ b/tests/integration/e2e-happy-path.test.ts @@ -273,7 +273,7 @@ describe("E2E Happy Path", () => { expect(task.contextId).toBe("test-context-1"); expect(task.status.state).toBe("submitted"); expect(task.history).toHaveLength(1); - expect(task.history[0]).toEqual(userMessage); + expect(task.history![0]).toEqual(userMessage); // Wait for worker to process the task // We'll poll the request handler to check for completion @@ -301,8 +301,8 @@ describe("E2E Happy Path", () => { expect(completedTask!.status.state).toBe("completed"); // Verify task has agent response in history - expect(completedTask!.history.length).toBeGreaterThan(1); - const agentMessage = completedTask!.history.find( + expect(completedTask!.history!.length).toBeGreaterThan(1); + const agentMessage = completedTask!.history!.find( (msg) => msg.kind === "message" && msg.role === "agent" ) as Message | undefined; expect(agentMessage).toBeDefined(); @@ -313,8 +313,8 @@ describe("E2E Happy Path", () => { // Verify artifact was created expect(completedTask!.artifacts).toHaveLength(1); - expect(completedTask!.artifacts[0].artifactId).toBe("test-artifact"); - expect(completedTask!.artifacts[0].name).toBe("result.txt"); + expect(completedTask!.artifacts![0]!.artifactId).toBe("test-artifact"); + expect(completedTask!.artifacts![0]!.name).toBe("result.txt"); }); it("should stream events through the full pipeline", { timeout: 15000 }, async () => { @@ -405,7 +405,7 @@ describe("E2E Happy Path", () => { while (Date.now() - startTime < maxWaitTime) { try { const currentTask = await requestHandler.getTask({ id: taskId }); - if (currentTask.status.state === "completed") { + if (currentTask && currentTask.status.state === "completed") { retrievedTask = currentTask; break; } @@ -422,9 +422,9 @@ describe("E2E Happy Path", () => { // Verify task structure expect(retrievedTask).toBeDefined(); - expect(retrievedTask.id).toBe(taskId); - expect(retrievedTask.status.state).toBe("completed"); - expect(retrievedTask.history.length).toBeGreaterThan(1); - expect(retrievedTask.artifacts).toHaveLength(1); + expect(retrievedTask!.id).toBe(taskId); + expect(retrievedTask!.status.state).toBe("completed"); + expect(retrievedTask!.history!.length).toBeGreaterThan(1); + expect(retrievedTask!.artifacts).toHaveLength(1); }); }); diff --git a/tests/unit/AMQPConnection.test.ts b/tests/unit/AMQPConnection.test.ts index aefba0d..fda6f8b 100644 --- a/tests/unit/AMQPConnection.test.ts +++ b/tests/unit/AMQPConnection.test.ts @@ -86,7 +86,7 @@ describe("AMQPConnection", () => { }); it("should not enable confirm mode when disabled in config", async () => { - config.publishing.confirmMode = false; + config.publishing!.confirmMode = false; const connection = new AMQPConnection(config); await connection.connect(); @@ -168,7 +168,7 @@ describe("AMQPConnection", () => { await expect(connection.close()).rejects.toThrow("Close failed"); // Should log error - expect(config.logger.error).toHaveBeenCalled(); + expect(config.logger!.error).toHaveBeenCalled(); }); }); @@ -200,7 +200,7 @@ describe("AMQPConnection", () => { await expect(connection.connect()).rejects.toThrow("Connection failed"); // Should log error - expect(config.logger.error).toHaveBeenCalled(); + expect(config.logger!.error).toHaveBeenCalled(); }); }); }); diff --git a/tests/unit/AMQPWorkQueue.test.ts b/tests/unit/AMQPWorkQueue.test.ts index 7191aa6..10a9d22 100644 --- a/tests/unit/AMQPWorkQueue.test.ts +++ b/tests/unit/AMQPWorkQueue.test.ts @@ -111,7 +111,7 @@ describe("AMQPWorkQueue", () => { await workQueue.initialize(); const mockChannel = await mockConnection.getPublishChannel(); - mockChannel.basicPublish.mockRejectedValueOnce(new Error("Publish failed")); + (mockChannel.basicPublish as any).mockRejectedValueOnce(new Error("Publish failed")); const taskMessage = createMockQueuedTask(); @@ -140,7 +140,7 @@ describe("AMQPWorkQueue", () => { // Empty generator for this test })(); - mockChannel.basicConsume.mockResolvedValueOnce({ + (mockChannel.basicConsume as any).mockResolvedValueOnce({ messages: mockMessages }); @@ -180,7 +180,7 @@ describe("AMQPWorkQueue", () => { yield message; })(); - mockChannel.basicConsume.mockResolvedValueOnce({ + (mockChannel.basicConsume as any).mockResolvedValueOnce({ messages: mockMessages }); @@ -188,7 +188,7 @@ describe("AMQPWorkQueue", () => { const result = await Promise.race([ generator.next(), new Promise((_, reject) => setTimeout(() => reject(new Error("Generator timeout")), 1000)) - ]); + ]) as IteratorResult; expect(result.done).toBe(false); expect(result.value).toMatchObject({ @@ -221,7 +221,7 @@ describe("AMQPWorkQueue", () => { yield validMessage; })(); - mockChannel.basicConsume.mockResolvedValueOnce({ + (mockChannel.basicConsume as any).mockResolvedValueOnce({ messages: mockMessages }); @@ -264,7 +264,7 @@ describe("AMQPWorkQueue", () => { yield validMessage; })(); - mockChannel.basicConsume.mockResolvedValueOnce({ + (mockChannel.basicConsume as any).mockResolvedValueOnce({ messages: mockMessages }); diff --git a/tests/unit/QueuedTaskMessage.test.ts b/tests/unit/QueuedTaskMessage.test.ts index b23d597..449a94d 100644 --- a/tests/unit/QueuedTaskMessage.test.ts +++ b/tests/unit/QueuedTaskMessage.test.ts @@ -38,7 +38,7 @@ describe("QueuedTaskMessageSchema", () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues[0].path).toContain("taskId"); + expect(result.error.issues[0]?.path).toContain("taskId"); } }); @@ -50,7 +50,7 @@ describe("QueuedTaskMessageSchema", () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues[0].path).toContain("contextId"); + expect(result.error.issues[0]?.path).toContain("contextId"); } }); @@ -62,7 +62,7 @@ describe("QueuedTaskMessageSchema", () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues[0].path).toContain("enqueuedAt"); + expect(result.error.issues[0]?.path).toContain("enqueuedAt"); } }); diff --git a/tsconfig.json b/tsconfig.json index 9b67e73..97d09e6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,6 @@ "declarationMap": true, "sourceMap": true, "outDir": "./dist", - "rootDir": "./src", // Interop "esModuleInterop": true, @@ -28,6 +27,6 @@ "skipLibCheck": true, "resolveJsonModule": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist"] } From 043fc476cbbb969c85a0f1f40258b6194535bd55 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Thu, 13 Nov 2025 15:13:46 +0100 Subject: [PATCH 08/10] chore: Remove outdated refactoring notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REFACTORING-NOTES.md described a partial refactoring of AMQPEventBus, TaskScopedEventBus, and AMQPStreamTaskStore. These classes have since been completely removed and replaced with a simpler architecture: - WorkerEventBus for publishing events from workers - QueuingRequestHandler with built-in event projection - No task store or factory pattern needed The document is no longer relevant to the current codebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- REFACTORING-NOTES.md | 119 ------------------------------------------- 1 file changed, 119 deletions(-) delete mode 100644 REFACTORING-NOTES.md diff --git a/REFACTORING-NOTES.md b/REFACTORING-NOTES.md deleted file mode 100644 index c587af4..0000000 --- a/REFACTORING-NOTES.md +++ /dev/null @@ -1,119 +0,0 @@ -# Refactoring Notes: Stateless EventBus Design - -## Current State (Partially Implemented) - -**Date**: 2025-01-10 - -### Overview -Working on refactoring `AMQPEventBus` from a stateful (task-scoped) design to a stateless (shared) design. This will simplify the architecture and reduce the number of EventBus instances. - -### Intended Architecture - -#### Before (Task-Scoped EventBus) -``` -AMQPAgentBackend - ├─ AMQPEventBusFactory - │ └─ Creates new AMQPEventBus per task (with taskId, contextId) - └─ Each EventBus instance tracks its own task -``` - -#### After (Stateless Shared EventBus) -``` -AMQPAgentBackend - ├─ Single shared AMQPEventBus (no taskId/contextId) - └─ TaskScopedEventBus wrapper provides task context - └─ Passes taskId, contextId on each publish() call -``` - -### Changes Made So Far - -1. ✅ **Removed sequence tracking** (completed, all tests pass) - - Removed `EventMetadata.sequence` field - - Removed `eventSequence` counter from AMQPEventBus - - Removed `sequenceMap` from AMQPAgentBackend - - Removed `getSequence()` from TaskScopedEventBus - -2. 🔶 **Made AMQPEventBus stateless** (partially done, currently broken) - - Changed constructor: `constructor(connection: AMQPConnection)` - no taskId/contextId - - Removed `extends EventEmitter` from AMQPEventBus - - Changed `publishForTask()` to `publish()` with (event, taskId, contextId) parameters - - Made it async: `async publish(...): Promise` - - Updated TaskScopedEventBus.publish() to call new signature - -### What's Broken (Known Issues) - -1. **AMQPEventBusFactory** still tries to create EventBus with old signature: - ```typescript - create(taskId: string, contextId?: string): AMQPEventBus { - return new AMQPEventBus(this.connection, taskId, contextId); // ❌ Wrong signature - } - ``` - -2. **Consumer Mode** (`startConsuming`, `stopConsuming`) references removed fields: - - References `this.taskId` and `this.contextId` which no longer exist - - Calls `this.emit()` but class no longer extends EventEmitter - - Used in legacy code path (probably can be removed entirely) - -3. **Unit Tests** instantiate EventBus with old signature: - ```typescript - new AMQPEventBus(mockConnection, taskId, contextId) // ❌ Wrong signature - ``` - -### TODO: Complete the Refactoring - -#### Option A: Minimal Fix (Keep Factory Pattern) -1. Fix AMQPEventBusFactory.create() to use new signature: - ```typescript - create(taskId: string, contextId?: string): AMQPEventBus { - return new AMQPEventBus(this.connection); // ✅ Correct - } - ``` - - Note: taskId/contextId parameters become unused (can be removed later) - -2. Remove consumer mode entirely (dead code since TaskStore handles consumption) - - Remove `startConsuming()` method - - Remove `stopConsuming()` method - - Remove `isInConsumerMode()` method - - Remove consumer mode fields: `isConsumerMode`, `consumer`, `consumerTag`, `streamQueueName` - -3. Update all unit tests: - ```typescript - new AMQPEventBus(mockConnection) // ✅ Correct - ``` - -#### Option B: Full Refactor (Remove Factory) -1. Remove AMQPEventBusFactory entirely -2. AMQPAgentBackend creates one shared EventBus directly -3. createEventBus() just returns TaskScopedEventBus wrappers -4. Remove all consumer mode code -5. Update all tests - -### Why This Design? - -#### Benefits of Stateless EventBus -1. **Fewer instances**: One shared EventBus instead of N task-scoped instances -2. **Simpler lifecycle**: No need to track/cleanup EventBus per task -3. **Clearer separation**: EventBus = AMQP publishing, TaskScopedEventBus = task context -4. **Already done for TaskStore**: TaskStore uses shared EventEmitter pattern successfully - -#### Event Consumption Strategy -- ✅ TaskStore handles AMQP consumption (single stream consumer) -- ✅ TaskStore emits to Node EventEmitter -- ✅ SSE clients subscribe to TaskStore events (fast in-memory) -- ❌ EventBus consumer mode no longer needed - -### Next Steps - -Choose Option A or B, then: -1. Fix the broken code -2. Update all tests -3. Run integration tests -4. Remove this document once complete - -### Related Files - -- `src/lib/AMQPEventBus.ts` - Main EventBus implementation -- `src/lib/TaskScopedEventBus.ts` - Wrapper that adds task context -- `src/lib/AMQPAgentBackend.ts` - Creates shared EventBus -- `src/lib/AMQPStreamTaskStore.ts` - Consumes events via EventEmitter -- `tests/unit/AMQPEventBus.test.ts` - Unit tests (need updating) From fe77dd23a172a736cbed87c2a2cab3dec013a6c8 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Thu, 13 Nov 2025 15:22:29 +0100 Subject: [PATCH 09/10] fix: Ensure exchange declared before queue binding + separate build/typecheck configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix "Exchange not found" error by calling getPublishChannel() before binding - Ensure exchange is declared in both QueuingRequestHandler and AMQPWorkQueue init - Create separate tsconfig.typecheck.json that includes tests for type checking - Main tsconfig.json now only compiles src/**/* to dist/ (excludes tests) - Prevents test files from being compiled and run twice This fixes the CI integration test failures caused by trying to bind queues to an undeclared exchange. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package.json | 2 +- src/lib/AMQPWorkQueue.ts | 3 +++ src/lib/QueuingRequestHandler.ts | 3 +++ tsconfig.json | 4 ++-- tsconfig.typecheck.json | 8 ++++++++ 5 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 tsconfig.typecheck.json diff --git a/package.json b/package.json index e1bd0bf..87b904a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ ], "scripts": { "build": "tsc", - "type-check": "tsc --noEmit", + "type-check": "tsc --project tsconfig.typecheck.json", "prepare": "bun run build", "server": "bun run src/examples/http-server.ts", "worker": "bun run src/examples/worker.ts", diff --git a/src/lib/AMQPWorkQueue.ts b/src/lib/AMQPWorkQueue.ts index 5f46b9a..81db744 100644 --- a/src/lib/AMQPWorkQueue.ts +++ b/src/lib/AMQPWorkQueue.ts @@ -35,6 +35,9 @@ export class AMQPWorkQueue { return; } + // Ensure exchange is declared before binding (publish channel declares exchange) + await this.connection.getPublishChannel(); + const channel = await this.connection.getConsumeChannel(); const exchange = this.connection.getExchange(); diff --git a/src/lib/QueuingRequestHandler.ts b/src/lib/QueuingRequestHandler.ts index 2f5d060..14f01f7 100644 --- a/src/lib/QueuingRequestHandler.ts +++ b/src/lib/QueuingRequestHandler.ts @@ -74,6 +74,9 @@ export class QueuingRequestHandler { return; } + // Ensure exchange is declared before binding (publish channel declares exchange) + await this.connection.getPublishChannel(); + const channel = await this.connection.getConsumeChannel(); const exchange = this.connection.getExchange(); const config = this.connection.getConfig(); diff --git a/tsconfig.json b/tsconfig.json index 97d09e6..b4f8536 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,6 @@ "skipLibCheck": true, "resolveJsonModule": true }, - "include": ["src/**/*", "tests/**/*"], - "exclude": ["node_modules", "dist"] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] } diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..30c8aea --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +} From 08f86da46e16b4ffd4d30e9c60b9d0a58db3195f Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Thu, 13 Nov 2025 15:32:06 +0100 Subject: [PATCH 10/10] refactor: Address Copilot code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace deprecated substr() with slice() throughout codebase - QueuingRequestHandler: 3 occurrences - AMQPWorkQueue: 1 occurrence - WorkerEventBus: 1 occurrence - worker.ts example: 1 occurrence - Remove unused 'promise' variable in AMQPWorkQueue.test.ts - Update slice length from 9 to 11 for consistency Note: Kept logger 'prefix' property in mock as it's needed for TypeScript type compatibility in tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/examples/worker.ts | 2 +- src/lib/AMQPWorkQueue.ts | 2 +- src/lib/QueuingRequestHandler.ts | 6 +++--- src/lib/WorkerEventBus.ts | 2 +- tests/unit/AMQPWorkQueue.test.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/examples/worker.ts b/src/examples/worker.ts index 1f32b2c..41709e2 100644 --- a/src/examples/worker.ts +++ b/src/examples/worker.ts @@ -23,7 +23,7 @@ import { AMQPAgentBackend, WorkerEventBus, type QueuedTaskMessage } from "../ind // AMQP configuration const AMQP_URL = process.env.AMQP_URL || "amqp://localhost:5672"; const AGENT_NAME = "greeting-agent-http"; -const WORKER_ID = process.env.WORKER_ID || `worker-${Math.random().toString(36).substr(2, 9)}`; +const WORKER_ID = process.env.WORKER_ID || `worker-${Math.random().toString(36).slice(2, 11)}`; // Implement the agent executor class GreetingExecutor implements AgentExecutor { diff --git a/src/lib/AMQPWorkQueue.ts b/src/lib/AMQPWorkQueue.ts index 81db744..06faaed 100644 --- a/src/lib/AMQPWorkQueue.ts +++ b/src/lib/AMQPWorkQueue.ts @@ -113,7 +113,7 @@ export class AMQPWorkQueue { } const channel = await this.connection.getConsumeChannel(); - const consumerTag = `worker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const consumerTag = `worker-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; await channel.prefetch(1); diff --git a/src/lib/QueuingRequestHandler.ts b/src/lib/QueuingRequestHandler.ts index 14f01f7..78d1048 100644 --- a/src/lib/QueuingRequestHandler.ts +++ b/src/lib/QueuingRequestHandler.ts @@ -288,7 +288,7 @@ export class QueuingRequestHandler { deliveryMode: config.publishing.persistent ? 2 : 1, priority: 0, timestamp: new Date(), - messageId: `${taskId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + messageId: `${taskId}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, headers: { taskId, contextId, @@ -385,7 +385,7 @@ export class QueuingRequestHandler { const message = params.message; // Generate task ID and context ID - const taskId = `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; const contextId = message.contextId || taskId; // Create initial Task event @@ -442,7 +442,7 @@ export class QueuingRequestHandler { const message = params.message; // Generate task ID and context ID - const taskId = `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; const contextId = message.contextId || taskId; // Create initial Task event diff --git a/src/lib/WorkerEventBus.ts b/src/lib/WorkerEventBus.ts index 5727fd9..f4ee5d4 100644 --- a/src/lib/WorkerEventBus.ts +++ b/src/lib/WorkerEventBus.ts @@ -111,7 +111,7 @@ export class WorkerEventBus extends EventEmitter implements ExecutionEventBus { deliveryMode: config.publishing.persistent ? 2 : 1, priority: 0, timestamp: new Date(), - messageId: `${this.taskId}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + messageId: `${this.taskId}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, headers: { taskId: this.taskId, contextId: this.contextId, diff --git a/tests/unit/AMQPWorkQueue.test.ts b/tests/unit/AMQPWorkQueue.test.ts index 10a9d22..88f3906 100644 --- a/tests/unit/AMQPWorkQueue.test.ts +++ b/tests/unit/AMQPWorkQueue.test.ts @@ -147,7 +147,7 @@ describe("AMQPWorkQueue", () => { const generator = workQueue.start(); // Start the generator (but don't wait for messages) - const promise = generator.next(); + generator.next(); // Give it time to set up await new Promise(resolve => setTimeout(resolve, 10));