ai-functions 2.0.2 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -5
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +361 -159
- package/dist/ai-promise.d.ts +47 -0
- package/dist/ai-promise.d.ts.map +1 -1
- package/dist/ai-promise.js +291 -3
- package/dist/ai-promise.js.map +1 -1
- package/dist/ai.d.ts +17 -18
- package/dist/ai.d.ts.map +1 -1
- package/dist/ai.js +93 -39
- package/dist/ai.js.map +1 -1
- package/dist/batch-map.d.ts +46 -4
- package/dist/batch-map.d.ts.map +1 -1
- package/dist/batch-map.js +35 -2
- package/dist/batch-map.js.map +1 -1
- package/dist/batch-queue.d.ts +116 -12
- package/dist/batch-queue.d.ts.map +1 -1
- package/dist/batch-queue.js +47 -2
- package/dist/batch-queue.js.map +1 -1
- package/dist/budget.d.ts +272 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +500 -0
- package/dist/budget.js.map +1 -0
- package/dist/cache.d.ts +272 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +412 -0
- package/dist/cache.js.map +1 -0
- package/dist/context.d.ts +32 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +16 -1
- package/dist/context.js.map +1 -1
- package/dist/eval/runner.d.ts +2 -1
- package/dist/eval/runner.d.ts.map +1 -1
- package/dist/eval/runner.js.map +1 -1
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +6 -10
- package/dist/generate.js.map +1 -1
- package/dist/index.d.ts +27 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +72 -42
- package/dist/index.js.map +1 -1
- package/dist/primitives.d.ts +17 -0
- package/dist/primitives.d.ts.map +1 -1
- package/dist/primitives.js +19 -1
- package/dist/primitives.js.map +1 -1
- package/dist/retry.d.ts +303 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +539 -0
- package/dist/retry.js.map +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1 -9
- package/dist/schema.js.map +1 -1
- package/dist/tool-orchestration.d.ts +391 -0
- package/dist/tool-orchestration.d.ts.map +1 -0
- package/dist/tool-orchestration.js +663 -0
- package/dist/tool-orchestration.js.map +1 -0
- package/dist/types.d.ts +50 -33
- package/dist/types.d.ts.map +1 -1
- package/evalite.config.js +14 -0
- package/evals/classification.eval.js +97 -0
- package/evals/marketing.eval.js +289 -0
- package/evals/math.eval.js +83 -0
- package/evals/run-evals.js +151 -0
- package/evals/structured-output.eval.js +131 -0
- package/evals/writing.eval.js +105 -0
- package/examples/batch-blog-posts.js +128 -0
- package/package.json +26 -26
- package/src/ai-promise.ts +359 -3
- package/src/ai.ts +155 -110
- package/src/batch/anthropic.js +256 -0
- package/src/batch/bedrock.js +584 -0
- package/src/batch/cloudflare.js +287 -0
- package/src/batch/google.js +359 -0
- package/src/batch/index.js +30 -0
- package/src/batch/memory.js +187 -0
- package/src/batch/openai.js +402 -0
- package/src/batch-map.ts +46 -4
- package/src/batch-queue.ts +116 -12
- package/src/budget.ts +727 -0
- package/src/cache.ts +653 -0
- package/src/context.ts +33 -1
- package/src/eval/index.js +7 -0
- package/src/eval/models.js +119 -0
- package/src/eval/runner.js +147 -0
- package/src/eval/runner.ts +3 -2
- package/src/generate.ts +7 -12
- package/src/index.ts +231 -53
- package/src/primitives.ts +19 -1
- package/src/retry.ts +776 -0
- package/src/schema.ts +1 -10
- package/src/tool-orchestration.ts +1008 -0
- package/src/types.ts +59 -41
- package/test/ai-proxy.test.js +157 -0
- package/test/async-iterators.test.js +261 -0
- package/test/backward-compat.test.ts +147 -0
- package/test/batch-autosubmit-errors.test.ts +598 -0
- package/test/batch-background.test.js +352 -0
- package/test/batch-blog-posts.test.js +293 -0
- package/test/blog-generation.test.js +390 -0
- package/test/browse-read.test.js +480 -0
- package/test/budget-tracking.test.ts +800 -0
- package/test/cache.test.ts +712 -0
- package/test/context-isolation.test.ts +687 -0
- package/test/core-functions.test.js +490 -0
- package/test/decide.test.js +260 -0
- package/test/define.test.js +232 -0
- package/test/e2e-bedrock-manual.js +136 -0
- package/test/e2e-bedrock.test.js +164 -0
- package/test/e2e-flex-gateway.js +131 -0
- package/test/e2e-flex-manual.js +156 -0
- package/test/e2e-flex.test.js +174 -0
- package/test/e2e-google-manual.js +150 -0
- package/test/e2e-google.test.js +181 -0
- package/test/embeddings.test.js +220 -0
- package/test/evals/define-function.eval.test.js +309 -0
- package/test/evals/deterministic.eval.test.ts +376 -0
- package/test/evals/primitives.eval.test.js +360 -0
- package/test/function-types.test.js +407 -0
- package/test/generate-core.test.js +213 -0
- package/test/generate.test.js +143 -0
- package/test/generic-order.test.ts +342 -0
- package/test/implicit-batch.test.js +326 -0
- package/test/json-parse-error-handling.test.ts +463 -0
- package/test/retry.test.ts +1016 -0
- package/test/schema.test.js +96 -0
- package/test/streaming.test.ts +316 -0
- package/test/tagged-templates.test.js +240 -0
- package/test/tool-orchestration.test.ts +770 -0
- package/vitest.config.js +39 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Concurrent Context Isolation
|
|
3
|
+
*
|
|
4
|
+
* TDD: RED phase - These tests expose race conditions in global context management
|
|
5
|
+
*
|
|
6
|
+
* The current implementation uses a global `defaultClient` variable that can cause
|
|
7
|
+
* context bleeding between concurrent operations. These tests demonstrate:
|
|
8
|
+
*
|
|
9
|
+
* 1. Context leakage between concurrent Promise.all operations
|
|
10
|
+
* 2. Configuration changes affecting in-flight requests
|
|
11
|
+
* 3. API key isolation failure in multi-tenant scenarios
|
|
12
|
+
* 4. Async/await interleaving causing wrong context
|
|
13
|
+
*
|
|
14
|
+
* Expected: These tests should FAIL or be FLAKY with the current implementation,
|
|
15
|
+
* demonstrating the need for AsyncLocalStorage-based context isolation.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
19
|
+
import {
|
|
20
|
+
configure,
|
|
21
|
+
resetContext,
|
|
22
|
+
withContext,
|
|
23
|
+
getContext,
|
|
24
|
+
getModel,
|
|
25
|
+
getProvider,
|
|
26
|
+
getBatchMode,
|
|
27
|
+
type ExecutionContext,
|
|
28
|
+
} from '../src/context.js'
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Test Utilities
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Simulates an async AI operation that takes variable time
|
|
36
|
+
* This helps expose race conditions by introducing realistic delays
|
|
37
|
+
*/
|
|
38
|
+
async function simulateAICall(delayMs: number): Promise<ExecutionContext> {
|
|
39
|
+
// Capture context at start
|
|
40
|
+
const startContext = getContext()
|
|
41
|
+
|
|
42
|
+
// Simulate network delay
|
|
43
|
+
await new Promise(resolve => setTimeout(resolve, delayMs))
|
|
44
|
+
|
|
45
|
+
// Capture context at end - should be same as start!
|
|
46
|
+
const endContext = getContext()
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
// Return both for comparison
|
|
50
|
+
...endContext,
|
|
51
|
+
_startModel: startContext.model,
|
|
52
|
+
_startProvider: startContext.provider,
|
|
53
|
+
} as ExecutionContext & { _startModel?: string; _startProvider?: string }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates a delayed operation that should maintain its context
|
|
58
|
+
*/
|
|
59
|
+
function createDelayedContextCapture(delayMs: number) {
|
|
60
|
+
return async (): Promise<{ model: string | undefined; provider: string | undefined }> => {
|
|
61
|
+
const beforeDelay = { model: getModel(), provider: getProvider() }
|
|
62
|
+
await new Promise(resolve => setTimeout(resolve, delayMs))
|
|
63
|
+
const afterDelay = { model: getModel(), provider: getProvider() }
|
|
64
|
+
|
|
65
|
+
// These should be equal - if not, context leaked!
|
|
66
|
+
return {
|
|
67
|
+
model: afterDelay.model,
|
|
68
|
+
provider: afterDelay.provider,
|
|
69
|
+
_beforeModel: beforeDelay.model,
|
|
70
|
+
_beforeProvider: beforeDelay.provider,
|
|
71
|
+
} as { model: string | undefined; provider: string | undefined }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============================================================================
|
|
76
|
+
// Context Isolation Tests
|
|
77
|
+
// ============================================================================
|
|
78
|
+
|
|
79
|
+
describe('Concurrent Context Isolation', () => {
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
resetContext()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
resetContext()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('Promise.all Context Leakage', () => {
|
|
89
|
+
it('should isolate context between concurrent withContext calls', async () => {
|
|
90
|
+
// This test exposes the race condition in the fallback implementation
|
|
91
|
+
// where global context is temporarily modified and restored
|
|
92
|
+
const results = await Promise.all([
|
|
93
|
+
withContext({ model: 'claude-opus-4-5', provider: 'anthropic' }, async () => {
|
|
94
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
95
|
+
return { model: getModel(), provider: getProvider() }
|
|
96
|
+
}),
|
|
97
|
+
withContext({ model: 'gpt-4o', provider: 'openai' }, async () => {
|
|
98
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
99
|
+
return { model: getModel(), provider: getProvider() }
|
|
100
|
+
}),
|
|
101
|
+
withContext({ model: 'gemini-pro', provider: 'google' }, async () => {
|
|
102
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
103
|
+
return { model: getModel(), provider: getProvider() }
|
|
104
|
+
}),
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
// Each operation should see its own context, not another's
|
|
108
|
+
expect(results[0]).toEqual({ model: 'claude-opus-4-5', provider: 'anthropic' })
|
|
109
|
+
expect(results[1]).toEqual({ model: 'gpt-4o', provider: 'openai' })
|
|
110
|
+
expect(results[2]).toEqual({ model: 'gemini-pro', provider: 'google' })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should not leak context when operations complete in different order', async () => {
|
|
114
|
+
// Operations with different completion times
|
|
115
|
+
const contexts: Array<{ model: string | undefined; provider: string | undefined; order: number }> = []
|
|
116
|
+
|
|
117
|
+
await Promise.all([
|
|
118
|
+
withContext({ model: 'slow-model', provider: 'anthropic' }, async () => {
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
120
|
+
contexts.push({ model: getModel(), provider: getProvider(), order: 1 })
|
|
121
|
+
}),
|
|
122
|
+
withContext({ model: 'fast-model', provider: 'openai' }, async () => {
|
|
123
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
124
|
+
contexts.push({ model: getModel(), provider: getProvider(), order: 2 })
|
|
125
|
+
}),
|
|
126
|
+
])
|
|
127
|
+
|
|
128
|
+
// Fast model completes first (order 2), slow model second (order 1)
|
|
129
|
+
// But each should see its own model
|
|
130
|
+
const slowResult = contexts.find(c => c.order === 1)
|
|
131
|
+
const fastResult = contexts.find(c => c.order === 2)
|
|
132
|
+
|
|
133
|
+
expect(slowResult?.model).toBe('slow-model')
|
|
134
|
+
expect(fastResult?.model).toBe('fast-model')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('should handle nested withContext calls concurrently', async () => {
|
|
138
|
+
const results = await Promise.all([
|
|
139
|
+
withContext({ model: 'outer-1' }, async () => {
|
|
140
|
+
const outerModel = getModel()
|
|
141
|
+
const innerResult = await withContext({ model: 'inner-1' }, async () => {
|
|
142
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
143
|
+
return getModel()
|
|
144
|
+
})
|
|
145
|
+
// After inner completes, should restore outer context
|
|
146
|
+
const afterInnerModel = getModel()
|
|
147
|
+
return { outerModel, innerResult, afterInnerModel }
|
|
148
|
+
}),
|
|
149
|
+
withContext({ model: 'outer-2' }, async () => {
|
|
150
|
+
const outerModel = getModel()
|
|
151
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
152
|
+
return { outerModel, innerResult: null, afterInnerModel: getModel() }
|
|
153
|
+
}),
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
expect(results[0].outerModel).toBe('outer-1')
|
|
157
|
+
expect(results[0].innerResult).toBe('inner-1')
|
|
158
|
+
expect(results[0].afterInnerModel).toBe('outer-1')
|
|
159
|
+
expect(results[1].outerModel).toBe('outer-2')
|
|
160
|
+
expect(results[1].afterInnerModel).toBe('outer-2')
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('API Key Isolation (Multi-tenant)', () => {
|
|
165
|
+
it('should isolate API keys between concurrent tenant requests', async () => {
|
|
166
|
+
// Simulate multi-tenant scenario where each request has different credentials
|
|
167
|
+
interface TenantContext {
|
|
168
|
+
apiKey: string
|
|
169
|
+
tenantId: string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const tenantResults: Array<TenantContext & { seenApiKey: string }> = []
|
|
173
|
+
|
|
174
|
+
await Promise.all([
|
|
175
|
+
withContext({ metadata: { apiKey: 'tenant-1-key', tenantId: 'tenant-1' } }, async () => {
|
|
176
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
177
|
+
const ctx = getContext()
|
|
178
|
+
tenantResults.push({
|
|
179
|
+
apiKey: 'tenant-1-key',
|
|
180
|
+
tenantId: 'tenant-1',
|
|
181
|
+
seenApiKey: (ctx.metadata as TenantContext)?.apiKey,
|
|
182
|
+
})
|
|
183
|
+
}),
|
|
184
|
+
withContext({ metadata: { apiKey: 'tenant-2-key', tenantId: 'tenant-2' } }, async () => {
|
|
185
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
186
|
+
const ctx = getContext()
|
|
187
|
+
tenantResults.push({
|
|
188
|
+
apiKey: 'tenant-2-key',
|
|
189
|
+
tenantId: 'tenant-2',
|
|
190
|
+
seenApiKey: (ctx.metadata as TenantContext)?.apiKey,
|
|
191
|
+
})
|
|
192
|
+
}),
|
|
193
|
+
withContext({ metadata: { apiKey: 'tenant-3-key', tenantId: 'tenant-3' } }, async () => {
|
|
194
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
195
|
+
const ctx = getContext()
|
|
196
|
+
tenantResults.push({
|
|
197
|
+
apiKey: 'tenant-3-key',
|
|
198
|
+
tenantId: 'tenant-3',
|
|
199
|
+
seenApiKey: (ctx.metadata as TenantContext)?.apiKey,
|
|
200
|
+
})
|
|
201
|
+
}),
|
|
202
|
+
])
|
|
203
|
+
|
|
204
|
+
// Each tenant should see their own API key, not another tenant's!
|
|
205
|
+
// This is a critical security issue if context leaks
|
|
206
|
+
for (const result of tenantResults) {
|
|
207
|
+
expect(result.seenApiKey).toBe(result.apiKey)
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('Configuration Changes Mid-flight', () => {
|
|
213
|
+
it('should not affect in-flight requests when global config changes', async () => {
|
|
214
|
+
configure({ model: 'initial-model', provider: 'anthropic' })
|
|
215
|
+
|
|
216
|
+
// Start a long-running operation
|
|
217
|
+
const operationPromise = withContext({}, async () => {
|
|
218
|
+
const startModel = getModel()
|
|
219
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
220
|
+
const endModel = getModel()
|
|
221
|
+
return { startModel, endModel }
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// Change global config while operation is in flight
|
|
225
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
226
|
+
configure({ model: 'changed-model', provider: 'openai' })
|
|
227
|
+
|
|
228
|
+
const result = await operationPromise
|
|
229
|
+
|
|
230
|
+
// The operation should see consistent context throughout
|
|
231
|
+
// Even though global config changed mid-flight
|
|
232
|
+
expect(result.startModel).toBe(result.endModel)
|
|
233
|
+
expect(result.startModel).toBe('initial-model')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it.skip('should isolate configure() calls from concurrent operations (known limitation - use withContext)', async () => {
|
|
237
|
+
const results: string[] = []
|
|
238
|
+
|
|
239
|
+
await Promise.all([
|
|
240
|
+
// Operation 1: Set config and use it
|
|
241
|
+
(async () => {
|
|
242
|
+
configure({ model: 'op1-model' })
|
|
243
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
244
|
+
results.push(`op1: ${getModel()}`)
|
|
245
|
+
})(),
|
|
246
|
+
// Operation 2: Set different config
|
|
247
|
+
(async () => {
|
|
248
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
249
|
+
configure({ model: 'op2-model' })
|
|
250
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
251
|
+
results.push(`op2: ${getModel()}`)
|
|
252
|
+
})(),
|
|
253
|
+
])
|
|
254
|
+
|
|
255
|
+
// With proper isolation, each operation would see its own config
|
|
256
|
+
// With current implementation, they interfere with each other
|
|
257
|
+
// The test documents the expected behavior vs actual behavior
|
|
258
|
+
expect(results).toContain('op1: op1-model')
|
|
259
|
+
expect(results).toContain('op2: op2-model')
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe('Async/Await Interleaving', () => {
|
|
264
|
+
it('should maintain context across await points', async () => {
|
|
265
|
+
const results: Array<{ step: string; model: string | undefined }> = []
|
|
266
|
+
|
|
267
|
+
await withContext({ model: 'test-model' }, async () => {
|
|
268
|
+
results.push({ step: 'before-await-1', model: getModel() })
|
|
269
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
270
|
+
|
|
271
|
+
results.push({ step: 'after-await-1', model: getModel() })
|
|
272
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
273
|
+
|
|
274
|
+
results.push({ step: 'after-await-2', model: getModel() })
|
|
275
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
276
|
+
|
|
277
|
+
results.push({ step: 'after-await-3', model: getModel() })
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// All steps should see the same model
|
|
281
|
+
for (const result of results) {
|
|
282
|
+
expect(result.model).toBe('test-model')
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('should handle interleaved async generators', async () => {
|
|
287
|
+
async function* contextAwareGenerator(contextModel: string, steps: number) {
|
|
288
|
+
for (let i = 0; i < steps; i++) {
|
|
289
|
+
await new Promise(resolve => setTimeout(resolve, Math.random() * 20))
|
|
290
|
+
yield { step: i, model: getModel(), expected: contextModel }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const results: Array<{ step: number; model: string | undefined; expected: string }> = []
|
|
295
|
+
|
|
296
|
+
// Run generators concurrently in different contexts
|
|
297
|
+
await Promise.all([
|
|
298
|
+
withContext({ model: 'generator-1' }, async () => {
|
|
299
|
+
for await (const item of contextAwareGenerator('generator-1', 5)) {
|
|
300
|
+
results.push(item)
|
|
301
|
+
}
|
|
302
|
+
}),
|
|
303
|
+
withContext({ model: 'generator-2' }, async () => {
|
|
304
|
+
for await (const item of contextAwareGenerator('generator-2', 5)) {
|
|
305
|
+
results.push(item)
|
|
306
|
+
}
|
|
307
|
+
}),
|
|
308
|
+
])
|
|
309
|
+
|
|
310
|
+
// Each generator should see its own context
|
|
311
|
+
for (const result of results) {
|
|
312
|
+
expect(result.model).toBe(result.expected)
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
describe('Batch Mode Isolation', () => {
|
|
318
|
+
it('should isolate batch mode settings between concurrent operations', async () => {
|
|
319
|
+
const results = await Promise.all([
|
|
320
|
+
withContext({ batchMode: 'immediate' }, async () => {
|
|
321
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
322
|
+
return getBatchMode()
|
|
323
|
+
}),
|
|
324
|
+
withContext({ batchMode: 'deferred' }, async () => {
|
|
325
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
326
|
+
return getBatchMode()
|
|
327
|
+
}),
|
|
328
|
+
withContext({ batchMode: 'flex' }, async () => {
|
|
329
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
330
|
+
return getBatchMode()
|
|
331
|
+
}),
|
|
332
|
+
])
|
|
333
|
+
|
|
334
|
+
expect(results[0]).toBe('immediate')
|
|
335
|
+
expect(results[1]).toBe('deferred')
|
|
336
|
+
expect(results[2]).toBe('flex')
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe('High Concurrency Stress Test', () => {
|
|
341
|
+
it('should maintain context isolation under high concurrency', async () => {
|
|
342
|
+
const concurrency = 50
|
|
343
|
+
const operations = Array.from({ length: concurrency }, (_, i) => i)
|
|
344
|
+
|
|
345
|
+
const results = await Promise.all(
|
|
346
|
+
operations.map(i =>
|
|
347
|
+
withContext({ model: `model-${i}`, metadata: { opId: i } }, async () => {
|
|
348
|
+
// Random delay to maximize interleaving
|
|
349
|
+
await new Promise(resolve => setTimeout(resolve, Math.random() * 50))
|
|
350
|
+
const ctx = getContext()
|
|
351
|
+
return {
|
|
352
|
+
expected: `model-${i}`,
|
|
353
|
+
actual: ctx.model,
|
|
354
|
+
opId: (ctx.metadata as { opId: number })?.opId,
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
// Every operation should see its own context
|
|
361
|
+
let failures = 0
|
|
362
|
+
for (const result of results) {
|
|
363
|
+
if (result.expected !== result.actual) {
|
|
364
|
+
failures++
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// With proper isolation, there should be 0 failures
|
|
369
|
+
// With the current global-based fallback, we expect failures
|
|
370
|
+
expect(failures).toBe(0)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('should handle rapid context switches without corruption', async () => {
|
|
374
|
+
const iterations = 100
|
|
375
|
+
const errors: string[] = []
|
|
376
|
+
|
|
377
|
+
for (let i = 0; i < iterations; i++) {
|
|
378
|
+
const expectedModel = `rapid-${i}`
|
|
379
|
+
|
|
380
|
+
await withContext({ model: expectedModel }, async () => {
|
|
381
|
+
const actualModel = getModel()
|
|
382
|
+
if (actualModel !== expectedModel) {
|
|
383
|
+
errors.push(`Iteration ${i}: expected ${expectedModel}, got ${actualModel}`)
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
expect(errors).toEqual([])
|
|
389
|
+
})
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
describe('Context Restoration After Error', () => {
|
|
393
|
+
it('should restore context after exception in withContext', async () => {
|
|
394
|
+
configure({ model: 'original-model' })
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
await withContext({ model: 'error-model' }, async () => {
|
|
398
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
399
|
+
throw new Error('Test error')
|
|
400
|
+
})
|
|
401
|
+
} catch {
|
|
402
|
+
// Expected error
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Context should be restored to original
|
|
406
|
+
expect(getModel()).toBe('original-model')
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('should restore context even when nested contexts throw', async () => {
|
|
410
|
+
configure({ model: 'root-model' })
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
await withContext({ model: 'outer-model' }, async () => {
|
|
414
|
+
await withContext({ model: 'inner-model' }, async () => {
|
|
415
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
416
|
+
throw new Error('Inner error')
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
} catch {
|
|
420
|
+
// Expected error
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Context should be fully restored
|
|
424
|
+
expect(getModel()).toBe('root-model')
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('should isolate error context from parallel operations', async () => {
|
|
428
|
+
const results: Array<{ id: number; model: string | undefined; error?: boolean }> = []
|
|
429
|
+
|
|
430
|
+
await Promise.allSettled([
|
|
431
|
+
withContext({ model: 'success-1' }, async () => {
|
|
432
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
433
|
+
results.push({ id: 1, model: getModel() })
|
|
434
|
+
}),
|
|
435
|
+
withContext({ model: 'error-op' }, async () => {
|
|
436
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
437
|
+
throw new Error('Deliberate error')
|
|
438
|
+
}),
|
|
439
|
+
withContext({ model: 'success-2' }, async () => {
|
|
440
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
441
|
+
results.push({ id: 2, model: getModel() })
|
|
442
|
+
}),
|
|
443
|
+
])
|
|
444
|
+
|
|
445
|
+
// The error in one operation should not affect others
|
|
446
|
+
const op1 = results.find(r => r.id === 1)
|
|
447
|
+
const op2 = results.find(r => r.id === 2)
|
|
448
|
+
|
|
449
|
+
expect(op1?.model).toBe('success-1')
|
|
450
|
+
expect(op2?.model).toBe('success-2')
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
// ============================================================================
|
|
456
|
+
// Global Configure Race Conditions
|
|
457
|
+
// ============================================================================
|
|
458
|
+
|
|
459
|
+
describe('Global Configure Race Conditions', () => {
|
|
460
|
+
beforeEach(() => {
|
|
461
|
+
resetContext()
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
afterEach(() => {
|
|
465
|
+
resetContext()
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
// NOTE: These tests document the EXPECTED race conditions when using configure()
|
|
469
|
+
// concurrently. The configure() function is for setting global defaults at startup,
|
|
470
|
+
// NOT for concurrent tenant isolation. Use withContext() for that.
|
|
471
|
+
//
|
|
472
|
+
// These tests are marked as .skip because they document known limitations,
|
|
473
|
+
// not bugs. The correct fix is to use withContext() for concurrent scenarios.
|
|
474
|
+
|
|
475
|
+
it.skip('should not allow configure() to affect already-started operations (known limitation - use withContext)', async () => {
|
|
476
|
+
// This is the core race condition: configure() modifies globalContext
|
|
477
|
+
// which is read by getContext() at arbitrary times
|
|
478
|
+
|
|
479
|
+
configure({ model: 'original' })
|
|
480
|
+
|
|
481
|
+
const operationResults: Array<{ stage: string; model: string | undefined }> = []
|
|
482
|
+
|
|
483
|
+
// Start operation that reads context multiple times
|
|
484
|
+
const operation = (async () => {
|
|
485
|
+
operationResults.push({ stage: 'start', model: getModel() })
|
|
486
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
487
|
+
operationResults.push({ stage: 'middle', model: getModel() })
|
|
488
|
+
await new Promise(resolve => setTimeout(resolve, 30))
|
|
489
|
+
operationResults.push({ stage: 'end', model: getModel() })
|
|
490
|
+
})()
|
|
491
|
+
|
|
492
|
+
// Interfering configure() calls while operation is running
|
|
493
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
494
|
+
configure({ model: 'interference-1' })
|
|
495
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
496
|
+
configure({ model: 'interference-2' })
|
|
497
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
498
|
+
configure({ model: 'interference-3' })
|
|
499
|
+
|
|
500
|
+
await operation
|
|
501
|
+
|
|
502
|
+
// The operation should see consistent context throughout
|
|
503
|
+
// But with global configure(), each stage might see different models!
|
|
504
|
+
const models = operationResults.map(r => r.model)
|
|
505
|
+
const uniqueModels = new Set(models)
|
|
506
|
+
|
|
507
|
+
// If isolation is working, should only see 1 model
|
|
508
|
+
// If broken, might see multiple models
|
|
509
|
+
expect(uniqueModels.size).toBe(1)
|
|
510
|
+
expect(models[0]).toBe('original')
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it.skip('should demonstrate configure() race in multi-tenant scenario (known limitation - use withContext)', async () => {
|
|
514
|
+
// Simulate multiple tenants configuring their own settings
|
|
515
|
+
// This is a realistic scenario that will fail without proper isolation
|
|
516
|
+
|
|
517
|
+
const tenantOperations = [
|
|
518
|
+
{ tenantId: 'tenant-a', model: 'claude-opus-4-5', delay: 100 },
|
|
519
|
+
{ tenantId: 'tenant-b', model: 'gpt-4o', delay: 50 },
|
|
520
|
+
{ tenantId: 'tenant-c', model: 'gemini-pro', delay: 75 },
|
|
521
|
+
]
|
|
522
|
+
|
|
523
|
+
const results: Array<{ tenantId: string; expected: string; actual: string | undefined }> = []
|
|
524
|
+
|
|
525
|
+
await Promise.all(
|
|
526
|
+
tenantOperations.map(async ({ tenantId, model, delay }) => {
|
|
527
|
+
// Each tenant configures their model
|
|
528
|
+
configure({ model })
|
|
529
|
+
|
|
530
|
+
// Some processing time...
|
|
531
|
+
await new Promise(resolve => setTimeout(resolve, delay))
|
|
532
|
+
|
|
533
|
+
// Check what model they see - should be their configured model
|
|
534
|
+
const actualModel = getModel()
|
|
535
|
+
results.push({ tenantId, expected: model, actual: actualModel })
|
|
536
|
+
})
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
// Each tenant should see their own model
|
|
540
|
+
// This WILL fail because configure() races with other tenants
|
|
541
|
+
for (const result of results) {
|
|
542
|
+
expect(result.actual).toBe(result.expected)
|
|
543
|
+
}
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
it('should show configure() racing with withContext()', async () => {
|
|
547
|
+
configure({ model: 'global-model' })
|
|
548
|
+
|
|
549
|
+
const results: string[] = []
|
|
550
|
+
|
|
551
|
+
await Promise.all([
|
|
552
|
+
// Operation using withContext (should be isolated)
|
|
553
|
+
withContext({ model: 'isolated-model' }, async () => {
|
|
554
|
+
results.push(`withContext-start: ${getModel()}`)
|
|
555
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
556
|
+
results.push(`withContext-end: ${getModel()}`)
|
|
557
|
+
}),
|
|
558
|
+
// Operation modifying global config (affects anyone reading globalContext)
|
|
559
|
+
(async () => {
|
|
560
|
+
await new Promise(resolve => setTimeout(resolve, 25))
|
|
561
|
+
configure({ model: 'racing-model' })
|
|
562
|
+
results.push(`configure-done: ${getModel()}`)
|
|
563
|
+
})(),
|
|
564
|
+
])
|
|
565
|
+
|
|
566
|
+
// withContext should maintain isolation
|
|
567
|
+
expect(results).toContain('withContext-start: isolated-model')
|
|
568
|
+
expect(results).toContain('withContext-end: isolated-model')
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it.skip('should expose getGlobalContext() mutation issues (known limitation - use withContext)', async () => {
|
|
572
|
+
configure({ model: 'safe-model' })
|
|
573
|
+
|
|
574
|
+
// Multiple operations reading global context
|
|
575
|
+
const reads: Array<{ time: number; model: string | undefined }> = []
|
|
576
|
+
|
|
577
|
+
const readerPromise = (async () => {
|
|
578
|
+
for (let i = 0; i < 10; i++) {
|
|
579
|
+
const ctx = getContext()
|
|
580
|
+
reads.push({ time: Date.now(), model: ctx.model })
|
|
581
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
582
|
+
}
|
|
583
|
+
})()
|
|
584
|
+
|
|
585
|
+
// Concurrent writer
|
|
586
|
+
const writerPromise = (async () => {
|
|
587
|
+
for (let i = 0; i < 5; i++) {
|
|
588
|
+
await new Promise(resolve => setTimeout(resolve, 15))
|
|
589
|
+
configure({ model: `mutated-${i}` })
|
|
590
|
+
}
|
|
591
|
+
})()
|
|
592
|
+
|
|
593
|
+
await Promise.all([readerPromise, writerPromise])
|
|
594
|
+
|
|
595
|
+
// The reader should see consistent values if properly isolated
|
|
596
|
+
// With current implementation, it will see a mix of values
|
|
597
|
+
const uniqueModels = new Set(reads.map(r => r.model))
|
|
598
|
+
|
|
599
|
+
// Document the race condition - multiple values seen
|
|
600
|
+
// With proper isolation, should only see initial value
|
|
601
|
+
console.log('Models seen during concurrent read/write:', [...uniqueModels])
|
|
602
|
+
|
|
603
|
+
// This assertion documents expected behavior - only 1 model should be seen
|
|
604
|
+
expect(uniqueModels.size).toBe(1)
|
|
605
|
+
})
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
// ============================================================================
|
|
609
|
+
// Edge Cases and Regression Tests
|
|
610
|
+
// ============================================================================
|
|
611
|
+
|
|
612
|
+
describe('Context Edge Cases', () => {
|
|
613
|
+
beforeEach(() => {
|
|
614
|
+
resetContext()
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
afterEach(() => {
|
|
618
|
+
resetContext()
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
it('should handle undefined context values', async () => {
|
|
622
|
+
const results = await Promise.all([
|
|
623
|
+
withContext({ model: undefined }, async () => {
|
|
624
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
625
|
+
return getModel()
|
|
626
|
+
}),
|
|
627
|
+
withContext({ model: 'defined-model' }, async () => {
|
|
628
|
+
await new Promise(resolve => setTimeout(resolve, 5))
|
|
629
|
+
return getModel()
|
|
630
|
+
}),
|
|
631
|
+
])
|
|
632
|
+
|
|
633
|
+
// Default model should be returned for undefined
|
|
634
|
+
expect(results[0]).toBe('sonnet')
|
|
635
|
+
expect(results[1]).toBe('defined-model')
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
it('should handle empty context object', async () => {
|
|
639
|
+
configure({ model: 'configured-model' })
|
|
640
|
+
|
|
641
|
+
const result = await withContext({}, async () => {
|
|
642
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
643
|
+
return getModel()
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
// Should inherit from global config
|
|
647
|
+
expect(result).toBe('configured-model')
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
it('should handle deeply nested concurrent contexts', async () => {
|
|
651
|
+
const results: string[] = []
|
|
652
|
+
|
|
653
|
+
await Promise.all([
|
|
654
|
+
withContext({ model: 'level-0-a' }, async () => {
|
|
655
|
+
results.push(`0a: ${getModel()}`)
|
|
656
|
+
await withContext({ model: 'level-1-a' }, async () => {
|
|
657
|
+
results.push(`1a: ${getModel()}`)
|
|
658
|
+
await Promise.all([
|
|
659
|
+
withContext({ model: 'level-2-a1' }, async () => {
|
|
660
|
+
await new Promise(resolve => setTimeout(resolve, 20))
|
|
661
|
+
results.push(`2a1: ${getModel()}`)
|
|
662
|
+
}),
|
|
663
|
+
withContext({ model: 'level-2-a2' }, async () => {
|
|
664
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
665
|
+
results.push(`2a2: ${getModel()}`)
|
|
666
|
+
}),
|
|
667
|
+
])
|
|
668
|
+
results.push(`1a-after: ${getModel()}`)
|
|
669
|
+
})
|
|
670
|
+
results.push(`0a-after: ${getModel()}`)
|
|
671
|
+
}),
|
|
672
|
+
withContext({ model: 'level-0-b' }, async () => {
|
|
673
|
+
await new Promise(resolve => setTimeout(resolve, 15))
|
|
674
|
+
results.push(`0b: ${getModel()}`)
|
|
675
|
+
}),
|
|
676
|
+
])
|
|
677
|
+
|
|
678
|
+
// Verify each level sees correct context
|
|
679
|
+
expect(results).toContain('0a: level-0-a')
|
|
680
|
+
expect(results).toContain('1a: level-1-a')
|
|
681
|
+
expect(results).toContain('2a1: level-2-a1')
|
|
682
|
+
expect(results).toContain('2a2: level-2-a2')
|
|
683
|
+
expect(results).toContain('1a-after: level-1-a')
|
|
684
|
+
expect(results).toContain('0a-after: level-0-a')
|
|
685
|
+
expect(results).toContain('0b: level-0-b')
|
|
686
|
+
})
|
|
687
|
+
})
|