@debriefer/core 2.0.0

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 (98) hide show
  1. package/README.md +86 -0
  2. package/dist/__tests__/base-source.test.d.ts +2 -0
  3. package/dist/__tests__/base-source.test.d.ts.map +1 -0
  4. package/dist/__tests__/base-source.test.js +333 -0
  5. package/dist/__tests__/base-source.test.js.map +1 -0
  6. package/dist/__tests__/batch-runner.test.d.ts +2 -0
  7. package/dist/__tests__/batch-runner.test.d.ts.map +1 -0
  8. package/dist/__tests__/batch-runner.test.js +217 -0
  9. package/dist/__tests__/batch-runner.test.js.map +1 -0
  10. package/dist/__tests__/cache.test.d.ts +2 -0
  11. package/dist/__tests__/cache.test.d.ts.map +1 -0
  12. package/dist/__tests__/cache.test.js +127 -0
  13. package/dist/__tests__/cache.test.js.map +1 -0
  14. package/dist/__tests__/confidence.test.d.ts +2 -0
  15. package/dist/__tests__/confidence.test.d.ts.map +1 -0
  16. package/dist/__tests__/confidence.test.js +81 -0
  17. package/dist/__tests__/confidence.test.js.map +1 -0
  18. package/dist/__tests__/cost-tracker.test.d.ts +2 -0
  19. package/dist/__tests__/cost-tracker.test.d.ts.map +1 -0
  20. package/dist/__tests__/cost-tracker.test.js +149 -0
  21. package/dist/__tests__/cost-tracker.test.js.map +1 -0
  22. package/dist/__tests__/orchestrator.test.d.ts +2 -0
  23. package/dist/__tests__/orchestrator.test.d.ts.map +1 -0
  24. package/dist/__tests__/orchestrator.test.js +751 -0
  25. package/dist/__tests__/orchestrator.test.js.map +1 -0
  26. package/dist/__tests__/rate-limiter.test.d.ts +2 -0
  27. package/dist/__tests__/rate-limiter.test.d.ts.map +1 -0
  28. package/dist/__tests__/rate-limiter.test.js +83 -0
  29. package/dist/__tests__/rate-limiter.test.js.map +1 -0
  30. package/dist/__tests__/reliability.test.d.ts +2 -0
  31. package/dist/__tests__/reliability.test.d.ts.map +1 -0
  32. package/dist/__tests__/reliability.test.js +207 -0
  33. package/dist/__tests__/reliability.test.js.map +1 -0
  34. package/dist/__tests__/synthesizer.test.d.ts +2 -0
  35. package/dist/__tests__/synthesizer.test.d.ts.map +1 -0
  36. package/dist/__tests__/synthesizer.test.js +50 -0
  37. package/dist/__tests__/synthesizer.test.js.map +1 -0
  38. package/dist/__tests__/telemetry.test.d.ts +2 -0
  39. package/dist/__tests__/telemetry.test.d.ts.map +1 -0
  40. package/dist/__tests__/telemetry.test.js +81 -0
  41. package/dist/__tests__/telemetry.test.js.map +1 -0
  42. package/dist/__tests__/types.test.d.ts +2 -0
  43. package/dist/__tests__/types.test.d.ts.map +1 -0
  44. package/dist/__tests__/types.test.js +708 -0
  45. package/dist/__tests__/types.test.js.map +1 -0
  46. package/dist/base-source.d.ts +91 -0
  47. package/dist/base-source.d.ts.map +1 -0
  48. package/dist/base-source.js +144 -0
  49. package/dist/base-source.js.map +1 -0
  50. package/dist/batch-runner.d.ts +40 -0
  51. package/dist/batch-runner.d.ts.map +1 -0
  52. package/dist/batch-runner.js +65 -0
  53. package/dist/batch-runner.js.map +1 -0
  54. package/dist/cache/in-memory.d.ts +26 -0
  55. package/dist/cache/in-memory.d.ts.map +1 -0
  56. package/dist/cache/in-memory.js +51 -0
  57. package/dist/cache/in-memory.js.map +1 -0
  58. package/dist/confidence.d.ts +13 -0
  59. package/dist/confidence.d.ts.map +1 -0
  60. package/dist/confidence.js +29 -0
  61. package/dist/confidence.js.map +1 -0
  62. package/dist/cost-tracker.d.ts +37 -0
  63. package/dist/cost-tracker.d.ts.map +1 -0
  64. package/dist/cost-tracker.js +62 -0
  65. package/dist/cost-tracker.js.map +1 -0
  66. package/dist/index.d.ts +25 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +28 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/orchestrator.d.ts +92 -0
  71. package/dist/orchestrator.d.ts.map +1 -0
  72. package/dist/orchestrator.js +373 -0
  73. package/dist/orchestrator.js.map +1 -0
  74. package/dist/rate-limiter.d.ts +31 -0
  75. package/dist/rate-limiter.d.ts.map +1 -0
  76. package/dist/rate-limiter.js +79 -0
  77. package/dist/rate-limiter.js.map +1 -0
  78. package/dist/reliability.d.ts +49 -0
  79. package/dist/reliability.d.ts.map +1 -0
  80. package/dist/reliability.js +67 -0
  81. package/dist/reliability.js.map +1 -0
  82. package/dist/synthesizer.d.ts +31 -0
  83. package/dist/synthesizer.d.ts.map +1 -0
  84. package/dist/synthesizer.js +47 -0
  85. package/dist/synthesizer.js.map +1 -0
  86. package/dist/telemetry/console.d.ts +7 -0
  87. package/dist/telemetry/console.d.ts.map +1 -0
  88. package/dist/telemetry/console.js +21 -0
  89. package/dist/telemetry/console.js.map +1 -0
  90. package/dist/telemetry/noop.d.ts +7 -0
  91. package/dist/telemetry/noop.d.ts.map +1 -0
  92. package/dist/telemetry/noop.js +12 -0
  93. package/dist/telemetry/noop.js.map +1 -0
  94. package/dist/types.d.ts +417 -0
  95. package/dist/types.d.ts.map +1 -0
  96. package/dist/types.js +102 -0
  97. package/dist/types.js.map +1 -0
  98. package/package.json +46 -0
@@ -0,0 +1,751 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { ResearchOrchestrator } from "../orchestrator.js";
3
+ import { ReliabilityTier } from "../reliability.js";
4
+ // ============================================================================
5
+ // Test Fixtures
6
+ // ============================================================================
7
+ const makeSubject = (overrides = {}) => ({
8
+ id: 1,
9
+ name: "Test Subject",
10
+ ...overrides,
11
+ });
12
+ function makeFinding(overrides = {}) {
13
+ return {
14
+ text: "Some relevant text about the subject.",
15
+ url: "https://example.com/article",
16
+ confidence: 0.8,
17
+ costUsd: 0.001,
18
+ ...overrides,
19
+ };
20
+ }
21
+ // ============================================================================
22
+ // Mock Source Factory
23
+ // ============================================================================
24
+ /**
25
+ * Create a mock source that implements the MinimalSource interface
26
+ * and the injection methods that the orchestrator calls.
27
+ */
28
+ function createMockSource(overrides = {}) {
29
+ const tier = overrides.reliabilityTier ?? ReliabilityTier.TIER_1_NEWS;
30
+ const finding = overrides.finding !== undefined ? overrides.finding : makeFinding();
31
+ const source = {
32
+ name: overrides.name ?? "MockSource",
33
+ type: overrides.type ?? "mock_source",
34
+ reliabilityTier: tier,
35
+ reliabilityScore: overrides.reliabilityScore ?? 0.95,
36
+ domain: overrides.domain ?? "mock.example.com",
37
+ isFree: overrides.isFree ?? true,
38
+ estimatedCostPerQuery: overrides.estimatedCostPerQuery ?? 0,
39
+ isAvailable: vi.fn().mockReturnValue(overrides.isAvailable ?? true),
40
+ lookup: vi.fn().mockImplementation(async () => {
41
+ overrides.onLookup?.();
42
+ if (overrides.shouldThrow) {
43
+ throw new Error(`Source ${overrides.name ?? "MockSource"} failed`);
44
+ }
45
+ if (overrides.lookupDelay) {
46
+ await new Promise((r) => setTimeout(r, overrides.lookupDelay));
47
+ }
48
+ return finding;
49
+ }),
50
+ setRateLimiter: vi.fn(),
51
+ setCache: vi.fn(),
52
+ setTelemetry: vi.fn(),
53
+ buildQuery: vi.fn().mockReturnValue("test query"),
54
+ };
55
+ return source;
56
+ }
57
+ // ============================================================================
58
+ // Mock Synthesizer
59
+ // ============================================================================
60
+ function createMockSynthesizer() {
61
+ return {
62
+ synthesize: vi.fn().mockResolvedValue({
63
+ data: { result: "synthesized" },
64
+ costUsd: 0.01,
65
+ inputTokens: 100,
66
+ outputTokens: 50,
67
+ model: "test-model",
68
+ }),
69
+ };
70
+ }
71
+ // ============================================================================
72
+ // Helper: create phase groups
73
+ // ============================================================================
74
+ function makePhase(phase, sources, name) {
75
+ return { phase, sources, name };
76
+ }
77
+ // ============================================================================
78
+ // Tests
79
+ // ============================================================================
80
+ describe("ResearchOrchestrator", () => {
81
+ describe("debrief", () => {
82
+ // Test 1: Executes phases sequentially
83
+ it("executes phases sequentially — phase 0 before phase 1", async () => {
84
+ const callOrder = [];
85
+ const sourceA = createMockSource({
86
+ name: "PhaseZeroSource",
87
+ type: "phase_zero",
88
+ onLookup: () => callOrder.push("phase0"),
89
+ });
90
+ const sourceB = createMockSource({
91
+ name: "PhaseOneSource",
92
+ type: "phase_one",
93
+ onLookup: () => callOrder.push("phase1"),
94
+ // Use a different tier so early stop doesn't trigger
95
+ reliabilityTier: ReliabilityTier.UNRELIABLE_UGC,
96
+ reliabilityScore: 0.35,
97
+ });
98
+ const synthesizer = createMockSynthesizer();
99
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [sourceA]), makePhase(1, [sourceB])], synthesizer, { earlyStopThreshold: 10 });
100
+ await orchestrator.debrief(makeSubject());
101
+ expect(callOrder).toEqual(["phase0", "phase1"]);
102
+ });
103
+ // Test 2: Sources within a phase run concurrently
104
+ it("sources within a phase run concurrently", async () => {
105
+ let inFlight = 0;
106
+ let peakInFlight = 0;
107
+ const makeParallelSource = (name) => {
108
+ const source = createMockSource({ name, type: name });
109
+ source.lookup.mockImplementation(async () => {
110
+ inFlight++;
111
+ peakInFlight = Math.max(peakInFlight, inFlight);
112
+ await new Promise((r) => setTimeout(r, 50));
113
+ inFlight--;
114
+ return makeFinding();
115
+ });
116
+ return source;
117
+ };
118
+ const s1 = makeParallelSource("src1");
119
+ const s2 = makeParallelSource("src2");
120
+ const s3 = makeParallelSource("src3");
121
+ const synthesizer = createMockSynthesizer();
122
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [s1, s2, s3])], synthesizer, {
123
+ earlyStopThreshold: 10,
124
+ });
125
+ await orchestrator.debrief(makeSubject());
126
+ // All 3 should have been in flight simultaneously
127
+ expect(peakInFlight).toBe(3);
128
+ });
129
+ // Test 3: Accumulates findings from all sources across phases
130
+ it("accumulates findings from all sources across phases", async () => {
131
+ const source1 = createMockSource({
132
+ name: "S1",
133
+ type: "s1",
134
+ finding: makeFinding({ text: "finding 1" }),
135
+ reliabilityTier: ReliabilityTier.UNRELIABLE_UGC,
136
+ reliabilityScore: 0.35,
137
+ });
138
+ const source2 = createMockSource({
139
+ name: "S2",
140
+ type: "s2",
141
+ finding: makeFinding({ text: "finding 2" }),
142
+ reliabilityTier: ReliabilityTier.UNRELIABLE_FAST,
143
+ reliabilityScore: 0.5,
144
+ });
145
+ const source3 = createMockSource({
146
+ name: "S3",
147
+ type: "s3",
148
+ finding: makeFinding({ text: "finding 3" }),
149
+ reliabilityTier: ReliabilityTier.AI_MODEL,
150
+ reliabilityScore: 0.55,
151
+ });
152
+ const synthesizer = createMockSynthesizer();
153
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [source1]), makePhase(1, [source2, source3])], synthesizer, { earlyStopThreshold: 10 });
154
+ const result = await orchestrator.debrief(makeSubject());
155
+ expect(result.findings).toHaveLength(3);
156
+ expect(result.findings.map((f) => f.text)).toEqual(["finding 1", "finding 2", "finding 3"]);
157
+ });
158
+ // Test 4: Passes all findings to synthesizer
159
+ it("passes all findings to synthesizer", async () => {
160
+ const source1 = createMockSource({
161
+ name: "S1",
162
+ type: "s1",
163
+ reliabilityTier: ReliabilityTier.UNRELIABLE_UGC,
164
+ reliabilityScore: 0.35,
165
+ });
166
+ const source2 = createMockSource({
167
+ name: "S2",
168
+ type: "s2",
169
+ reliabilityTier: ReliabilityTier.UNRELIABLE_FAST,
170
+ reliabilityScore: 0.5,
171
+ });
172
+ const synthesizer = createMockSynthesizer();
173
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [source1, source2])], synthesizer, { earlyStopThreshold: 10 });
174
+ const subject = makeSubject();
175
+ await orchestrator.debrief(subject);
176
+ expect(synthesizer.synthesize).toHaveBeenCalledTimes(1);
177
+ const [passedSubject, passedFindings] = synthesizer.synthesize.mock.calls[0];
178
+ expect(passedSubject).toBe(subject);
179
+ expect(passedFindings).toHaveLength(2);
180
+ expect(passedFindings[0]).toHaveProperty("sourceType", "s1");
181
+ expect(passedFindings[1]).toHaveProperty("sourceType", "s2");
182
+ });
183
+ // Test 5: Returns DebriefResult with correct fields
184
+ it("returns DebriefResult with correct fields", async () => {
185
+ const source = createMockSource({
186
+ finding: makeFinding({ costUsd: 0.005 }),
187
+ });
188
+ const synthesizer = createMockSynthesizer();
189
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [source])], synthesizer);
190
+ const subject = makeSubject({ id: 42, name: "John Wayne" });
191
+ const result = await orchestrator.debrief(subject);
192
+ expect(result.subject).toBe(subject);
193
+ expect(result.data).toEqual({ result: "synthesized" });
194
+ expect(result.findings).toHaveLength(1);
195
+ expect(result.synthesisResult).toBeDefined();
196
+ expect(result.synthesisResult.model).toBe("test-model");
197
+ // totalCostUsd = source cost (0.005) + synthesis cost (0.01)
198
+ expect(result.totalCostUsd).toBeCloseTo(0.015, 5);
199
+ expect(result.sourcesAttempted).toBe(1);
200
+ expect(result.sourcesSucceeded).toBe(1);
201
+ expect(result.durationMs).toBeGreaterThanOrEqual(0);
202
+ });
203
+ // Test 6: Early stops when high-quality family threshold is met
204
+ it("early stops when earlyStopThreshold is met", async () => {
205
+ // Create 3 sources in phase 0 with different reliability tiers
206
+ // that all exceed both confidence and reliability thresholds
207
+ const source1 = createMockSource({
208
+ name: "S1",
209
+ type: "s1",
210
+ reliabilityTier: ReliabilityTier.TIER_1_NEWS,
211
+ reliabilityScore: 0.95,
212
+ finding: makeFinding({ confidence: 0.9 }),
213
+ });
214
+ const source2 = createMockSource({
215
+ name: "S2",
216
+ type: "s2",
217
+ reliabilityTier: ReliabilityTier.TRADE_PRESS,
218
+ reliabilityScore: 0.9,
219
+ finding: makeFinding({ confidence: 0.8 }),
220
+ });
221
+ const source3 = createMockSource({
222
+ name: "S3",
223
+ type: "s3",
224
+ reliabilityTier: ReliabilityTier.ARCHIVAL,
225
+ reliabilityScore: 0.9,
226
+ finding: makeFinding({ confidence: 0.7 }),
227
+ });
228
+ // Phase 1 source should never be called
229
+ const phase1Source = createMockSource({
230
+ name: "NeverCalled",
231
+ type: "never_called",
232
+ });
233
+ const synthesizer = createMockSynthesizer();
234
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [source1, source2, source3]), makePhase(1, [phase1Source])], synthesizer, { earlyStopThreshold: 3, confidenceThreshold: 0.6, reliabilityThreshold: 0.6 });
235
+ const result = await orchestrator.debrief(makeSubject());
236
+ // Phase 1 source should not have been called
237
+ expect(phase1Source.lookup).not.toHaveBeenCalled();
238
+ expect(result.stoppedAtPhase).toBe(0);
239
+ expect(result.findings).toHaveLength(3);
240
+ });
241
+ // Test 7: Continues when threshold NOT met
242
+ it("continues to next phase when threshold is NOT met", async () => {
243
+ // Only 1 high-quality source in phase 0 (threshold is 3)
244
+ const source1 = createMockSource({
245
+ name: "S1",
246
+ type: "s1",
247
+ reliabilityTier: ReliabilityTier.TIER_1_NEWS,
248
+ reliabilityScore: 0.95,
249
+ finding: makeFinding({ confidence: 0.9 }),
250
+ });
251
+ const phase1Source = createMockSource({
252
+ name: "P1Source",
253
+ type: "p1_source",
254
+ reliabilityTier: ReliabilityTier.UNRELIABLE_UGC,
255
+ reliabilityScore: 0.35,
256
+ finding: makeFinding({ confidence: 0.3 }),
257
+ });
258
+ const synthesizer = createMockSynthesizer();
259
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [source1]), makePhase(1, [phase1Source])], synthesizer, { earlyStopThreshold: 3 });
260
+ const result = await orchestrator.debrief(makeSubject());
261
+ // Phase 1 source SHOULD have been called
262
+ expect(phase1Source.lookup).toHaveBeenCalled();
263
+ expect(result.stoppedAtPhase).toBeUndefined();
264
+ expect(result.findings).toHaveLength(2);
265
+ });
266
+ // Test 8: Respects per-subject cost limit
267
+ it("respects per-subject cost limit", async () => {
268
+ const expensiveSource = createMockSource({
269
+ name: "Expensive",
270
+ type: "expensive",
271
+ finding: makeFinding({ costUsd: 0.5 }),
272
+ reliabilityTier: ReliabilityTier.UNRELIABLE_UGC,
273
+ reliabilityScore: 0.35,
274
+ });
275
+ const phase1Source = createMockSource({
276
+ name: "CheapSource",
277
+ type: "cheap",
278
+ });
279
+ const synthesizer = createMockSynthesizer();
280
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [expensiveSource]), makePhase(1, [phase1Source])], synthesizer, {
281
+ earlyStopThreshold: 10,
282
+ costLimits: { maxCostPerSubject: 0.25 },
283
+ });
284
+ const result = await orchestrator.debrief(makeSubject());
285
+ // Phase 1 should not have been called — cost limit hit after phase 0
286
+ expect(phase1Source.lookup).not.toHaveBeenCalled();
287
+ expect(result.stoppedAtPhase).toBe(0);
288
+ });
289
+ // Test 9: Handles source errors gracefully
290
+ it("handles source errors gracefully — other sources still run", async () => {
291
+ const failingSource = createMockSource({
292
+ name: "Failing",
293
+ type: "failing",
294
+ shouldThrow: true,
295
+ });
296
+ const workingSource = createMockSource({
297
+ name: "Working",
298
+ type: "working",
299
+ finding: makeFinding({ text: "good data" }),
300
+ });
301
+ const synthesizer = createMockSynthesizer();
302
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [failingSource, workingSource])], synthesizer, { earlyStopThreshold: 10 });
303
+ const result = await orchestrator.debrief(makeSubject());
304
+ // The working source's finding should be collected
305
+ expect(result.findings).toHaveLength(1);
306
+ expect(result.findings[0].text).toBe("good data");
307
+ expect(result.sourcesAttempted).toBe(2);
308
+ expect(result.sourcesSucceeded).toBe(1);
309
+ });
310
+ // Test 10: Returns null data when no findings
311
+ it("returns null data when no findings — synthesis not called", async () => {
312
+ const emptySource = createMockSource({
313
+ name: "Empty",
314
+ type: "empty",
315
+ finding: null,
316
+ });
317
+ const synthesizer = createMockSynthesizer();
318
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [emptySource])], synthesizer);
319
+ const result = await orchestrator.debrief(makeSubject());
320
+ expect(result.data).toBeNull();
321
+ expect(result.findings).toHaveLength(0);
322
+ expect(result.synthesisResult).toBeUndefined();
323
+ expect(synthesizer.synthesize).not.toHaveBeenCalled();
324
+ });
325
+ // Test 11: debrief fires lifecycle hooks at correct points
326
+ it("debrief fires lifecycle hooks at correct points", async () => {
327
+ const source1 = createMockSource({
328
+ name: "S1",
329
+ type: "s1",
330
+ reliabilityTier: ReliabilityTier.TIER_1_NEWS,
331
+ reliabilityScore: 0.95,
332
+ finding: makeFinding({ confidence: 0.9, costUsd: 0.002 }),
333
+ });
334
+ const source2 = createMockSource({
335
+ name: "S2",
336
+ type: "s2",
337
+ reliabilityTier: ReliabilityTier.TRADE_PRESS,
338
+ reliabilityScore: 0.9,
339
+ finding: makeFinding({ confidence: 0.8, costUsd: 0.003 }),
340
+ });
341
+ const synthesizer = createMockSynthesizer();
342
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [source1]), makePhase(1, [source2])], synthesizer, { earlyStopThreshold: 10 });
343
+ const hooks = {
344
+ onSourceAttempt: vi.fn(),
345
+ onSourceComplete: vi.fn(),
346
+ onPhaseComplete: vi.fn(),
347
+ onEarlyStop: vi.fn(),
348
+ onSynthesisStart: vi.fn(),
349
+ onSynthesisComplete: vi.fn(),
350
+ onCostLimitReached: vi.fn(),
351
+ };
352
+ const subject = makeSubject({ id: 1, name: "Hook Test" });
353
+ await orchestrator.debrief(subject, { hooks });
354
+ // onSourceAttempt: once per source (2 sources across 2 phases)
355
+ expect(hooks.onSourceAttempt).toHaveBeenCalledTimes(2);
356
+ expect(hooks.onSourceAttempt).toHaveBeenCalledWith(subject, "S1", 0);
357
+ expect(hooks.onSourceAttempt).toHaveBeenCalledWith(subject, "S2", 1);
358
+ // onSourceComplete: once per source
359
+ expect(hooks.onSourceComplete).toHaveBeenCalledTimes(2);
360
+ expect(hooks.onSourceComplete).toHaveBeenCalledWith(subject, "S1", expect.objectContaining({ confidence: 0.9 }), 0.002);
361
+ expect(hooks.onSourceComplete).toHaveBeenCalledWith(subject, "S2", expect.objectContaining({ confidence: 0.8 }), 0.003);
362
+ // onPhaseComplete: once per phase
363
+ expect(hooks.onPhaseComplete).toHaveBeenCalledTimes(2);
364
+ expect(hooks.onPhaseComplete).toHaveBeenCalledWith(subject, 0, expect.arrayContaining([expect.objectContaining({ sourceType: "s1" })]));
365
+ expect(hooks.onPhaseComplete).toHaveBeenCalledWith(subject, 1, expect.arrayContaining([expect.objectContaining({ sourceType: "s2" })]));
366
+ // onSynthesisStart: once with total finding count
367
+ expect(hooks.onSynthesisStart).toHaveBeenCalledTimes(1);
368
+ expect(hooks.onSynthesisStart).toHaveBeenCalledWith(subject, 2);
369
+ // onSynthesisComplete: once with synthesis result
370
+ expect(hooks.onSynthesisComplete).toHaveBeenCalledTimes(1);
371
+ expect(hooks.onSynthesisComplete).toHaveBeenCalledWith(subject, expect.objectContaining({ data: { result: "synthesized" }, model: "test-model" }));
372
+ // No early stop or cost limit should have fired
373
+ expect(hooks.onEarlyStop).not.toHaveBeenCalled();
374
+ expect(hooks.onCostLimitReached).not.toHaveBeenCalled();
375
+ });
376
+ // Test 11b: debrief fires onEarlyStop hook when early stopping triggers
377
+ it("debrief fires onEarlyStop hook when early stopping triggers", async () => {
378
+ const source1 = createMockSource({
379
+ name: "S1",
380
+ type: "s1",
381
+ reliabilityTier: ReliabilityTier.TIER_1_NEWS,
382
+ reliabilityScore: 0.95,
383
+ finding: makeFinding({ confidence: 0.9 }),
384
+ });
385
+ const source2 = createMockSource({
386
+ name: "S2",
387
+ type: "s2",
388
+ reliabilityTier: ReliabilityTier.TRADE_PRESS,
389
+ reliabilityScore: 0.9,
390
+ finding: makeFinding({ confidence: 0.8 }),
391
+ });
392
+ const source3 = createMockSource({
393
+ name: "S3",
394
+ type: "s3",
395
+ reliabilityTier: ReliabilityTier.ARCHIVAL,
396
+ reliabilityScore: 0.9,
397
+ finding: makeFinding({ confidence: 0.7 }),
398
+ });
399
+ const phase1Source = createMockSource({
400
+ name: "NeverCalled",
401
+ type: "never_called",
402
+ });
403
+ const synthesizer = createMockSynthesizer();
404
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [source1, source2, source3]), makePhase(1, [phase1Source])], synthesizer, { earlyStopThreshold: 3, confidenceThreshold: 0.6, reliabilityThreshold: 0.6 });
405
+ const hooks = {
406
+ onEarlyStop: vi.fn(),
407
+ onCostLimitReached: vi.fn(),
408
+ };
409
+ const subject = makeSubject();
410
+ await orchestrator.debrief(subject, { hooks });
411
+ expect(hooks.onEarlyStop).toHaveBeenCalledTimes(1);
412
+ expect(hooks.onEarlyStop).toHaveBeenCalledWith(subject, 0, expect.stringContaining("3"));
413
+ expect(hooks.onCostLimitReached).not.toHaveBeenCalled();
414
+ });
415
+ // Test 11c: debrief fires onCostLimitReached hook
416
+ it("debrief fires onCostLimitReached hook when cost limit hit", async () => {
417
+ const expensiveSource = createMockSource({
418
+ name: "Expensive",
419
+ type: "expensive",
420
+ finding: makeFinding({ costUsd: 0.5 }),
421
+ reliabilityTier: ReliabilityTier.UNRELIABLE_UGC,
422
+ reliabilityScore: 0.35,
423
+ });
424
+ const phase1Source = createMockSource({
425
+ name: "CheapSource",
426
+ type: "cheap",
427
+ });
428
+ const synthesizer = createMockSynthesizer();
429
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [expensiveSource]), makePhase(1, [phase1Source])], synthesizer, {
430
+ earlyStopThreshold: 10,
431
+ costLimits: { maxCostPerSubject: 0.25 },
432
+ });
433
+ const hooks = {
434
+ onCostLimitReached: vi.fn(),
435
+ onEarlyStop: vi.fn(),
436
+ };
437
+ const subject = makeSubject();
438
+ await orchestrator.debrief(subject, { hooks });
439
+ expect(hooks.onEarlyStop).toHaveBeenCalledTimes(1);
440
+ expect(hooks.onEarlyStop).toHaveBeenCalledWith(subject, 0, "cost_limit");
441
+ expect(hooks.onCostLimitReached).toHaveBeenCalledTimes(1);
442
+ expect(hooks.onCostLimitReached).toHaveBeenCalledWith(subject, 0.5, 0.25);
443
+ });
444
+ // Test 12: Skips unavailable sources
445
+ it("skips unavailable sources — never calls lookup", async () => {
446
+ const unavailableSource = createMockSource({
447
+ name: "Unavailable",
448
+ type: "unavailable",
449
+ isAvailable: false,
450
+ });
451
+ const availableSource = createMockSource({
452
+ name: "Available",
453
+ type: "available",
454
+ finding: makeFinding({ text: "available data" }),
455
+ });
456
+ const synthesizer = createMockSynthesizer();
457
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [unavailableSource, availableSource])], synthesizer, { earlyStopThreshold: 10 });
458
+ const result = await orchestrator.debrief(makeSubject());
459
+ expect(unavailableSource.lookup).not.toHaveBeenCalled();
460
+ expect(result.findings).toHaveLength(1);
461
+ expect(result.sourcesAttempted).toBe(1);
462
+ });
463
+ });
464
+ describe("debriefBatch", () => {
465
+ // Test 12: Processes multiple subjects
466
+ it("processes multiple subjects and returns results in map", async () => {
467
+ const source = createMockSource({ name: "BatchSource", type: "batch" });
468
+ const synthesizer = createMockSynthesizer();
469
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [source])], synthesizer, {
470
+ earlyStopThreshold: 10,
471
+ });
472
+ const subjects = [
473
+ makeSubject({ id: "s1", name: "Subject 1" }),
474
+ makeSubject({ id: "s2", name: "Subject 2" }),
475
+ makeSubject({ id: "s3", name: "Subject 3" }),
476
+ ];
477
+ const results = await orchestrator.debriefBatch(subjects);
478
+ expect(results.size).toBe(3);
479
+ expect(results.has("s1")).toBe(true);
480
+ expect(results.has("s2")).toBe(true);
481
+ expect(results.has("s3")).toBe(true);
482
+ expect(results.get("s1").data).toEqual({ result: "synthesized" });
483
+ });
484
+ // Test 13: Fires lifecycle hooks
485
+ it("fires lifecycle hooks in correct order", async () => {
486
+ const source = createMockSource({ name: "HookSource", type: "hook" });
487
+ const synthesizer = createMockSynthesizer();
488
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [source])], synthesizer, {
489
+ concurrency: 1,
490
+ earlyStopThreshold: 10,
491
+ });
492
+ const hooks = {
493
+ onRunStart: vi.fn(),
494
+ onSubjectStart: vi.fn(),
495
+ onSubjectComplete: vi.fn(),
496
+ onBatchProgress: vi.fn(),
497
+ onRunComplete: vi.fn(),
498
+ };
499
+ const subjects = [makeSubject({ id: "a", name: "A" }), makeSubject({ id: "b", name: "B" })];
500
+ await orchestrator.debriefBatch(subjects, hooks);
501
+ // onRunStart called once with subject count
502
+ expect(hooks.onRunStart).toHaveBeenCalledTimes(1);
503
+ expect(hooks.onRunStart).toHaveBeenCalledWith(2, expect.any(Object));
504
+ // onSubjectStart called for each subject
505
+ expect(hooks.onSubjectStart).toHaveBeenCalledTimes(2);
506
+ // onSubjectComplete called for each subject
507
+ expect(hooks.onSubjectComplete).toHaveBeenCalledTimes(2);
508
+ // onBatchProgress called for each subject
509
+ expect(hooks.onBatchProgress).toHaveBeenCalledTimes(2);
510
+ expect(hooks.onBatchProgress).toHaveBeenCalledWith(expect.objectContaining({
511
+ completed: expect.any(Number),
512
+ total: 2,
513
+ costUsd: expect.any(Number),
514
+ elapsedMs: expect.any(Number),
515
+ }));
516
+ // onRunComplete called once with batch stats
517
+ expect(hooks.onRunComplete).toHaveBeenCalledTimes(1);
518
+ expect(hooks.onRunComplete).toHaveBeenCalledWith(expect.objectContaining({
519
+ completed: 2,
520
+ total: 2,
521
+ succeeded: 2,
522
+ failed: 0,
523
+ costUsd: expect.any(Number),
524
+ elapsedMs: expect.any(Number),
525
+ avgCostPerSubject: expect.any(Number),
526
+ avgDurationMs: expect.any(Number),
527
+ }));
528
+ });
529
+ // Test 14: Respects total cost limit
530
+ it("respects total cost limit — remaining subjects get empty results", async () => {
531
+ // Each source returns a finding costing $0.50 + synthesis costs $0.01
532
+ const source = createMockSource({
533
+ name: "CostlySource",
534
+ type: "costly",
535
+ finding: makeFinding({ costUsd: 0.5 }),
536
+ });
537
+ const synthesizer = createMockSynthesizer();
538
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [source])], synthesizer, {
539
+ concurrency: 1,
540
+ earlyStopThreshold: 10,
541
+ costLimits: { maxTotalCost: 0.6 },
542
+ });
543
+ const subjects = [
544
+ makeSubject({ id: "s1", name: "Subject 1" }),
545
+ makeSubject({ id: "s2", name: "Subject 2" }),
546
+ makeSubject({ id: "s3", name: "Subject 3" }),
547
+ ];
548
+ const results = await orchestrator.debriefBatch(subjects);
549
+ // First subject should succeed (cost ~$0.51)
550
+ const s1Result = results.get("s1");
551
+ expect(s1Result).toBeDefined();
552
+ expect(s1Result.data).toEqual({ result: "synthesized" });
553
+ // After first subject, total cost exceeds $0.60 limit.
554
+ // Remaining subjects should get empty results.
555
+ const emptyResults = Array.from(results.values()).filter((r) => r.data === null);
556
+ expect(emptyResults.length).toBeGreaterThanOrEqual(1);
557
+ });
558
+ });
559
+ describe("infrastructure injection", () => {
560
+ it("injects rate limiter into all sources", () => {
561
+ const source = createMockSource({ name: "InjSource" });
562
+ const synthesizer = createMockSynthesizer();
563
+ new ResearchOrchestrator([makePhase(0, [source])], synthesizer);
564
+ expect(source.setRateLimiter).toHaveBeenCalledTimes(1);
565
+ });
566
+ it("injects cache when provided in config", () => {
567
+ const source = createMockSource({ name: "CacheSource" });
568
+ const synthesizer = createMockSynthesizer();
569
+ const cache = {
570
+ get: vi.fn(),
571
+ set: vi.fn(),
572
+ delete: vi.fn(),
573
+ };
574
+ new ResearchOrchestrator([makePhase(0, [source])], synthesizer, { cache });
575
+ expect(source.setCache).toHaveBeenCalledWith(cache);
576
+ });
577
+ it("injects telemetry into all sources", () => {
578
+ const source = createMockSource({ name: "TelSource" });
579
+ const synthesizer = createMockSynthesizer();
580
+ new ResearchOrchestrator([makePhase(0, [source])], synthesizer);
581
+ expect(source.setTelemetry).toHaveBeenCalledTimes(1);
582
+ });
583
+ });
584
+ describe("synthesis error handling", () => {
585
+ it("returns null data when synthesis throws", async () => {
586
+ const source = createMockSource({ name: "GoodSource" });
587
+ const synthesizer = createMockSynthesizer();
588
+ synthesizer.synthesize.mockRejectedValue(new Error("Synthesis failed"));
589
+ const orchestrator = new ResearchOrchestrator([makePhase(0, [source])], synthesizer, {
590
+ earlyStopThreshold: 10,
591
+ });
592
+ const result = await orchestrator.debrief(makeSubject());
593
+ expect(result.data).toBeNull();
594
+ expect(result.findings).toHaveLength(1); // Finding still collected
595
+ expect(result.synthesisResult).toBeUndefined();
596
+ });
597
+ });
598
+ describe("sequential phase execution", () => {
599
+ it("executes sources one at a time when sequential is true", async () => {
600
+ let inFlight = 0;
601
+ let peakInFlight = 0;
602
+ const callOrder = [];
603
+ const makeSeqSource = (name, finding) => {
604
+ const source = createMockSource({ name, type: name, finding });
605
+ source.lookup.mockImplementation(async () => {
606
+ inFlight++;
607
+ peakInFlight = Math.max(peakInFlight, inFlight);
608
+ callOrder.push(name);
609
+ await new Promise((r) => setTimeout(r, 30));
610
+ inFlight--;
611
+ return finding;
612
+ });
613
+ return source;
614
+ };
615
+ const s1 = makeSeqSource("seq1", makeFinding({ text: "first" }));
616
+ const s2 = makeSeqSource("seq2", makeFinding({ text: "second" }));
617
+ const s3 = makeSeqSource("seq3", makeFinding({ text: "third" }));
618
+ const synthesizer = createMockSynthesizer();
619
+ const orchestrator = new ResearchOrchestrator([{ phase: 0, sources: [s1, s2, s3], sequential: true }], synthesizer, { earlyStopThreshold: 10 });
620
+ const result = await orchestrator.debrief(makeSubject());
621
+ // Only first source should have been called (it returned a finding)
622
+ expect(peakInFlight).toBe(1);
623
+ expect(callOrder).toEqual(["seq1"]);
624
+ expect(result.findings).toHaveLength(1);
625
+ expect(result.findings[0].text).toBe("first");
626
+ // Only 1 source attempted because sequential stops at first success
627
+ expect(result.sourcesAttempted).toBe(1);
628
+ expect(result.sourcesSucceeded).toBe(1);
629
+ });
630
+ it("tries next source when previous returns null", async () => {
631
+ const callOrder = [];
632
+ const s1 = createMockSource({
633
+ name: "null_source",
634
+ type: "null_source",
635
+ finding: null,
636
+ onLookup: () => callOrder.push("null_source"),
637
+ });
638
+ const s2 = createMockSource({
639
+ name: "good_source",
640
+ type: "good_source",
641
+ finding: makeFinding({ text: "found it" }),
642
+ onLookup: () => callOrder.push("good_source"),
643
+ });
644
+ const s3 = createMockSource({
645
+ name: "never_called",
646
+ type: "never_called",
647
+ onLookup: () => callOrder.push("never_called"),
648
+ });
649
+ const synthesizer = createMockSynthesizer();
650
+ const orchestrator = new ResearchOrchestrator([{ phase: 0, sources: [s1, s2, s3], sequential: true }], synthesizer, { earlyStopThreshold: 10 });
651
+ const result = await orchestrator.debrief(makeSubject());
652
+ expect(callOrder).toEqual(["null_source", "good_source"]);
653
+ expect(result.findings).toHaveLength(1);
654
+ expect(result.findings[0].text).toBe("found it");
655
+ expect(result.sourcesAttempted).toBe(2);
656
+ });
657
+ it("tries all sources when all return null", async () => {
658
+ const callOrder = [];
659
+ const s1 = createMockSource({
660
+ name: "empty1",
661
+ type: "empty1",
662
+ finding: null,
663
+ onLookup: () => callOrder.push("empty1"),
664
+ });
665
+ const s2 = createMockSource({
666
+ name: "empty2",
667
+ type: "empty2",
668
+ finding: null,
669
+ onLookup: () => callOrder.push("empty2"),
670
+ });
671
+ const synthesizer = createMockSynthesizer();
672
+ const orchestrator = new ResearchOrchestrator([{ phase: 0, sources: [s1, s2], sequential: true }], synthesizer, { earlyStopThreshold: 10 });
673
+ const result = await orchestrator.debrief(makeSubject());
674
+ expect(callOrder).toEqual(["empty1", "empty2"]);
675
+ expect(result.findings).toHaveLength(0);
676
+ expect(result.sourcesAttempted).toBe(2);
677
+ expect(result.sourcesSucceeded).toBe(0);
678
+ });
679
+ it("skips failing sources and continues to next", async () => {
680
+ const callOrder = [];
681
+ const s1 = createMockSource({
682
+ name: "failing",
683
+ type: "failing",
684
+ shouldThrow: true,
685
+ onLookup: () => callOrder.push("failing"),
686
+ });
687
+ const s2 = createMockSource({
688
+ name: "working",
689
+ type: "working",
690
+ finding: makeFinding({ text: "success" }),
691
+ onLookup: () => callOrder.push("working"),
692
+ });
693
+ const synthesizer = createMockSynthesizer();
694
+ const orchestrator = new ResearchOrchestrator([{ phase: 0, sources: [s1, s2], sequential: true }], synthesizer, { earlyStopThreshold: 10 });
695
+ const result = await orchestrator.debrief(makeSubject());
696
+ expect(callOrder).toEqual(["failing", "working"]);
697
+ expect(result.findings).toHaveLength(1);
698
+ expect(result.findings[0].text).toBe("success");
699
+ });
700
+ it("fires lifecycle hooks for each sequential source", async () => {
701
+ const s1 = createMockSource({
702
+ name: "S1",
703
+ type: "s1",
704
+ finding: null,
705
+ });
706
+ const s2 = createMockSource({
707
+ name: "S2",
708
+ type: "s2",
709
+ finding: makeFinding({ text: "found", costUsd: 0.01 }),
710
+ });
711
+ const synthesizer = createMockSynthesizer();
712
+ const orchestrator = new ResearchOrchestrator([{ phase: 0, sources: [s1, s2], sequential: true }], synthesizer, { earlyStopThreshold: 10 });
713
+ const hooks = {
714
+ onSourceAttempt: vi.fn(),
715
+ onSourceComplete: vi.fn(),
716
+ onPhaseComplete: vi.fn(),
717
+ };
718
+ await orchestrator.debrief(makeSubject(), { hooks });
719
+ expect(hooks.onSourceAttempt).toHaveBeenCalledTimes(2);
720
+ expect(hooks.onSourceAttempt).toHaveBeenCalledWith(expect.anything(), "S1", 0);
721
+ expect(hooks.onSourceAttempt).toHaveBeenCalledWith(expect.anything(), "S2", 0);
722
+ expect(hooks.onSourceComplete).toHaveBeenCalledTimes(2);
723
+ expect(hooks.onSourceComplete).toHaveBeenCalledWith(expect.anything(), "S1", null, 0);
724
+ expect(hooks.onSourceComplete).toHaveBeenCalledWith(expect.anything(), "S2", expect.objectContaining({ text: "found" }), 0.01);
725
+ expect(hooks.onPhaseComplete).toHaveBeenCalledTimes(1);
726
+ });
727
+ it("does not affect concurrent execution when sequential is false/undefined", async () => {
728
+ let inFlight = 0;
729
+ let peakInFlight = 0;
730
+ const makeConcSource = (name) => {
731
+ const source = createMockSource({ name, type: name });
732
+ source.lookup.mockImplementation(async () => {
733
+ inFlight++;
734
+ peakInFlight = Math.max(peakInFlight, inFlight);
735
+ await new Promise((r) => setTimeout(r, 30));
736
+ inFlight--;
737
+ return makeFinding();
738
+ });
739
+ return source;
740
+ };
741
+ const s1 = makeConcSource("c1");
742
+ const s2 = makeConcSource("c2");
743
+ const s3 = makeConcSource("c3");
744
+ const synthesizer = createMockSynthesizer();
745
+ const orchestrator = new ResearchOrchestrator([{ phase: 0, sources: [s1, s2, s3], sequential: false }], synthesizer, { earlyStopThreshold: 10 });
746
+ await orchestrator.debrief(makeSubject());
747
+ expect(peakInFlight).toBe(3); // All concurrent
748
+ });
749
+ });
750
+ });
751
+ //# sourceMappingURL=orchestrator.test.js.map