Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 0 additions & 1 deletion src/a2a/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 1 addition & 1 deletion src/a2a/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
77 changes: 75 additions & 2 deletions test/integ/__fixtures__/_setup-global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -59,7 +64,7 @@ async function loadApiKeysFromSecretsManager(): Promise<void> {
/**
* Perform shared setup for the integration tests.
*/
export async function setup(project: TestProject): Promise<void> {
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')
Expand All @@ -72,6 +77,13 @@ export async function setup(project: TestProject): Promise<void> {
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<ProvidedContext['provider-openai']> {
Expand Down Expand Up @@ -149,3 +161,64 @@ async function getGeminiTestContext(_isCI: boolean): Promise<ProvidedContext['pr
shouldSkip: shouldSkip,
}
}

async function getA2AServerContext(
project: TestProject
): Promise<ProvidedContext['a2a-server'] & { abort?: () => 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)
})
}
48 changes: 48 additions & 0 deletions test/integ/a2a/a2a-agent.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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))

Expand Down Expand Up @@ -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(() => {
Expand All @@ -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) => {
Expand Down
4 changes: 4 additions & 0 deletions test/integ/vitest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,9 @@ declare module 'vitest' {
shouldSkip: boolean
apiKey: string | undefined
}
['a2a-server']: {
shouldSkip: boolean
url: string | undefined
}
}
}