feat: Core Agent Integration Tests (Node.js) - Task 168.1 (#226)

* feat: add parameterized Agent integration tests for BedrockModel and OpenAIModel

- Create tests_integ/agent.test.ts with test.each parameterization
- Cover basic invocation, streaming, system prompts
- Test tool use and multi-turn conversations
- Test stop reasons (endTurn, toolUse, maxTokens)
- Test message history management
- Include Document and Image block media tests
- Use loadFixture helper for loading image fixtures
- All tests pass for BedrockModel (OpenAI skipped without API key)

Resolves: #193

* refactor: consolidate integration test helpers and reduce API calls

- Create tests_integ/__fixtures__/test-helpers.ts with loadFixture and hasOpenAIApiKey helpers
- Update bedrock.test.ts to use centralized loadFixture helper
- Update openai.test.ts to use centralized hasOpenAIApiKey helper
- Combine basic invocation and streaming tests into single test
- Consolidate multi-turn and message history tests
- Add comprehensive media blocks test with multiple media types in one call
- Reduce total API calls from 14 to 9 tests

Addresses feedback from PR #226

* refactor: improve test organization and OpenAI skip logic

- Rename hasOpenAIApiKey() to shouldSkipOpenAITests() for clarity
- Fix shouldSkipOpenAITests() to always check for actual API key presence
- Use describe.skipIf() instead of manual skip logic for better test visibility
- Include tool use in basic functionality test
- Remove redundant tests:
  - Remove separate tool execution flow test (covered in basic test)
  - Remove individual document and image block tests (covered by multi-media test)
  - Remove maxTokens error handling test
- Reduce total test count from 9 to 3 per provider while maintaining coverage

Addresses second round of feedback from PR #226

* refactor: improve test quality and CI behavior

- Update shouldSkipOpenAITests() to fail in CI when key is missing (not skip)
- Update basic functionality test to use calculator tool with 123 * 456
- Verify tool use in agent messages and result (56088)
- Remove initial state check from multi-turn test
- Initialize agent with messages array for media blocks test
- Use invoke() with text prompt instead of pushing to messages

Addresses third round of feedback from PR #226

* fix: wrap initial messages in Message constructor

- Import Message class from SDK
- Wrap messages array initialization in new Message() constructor
- Fixes 'Unknown ContentBlockData type' error in media blocks test
- Agent constructor processes Message instances directly without conversion

Fixes integration test failures in PR #226

* fix: add text block to initial message for Bedrock document requirement

- Import TextBlock from SDK
- Include TextBlock in initial message content array with document and image
- Bedrock requires text block when using documents
- Call invoke() without parameters since message is already initialized

Fixes ValidationException in media blocks test

---------

Co-authored-by: Strands Agent <217235299+strands-agent@users.noreply.github.com>
This commit is contained in:
Nick Clegg
2025-11-25 01:32:42 +00:00
committed by GitHub
co-authored by Strands Agent
parent fd7e5d5f15
commit 6e7baf37e3
4 changed files with 211 additions and 28 deletions
+52
View File
@@ -0,0 +1,52 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
/**
* Helper to load fixture files from Vite URL imports.
* Vite ?url imports return paths like '/tests_integ/__resources__/file.png' in test environment.
*
* @param url - The URL from a Vite ?url import
* @returns The file contents as a Uint8Array
*/
export const loadFixture = (url: string): Uint8Array => {
const relativePath = url.startsWith('/') ? url.slice(1) : url
const filePath = join(process.cwd(), relativePath)
return new Uint8Array(readFileSync(filePath))
}
/**
* Determines if OpenAI integration tests should be skipped.
* In CI environments, throws an error if API key is missing (tests should not be skipped).
* In local development, skips tests if API key is not available.
*
* @returns true if tests should be skipped, false if they should run
* @throws Error if running in CI and API key is missing
*/
export const shouldSkipOpenAITests = (): boolean => {
try {
const isCI = !!process.env.CI
const hasKey = !!process.env.OPENAI_API_KEY
if (isCI && !hasKey) {
throw new Error('OpenAI API key must be available in CI environments')
}
if (hasKey) {
if (isCI) {
console.log('✅ Running in CI environment with OpenAI API key - tests will run')
} else {
console.log('✅ OpenAI API key found for integration tests')
}
return false
} else {
console.log('⏭️ OpenAI API key not available - integration tests will be skipped')
return true
}
} catch (error) {
if (error instanceof Error && error.message.includes('CI environments')) {
throw error
}
console.log('⏭️ OpenAI API key not available - integration tests will be skipped')
return true
}
}
+156
View File
@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest'
import { Agent, DocumentBlock, ImageBlock, Message, TextBlock, tool } from '@strands-agents/sdk'
import { BedrockModel } from '@strands-agents/sdk/bedrock'
import { OpenAIModel } from '@strands-agents/sdk/openai'
import { z } from 'zod'
// eslint-disable-next-line no-restricted-imports
import { collectGenerator } from '../src/__fixtures__/model-test-helpers.js'
import { shouldRunTests } from './__fixtures__/model-test-helpers.js'
import { loadFixture, shouldSkipOpenAITests } from './__fixtures__/test-helpers.js'
// Import fixtures using Vite's ?url suffix
import yellowPngUrl from './__resources__/yellow.png?url'
// Calculator tool for testing
const calculatorTool = tool({
name: 'calculator',
description: 'Performs basic arithmetic operations',
inputSchema: z.object({
operation: z.enum(['add', 'subtract', 'multiply', 'divide']),
a: z.number(),
b: z.number(),
}),
callback: async ({ operation, a, b }) => {
const ops = {
add: a + b,
subtract: a - b,
multiply: a * b,
divide: a / b,
}
return `Result: ${ops[operation]}`
},
})
// Provider configurations
const providers = [
{
name: 'BedrockModel',
skip: !(await shouldRunTests()),
createModel: () => new BedrockModel({ maxTokens: 100 }),
},
{
name: 'OpenAIModel',
skip: shouldSkipOpenAITests(),
createModel: () => new OpenAIModel({ modelId: 'gpt-4o-mini', maxTokens: 100 }),
},
]
describe.each(providers)('Agent with $name', ({ name, skip, createModel }) => {
describe.skipIf(skip)(`${name} Integration Tests`, () => {
describe('Basic Functionality', () => {
it('handles invocation, streaming, system prompts, and tool use', async () => {
// Test basic invocation with system prompt and tool
const agent = new Agent({
model: createModel(),
printer: false,
systemPrompt: 'Use the calculator tool to solve math problems. Respond with only the numeric result.',
tools: [calculatorTool],
})
// Test streaming with event collection
const { items, result } = await collectGenerator(agent.stream('What is 123 * 456?'))
// Verify high-level agent events are yielded
expect(items.some((item) => item.type === 'beforeInvocationEvent')).toBe(true)
// Verify result structure and stop reason
expect(result.stopReason).toBe('endTurn')
expect(result.lastMessage.role).toBe('assistant')
expect(result.lastMessage.content.length).toBeGreaterThan(0)
// Verify tool was used by checking message history
const toolUseMessage = agent.messages.find((msg) => msg.content.some((block) => block.type === 'toolUseBlock'))
expect(toolUseMessage).toBeDefined()
// Verify final response contains the result (123 * 456 = 56088)
const textContent = result.lastMessage.content.find((block) => block.type === 'textBlock')
expect(textContent).toBeDefined()
expect(textContent?.text).toMatch(/56088/)
})
})
describe('Multi-turn Conversations', () => {
it('maintains message history and conversation context', async () => {
const agent = new Agent({ model: createModel(), printer: false })
// First turn
await agent.invoke('My name is Alice')
expect(agent.messages).toHaveLength(2) // user + assistant
// Second turn
await agent.invoke('What is my name?')
expect(agent.messages).toHaveLength(4) // 2 user + 2 assistant
// Verify message ordering
expect(agent.messages[0].role).toBe('user')
expect(agent.messages[1].role).toBe('assistant')
expect(agent.messages[2].role).toBe('user')
expect(agent.messages[3].role).toBe('assistant')
// Verify conversation context is preserved
const lastMessage = agent.messages[agent.messages.length - 1]
const textContent = lastMessage.content.find((block) => block.type === 'textBlock')
expect(textContent?.text).toMatch(/Alice/i)
})
})
describe('Media Blocks', () => {
it('handles multiple media blocks in single request', async () => {
// Create document block
const docBlock = new DocumentBlock({
name: 'test-document',
format: 'txt',
source: { text: 'The document contains the word ZEBRA.' },
})
// Create image block
const imageBytes = loadFixture(yellowPngUrl)
const imageBlock = new ImageBlock({
format: 'png',
source: { bytes: imageBytes },
})
// Initialize agent with messages array containing Message instance
// Note: Bedrock requires a text block when using documents
const agent = new Agent({
model: createModel(),
messages: [
new Message({
role: 'user',
content: [
docBlock,
imageBlock,
new TextBlock(
'I shared a document and an image. What animal is in the document and what color is the image? Answer briefly.'
),
],
}),
],
printer: false,
})
const result = await agent.invoke()
expect(result.stopReason).toBe('endTurn')
expect(result.lastMessage.role).toBe('assistant')
// Response should reference both the document content and image color
const textContent = result.lastMessage.content.find((block) => block.type === 'textBlock')
expect(textContent).toBeDefined()
expect(textContent?.text).toMatch(/zebra/i)
expect(textContent?.text).toMatch(/yellow/i)
})
})
})
})
+1 -12
View File
@@ -12,9 +12,6 @@ import {
SlidingWindowConversationManager,
} from '@strands-agents/sdk'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
// Import fixtures using Vite's ?url suffix
import yellowPngUrl from './__resources__/yellow.png?url'
import letterPdfUrl from './__resources__/letter.pdf?url'
@@ -22,15 +19,7 @@ import letterPdfUrl from './__resources__/letter.pdf?url'
// eslint-disable-next-line no-restricted-imports
import { collectIterator, collectGenerator } from '../src/__fixtures__/model-test-helpers.js'
import { shouldRunTests } from './__fixtures__/model-test-helpers.js'
// Helper to load fixture files from Vite URL imports
// Vite ?url imports return paths like '/tests_integ/fixtures/yellow.png' in test environment
const loadFixture = (url: string) => {
// Remove leading slash and resolve from project root
const relativePath = url.startsWith('/') ? url.slice(1) : url
const filePath = join(process.cwd(), relativePath)
return new Uint8Array(readFileSync(filePath))
}
import { loadFixture } from './__fixtures__/test-helpers.js'
describe.skipIf(!(await shouldRunTests()))('BedrockModel Integration Tests', () => {
describe('Non-Streaming', () => {
+2 -16
View File
@@ -6,23 +6,9 @@ import type { ToolSpec } from '@strands-agents/sdk'
// eslint-disable-next-line no-restricted-imports
import { collectGenerator, collectIterator } from '../src/__fixtures__/model-test-helpers.js'
import { shouldSkipOpenAITests } from './__fixtures__/test-helpers.js'
// Check for OpenAI API key at module level so skipIf can use it
let hasApiKey = false
try {
if (process.env.OPENAI_API_KEY) {
hasApiKey = true
console.log('✅ OpenAI API key found for integration tests')
} else {
hasApiKey = false
console.log('⏭️ OpenAI API key not available - integration tests will be skipped')
}
} catch {
hasApiKey = false
console.log('⏭️ OpenAI API key not available - integration tests will be skipped')
}
describe.skipIf(!hasApiKey)('OpenAIModel Integration Tests', () => {
describe.skipIf(shouldSkipOpenAITests())('OpenAIModel Integration Tests', () => {
describe('Basic Streaming', () => {
it.concurrent('streams a simple text response', async () => {
const provider = new OpenAIModel({