@moxxy/mode-deep-research 0.26.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/dist/approval.d.ts +32 -0
  3. package/dist/approval.d.ts.map +1 -0
  4. package/dist/approval.js +84 -0
  5. package/dist/approval.js.map +1 -0
  6. package/dist/constants.d.ts +62 -0
  7. package/dist/constants.d.ts.map +1 -0
  8. package/dist/constants.js +141 -0
  9. package/dist/constants.js.map +1 -0
  10. package/dist/fanout-phase.d.ts +44 -0
  11. package/dist/fanout-phase.d.ts.map +1 -0
  12. package/dist/fanout-phase.js +133 -0
  13. package/dist/fanout-phase.js.map +1 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +18 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/parse-queries.d.ts +13 -0
  19. package/dist/parse-queries.d.ts.map +1 -0
  20. package/dist/parse-queries.js +71 -0
  21. package/dist/parse-queries.js.map +1 -0
  22. package/dist/query-phase.d.ts +15 -0
  23. package/dist/query-phase.d.ts.map +1 -0
  24. package/dist/query-phase.js +81 -0
  25. package/dist/query-phase.js.map +1 -0
  26. package/dist/research-loop.d.ts +18 -0
  27. package/dist/research-loop.d.ts.map +1 -0
  28. package/dist/research-loop.js +357 -0
  29. package/dist/research-loop.js.map +1 -0
  30. package/dist/synthesis-phase.d.ts +8 -0
  31. package/dist/synthesis-phase.d.ts.map +1 -0
  32. package/dist/synthesis-phase.js +21 -0
  33. package/dist/synthesis-phase.js.map +1 -0
  34. package/package.json +73 -0
  35. package/src/approval.test.ts +82 -0
  36. package/src/approval.ts +105 -0
  37. package/src/constants.ts +153 -0
  38. package/src/fanout-phase.ts +177 -0
  39. package/src/index.test.ts +575 -0
  40. package/src/index.ts +27 -0
  41. package/src/parse-queries.ts +72 -0
  42. package/src/query-phase.ts +123 -0
  43. package/src/research-loop.ts +414 -0
  44. package/src/synthesis-phase.ts +25 -0
@@ -0,0 +1,575 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { collectTurn } from '@moxxy/core';
3
+ import { FakeProvider, createFakeSession, textReply } from '@moxxy/testing';
4
+ import type {
5
+ LLMProvider,
6
+ ModeContext,
7
+ ProviderEvent,
8
+ ProviderRequest,
9
+ SubagentResult,
10
+ SubagentSpawner,
11
+ } from '@moxxy/sdk';
12
+ import { asSessionId } from '@moxxy/sdk';
13
+
14
+ import {
15
+ buildFanoutDigest,
16
+ buildSynthesisInput,
17
+ deepResearchModePlugin,
18
+ RESEARCH_MODE_NAME,
19
+ parseFollowups,
20
+ parseQueries,
21
+ type RoundFinding,
22
+ } from './index.js';
23
+ import { runFanout } from './fanout-phase.js';
24
+ import { SUBAGENT_PRIOR_FINDING_MAX_CHARS } from './constants.js';
25
+
26
+ describe('parseQueries', () => {
27
+ it('extracts numbered queries after the QUERIES: header', () => {
28
+ expect(
29
+ parseQueries(
30
+ 'QUERIES:\n1. What is foo?\n2. How does bar work?\n3. Where is baz used?',
31
+ ),
32
+ ).toEqual(['What is foo?', 'How does bar work?', 'Where is baz used?']);
33
+ });
34
+
35
+ it('accepts dashes/bullets', () => {
36
+ expect(parseQueries('QUERIES:\n- alpha\n* beta')).toEqual(['alpha', 'beta']);
37
+ });
38
+
39
+ it('returns empty when nothing matches', () => {
40
+ expect(parseQueries('No queries here.')).toEqual([]);
41
+ });
42
+
43
+ it('joins a wrapped continuation onto the prior item instead of truncating it', () => {
44
+ // Worst case: the planner wraps a long query across two lines. The
45
+ // continuation must be re-joined, not dropped — a truncated half-question
46
+ // would otherwise be sent verbatim to a subagent.
47
+ expect(
48
+ parseQueries('QUERIES:\n1. first part of a long query\nthat wrapped onto a new line\n2. second query'),
49
+ ).toEqual(['first part of a long query that wrapped onto a new line', 'second query']);
50
+ });
51
+
52
+ it('does NOT glue an item across a blank line or a trailing paragraph', () => {
53
+ // A blank line ends the continuation run, so an unrelated trailing note is
54
+ // not appended to the last query.
55
+ expect(
56
+ parseQueries('QUERIES:\n1. only real query\n\nThis is unrelated trailing prose.'),
57
+ ).toEqual(['only real query']);
58
+ });
59
+
60
+ it('drops a non-list line that precedes any item (no item to continue)', () => {
61
+ // A continuation with nothing open is junk preamble — dropped, never
62
+ // promoted to its own item.
63
+ expect(parseQueries('QUERIES:\nstray preamble line\n1. the real query')).toEqual([
64
+ 'the real query',
65
+ ]);
66
+ });
67
+
68
+ it('bounds a single item even under a flood of continuation lines', () => {
69
+ // Hostile input: hundreds of continuation lines all attach to one item.
70
+ // The joined item must stay bounded rather than growing without limit.
71
+ const flood = ['QUERIES:', '1. start'];
72
+ for (let i = 0; i < 500; i += 1) flood.push('x'.repeat(50));
73
+ const [item] = parseQueries(flood.join('\n'));
74
+ expect(item).toBeDefined();
75
+ expect(item!.length).toBeLessThanOrEqual(2000);
76
+ });
77
+ });
78
+
79
+ describe('parseFollowups', () => {
80
+ it('parses a numbered FOLLOWUPS block', () => {
81
+ expect(parseFollowups('FOLLOWUPS:\n1. dig deeper on A\n2. verify B')).toEqual([
82
+ 'dig deeper on A',
83
+ 'verify B',
84
+ ]);
85
+ });
86
+
87
+ it('returns empty when the model emits FOLLOWUPS: (none)', () => {
88
+ expect(parseFollowups('FOLLOWUPS: (none)')).toEqual([]);
89
+ });
90
+
91
+ it('returns empty when format is missing', () => {
92
+ expect(parseFollowups('I am done, no follow-ups.')).toEqual([]);
93
+ });
94
+
95
+ it('does not let a parenthetical near the header swallow a real list', () => {
96
+ // Regression: the "(none)" sentinel must be anchored to a full line so a
97
+ // header-adjacent parenthetical does not discard genuine follow-ups.
98
+ expect(
99
+ parseFollowups(
100
+ 'FOLLOWUPS:\n(none of the prior sources covered cost)\n1. find cost data\n2. verify pricing',
101
+ ),
102
+ ).toEqual(['find cost data', 'verify pricing']);
103
+ });
104
+
105
+ it('still honors a bare FOLLOWUPS: (none) line', () => {
106
+ expect(parseFollowups('FOLLOWUPS:\n(none)')).toEqual([]);
107
+ });
108
+ });
109
+
110
+ describe('buildFanoutDigest', () => {
111
+ it('marks errored subagents and round numbers explicitly', () => {
112
+ const findings: RoundFinding[] = [
113
+ { round: 1, question: 'Q1', text: 'first headline that should appear' },
114
+ { round: 1, question: 'Q2', text: '', error: 'timed out' },
115
+ { round: 2, question: 'Q3 follow-up', text: 'second-round detail' },
116
+ ];
117
+ const out = buildFanoutDigest(findings);
118
+ expect(out).toContain('2 of 3 subagents returned');
119
+ expect(out).toContain('1 errored');
120
+ expect(out).toContain('errored: timed out');
121
+ expect(out).toContain('first headline');
122
+ expect(out).toContain('[round 1]');
123
+ expect(out).toContain('[round 2]');
124
+ });
125
+ });
126
+
127
+ describe('buildSynthesisInput', () => {
128
+ it('weaves original prompt with per-round findings', () => {
129
+ const findings: RoundFinding[] = [
130
+ { round: 1, question: 'sub-q one', text: 'finding one' },
131
+ { round: 2, question: 'follow-up two', text: 'finding two' },
132
+ ];
133
+ const body = buildSynthesisInput('What is the question?', findings);
134
+ expect(body).toContain('Original question:');
135
+ expect(body).toContain('What is the question?');
136
+ expect(body).toContain('(round 1): sub-q one');
137
+ expect(body).toContain('finding one');
138
+ expect(body).toContain('(round 2): follow-up two');
139
+ expect(body).toContain('finding two');
140
+ });
141
+ });
142
+
143
+ describe('runFanout (resilience + bounded prompts)', () => {
144
+ function ctxWithSpawner(spawnAll: SubagentSpawner['spawnAll']): ModeContext {
145
+ const subagents: SubagentSpawner = {
146
+ async spawn() {
147
+ return fakeResult('unused');
148
+ },
149
+ spawnAll,
150
+ };
151
+ return { subagents } as unknown as ModeContext;
152
+ }
153
+
154
+ it('isolates a spawnAll rejection into per-query error entries instead of crashing', async () => {
155
+ // Worst case: a single child's setup work throws and rejects the whole
156
+ // Promise.all batch. runFanout must NOT propagate — it returns synthetic
157
+ // error entries so the loop can still carry findings into synthesis.
158
+ const ctx = ctxWithSpawner(async () => {
159
+ throw new Error('child setup blew up');
160
+ });
161
+
162
+ const outcome = await runFanout(ctx, ['q1', 'q2', 'q3']);
163
+ expect(outcome.results).toEqual([]);
164
+ expect(outcome.errored).toEqual([
165
+ { index: 0, message: 'child setup blew up' },
166
+ { index: 1, message: 'child setup blew up' },
167
+ { index: 2, message: 'child setup blew up' },
168
+ ]);
169
+ });
170
+
171
+ it('bounds each prior finding embedded into follow-up subagent prompts', async () => {
172
+ const captured: string[] = [];
173
+ const ctx = ctxWithSpawner(async (specs) => {
174
+ for (const s of specs) captured.push(s.prompt);
175
+ return specs.map(() => fakeResult('ok'));
176
+ });
177
+
178
+ const huge = 'x'.repeat(SUBAGENT_PRIOR_FINDING_MAX_CHARS * 4);
179
+ const prior: RoundFinding[] = [{ round: 1, question: 'Q1', text: huge }];
180
+
181
+ await runFanout(ctx, ['follow-up query'], prior);
182
+
183
+ expect(captured).toHaveLength(1);
184
+ const prompt = captured[0]!;
185
+ expect(prompt).toContain('[truncated]');
186
+ // The full 4x-cap blob must not have been embedded verbatim.
187
+ expect(prompt).not.toContain(huge);
188
+ expect(prompt.length).toBeLessThan(huge.length);
189
+ });
190
+ });
191
+
192
+ describe('deepResearchMode end-to-end (headless)', () => {
193
+ it('emits a fatal error when ctx.subagents is unavailable', async () => {
194
+ const provider = new FakeProvider({ script: [textReply('does not matter')] });
195
+ const session = createFakeSession({ provider });
196
+ session.pluginHost.registerStatic(deepResearchModePlugin);
197
+ session.modes.setActive(RESEARCH_MODE_NAME);
198
+
199
+ const realMode = session.modes.list().find((m) => m.name === RESEARCH_MODE_NAME)!;
200
+ session.modes.replace({
201
+ name: realMode.name,
202
+ run: (ctx) => realMode.run({ ...ctx, subagents: undefined }),
203
+ });
204
+ session.modes.setActive(realMode.name);
205
+
206
+ const events = await collectTurn(session, 'investigate something');
207
+ const fatal = events.find((e) => e.type === 'error' && e.kind === 'fatal');
208
+ expect(fatal).toBeDefined();
209
+ if (fatal?.type !== 'error') throw new Error();
210
+ expect(fatal.message).toMatch(/subagents/i);
211
+ });
212
+
213
+ it('survives a throwing spawnAll and still reaches synthesis instead of crashing', async () => {
214
+ // Worst case: the round-1 fan-out rejects entirely. The turn must not crash;
215
+ // it must record every query as errored and still synthesize.
216
+ const provider = new FakeProvider({
217
+ script: [
218
+ textReply('QUERIES:\n1. First angle?\n2. Second angle?'),
219
+ textReply('FOLLOWUPS: (none)'),
220
+ textReply(
221
+ '## Executive summary\n- bullet\n\n## Key findings\nfinding\n\n## Sources\n(none)\n\n## Open questions\n- none',
222
+ ),
223
+ ],
224
+ });
225
+
226
+ const session = createFakeSession({ provider });
227
+ session.pluginHost.registerStatic(deepResearchModePlugin);
228
+ session.modes.setActive(RESEARCH_MODE_NAME);
229
+
230
+ const realMode = session.modes.list().find((m) => m.name === RESEARCH_MODE_NAME)!;
231
+ const throwingSpawner: SubagentSpawner = {
232
+ async spawn() {
233
+ return fakeResult('unused');
234
+ },
235
+ async spawnAll() {
236
+ throw new Error('child setup blew up');
237
+ },
238
+ };
239
+ session.modes.replace({
240
+ name: realMode.name,
241
+ run: (ctx) => realMode.run({ ...ctx, subagents: throwingSpawner }),
242
+ });
243
+ session.modes.setActive(realMode.name);
244
+
245
+ const events = await collectTurn(session, 'investigate but fan-out fails');
246
+
247
+ // No fatal error escaped the loop, and the round still "completed" with all errored.
248
+ const fatal = events.find((e) => e.type === 'error' && e.kind === 'fatal');
249
+ expect(fatal).toBeUndefined();
250
+ const round1Done = events.find(
251
+ (e) =>
252
+ e.type === 'plugin_event' &&
253
+ e.subtype === 'deep_research_fanout_completed' &&
254
+ (e.payload as { round: number }).round === 1,
255
+ );
256
+ expect(round1Done).toBeDefined();
257
+ if (round1Done?.type !== 'plugin_event') throw new Error();
258
+ expect((round1Done.payload as { errored: number }).errored).toBe(2);
259
+ const synthDone = events.find(
260
+ (e) => e.type === 'plugin_event' && e.subtype === 'deep_research_synthesis_completed',
261
+ );
262
+ expect(synthDone).toBeDefined();
263
+ });
264
+
265
+ it('redrafts an oversized query plan when an approval resolver is present', async () => {
266
+ // Worst case: planner over-produces. With an approval gate + redraft budget,
267
+ // the loop must auto-redraft (narrow-scope feedback) rather than fatally abort.
268
+ const provider = new FakeProvider({
269
+ script: [
270
+ // First plan: 7 queries — over MAX_SUBAGENTS (6).
271
+ textReply(
272
+ 'QUERIES:\n1. a?\n2. b?\n3. c?\n4. d?\n5. e?\n6. f?\n7. g?',
273
+ ),
274
+ // Redraft: a sane 2-query plan.
275
+ textReply('QUERIES:\n1. First angle?\n2. Second angle?'),
276
+ textReply('FOLLOWUPS: (none)'),
277
+ textReply(
278
+ '## Executive summary\n- bullet\n\n## Key findings\nfinding\n\n## Sources\n(none)\n\n## Open questions\n- none',
279
+ ),
280
+ ],
281
+ });
282
+
283
+ const session = createFakeSession({ provider });
284
+ session.pluginHost.registerStatic(deepResearchModePlugin);
285
+ session.modes.setActive(RESEARCH_MODE_NAME);
286
+
287
+ const realMode = session.modes.list().find((m) => m.name === RESEARCH_MODE_NAME)!;
288
+ const approval = { name: 'fake', confirm: async () => ({ optionId: 'approve' }) };
289
+ const okSpawner: SubagentSpawner = {
290
+ async spawn() {
291
+ return fakeResult('unused');
292
+ },
293
+ async spawnAll(specs) {
294
+ return specs.map((_, i) =>
295
+ fakeResult(`FINDINGS: angle ${i + 1}.\n\nSOURCES:\n[1] X — http://x`),
296
+ );
297
+ },
298
+ };
299
+ session.modes.replace({
300
+ name: realMode.name,
301
+ run: (ctx) =>
302
+ realMode.run({ ...ctx, subagents: okSpawner, approval } as typeof ctx),
303
+ });
304
+ session.modes.setActive(realMode.name);
305
+
306
+ const events = await collectTurn(session, 'be exhaustive about something');
307
+
308
+ // The oversized plan must NOT have produced a fatal abort.
309
+ const fatal = events.find((e) => e.type === 'error' && e.kind === 'fatal');
310
+ expect(fatal).toBeUndefined();
311
+ // A second drafted-queries event proves the redraft happened.
312
+ const drafts = events.filter(
313
+ (e) => e.type === 'plugin_event' && e.subtype === 'deep_research_queries_drafted',
314
+ );
315
+ expect(drafts.length).toBeGreaterThanOrEqual(2);
316
+ const synthDone = events.find(
317
+ (e) => e.type === 'plugin_event' && e.subtype === 'deep_research_synthesis_completed',
318
+ );
319
+ expect(synthDone).toBeDefined();
320
+ });
321
+
322
+ it('emits a fatal error when the log has no user prompt to anchor on', async () => {
323
+ const provider = new FakeProvider({ script: [textReply('QUERIES:\n1. x?')] });
324
+ const session = createFakeSession({ provider });
325
+ session.pluginHost.registerStatic(deepResearchModePlugin);
326
+ session.modes.setActive(RESEARCH_MODE_NAME);
327
+
328
+ const realMode = session.modes.list().find((m) => m.name === RESEARCH_MODE_NAME)!;
329
+ const okSpawner: SubagentSpawner = {
330
+ async spawn() {
331
+ return fakeResult('unused');
332
+ },
333
+ async spawnAll(specs) {
334
+ return specs.map(() => fakeResult('FINDINGS: x'));
335
+ },
336
+ };
337
+ // Override ctx.log to report no user_prompt events — the empty-log worst case.
338
+ session.modes.replace({
339
+ name: realMode.name,
340
+ run: (ctx) =>
341
+ realMode.run({
342
+ ...ctx,
343
+ subagents: okSpawner,
344
+ log: { ...ctx.log, slice: () => [] },
345
+ } as typeof ctx),
346
+ });
347
+ session.modes.setActive(realMode.name);
348
+
349
+ const events = await collectTurn(session, 'this prompt is ignored by the patched log');
350
+ const fatal = events.find((e) => e.type === 'error' && e.kind === 'fatal');
351
+ expect(fatal).toBeDefined();
352
+ if (fatal?.type !== 'error') throw new Error();
353
+ expect(fatal.message).toMatch(/no user prompt/i);
354
+ });
355
+
356
+ it('runs gather → followup-plan(none) → synthesis when model says no follow-ups', async () => {
357
+ const provider = new FakeProvider({
358
+ script: [
359
+ textReply('QUERIES:\n1. First angle?\n2. Second angle?'),
360
+ textReply('FOLLOWUPS: (none)'),
361
+ textReply(
362
+ '## Executive summary\n- bullet\n\n## Key findings\nfinding [1]\n\n## Sources\n[1] x — http://x\n\n## Open questions\n- none',
363
+ ),
364
+ ],
365
+ });
366
+
367
+ const session = createFakeSession({ provider });
368
+ session.pluginHost.registerStatic(deepResearchModePlugin);
369
+ session.modes.setActive(RESEARCH_MODE_NAME);
370
+
371
+ const realMode = session.modes.list().find((m) => m.name === RESEARCH_MODE_NAME)!;
372
+ const fakeSpawner: SubagentSpawner = {
373
+ async spawn() {
374
+ return fakeResult('single-spawn unused in this test');
375
+ },
376
+ async spawnAll(specs) {
377
+ return specs.map((_, i) =>
378
+ fakeResult(`FINDINGS: angle ${i + 1} answered.\n\nSOURCES:\n[1] X — http://x`),
379
+ );
380
+ },
381
+ };
382
+ session.modes.replace({
383
+ name: realMode.name,
384
+ run: (ctx) => realMode.run({ ...ctx, subagents: fakeSpawner }),
385
+ });
386
+ session.modes.setActive(realMode.name);
387
+
388
+ const events = await collectTurn(session, 'investigate something else');
389
+
390
+ const queriesDrafted = events.find(
391
+ (e) => e.type === 'plugin_event' && e.subtype === 'deep_research_queries_drafted',
392
+ );
393
+ expect(queriesDrafted).toBeDefined();
394
+
395
+ const round1Completed = events.find(
396
+ (e) =>
397
+ e.type === 'plugin_event' &&
398
+ e.subtype === 'deep_research_fanout_completed' &&
399
+ (e.payload as { round: number }).round === 1,
400
+ );
401
+ expect(round1Completed).toBeDefined();
402
+
403
+ const followupsNone = events.find(
404
+ (e) => e.type === 'plugin_event' && e.subtype === 'deep_research_followups_none',
405
+ );
406
+ expect(followupsNone).toBeDefined();
407
+
408
+ const synthDone = events.find(
409
+ (e) => e.type === 'plugin_event' && e.subtype === 'deep_research_synthesis_completed',
410
+ );
411
+ expect(synthDone).toBeDefined();
412
+ });
413
+
414
+ it('emits an abort (not the report) when the signal fires during synthesis', async () => {
415
+ // u65-4: every other phase guards ctx.signal.aborted; synthesis must too —
416
+ // a cancel during the multi-second synthesis call should yield an abort, not
417
+ // a finished assistant_message.
418
+ const fake = new FakeProvider({
419
+ script: [
420
+ textReply('QUERIES:\n1. First angle?\n2. Second angle?'),
421
+ textReply('FOLLOWUPS: (none)'),
422
+ textReply(
423
+ '## Executive summary\n- bullet\n\n## Key findings\nfinding [1]\n\n## Sources\n[1] x — http://x\n\n## Open questions\n- none',
424
+ ),
425
+ ],
426
+ });
427
+ const controller = new AbortController();
428
+ const provider = abortOnSynthesisProvider(fake, controller);
429
+
430
+ const session = createFakeSession({ provider });
431
+ session.pluginHost.registerStatic(deepResearchModePlugin);
432
+ session.modes.setActive(RESEARCH_MODE_NAME);
433
+
434
+ const fakeSpawner: SubagentSpawner = {
435
+ async spawn() {
436
+ return fakeResult('unused');
437
+ },
438
+ async spawnAll(specs) {
439
+ return specs.map((_, i) =>
440
+ fakeResult(`FINDINGS: angle ${i + 1} answered.\n\nSOURCES:\n[1] X — http://x`),
441
+ );
442
+ },
443
+ };
444
+ const realMode = session.modes.list().find((m) => m.name === RESEARCH_MODE_NAME)!;
445
+ session.modes.replace({
446
+ name: realMode.name,
447
+ run: (ctx) =>
448
+ realMode.run({ ...ctx, subagents: fakeSpawner, signal: controller.signal }),
449
+ });
450
+ session.modes.setActive(realMode.name);
451
+
452
+ const events = await collectTurn(session, 'investigate then cancel');
453
+
454
+ const abort = events.find((e) => e.type === 'abort');
455
+ expect(abort).toBeDefined();
456
+ if (abort?.type === 'abort') expect(abort.reason).toMatch(/synthesis/);
457
+
458
+ // The finished report must NOT have been emitted as an assistant_message,
459
+ // nor the synthesis-completed plugin event.
460
+ const reportEmitted = events.some(
461
+ (e) => e.type === 'assistant_message' && e.content.includes('Executive summary'),
462
+ );
463
+ expect(reportEmitted).toBe(false);
464
+ const synthDone = events.find(
465
+ (e) => e.type === 'plugin_event' && e.subtype === 'deep_research_synthesis_completed',
466
+ );
467
+ expect(synthDone).toBeUndefined();
468
+ });
469
+
470
+ it('runs gather → followup-plan(2 queries) → round-2 fanout → synthesis', async () => {
471
+ const provider = new FakeProvider({
472
+ script: [
473
+ // Round-1 query plan
474
+ textReply('QUERIES:\n1. First angle?\n2. Second angle?'),
475
+ // Follow-up plan after round 1 — model asks for 2 follow-ups
476
+ textReply('FOLLOWUPS:\n1. verify claim X\n2. cross-check source Y'),
477
+ // Follow-up plan after round 2 — model says we're done
478
+ textReply('FOLLOWUPS: (none)'),
479
+ // Synthesis
480
+ textReply(
481
+ '## Executive summary\n- bullet\n\n## Key findings\nfinding [1]\n\n## Sources\n[1] x — http://x\n\n## Open questions\n- none',
482
+ ),
483
+ ],
484
+ });
485
+
486
+ const session = createFakeSession({ provider });
487
+ session.pluginHost.registerStatic(deepResearchModePlugin);
488
+ session.modes.setActive(RESEARCH_MODE_NAME);
489
+
490
+ const calls: number[] = [];
491
+ const fakeSpawner: SubagentSpawner = {
492
+ async spawn() {
493
+ return fakeResult('single-spawn unused');
494
+ },
495
+ async spawnAll(specs) {
496
+ calls.push(specs.length);
497
+ return specs.map((s, i) =>
498
+ fakeResult(
499
+ `FINDINGS: ${s.label ?? 'sub'} #${i + 1} answered.\n\nSOURCES:\n[1] X — http://x`,
500
+ ),
501
+ );
502
+ },
503
+ };
504
+ const realMode = session.modes.list().find((m) => m.name === RESEARCH_MODE_NAME)!;
505
+ session.modes.replace({
506
+ name: realMode.name,
507
+ run: (ctx) => realMode.run({ ...ctx, subagents: fakeSpawner }),
508
+ });
509
+ session.modes.setActive(realMode.name);
510
+
511
+ const events = await collectTurn(session, 'investigate iran-usa coverage');
512
+
513
+ // Two rounds of fan-out happened (2 round-1 + 2 round-2 = 4 subagent calls).
514
+ expect(calls).toEqual([2, 2]);
515
+
516
+ const round2Completed = events.find(
517
+ (e) =>
518
+ e.type === 'plugin_event' &&
519
+ e.subtype === 'deep_research_fanout_completed' &&
520
+ (e.payload as { round: number }).round === 2,
521
+ );
522
+ expect(round2Completed).toBeDefined();
523
+
524
+ const followupsDrafted = events.filter(
525
+ (e) => e.type === 'plugin_event' && e.subtype === 'deep_research_followups_drafted',
526
+ );
527
+ // One drafted-event for round 2 (2 follow-ups). Round 3's plan event
528
+ // is the "(none)" form which fires followups_none, not followups_drafted.
529
+ expect(followupsDrafted).toHaveLength(1);
530
+
531
+ const synthDone = events.find(
532
+ (e) => e.type === 'plugin_event' && e.subtype === 'deep_research_synthesis_completed',
533
+ );
534
+ expect(synthDone).toBeDefined();
535
+ if (synthDone?.type !== 'plugin_event') throw new Error();
536
+ expect((synthDone.payload as { totalFindings: number; rounds: number }).totalFindings).toBe(4);
537
+ expect((synthDone.payload as { totalFindings: number; rounds: number }).rounds).toBe(2);
538
+ });
539
+ });
540
+
541
+ /**
542
+ * Wraps a FakeProvider and aborts `controller` the instant the SYNTHESIS stream
543
+ * finishes — simulating the user cancelling DURING the synthesis provider call.
544
+ * Aborting after the stream completes (not before) lets `collectSynthesis`
545
+ * return its text normally, so the loop reaches the post-synthesis abort guard.
546
+ */
547
+ function abortOnSynthesisProvider(
548
+ inner: FakeProvider,
549
+ controller: AbortController,
550
+ ): LLMProvider {
551
+ return {
552
+ name: inner.name,
553
+ models: inner.models,
554
+ countTokens: (req) => inner.countTokens(req),
555
+ async *stream(req: ProviderRequest): AsyncIterable<ProviderEvent> {
556
+ const isSynthesis = req.messages.some((m) =>
557
+ m.content.some(
558
+ (c) => 'text' in c && c.text.includes('synthesizing a deep-research report'),
559
+ ),
560
+ );
561
+ for await (const ev of inner.stream(req)) yield ev;
562
+ if (isSynthesis) controller.abort();
563
+ },
564
+ };
565
+ }
566
+
567
+ function fakeResult(text: string, opts: { error?: string } = {}): SubagentResult {
568
+ return {
569
+ label: 'fake',
570
+ childSessionId: asSessionId('fake-child'),
571
+ text,
572
+ stopReason: 'end_turn',
573
+ ...(opts.error ? { error: { message: opts.error } } : {}),
574
+ };
575
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { defineMode, definePlugin } from '@moxxy/sdk';
2
+
3
+ import { RESEARCH_MODE_NAME } from './constants.js';
4
+ import { runDeepResearchMode } from './research-loop.js';
5
+
6
+ export { RESEARCH_MODE_NAME } from './constants.js';
7
+ export { parseFollowups, parseQueries } from './parse-queries.js';
8
+ export {
9
+ buildFanoutDigest,
10
+ buildSynthesisInput,
11
+ flattenOutcome,
12
+ type RoundFinding,
13
+ } from './fanout-phase.js';
14
+
15
+ export const deepResearchMode = defineMode({
16
+ name: RESEARCH_MODE_NAME,
17
+ description: 'Fan-out research: plan queries, run subagents in parallel, synthesise a report',
18
+ run: runDeepResearchMode,
19
+ });
20
+
21
+ export const deepResearchModePlugin = definePlugin({
22
+ name: '@moxxy/mode-deep-research',
23
+ version: '0.0.0',
24
+ modes: [deepResearchMode],
25
+ });
26
+
27
+ export default deepResearchModePlugin;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Parse the planner's output into individual sub-question strings — a
3
+ * numbered list under a QUERIES: header.
4
+ */
5
+ export function parseQueries(text: string): string[] {
6
+ return parseNumberedBlock(text, /^queries\s*:?$/i);
7
+ }
8
+
9
+ /**
10
+ * Parse the follow-up planner output. Accepts either a numbered list
11
+ * under `FOLLOWUPS:` OR the literal `FOLLOWUPS: (none)` form. Returns
12
+ * an empty array in both the "(none)" and "no parseable items" cases —
13
+ * the loop treats both as "no more research needed, proceed to synthesis".
14
+ */
15
+ export function parseFollowups(text: string): string[] {
16
+ const items = parseNumberedBlock(text, /^followups\s*:?$/i);
17
+ // "(none)" sentinel — the model is telling us no follow-ups are needed.
18
+ // Anchor it to a full line so a parenthetical that merely follows the
19
+ // header (e.g. "FOLLOWUPS:\n(none of the prior sources covered cost)\n1. …")
20
+ // doesn't swallow a genuine numbered list below it. Only honor the sentinel
21
+ // when no numbered items were parsed.
22
+ if (items.length === 0 && /^followups\s*:\s*\(none\)\s*$/im.test(text)) return [];
23
+ return items;
24
+ }
25
+
26
+ /**
27
+ * Cap on a single joined query/follow-up. A hostile or malformed model could
28
+ * emit thousands of continuation lines that all attach to one item; bound the
29
+ * per-item growth so a mangled plan can't produce a multi-MB subagent prompt.
30
+ */
31
+ const MAX_ITEM_CHARS = 2000;
32
+
33
+ function parseNumberedBlock(text: string, headerRegex: RegExp): string[] {
34
+ const lines = text.split('\n');
35
+ const items: string[] = [];
36
+ // Tracks whether the previous meaningful line was (or extended) a list item,
37
+ // so we only ever join wrapped continuations onto a real item.
38
+ let continuationOpen = false;
39
+ for (const raw of lines) {
40
+ const line = raw.trim();
41
+ // A blank line ends the current item's continuation run: a wrapped query
42
+ // never spans a blank line, so this prevents an unrelated trailing
43
+ // paragraph from being glued onto the last query.
44
+ if (!line) {
45
+ continuationOpen = false;
46
+ continue;
47
+ }
48
+ if (headerRegex.test(line)) {
49
+ // A header also closes any open continuation run.
50
+ continuationOpen = false;
51
+ continue;
52
+ }
53
+ const m = /^(?:\d+[.)]|[-*•])\s*(.+)$/.exec(line);
54
+ if (m) {
55
+ items.push(m[1]!.trim());
56
+ continuationOpen = true;
57
+ continue;
58
+ }
59
+ // Non-list line. If it directly continues an open item (the model wrapped a
60
+ // long query across lines), join it on rather than silently truncating the
61
+ // query — a dropped continuation gets sent to a subagent as a half-question.
62
+ // A non-list line with NO open item (junk preamble, a "(none …)" header
63
+ // parenthetical, etc.) is still dropped.
64
+ if (continuationOpen && items.length > 0) {
65
+ const last = items[items.length - 1]!;
66
+ const joined = `${last} ${line}`;
67
+ items[items.length - 1] =
68
+ joined.length > MAX_ITEM_CHARS ? joined.slice(0, MAX_ITEM_CHARS) : joined;
69
+ }
70
+ }
71
+ return items;
72
+ }