@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.
- package/README.md +86 -0
- package/dist/__tests__/base-source.test.d.ts +2 -0
- package/dist/__tests__/base-source.test.d.ts.map +1 -0
- package/dist/__tests__/base-source.test.js +333 -0
- package/dist/__tests__/base-source.test.js.map +1 -0
- package/dist/__tests__/batch-runner.test.d.ts +2 -0
- package/dist/__tests__/batch-runner.test.d.ts.map +1 -0
- package/dist/__tests__/batch-runner.test.js +217 -0
- package/dist/__tests__/batch-runner.test.js.map +1 -0
- package/dist/__tests__/cache.test.d.ts +2 -0
- package/dist/__tests__/cache.test.d.ts.map +1 -0
- package/dist/__tests__/cache.test.js +127 -0
- package/dist/__tests__/cache.test.js.map +1 -0
- package/dist/__tests__/confidence.test.d.ts +2 -0
- package/dist/__tests__/confidence.test.d.ts.map +1 -0
- package/dist/__tests__/confidence.test.js +81 -0
- package/dist/__tests__/confidence.test.js.map +1 -0
- package/dist/__tests__/cost-tracker.test.d.ts +2 -0
- package/dist/__tests__/cost-tracker.test.d.ts.map +1 -0
- package/dist/__tests__/cost-tracker.test.js +149 -0
- package/dist/__tests__/cost-tracker.test.js.map +1 -0
- package/dist/__tests__/orchestrator.test.d.ts +2 -0
- package/dist/__tests__/orchestrator.test.d.ts.map +1 -0
- package/dist/__tests__/orchestrator.test.js +751 -0
- package/dist/__tests__/orchestrator.test.js.map +1 -0
- package/dist/__tests__/rate-limiter.test.d.ts +2 -0
- package/dist/__tests__/rate-limiter.test.d.ts.map +1 -0
- package/dist/__tests__/rate-limiter.test.js +83 -0
- package/dist/__tests__/rate-limiter.test.js.map +1 -0
- package/dist/__tests__/reliability.test.d.ts +2 -0
- package/dist/__tests__/reliability.test.d.ts.map +1 -0
- package/dist/__tests__/reliability.test.js +207 -0
- package/dist/__tests__/reliability.test.js.map +1 -0
- package/dist/__tests__/synthesizer.test.d.ts +2 -0
- package/dist/__tests__/synthesizer.test.d.ts.map +1 -0
- package/dist/__tests__/synthesizer.test.js +50 -0
- package/dist/__tests__/synthesizer.test.js.map +1 -0
- package/dist/__tests__/telemetry.test.d.ts +2 -0
- package/dist/__tests__/telemetry.test.d.ts.map +1 -0
- package/dist/__tests__/telemetry.test.js +81 -0
- package/dist/__tests__/telemetry.test.js.map +1 -0
- package/dist/__tests__/types.test.d.ts +2 -0
- package/dist/__tests__/types.test.d.ts.map +1 -0
- package/dist/__tests__/types.test.js +708 -0
- package/dist/__tests__/types.test.js.map +1 -0
- package/dist/base-source.d.ts +91 -0
- package/dist/base-source.d.ts.map +1 -0
- package/dist/base-source.js +144 -0
- package/dist/base-source.js.map +1 -0
- package/dist/batch-runner.d.ts +40 -0
- package/dist/batch-runner.d.ts.map +1 -0
- package/dist/batch-runner.js +65 -0
- package/dist/batch-runner.js.map +1 -0
- package/dist/cache/in-memory.d.ts +26 -0
- package/dist/cache/in-memory.d.ts.map +1 -0
- package/dist/cache/in-memory.js +51 -0
- package/dist/cache/in-memory.js.map +1 -0
- package/dist/confidence.d.ts +13 -0
- package/dist/confidence.d.ts.map +1 -0
- package/dist/confidence.js +29 -0
- package/dist/confidence.js.map +1 -0
- package/dist/cost-tracker.d.ts +37 -0
- package/dist/cost-tracker.d.ts.map +1 -0
- package/dist/cost-tracker.js +62 -0
- package/dist/cost-tracker.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/orchestrator.d.ts +92 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +373 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/rate-limiter.d.ts +31 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +79 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/reliability.d.ts +49 -0
- package/dist/reliability.d.ts.map +1 -0
- package/dist/reliability.js +67 -0
- package/dist/reliability.js.map +1 -0
- package/dist/synthesizer.d.ts +31 -0
- package/dist/synthesizer.d.ts.map +1 -0
- package/dist/synthesizer.js +47 -0
- package/dist/synthesizer.js.map +1 -0
- package/dist/telemetry/console.d.ts +7 -0
- package/dist/telemetry/console.d.ts.map +1 -0
- package/dist/telemetry/console.js +21 -0
- package/dist/telemetry/console.js.map +1 -0
- package/dist/telemetry/noop.d.ts +7 -0
- package/dist/telemetry/noop.d.ts.map +1 -0
- package/dist/telemetry/noop.js +12 -0
- package/dist/telemetry/noop.js.map +1 -0
- package/dist/types.d.ts +417 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +102 -0
- package/dist/types.js.map +1 -0
- 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
|