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.
Files changed (130) hide show
  1. package/.turbo/turbo-build.log +4 -5
  2. package/CHANGELOG.md +38 -0
  3. package/LICENSE +21 -0
  4. package/README.md +361 -159
  5. package/dist/ai-promise.d.ts +47 -0
  6. package/dist/ai-promise.d.ts.map +1 -1
  7. package/dist/ai-promise.js +291 -3
  8. package/dist/ai-promise.js.map +1 -1
  9. package/dist/ai.d.ts +17 -18
  10. package/dist/ai.d.ts.map +1 -1
  11. package/dist/ai.js +93 -39
  12. package/dist/ai.js.map +1 -1
  13. package/dist/batch-map.d.ts +46 -4
  14. package/dist/batch-map.d.ts.map +1 -1
  15. package/dist/batch-map.js +35 -2
  16. package/dist/batch-map.js.map +1 -1
  17. package/dist/batch-queue.d.ts +116 -12
  18. package/dist/batch-queue.d.ts.map +1 -1
  19. package/dist/batch-queue.js +47 -2
  20. package/dist/batch-queue.js.map +1 -1
  21. package/dist/budget.d.ts +272 -0
  22. package/dist/budget.d.ts.map +1 -0
  23. package/dist/budget.js +500 -0
  24. package/dist/budget.js.map +1 -0
  25. package/dist/cache.d.ts +272 -0
  26. package/dist/cache.d.ts.map +1 -0
  27. package/dist/cache.js +412 -0
  28. package/dist/cache.js.map +1 -0
  29. package/dist/context.d.ts +32 -1
  30. package/dist/context.d.ts.map +1 -1
  31. package/dist/context.js +16 -1
  32. package/dist/context.js.map +1 -1
  33. package/dist/eval/runner.d.ts +2 -1
  34. package/dist/eval/runner.d.ts.map +1 -1
  35. package/dist/eval/runner.js.map +1 -1
  36. package/dist/generate.d.ts.map +1 -1
  37. package/dist/generate.js +6 -10
  38. package/dist/generate.js.map +1 -1
  39. package/dist/index.d.ts +27 -20
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +72 -42
  42. package/dist/index.js.map +1 -1
  43. package/dist/primitives.d.ts +17 -0
  44. package/dist/primitives.d.ts.map +1 -1
  45. package/dist/primitives.js +19 -1
  46. package/dist/primitives.js.map +1 -1
  47. package/dist/retry.d.ts +303 -0
  48. package/dist/retry.d.ts.map +1 -0
  49. package/dist/retry.js +539 -0
  50. package/dist/retry.js.map +1 -0
  51. package/dist/schema.d.ts.map +1 -1
  52. package/dist/schema.js +1 -9
  53. package/dist/schema.js.map +1 -1
  54. package/dist/tool-orchestration.d.ts +391 -0
  55. package/dist/tool-orchestration.d.ts.map +1 -0
  56. package/dist/tool-orchestration.js +663 -0
  57. package/dist/tool-orchestration.js.map +1 -0
  58. package/dist/types.d.ts +50 -33
  59. package/dist/types.d.ts.map +1 -1
  60. package/evalite.config.js +14 -0
  61. package/evals/classification.eval.js +97 -0
  62. package/evals/marketing.eval.js +289 -0
  63. package/evals/math.eval.js +83 -0
  64. package/evals/run-evals.js +151 -0
  65. package/evals/structured-output.eval.js +131 -0
  66. package/evals/writing.eval.js +105 -0
  67. package/examples/batch-blog-posts.js +128 -0
  68. package/package.json +26 -26
  69. package/src/ai-promise.ts +359 -3
  70. package/src/ai.ts +155 -110
  71. package/src/batch/anthropic.js +256 -0
  72. package/src/batch/bedrock.js +584 -0
  73. package/src/batch/cloudflare.js +287 -0
  74. package/src/batch/google.js +359 -0
  75. package/src/batch/index.js +30 -0
  76. package/src/batch/memory.js +187 -0
  77. package/src/batch/openai.js +402 -0
  78. package/src/batch-map.ts +46 -4
  79. package/src/batch-queue.ts +116 -12
  80. package/src/budget.ts +727 -0
  81. package/src/cache.ts +653 -0
  82. package/src/context.ts +33 -1
  83. package/src/eval/index.js +7 -0
  84. package/src/eval/models.js +119 -0
  85. package/src/eval/runner.js +147 -0
  86. package/src/eval/runner.ts +3 -2
  87. package/src/generate.ts +7 -12
  88. package/src/index.ts +231 -53
  89. package/src/primitives.ts +19 -1
  90. package/src/retry.ts +776 -0
  91. package/src/schema.ts +1 -10
  92. package/src/tool-orchestration.ts +1008 -0
  93. package/src/types.ts +59 -41
  94. package/test/ai-proxy.test.js +157 -0
  95. package/test/async-iterators.test.js +261 -0
  96. package/test/backward-compat.test.ts +147 -0
  97. package/test/batch-autosubmit-errors.test.ts +598 -0
  98. package/test/batch-background.test.js +352 -0
  99. package/test/batch-blog-posts.test.js +293 -0
  100. package/test/blog-generation.test.js +390 -0
  101. package/test/browse-read.test.js +480 -0
  102. package/test/budget-tracking.test.ts +800 -0
  103. package/test/cache.test.ts +712 -0
  104. package/test/context-isolation.test.ts +687 -0
  105. package/test/core-functions.test.js +490 -0
  106. package/test/decide.test.js +260 -0
  107. package/test/define.test.js +232 -0
  108. package/test/e2e-bedrock-manual.js +136 -0
  109. package/test/e2e-bedrock.test.js +164 -0
  110. package/test/e2e-flex-gateway.js +131 -0
  111. package/test/e2e-flex-manual.js +156 -0
  112. package/test/e2e-flex.test.js +174 -0
  113. package/test/e2e-google-manual.js +150 -0
  114. package/test/e2e-google.test.js +181 -0
  115. package/test/embeddings.test.js +220 -0
  116. package/test/evals/define-function.eval.test.js +309 -0
  117. package/test/evals/deterministic.eval.test.ts +376 -0
  118. package/test/evals/primitives.eval.test.js +360 -0
  119. package/test/function-types.test.js +407 -0
  120. package/test/generate-core.test.js +213 -0
  121. package/test/generate.test.js +143 -0
  122. package/test/generic-order.test.ts +342 -0
  123. package/test/implicit-batch.test.js +326 -0
  124. package/test/json-parse-error-handling.test.ts +463 -0
  125. package/test/retry.test.ts +1016 -0
  126. package/test/schema.test.js +96 -0
  127. package/test/streaming.test.ts +316 -0
  128. package/test/tagged-templates.test.js +240 -0
  129. package/test/tool-orchestration.test.ts +770 -0
  130. 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
+ })