ai-functions
Version:
Core AI primitives for building intelligent applications
511 lines (413 loc) ⢠16.5 kB
text/typescript
/**
* Blog Post Generation Live Tests
*
* These tests run LIVE against real AI providers by default.
* They verify the complete blog generation workflow:
*
* ```ts
* const titles = await list`10 blog post titles about ${topic}`
* const posts = titles.map(title => write`a blog post starting with "# ${title}"`)
* ```
*
* Tests cover:
* - Real API calls to OpenAI, Anthropic, etc.
* - Action/event storage in the database
* - Both realtime and batch execution modes
* - Multiple providers
*
* Run with:
* ```bash
* pnpm test blog-generation.live
* ```
*
* Skip live tests (CI without API keys):
* ```bash
* SKIP_LIVE_TESTS=true pnpm test blog-generation.live
* ```
*
* @packageDocumentation
*/
import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest'
import {
configure,
resetContext,
withContext,
getProvider,
getModel,
getBatchMode,
} from '../src/context.js'
import { createBatch, withBatch, type BatchProvider } from '../src/batch-queue.js'
import { generateObject, generateText } from '../src/generate.js'
// Database provider for action/event storage
import { createMemoryProvider, type MemoryProvider, type Action, type Event } from '../../ai-database/src/memory-provider.js'
// Batch storage
import '../src/batch/memory.js'
import { configureMemoryAdapter, clearBatches, getBatches } from '../src/batch/memory.js'
// ============================================================================
// Configuration
// ============================================================================
const SKIP_LIVE = process.env.SKIP_LIVE_TESTS === 'true'
const describeLive = SKIP_LIVE ? describe.skip : describe
// Detect available providers
const hasOpenAI = !!process.env.OPENAI_API_KEY
const hasAnthropic = !!process.env.ANTHROPIC_API_KEY
// Provider configs
const PROVIDERS: Record<string, { model: string; available: boolean }> = {
openai: { model: 'gpt-4o-mini', available: hasOpenAI },
anthropic: { model: 'claude-sonnet-4-20250514', available: hasAnthropic },
}
// Get first available provider
const defaultProvider = Object.entries(PROVIDERS).find(([, cfg]) => cfg.available)?.[0] || 'openai'
const defaultModel = PROVIDERS[defaultProvider]?.model || 'gpt-4o-mini'
// ============================================================================
// Database Setup
// ============================================================================
let db: MemoryProvider
let capturedEvents: Event[] = []
// ============================================================================
// Test Helpers
// ============================================================================
async function createAction(data: {
action: string
object: string
objectData?: Record<string, unknown>
total?: number
}): Promise<Action> {
return db.createAction({
actor: 'test:live',
action: data.action,
object: data.object,
objectData: data.objectData,
total: data.total,
})
}
async function generateTitles(topic: string, count: number): Promise<{ titles: string[]; action: Action }> {
const action = await createAction({
action: 'generate',
object: 'BlogTitles',
objectData: { topic, count },
total: 1,
})
await db.updateAction(action.id, { status: 'active' })
const result = await generateObject({
model: getModel(),
schema: { titles: [`${count} blog post titles about ${topic}`] },
prompt: `Generate exactly ${count} creative blog post titles about "${topic}".`,
})
const titles = (result.object as { titles: string[] }).titles
await db.updateAction(action.id, {
status: 'completed',
progress: 1,
result: { titles },
})
return { titles, action }
}
async function generatePost(title: string): Promise<string> {
const result = await generateText({
model: getModel(),
prompt: `Write a blog post starting with "# ${title}"\n\nInclude an introduction, 2-3 sections, and conclusion. Be concise.`,
maxTokens: 800,
})
return result.text
}
async function generatePosts(
titles: string[],
mode: 'realtime' | 'batch' = 'realtime'
): Promise<{ posts: string[]; action: Action }> {
const action = await createAction({
action: 'generate',
object: 'BlogPosts',
objectData: { titles, mode },
total: titles.length,
})
await db.updateAction(action.id, { status: 'active' })
const posts: string[] = []
if (mode === 'batch') {
const batch = createBatch({
provider: getProvider(),
model: getModel(),
metadata: { actionId: action.id },
})
titles.forEach((title, i) => {
batch.add(
`Write a blog post starting with "# ${title}"\n\nBe concise.`,
{ customId: `post-${i}`, metadata: { title } }
)
})
const { completion } = await batch.submit()
const results = await completion
for (const result of results) {
posts.push(result.status === 'completed' ? (result.result as string) : `[Failed]`)
await db.updateAction(action.id, { progress: posts.length })
}
} else {
for (let i = 0; i < titles.length; i++) {
const post = await generatePost(titles[i])
posts.push(post)
await db.updateAction(action.id, { progress: i + 1 })
}
}
await db.updateAction(action.id, { status: 'completed', result: { count: posts.length } })
return { posts, action }
}
// ============================================================================
// Tests
// ============================================================================
describeLive('Blog Generation (Live)', () => {
beforeAll(() => {
console.log('\nš“ LIVE TEST MODE')
console.log(` Default provider: ${defaultProvider}`)
console.log(` OpenAI available: ${hasOpenAI}`)
console.log(` Anthropic available: ${hasAnthropic}\n`)
})
beforeEach(() => {
resetContext()
db = createMemoryProvider()
capturedEvents = []
db.on('*', (e) => capturedEvents.push(e))
configure({ provider: defaultProvider as BatchProvider, model: defaultModel, batchMode: 'immediate' })
clearBatches()
configureMemoryAdapter({})
})
afterEach(() => {
resetContext()
clearBatches()
db.clear()
})
// ==========================================================================
// Core Pattern Tests
// ==========================================================================
describe('Core Pattern: list ā write', () => {
it('generates titles from a topic', async () => {
const { titles, action } = await generateTitles('building AI products', 3)
expect(titles).toHaveLength(3)
titles.forEach((t) => expect(typeof t).toBe('string'))
// Verify action tracking
expect(action.status).toBe('completed')
expect(action.result).toEqual({ titles })
}, 30000)
it('generates a blog post from a title', async () => {
const title = 'The Future of AI Development'
const post = await generatePost(title)
expect(post).toContain(`# ${title}`)
expect(post.length).toBeGreaterThan(200)
}, 30000)
it('generates multiple posts from titles', async () => {
const { titles } = await generateTitles('startup growth', 2)
const { posts, action } = await generatePosts(titles)
expect(posts).toHaveLength(2)
posts.forEach((post, i) => {
expect(post).toContain(`# ${titles[i]}`)
})
expect(action.progress).toBe(2)
expect(action.total).toBe(2)
}, 60000)
})
// ==========================================================================
// Action & Event Storage
// ==========================================================================
describe('Action & Event Storage', () => {
it('creates actions with verb conjugation', async () => {
const { action } = await generateTitles('test topic', 2)
expect(action.action).toBe('generate')
expect(action.act).toBe('generates')
expect(action.activity).toBe('generating')
expect(action.actor).toBe('test:live')
}, 30000)
it('tracks action lifecycle timestamps', async () => {
const { action } = await generateTitles('test', 2)
const final = await db.getAction(action.id)
expect(final?.createdAt).toBeInstanceOf(Date)
expect(final?.startedAt).toBeInstanceOf(Date)
expect(final?.completedAt).toBeInstanceOf(Date)
expect(final?.startedAt!.getTime()).toBeGreaterThanOrEqual(final?.createdAt!.getTime()!)
expect(final?.completedAt!.getTime()).toBeGreaterThanOrEqual(final?.startedAt!.getTime()!)
}, 30000)
it('emits events for state transitions', async () => {
await generateTitles('test', 2)
const actionEvents = capturedEvents.filter((e) => e.event.startsWith('Action.'))
const eventTypes = actionEvents.map((e) => e.event)
expect(eventTypes).toContain('Action.created')
expect(eventTypes).toContain('Action.started')
expect(eventTypes).toContain('Action.completed')
}, 30000)
it('stores action result data', async () => {
const { titles, action } = await generateTitles('AI development', 3)
const stored = await db.getAction(action.id)
expect(stored?.result).toEqual({ titles })
expect(stored?.objectData).toEqual({ topic: 'AI development', count: 3 })
}, 30000)
it('queries actions by status', async () => {
await generateTitles('topic 1', 2)
await generateTitles('topic 2', 2)
const completed = await db.listActions({ status: 'completed' })
expect(completed.length).toBe(2)
}, 60000)
it('queries events by type pattern', async () => {
await generateTitles('test', 2)
const created = await db.listEvents({ event: 'Action.created' })
const allAction = await db.listEvents({ event: 'Action.*' })
expect(created.length).toBeGreaterThanOrEqual(1)
expect(allAction.length).toBeGreaterThanOrEqual(3) // created, started, completed
}, 30000)
})
// ==========================================================================
// Realtime Execution
// ==========================================================================
describe('Realtime Execution', () => {
beforeEach(() => {
configure({ batchMode: 'immediate' })
})
it('executes requests immediately', async () => {
const start = Date.now()
const { titles } = await generateTitles('quick test', 2)
const elapsed = Date.now() - start
expect(titles).toHaveLength(2)
// Should complete in reasonable time (not batched/delayed)
expect(elapsed).toBeLessThan(30000)
}, 30000)
it('tracks progress during sequential generation', async () => {
const titles = ['Post One', 'Post Two']
const { posts, action } = await generatePosts(titles, 'realtime')
expect(posts).toHaveLength(2)
const final = await db.getAction(action.id)
expect(final?.progress).toBe(2)
expect(final?.total).toBe(2)
}, 60000)
})
// ==========================================================================
// Batch Execution
// ==========================================================================
describe('Batch Execution', () => {
beforeEach(() => {
configure({ batchMode: 'deferred' })
})
it('creates and submits batch jobs', async () => {
const titles = ['Batch Post 1', 'Batch Post 2']
const { posts, action } = await generatePosts(titles, 'batch')
expect(posts).toHaveLength(2)
// Verify batch was stored
const batches = getBatches()
expect(batches.size).toBe(1)
const [, batch] = [...batches.entries()][0]
expect(batch.items).toHaveLength(2)
expect(batch.status).toBe('completed')
}, 90000)
it('stores batch metadata', async () => {
const batch = createBatch({
provider: getProvider(),
model: getModel(),
metadata: { task: 'test-batch', timestamp: Date.now() },
})
batch.add('Write a test post', { customId: 'test-1' })
const { job } = await batch.submit()
await batch.wait()
const stored = getBatches().get(job.id)
expect(stored?.options.metadata?.task).toBe('test-batch')
}, 30000)
})
// ==========================================================================
// Multi-Provider Tests
// ==========================================================================
describe('Multi-Provider', () => {
it.skipIf(!hasOpenAI)('generates with OpenAI', async () => {
configure({ provider: 'openai', model: 'gpt-4o-mini' })
const { titles } = await generateTitles('OpenAI test', 2)
expect(titles).toHaveLength(2)
expect(getProvider()).toBe('openai')
}, 30000)
it.skipIf(!hasAnthropic)('generates with Anthropic', async () => {
configure({ provider: 'anthropic', model: 'claude-sonnet-4-20250514' })
const { titles } = await generateTitles('Anthropic test', 2)
expect(titles).toHaveLength(2)
expect(getProvider()).toBe('anthropic')
}, 30000)
it.skipIf(!hasOpenAI || !hasAnthropic)('switches providers mid-workflow', async () => {
// Generate titles with OpenAI
configure({ provider: 'openai', model: 'gpt-4o-mini' })
const { titles } = await generateTitles('cross-provider test', 2)
// Generate posts with Anthropic
const posts = await withContext(
{ provider: 'anthropic', model: 'claude-sonnet-4-20250514' },
async () => {
expect(getProvider()).toBe('anthropic')
return Promise.all(titles.slice(0, 1).map(generatePost))
}
)
expect(posts).toHaveLength(1)
expect(getProvider()).toBe('openai') // restored
}, 60000)
it.skipIf(!hasOpenAI || !hasAnthropic)('runs providers in parallel', async () => {
const [openaiResult, anthropicResult] = await Promise.all([
withContext({ provider: 'openai', model: 'gpt-4o-mini' }, () =>
generateTitles('OpenAI parallel', 2)
),
withContext({ provider: 'anthropic', model: 'claude-sonnet-4-20250514' }, () =>
generateTitles('Anthropic parallel', 2)
),
])
expect(openaiResult.titles).toHaveLength(2)
expect(anthropicResult.titles).toHaveLength(2)
}, 60000)
})
// ==========================================================================
// Error Handling
// ==========================================================================
describe('Error Handling', () => {
it('tracks failed actions', async () => {
const action = await createAction({
action: 'generate',
object: 'FailTest',
total: 1,
})
await db.updateAction(action.id, { status: 'active' })
// Simulate failure
await db.updateAction(action.id, {
status: 'failed',
error: 'Simulated failure',
})
const failed = await db.listActions({ status: 'failed' })
expect(failed).toHaveLength(1)
expect(failed[0].error).toBe('Simulated failure')
// Verify failure event
const failEvents = capturedEvents.filter((e) => e.event === 'Action.failed')
expect(failEvents.length).toBe(1)
})
it('handles invalid model gracefully', async () => {
configure({ model: 'invalid-model-xyz' })
const action = await createAction({
action: 'generate',
object: 'InvalidModelTest',
total: 1,
})
await db.updateAction(action.id, { status: 'active' })
try {
await generateObject({
model: 'invalid-model-xyz',
schema: { test: 'test' },
prompt: 'test',
})
} catch (e) {
await db.updateAction(action.id, {
status: 'failed',
error: e instanceof Error ? e.message : 'Unknown error',
})
}
const final = await db.getAction(action.id)
expect(final?.status).toBe('failed')
expect(final?.error).toBeDefined()
}, 30000)
})
// ==========================================================================
// Database Statistics
// ==========================================================================
describe('Database Statistics', () => {
it('tracks aggregate stats', async () => {
await generateTitles('stats test 1', 2)
await generateTitles('stats test 2', 2)
const stats = db.stats()
expect(stats.actions.completed).toBe(2)
expect(stats.events).toBeGreaterThan(0)
}, 60000)
})
})