diff --git a/package.json b/package.json index 89a16aa1..52f97f19 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,10 @@ "types": "./dist/src/a2a/index.d.ts", "default": "./dist/src/a2a/index.js" }, + "./a2a/express": { + "types": "./dist/src/a2a/express-server.d.ts", + "default": "./dist/src/a2a/express-server.js" + }, "./session/s3-storage": { "types": "./dist/src/session/s3-storage.d.ts", "default": "./dist/src/session/s3-storage.js" diff --git a/src/a2a/index.ts b/src/a2a/index.ts index 1c36e70b..40818e7c 100644 --- a/src/a2a/index.ts +++ b/src/a2a/index.ts @@ -10,7 +10,6 @@ */ export { A2AServer, type A2AServerConfig } from './server.js' -export { A2AExpressServer, type A2AExpressServerConfig } from './express-server.js' export { A2AAgent, type A2AAgentConfig } from './a2a-agent.js' export { A2AStreamUpdateEvent, A2AResultEvent, type A2AEventData, type A2AStreamEvent } from './events.js' export { A2AExecutor } from './executor.js' diff --git a/src/a2a/server.ts b/src/a2a/server.ts index 8ea62a08..eddd0226 100644 --- a/src/a2a/server.ts +++ b/src/a2a/server.ts @@ -43,7 +43,7 @@ export interface A2AServerConfig { * @example * ```typescript * import { Agent } from '@strands-agents/sdk' - * import { A2AExpressServer } from '@strands-agents/sdk/a2a' + * import { A2AExpressServer } from '@strands-agents/sdk/a2a/express' * * const agent = new Agent({ model: 'my-model' }) * const server = new A2AExpressServer({ diff --git a/test/integ/__fixtures__/_setup-global.ts b/test/integ/__fixtures__/_setup-global.ts index 53c742cd..256b4530 100644 --- a/test/integ/__fixtures__/_setup-global.ts +++ b/test/integ/__fixtures__/_setup-global.ts @@ -5,9 +5,14 @@ */ import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager' +import { fromNodeProviderChain } from '@aws-sdk/credential-providers' +import express from 'express' import type { TestProject } from 'vitest/node' import type { ProvidedContext } from 'vitest' -import { fromNodeProviderChain } from '@aws-sdk/credential-providers' + +import { Agent } from '../../../src/agent/agent.js' +import { A2AExpressServer } from '../../../src/a2a/express-server.js' +import { BedrockModel } from '../../../src/models/bedrock.js' /** * Load API keys as environment variables from AWS Secrets Manager @@ -59,7 +64,7 @@ async function loadApiKeysFromSecretsManager(): Promise { /** * Perform shared setup for the integration tests. */ -export async function setup(project: TestProject): Promise { +export async function setup(project: TestProject): Promise<() => void> { console.log('Global setup: Loading API keys from Secrets Manager...') await loadApiKeysFromSecretsManager() console.log('Global setup: API keys loaded into environment') @@ -72,6 +77,13 @@ export async function setup(project: TestProject): Promise { project.provide('provider-bedrock', await getBedrockTestContext(isCI)) project.provide('provider-anthropic', await getAnthropicTestContext(isCI)) project.provide('provider-gemini', await getGeminiTestContext(isCI)) + + const a2aContext = await getA2AServerContext(project) + project.provide('a2a-server', { shouldSkip: a2aContext.shouldSkip, url: a2aContext.url }) + + return () => { + a2aContext.abort?.() + } } async function getOpenAITestContext(isCI: boolean): Promise { @@ -149,3 +161,64 @@ async function getGeminiTestContext(_isCI: boolean): Promise void }> { + const { testFiles } = await project.globTestFiles() + const hasA2ATests = testFiles.some((f) => f.includes('/a2a/')) + + if (!hasA2ATests) { + return { shouldSkip: true, url: undefined } + } + + let credentials + try { + const credentialProvider = fromNodeProviderChain() + credentials = await credentialProvider() + } catch { + console.log('⏭️ A2A server not available (no Bedrock credentials) - A2A integration tests will be skipped') + return { shouldSkip: true, url: undefined } + } + + const model = new BedrockModel({ clientConfig: { credentials } }) + const agent = new Agent({ + model, + printer: false, + systemPrompt: 'You are a helpful assistant. Always respond in a single short sentence.', + }) + + const a2aServer = new A2AExpressServer({ + agent, + name: 'Test A2A Agent', + description: 'Integration test agent', + }) + + // Use createMiddleware() with CORS headers so browser integ tests can reach the server. + // Browser tests run on a different port (Vitest dev server), making this a cross-origin request. + const app = express() + app.use((_req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', '*') + res.setHeader('Access-Control-Allow-Headers', '*') + next() + }) + app.use(a2aServer.createMiddleware()) + + return new Promise((resolve, reject) => { + const server = app.listen(0, '127.0.0.1', () => { + const addr = server.address() as { port: number } + const url = `http://127.0.0.1:${addr.port}` + // Update the agent card URL to reflect the actual bound port. + // createMiddleware() doesn't do this automatically (unlike serve()). + a2aServer.agentCard.url = url + console.log(`⏭️ A2A server started on ${url}`) + resolve({ + shouldSkip: false, + url, + abort: () => server.close(), + }) + }) + server.on('error', reject) + }) +} diff --git a/test/integ/a2a/a2a-agent.test.ts b/test/integ/a2a/a2a-agent.test.ts new file mode 100644 index 00000000..4eafa34a --- /dev/null +++ b/test/integ/a2a/a2a-agent.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, inject, beforeAll } from 'vitest' +import { A2AAgent, A2AStreamUpdateEvent } from '$/sdk/a2a/index.js' +import { collectGenerator } from '$/sdk/__fixtures__/model-test-helpers.js' + +const a2aServer = { + get skip() { + return inject('a2a-server').shouldSkip + }, + get url() { + const url = inject('a2a-server').url + if (!url) throw new Error('A2A server URL not provided') + return url + }, +} + +describe.skipIf(a2aServer.skip)('A2AAgent', () => { + let agent: A2AAgent + + beforeAll(() => { + agent = new A2AAgent({ url: a2aServer.url }) + }) + + describe('invoke', () => { + it('receives a text response and populates agent card metadata', async () => { + const result = await agent.invoke('What is 2+2? Reply with just the number.') + + expect(result.stopReason).toBe('endTurn') + expect(result.lastMessage.role).toBe('assistant') + expect(result.lastMessage.content.length).toBeGreaterThan(0) + expect(result.toString()).toMatch(/4/) + + expect(agent.name).toBe('Test A2A Agent') + expect(agent.description).toBe('Integration test agent') + }) + }) + + describe('stream', () => { + it('yields events and returns final result', async () => { + const { items, result } = await collectGenerator(agent.stream('Say the word test')) + + const streamUpdates = items.filter((e) => e instanceof A2AStreamUpdateEvent) + expect(streamUpdates.length).toBeGreaterThan(0) + + expect(result.stopReason).toBe('endTurn') + expect(result.lastMessage.content[0]!.type).toBe('textBlock') + }) + }) +}) diff --git a/test/integ/a2a/a2a-agent.test.node.ts b/test/integ/a2a/express-server.test.node.ts similarity index 79% rename from test/integ/a2a/a2a-agent.test.node.ts rename to test/integ/a2a/express-server.test.node.ts index 6654a368..50c90a65 100644 --- a/test/integ/a2a/a2a-agent.test.node.ts +++ b/test/integ/a2a/express-server.test.node.ts @@ -7,15 +7,15 @@ import type { Task } from '@a2a-js/sdk' import express from 'express' import { ClientFactory } from '@a2a-js/sdk/client' import { Agent } from '@strands-agents/sdk' -import { A2AExpressServer, A2AAgent, A2AStreamUpdateEvent, A2AResultEvent } from '$/sdk/a2a/index.js' +import { A2AAgent, A2AStreamUpdateEvent, A2AResultEvent } from '$/sdk/a2a/index.js' +import { A2AExpressServer } from '$/sdk/a2a/express-server.js' import { TextBlock } from '$/sdk/types/messages.js' import { encodeBase64 } from '$/sdk/types/media.js' import { collectGenerator } from '$/sdk/__fixtures__/model-test-helpers.js' import { bedrock } from '../__fixtures__/model-providers.js' -describe.skipIf(bedrock.skip)('A2AAgent integration', () => { - describe('with standalone server (A2AExpressServer.serve)', () => { - let a2aAgent: A2AAgent +describe.skipIf(bedrock.skip)('A2AExpressServer', () => { + describe('serve', () => { let a2aServer: A2AExpressServer let abortController: AbortController @@ -35,24 +35,23 @@ describe.skipIf(bedrock.skip)('A2AAgent integration', () => { abortController = new AbortController() await a2aServer.serve({ signal: abortController.signal }) - - a2aAgent = new A2AAgent({ url: `http://127.0.0.1:${a2aServer.port}` }) }) - afterAll(async () => { + afterAll(() => { abortController?.abort() }) - it('invoke receives a text response', async () => { - const result = await a2aAgent.invoke('What is 2+2? Reply with just the number.') + it('serves agent card at well-known endpoint', async () => { + const factory = new ClientFactory() + const client = await factory.createFromUrl(`http://127.0.0.1:${a2aServer.port}`) + const card = await client.getAgentCard() - expect(result.stopReason).toBe('endTurn') - expect(result.lastMessage.role).toBe('assistant') - expect(result.lastMessage.content.length).toBeGreaterThan(0) - expect(result.toString()).toMatch(/4/) + expect(card.name).toBe('Test A2A Agent') + expect(card.description).toBe('Integration test agent') + expect(card.capabilities?.streaming).toBe(true) }) - it('invoke processes an image sent as a file part', async () => { + it('processes an image sent as a file part', async () => { const imagePath = join(process.cwd(), 'test/integ/__resources__/yellow.png') const imageBytes = new Uint8Array(await readFile(imagePath)) @@ -85,17 +84,9 @@ describe.skipIf(bedrock.skip)('A2AAgent integration', () => { expect(texts.toLowerCase()).toContain('yellow') }) - - it('stream yields events and returns final result', async () => { - const { items, result } = await collectGenerator(a2aAgent.stream('Say the word test')) - - expect(items.length).toBeGreaterThan(0) - expect(result.stopReason).toBe('endTurn') - expect(result.lastMessage.content[0]!.type).toBe('textBlock') - }) }) - describe('with express middleware (A2AExpressServer.createMiddleware)', () => { + describe('createMiddleware', () => { const servers: Server[] = [] afterEach(() => { @@ -107,8 +98,6 @@ describe.skipIf(bedrock.skip)('A2AAgent integration', () => { /** * Starts an A2A server on an OS-assigned port and returns the URL. - * We bind express first to discover the port, then create the A2AExpressServer - * with the correct httpUrl so the agent card advertises the right address. */ async function startServer(agent: Agent): Promise<{ url: string }> { return new Promise((resolve, reject) => { diff --git a/test/integ/vitest.d.ts b/test/integ/vitest.d.ts index e0e39ef2..0a5988de 100644 --- a/test/integ/vitest.d.ts +++ b/test/integ/vitest.d.ts @@ -21,5 +21,9 @@ declare module 'vitest' { shouldSkip: boolean apiKey: string | undefined } + ['a2a-server']: { + shouldSkip: boolean + url: string | undefined + } } }