mirror of
https://github.com/strands-agents/harness-sdk.git
synced 2026-10-02 02:44:48 +08:00
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:
co-authored by
Strands Agent
parent
fd7e5d5f15
commit
6e7baf37e3
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user