Skip to content

Commit aa916f2

Browse files
authored
fix: move A2AExpressServer to dedicated subpath export for browser compatibility (#721)
1 parent 94a5a67 commit aa916f2

File tree

7 files changed

+146
-29
lines changed

7 files changed

+146
-29
lines changed

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@
5454
"types": "./dist/src/a2a/index.d.ts",
5555
"default": "./dist/src/a2a/index.js"
5656
},
57+
"./a2a/express": {
58+
"types": "./dist/src/a2a/express-server.d.ts",
59+
"default": "./dist/src/a2a/express-server.js"
60+
},
5761
"./session/s3-storage": {
5862
"types": "./dist/src/session/s3-storage.d.ts",
5963
"default": "./dist/src/session/s3-storage.js"

src/a2a/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
*/
1111

1212
export { A2AServer, type A2AServerConfig } from './server.js'
13-
export { A2AExpressServer, type A2AExpressServerConfig } from './express-server.js'
1413
export { A2AAgent, type A2AAgentConfig } from './a2a-agent.js'
1514
export { A2AStreamUpdateEvent, A2AResultEvent, type A2AEventData, type A2AStreamEvent } from './events.js'
1615
export { A2AExecutor } from './executor.js'

src/a2a/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export interface A2AServerConfig {
4343
* @example
4444
* ```typescript
4545
* import { Agent } from '@strands-agents/sdk'
46-
* import { A2AExpressServer } from '@strands-agents/sdk/a2a'
46+
* import { A2AExpressServer } from '@strands-agents/sdk/a2a/express'
4747
*
4848
* const agent = new Agent({ model: 'my-model' })
4949
* const server = new A2AExpressServer({

test/integ/__fixtures__/_setup-global.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@
55
*/
66

77
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'
8+
import { fromNodeProviderChain } from '@aws-sdk/credential-providers'
9+
import express from 'express'
810
import type { TestProject } from 'vitest/node'
911
import type { ProvidedContext } from 'vitest'
10-
import { fromNodeProviderChain } from '@aws-sdk/credential-providers'
12+
13+
import { Agent } from '../../../src/agent/agent.js'
14+
import { A2AExpressServer } from '../../../src/a2a/express-server.js'
15+
import { BedrockModel } from '../../../src/models/bedrock.js'
1116

1217
/**
1318
* Load API keys as environment variables from AWS Secrets Manager
@@ -59,7 +64,7 @@ async function loadApiKeysFromSecretsManager(): Promise<void> {
5964
/**
6065
* Perform shared setup for the integration tests.
6166
*/
62-
export async function setup(project: TestProject): Promise<void> {
67+
export async function setup(project: TestProject): Promise<() => void> {
6368
console.log('Global setup: Loading API keys from Secrets Manager...')
6469
await loadApiKeysFromSecretsManager()
6570
console.log('Global setup: API keys loaded into environment')
@@ -72,6 +77,13 @@ export async function setup(project: TestProject): Promise<void> {
7277
project.provide('provider-bedrock', await getBedrockTestContext(isCI))
7378
project.provide('provider-anthropic', await getAnthropicTestContext(isCI))
7479
project.provide('provider-gemini', await getGeminiTestContext(isCI))
80+
81+
const a2aContext = await getA2AServerContext(project)
82+
project.provide('a2a-server', { shouldSkip: a2aContext.shouldSkip, url: a2aContext.url })
83+
84+
return () => {
85+
a2aContext.abort?.()
86+
}
7587
}
7688

7789
async function getOpenAITestContext(isCI: boolean): Promise<ProvidedContext['provider-openai']> {
@@ -149,3 +161,64 @@ async function getGeminiTestContext(_isCI: boolean): Promise<ProvidedContext['pr
149161
shouldSkip: shouldSkip,
150162
}
151163
}
164+
165+
async function getA2AServerContext(
166+
project: TestProject
167+
): Promise<ProvidedContext['a2a-server'] & { abort?: () => void }> {
168+
const { testFiles } = await project.globTestFiles()
169+
const hasA2ATests = testFiles.some((f) => f.includes('/a2a/'))
170+
171+
if (!hasA2ATests) {
172+
return { shouldSkip: true, url: undefined }
173+
}
174+
175+
let credentials
176+
try {
177+
const credentialProvider = fromNodeProviderChain()
178+
credentials = await credentialProvider()
179+
} catch {
180+
console.log('⏭️ A2A server not available (no Bedrock credentials) - A2A integration tests will be skipped')
181+
return { shouldSkip: true, url: undefined }
182+
}
183+
184+
const model = new BedrockModel({ clientConfig: { credentials } })
185+
const agent = new Agent({
186+
model,
187+
printer: false,
188+
systemPrompt: 'You are a helpful assistant. Always respond in a single short sentence.',
189+
})
190+
191+
const a2aServer = new A2AExpressServer({
192+
agent,
193+
name: 'Test A2A Agent',
194+
description: 'Integration test agent',
195+
})
196+
197+
// Use createMiddleware() with CORS headers so browser integ tests can reach the server.
198+
// Browser tests run on a different port (Vitest dev server), making this a cross-origin request.
199+
const app = express()
200+
app.use((_req, res, next) => {
201+
res.setHeader('Access-Control-Allow-Origin', '*')
202+
res.setHeader('Access-Control-Allow-Methods', '*')
203+
res.setHeader('Access-Control-Allow-Headers', '*')
204+
next()
205+
})
206+
app.use(a2aServer.createMiddleware())
207+
208+
return new Promise((resolve, reject) => {
209+
const server = app.listen(0, '127.0.0.1', () => {
210+
const addr = server.address() as { port: number }
211+
const url = `http://127.0.0.1:${addr.port}`
212+
// Update the agent card URL to reflect the actual bound port.
213+
// createMiddleware() doesn't do this automatically (unlike serve()).
214+
a2aServer.agentCard.url = url
215+
console.log(`⏭️ A2A server started on ${url}`)
216+
resolve({
217+
shouldSkip: false,
218+
url,
219+
abort: () => server.close(),
220+
})
221+
})
222+
server.on('error', reject)
223+
})
224+
}

test/integ/a2a/a2a-agent.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it, inject, beforeAll } from 'vitest'
2+
import { A2AAgent, A2AStreamUpdateEvent } from '$/sdk/a2a/index.js'
3+
import { collectGenerator } from '$/sdk/__fixtures__/model-test-helpers.js'
4+
5+
const a2aServer = {
6+
get skip() {
7+
return inject('a2a-server').shouldSkip
8+
},
9+
get url() {
10+
const url = inject('a2a-server').url
11+
if (!url) throw new Error('A2A server URL not provided')
12+
return url
13+
},
14+
}
15+
16+
describe.skipIf(a2aServer.skip)('A2AAgent', () => {
17+
let agent: A2AAgent
18+
19+
beforeAll(() => {
20+
agent = new A2AAgent({ url: a2aServer.url })
21+
})
22+
23+
describe('invoke', () => {
24+
it('receives a text response and populates agent card metadata', async () => {
25+
const result = await agent.invoke('What is 2+2? Reply with just the number.')
26+
27+
expect(result.stopReason).toBe('endTurn')
28+
expect(result.lastMessage.role).toBe('assistant')
29+
expect(result.lastMessage.content.length).toBeGreaterThan(0)
30+
expect(result.toString()).toMatch(/4/)
31+
32+
expect(agent.name).toBe('Test A2A Agent')
33+
expect(agent.description).toBe('Integration test agent')
34+
})
35+
})
36+
37+
describe('stream', () => {
38+
it('yields events and returns final result', async () => {
39+
const { items, result } = await collectGenerator(agent.stream('Say the word test'))
40+
41+
const streamUpdates = items.filter((e) => e instanceof A2AStreamUpdateEvent)
42+
expect(streamUpdates.length).toBeGreaterThan(0)
43+
44+
expect(result.stopReason).toBe('endTurn')
45+
expect(result.lastMessage.content[0]!.type).toBe('textBlock')
46+
})
47+
})
48+
})

test/integ/a2a/a2a-agent.test.node.ts renamed to test/integ/a2a/express-server.test.node.ts

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ import type { Task } from '@a2a-js/sdk'
77
import express from 'express'
88
import { ClientFactory } from '@a2a-js/sdk/client'
99
import { Agent } from '@strands-agents/sdk'
10-
import { A2AExpressServer, A2AAgent, A2AStreamUpdateEvent, A2AResultEvent } from '$/sdk/a2a/index.js'
10+
import { A2AAgent, A2AStreamUpdateEvent, A2AResultEvent } from '$/sdk/a2a/index.js'
11+
import { A2AExpressServer } from '$/sdk/a2a/express-server.js'
1112
import { TextBlock } from '$/sdk/types/messages.js'
1213
import { encodeBase64 } from '$/sdk/types/media.js'
1314
import { collectGenerator } from '$/sdk/__fixtures__/model-test-helpers.js'
1415
import { bedrock } from '../__fixtures__/model-providers.js'
1516

16-
describe.skipIf(bedrock.skip)('A2AAgent integration', () => {
17-
describe('with standalone server (A2AExpressServer.serve)', () => {
18-
let a2aAgent: A2AAgent
17+
describe.skipIf(bedrock.skip)('A2AExpressServer', () => {
18+
describe('serve', () => {
1919
let a2aServer: A2AExpressServer
2020
let abortController: AbortController
2121

@@ -35,24 +35,23 @@ describe.skipIf(bedrock.skip)('A2AAgent integration', () => {
3535

3636
abortController = new AbortController()
3737
await a2aServer.serve({ signal: abortController.signal })
38-
39-
a2aAgent = new A2AAgent({ url: `http://127.0.0.1:${a2aServer.port}` })
4038
})
4139

42-
afterAll(async () => {
40+
afterAll(() => {
4341
abortController?.abort()
4442
})
4543

46-
it('invoke receives a text response', async () => {
47-
const result = await a2aAgent.invoke('What is 2+2? Reply with just the number.')
44+
it('serves agent card at well-known endpoint', async () => {
45+
const factory = new ClientFactory()
46+
const client = await factory.createFromUrl(`http://127.0.0.1:${a2aServer.port}`)
47+
const card = await client.getAgentCard()
4848

49-
expect(result.stopReason).toBe('endTurn')
50-
expect(result.lastMessage.role).toBe('assistant')
51-
expect(result.lastMessage.content.length).toBeGreaterThan(0)
52-
expect(result.toString()).toMatch(/4/)
49+
expect(card.name).toBe('Test A2A Agent')
50+
expect(card.description).toBe('Integration test agent')
51+
expect(card.capabilities?.streaming).toBe(true)
5352
})
5453

55-
it('invoke processes an image sent as a file part', async () => {
54+
it('processes an image sent as a file part', async () => {
5655
const imagePath = join(process.cwd(), 'test/integ/__resources__/yellow.png')
5756
const imageBytes = new Uint8Array(await readFile(imagePath))
5857

@@ -85,17 +84,9 @@ describe.skipIf(bedrock.skip)('A2AAgent integration', () => {
8584

8685
expect(texts.toLowerCase()).toContain('yellow')
8786
})
88-
89-
it('stream yields events and returns final result', async () => {
90-
const { items, result } = await collectGenerator(a2aAgent.stream('Say the word test'))
91-
92-
expect(items.length).toBeGreaterThan(0)
93-
expect(result.stopReason).toBe('endTurn')
94-
expect(result.lastMessage.content[0]!.type).toBe('textBlock')
95-
})
9687
})
9788

98-
describe('with express middleware (A2AExpressServer.createMiddleware)', () => {
89+
describe('createMiddleware', () => {
9990
const servers: Server[] = []
10091

10192
afterEach(() => {
@@ -107,8 +98,6 @@ describe.skipIf(bedrock.skip)('A2AAgent integration', () => {
10798

10899
/**
109100
* Starts an A2A server on an OS-assigned port and returns the URL.
110-
* We bind express first to discover the port, then create the A2AExpressServer
111-
* with the correct httpUrl so the agent card advertises the right address.
112101
*/
113102
async function startServer(agent: Agent): Promise<{ url: string }> {
114103
return new Promise((resolve, reject) => {

test/integ/vitest.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,9 @@ declare module 'vitest' {
2121
shouldSkip: boolean
2222
apiKey: string | undefined
2323
}
24+
['a2a-server']: {
25+
shouldSkip: boolean
26+
url: string | undefined
27+
}
2428
}
2529
}

0 commit comments

Comments
 (0)