@exulu/backend 3.0.0 → 3.1.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.
@@ -1,6 +1,6 @@
1
1
  import { generateHydePassage, clearHydeCache } from "./hyde";
2
2
 
3
- jest.mock("ai", () => ({ generateText: jest.fn() }));
3
+ jest.mock("ai", () => ({ ...jest.requireActual("ai"), generateText: jest.fn() }));
4
4
  import { generateText } from "ai";
5
5
 
6
6
  beforeEach(() => {
@@ -1,4 +1,4 @@
1
- import { generateText } from "ai";
1
+ import { microCall } from "./micro-call";
2
2
  import { extractIdentifierTokens } from "./text-utils";
3
3
 
4
4
  // Per-question memoization of the HyDE passage. A single search invokes
@@ -122,11 +122,14 @@ IMPORTANT:
122
122
  Question: "${originalQuestion}"
123
123
  Relevant keywords: ${relevantKeywords.join(", ")}`;
124
124
 
125
- const { text } = await generateText({
125
+ // Single attempt on purpose: HyDE is an optional search anchor with a null
126
+ // fallback and per-question cache eviction on failure — retry backoff here
127
+ // would delay every search phase behind it.
128
+ const { text } = await microCall({
126
129
  model,
127
130
  prompt,
128
131
  temperature: 0.3,
129
- maxOutputTokens: 500,
132
+ maxAttempts: 1,
130
133
  });
131
134
  const passage = (text || "").trim();
132
135
  return passage.length > 0 ? passage : null;
@@ -1,7 +1,11 @@
1
1
  // ee/agentic-retrieval/pipeline/memory.test.ts
2
2
  import { runMemoryPhase, clearMemoryItemCache } from "./memory";
3
3
 
4
- jest.mock("ai", () => ({ generateText: jest.fn(), Output: { object: (x: any) => x } }));
4
+ jest.mock("ai", () => ({
5
+ ...jest.requireActual("ai"),
6
+ generateText: jest.fn(),
7
+ Output: { object: (x: any) => x },
8
+ }));
5
9
  jest.mock("./multi-query", () => ({ singleSearch: jest.fn(async () => []) }));
6
10
  jest.mock("./prefilter", () => ({ fuzzyPrefilter: jest.fn(async () => []) }));
7
11
  import { generateText } from "ai";
@@ -1,6 +1,5 @@
1
- import { generateText, Output } from "ai";
2
1
  import { z } from "zod";
3
- import { withRetry } from "@SRC/utils/with-retry";
2
+ import { microCall } from "./micro-call";
4
3
  import { singleSearch } from "./multi-query";
5
4
  import { fuzzyPrefilter } from "./prefilter";
6
5
  import { deriveKeywordVariants, normalizeFileName, stripSeparators } from "./text-utils";
@@ -219,35 +218,27 @@ export async function runMemoryPhase({
219
218
 
220
219
  let relevantMemoryChunks: Chunk[] = [];
221
220
  try {
222
- const { output: output_relevant_memory } = await withRetry(
223
- () =>
224
- generateText({
225
- model,
226
- temperature: 0,
227
- system: CHECK_MEMORIES_FOR_RELEVANT_INFORMATION,
228
- messages: [
229
- {
230
- role: "user",
231
- content: `
221
+ const { output: output_relevant_memory } = await microCall({
222
+ model,
223
+ system: CHECK_MEMORIES_FOR_RELEVANT_INFORMATION,
224
+ messages: [
225
+ {
226
+ role: "user",
227
+ content: `
232
228
  <user_question>${question}</user_question>
233
229
  <relevant_keywords>${keywords.join(", ")}</relevant_keywords>
234
230
  <important_keyword>${importantKeyword}</important_keyword>
235
231
  `,
236
- },
237
- ],
238
- output: Output.object({
239
- schema: z.object({
240
- relevantChunkIds: z
241
- .array(z.string())
242
- .describe(
243
- "The chunk_ids (UUIDs at the start of each bullet) of chunks containing information relevant to the user's question. Empty array if none are relevant.",
244
- ),
245
- }),
246
- }),
247
- maxOutputTokens: 400,
248
- }),
249
- 3,
250
- );
232
+ },
233
+ ],
234
+ schema: z.object({
235
+ relevantChunkIds: z
236
+ .array(z.string())
237
+ .describe(
238
+ "The chunk_ids (UUIDs at the start of each bullet) of chunks containing information relevant to the user's question. Empty array if none are relevant.",
239
+ ),
240
+ }),
241
+ });
251
242
 
252
243
  const ids = new Set(output_relevant_memory?.relevantChunkIds ?? []);
253
244
  relevantMemoryChunks =
@@ -360,50 +351,42 @@ export async function runMemoryPhase({
360
351
  const [overrideResult, fileResult, queryResult] = await Promise.all([
361
352
  // Override check: strict gate to decide if memory should be authoritative
362
353
  memoryConfig.override
363
- ? withRetry(
364
- () =>
365
- generateText({
366
- model,
367
- temperature: 0,
368
- system: CHECK_MEMORY_OVERRIDE,
369
- messages: [
370
- {
371
- role: "user",
372
- content: `
354
+ ? microCall({
355
+ model,
356
+ system: CHECK_MEMORY_OVERRIDE,
357
+ messages: [
358
+ {
359
+ role: "user",
360
+ content: `
373
361
  <user_question>${question}</user_question>
374
362
  <relevant_keywords>${keywords.join(", ")}</relevant_keywords>
375
363
  <important_keyword>${importantKeyword}</important_keyword>
376
364
  `,
377
- },
378
- ],
379
- output: Output.object({
380
- schema: z.object({
381
- overrides: z
382
- .boolean()
383
- .describe(
384
- "True ONLY if a memory chunk directly and sufficiently answers the user's question and should be authoritative over the documents. Be strict; when unsure, false.",
385
- ),
386
- confidence: z
387
- .enum(["high", "medium", "low"])
388
- .describe(
389
- "Confidence that the selected memory chunk(s) fully and directly answer the question.",
390
- ),
391
- authoritativeChunkIds: z
392
- .array(z.string())
393
- .describe(
394
- "The chunk_ids of the memory chunk(s) that directly answer the question. Empty if overrides is false.",
395
- ),
396
- reason: z
397
- .string()
398
- .describe(
399
- "One short sentence: why this memory does or does not directly answer the question.",
400
- ),
401
- }),
402
- }),
403
- maxOutputTokens: 300,
404
- }),
405
- 3,
406
- ).catch(() => ({
365
+ },
366
+ ],
367
+ schema: z.object({
368
+ overrides: z
369
+ .boolean()
370
+ .describe(
371
+ "True ONLY if a memory chunk directly and sufficiently answers the user's question and should be authoritative over the documents. Be strict; when unsure, false.",
372
+ ),
373
+ confidence: z
374
+ .enum(["high", "medium", "low"])
375
+ .describe(
376
+ "Confidence that the selected memory chunk(s) fully and directly answer the question.",
377
+ ),
378
+ authoritativeChunkIds: z
379
+ .array(z.string())
380
+ .describe(
381
+ "The chunk_ids of the memory chunk(s) that directly answer the question. Empty if overrides is false.",
382
+ ),
383
+ reason: z
384
+ .string()
385
+ .describe(
386
+ "One short sentence: why this memory does or does not directly answer the question.",
387
+ ),
388
+ }),
389
+ }).catch(() => ({
407
390
  output: {
408
391
  overrides: false,
409
392
  confidence: "low",
@@ -422,24 +405,16 @@ export async function runMemoryPhase({
422
405
 
423
406
  // File prioritization: detect explicit document-pinning instructions in memory
424
407
  memoryConfig.filePrioritization
425
- ? withRetry(
426
- () =>
427
- generateText({
428
- model,
429
- temperature: 0,
430
- system:
431
- "You are a helpful assistant that will strictly follow the user's instructions.",
432
- messages: [{ role: "user", content: PROMPT_EXTRACT_PRIORITIZED_FILES }],
433
- output: Output.object({
434
- schema: z.object({
435
- shouldPrioritizeFiles: z.boolean(),
436
- fileNameHints: z.array(z.string()).optional(),
437
- }),
438
- }),
439
- maxOutputTokens: 300,
440
- }),
441
- 3,
442
- ).catch(() => ({
408
+ ? microCall({
409
+ model,
410
+ system:
411
+ "You are a helpful assistant that will strictly follow the user's instructions.",
412
+ messages: [{ role: "user", content: PROMPT_EXTRACT_PRIORITIZED_FILES }],
413
+ schema: z.object({
414
+ shouldPrioritizeFiles: z.boolean(),
415
+ fileNameHints: z.array(z.string()).optional(),
416
+ }),
417
+ }).catch(() => ({
443
418
  output: { shouldPrioritizeFiles: false, fileNameHints: [] as string[] },
444
419
  }))
445
420
  : Promise.resolve({
@@ -448,25 +423,17 @@ export async function runMemoryPhase({
448
423
 
449
424
  // Query augmentation: expand keywords with synonyms/abbreviations from memory
450
425
  memoryConfig.queryAugmentation && hasAugmentationContent
451
- ? withRetry(
452
- () =>
453
- generateText({
454
- model,
455
- temperature: 0,
456
- system:
457
- "You are a helpful assistant that will strictly follow the user's instructions.",
458
- messages: [{ role: "user", content: QUERY_AUGMENTATION_PROMPT }],
459
- output: Output.object({
460
- schema: z.object({
461
- updatedUserQuestion: z.string(),
462
- updatedRelevantKeywords: z.array(z.string()),
463
- updatedImportantKeyword: z.string(),
464
- }),
465
- }),
466
- maxOutputTokens: 600,
467
- }),
468
- 3,
469
- ).catch(() => ({
426
+ ? microCall({
427
+ model,
428
+ system:
429
+ "You are a helpful assistant that will strictly follow the user's instructions.",
430
+ messages: [{ role: "user", content: QUERY_AUGMENTATION_PROMPT }],
431
+ schema: z.object({
432
+ updatedUserQuestion: z.string(),
433
+ updatedRelevantKeywords: z.array(z.string()),
434
+ updatedImportantKeyword: z.string(),
435
+ }),
436
+ }).catch(() => ({
470
437
  output: {
471
438
  updatedUserQuestion: question,
472
439
  updatedRelevantKeywords: [],
@@ -0,0 +1,112 @@
1
+ // ee/agentic-retrieval/pipeline/micro-call.test.ts
2
+ import { z } from "zod";
3
+
4
+ jest.mock("ai", () => {
5
+ const actual = jest.requireActual("ai");
6
+ return { ...actual, generateText: jest.fn() };
7
+ });
8
+ import { generateText } from "ai";
9
+ import {
10
+ microCall,
11
+ microCallProviderOptions,
12
+ MICRO_CALL_MAX_OUTPUT_TOKENS,
13
+ } from "./micro-call";
14
+
15
+ const { NoOutputGeneratedError } = jest.requireActual("ai");
16
+
17
+ beforeEach(() => {
18
+ (generateText as jest.Mock).mockReset();
19
+ });
20
+
21
+ describe("microCallProviderOptions", () => {
22
+ it("disables thinking for gemini model ids (object and string forms)", () => {
23
+ expect(microCallProviderOptions({ modelId: "vertex-gemini-3.5-flash" } as any)).toEqual({
24
+ litellm: { reasoningEffort: "disable" },
25
+ });
26
+ expect(microCallProviderOptions("gemini-3.1-flash-lite")).toEqual({
27
+ litellm: { reasoningEffort: "disable" },
28
+ });
29
+ });
30
+
31
+ it("returns undefined for non-gemini models", () => {
32
+ expect(microCallProviderOptions({ modelId: "qwen3-235b" } as any)).toBeUndefined();
33
+ expect(microCallProviderOptions({} as any)).toBeUndefined();
34
+ });
35
+ });
36
+
37
+ describe("microCall", () => {
38
+ const schema = z.object({ ok: z.boolean() });
39
+
40
+ it("applies shared defaults: temperature 0, raised token cap, no SDK-internal retries", async () => {
41
+ (generateText as jest.Mock).mockResolvedValue({ output: { ok: true }, text: "" });
42
+ await microCall({
43
+ model: { modelId: "vertex-gemini-3.5-flash" } as any,
44
+ system: "s",
45
+ messages: [{ role: "user", content: "q" }],
46
+ schema,
47
+ });
48
+ const args = (generateText as jest.Mock).mock.calls[0][0];
49
+ expect(args.temperature).toBe(0);
50
+ expect(args.maxOutputTokens).toBe(MICRO_CALL_MAX_OUTPUT_TOKENS);
51
+ expect(MICRO_CALL_MAX_OUTPUT_TOKENS).toBe(2000);
52
+ expect(args.maxRetries).toBe(0);
53
+ expect(args.providerOptions).toEqual({ litellm: { reasoningEffort: "disable" } });
54
+ });
55
+
56
+ it("returns the parsed output for schema calls", async () => {
57
+ (generateText as jest.Mock).mockResolvedValue({ output: { ok: true }, text: "{}" });
58
+ const { output } = await microCall({
59
+ model: {} as any,
60
+ system: "s",
61
+ messages: [{ role: "user", content: "q" }],
62
+ schema,
63
+ });
64
+ expect(output).toEqual({ ok: true });
65
+ });
66
+
67
+ it("returns text and omits structured output when no schema is given", async () => {
68
+ (generateText as jest.Mock).mockResolvedValue({ text: "a passage" });
69
+ const { text } = await microCall({
70
+ model: {} as any,
71
+ prompt: "write",
72
+ temperature: 0.3,
73
+ });
74
+ expect(text).toBe("a passage");
75
+ const args = (generateText as jest.Mock).mock.calls[0][0];
76
+ expect(args.output).toBeUndefined();
77
+ expect(args.temperature).toBe(0.3);
78
+ });
79
+
80
+ it("fails fast without retrying when the model generated no output", async () => {
81
+ (generateText as jest.Mock).mockResolvedValue({
82
+ text: "",
83
+ get output(): unknown {
84
+ throw new NoOutputGeneratedError({ message: "No output generated." });
85
+ },
86
+ });
87
+ await expect(
88
+ microCall({
89
+ model: {} as any,
90
+ system: "s",
91
+ messages: [{ role: "user", content: "q" }],
92
+ schema,
93
+ }),
94
+ ).rejects.toThrow("No output generated.");
95
+ expect(generateText).toHaveBeenCalledTimes(1);
96
+ });
97
+
98
+ it("retries transient errors and succeeds", async () => {
99
+ (generateText as jest.Mock)
100
+ .mockRejectedValueOnce(new Error("socket hang up"))
101
+ .mockResolvedValueOnce({ output: { ok: true }, text: "" });
102
+ const { output } = await microCall({
103
+ model: {} as any,
104
+ system: "s",
105
+ messages: [{ role: "user", content: "q" }],
106
+ schema,
107
+ retryBaseDelayMs: 1,
108
+ });
109
+ expect(output).toEqual({ ok: true });
110
+ expect(generateText).toHaveBeenCalledTimes(2);
111
+ });
112
+ });
@@ -0,0 +1,98 @@
1
+ // ee/agentic-retrieval/pipeline/micro-call.ts
2
+ //
3
+ // Shared wrapper for the pipeline's internal LLM micro-calls (routing,
4
+ // classification, memory checks, identifier extraction, HyDE). Centralizes the
5
+ // settings that keep these calls working on reasoning models.
6
+ import { generateText, NoOutputGeneratedError, Output } from "ai";
7
+ import type { LanguageModel, ModelMessage } from "ai";
8
+ import type { z } from "zod";
9
+ import { withRetry } from "@SRC/utils/with-retry";
10
+
11
+ /**
12
+ * Reasoning models (Gemini 3+) count thinking tokens against maxOutputTokens.
13
+ * The earlier 200–600 caps got fully consumed by thinking on such models —
14
+ * finishReason "length" with zero visible text — so every micro-call failed
15
+ * with NoOutputGeneratedError and its phase fell back to the degraded path.
16
+ * 2000 leaves headroom even when thinking can only be minimized, not disabled.
17
+ */
18
+ export const MICRO_CALL_MAX_OUTPUT_TOKENS = 2000;
19
+
20
+ /**
21
+ * Thinking off for Gemini utility models: LiteLLM maps reasoning_effort
22
+ * "disable" to thinkingBudget 0 (Gemini ≤2.5) or thinkingLevel "minimal"
23
+ * (Gemini 3+, which cannot fully disable thinking). Gated on the model id
24
+ * because LiteLLM rejects reasoning params for providers without a mapping
25
+ * (e.g. vertex qwen MaaS) unless drop_params is set in the proxy config.
26
+ */
27
+ export function microCallProviderOptions(
28
+ model: LanguageModel,
29
+ ): Record<string, Record<string, string>> | undefined {
30
+ const modelId = typeof model === "string" ? model : model?.modelId;
31
+ return typeof modelId === "string" && /gemini/i.test(modelId)
32
+ ? { litellm: { reasoningEffort: "disable" } }
33
+ : undefined;
34
+ }
35
+
36
+ export type MicroCallArgs<OUTPUT> = {
37
+ model: LanguageModel;
38
+ system?: string;
39
+ prompt?: string;
40
+ messages?: ModelMessage[];
41
+ /** Structured output schema; omit for free-text calls. */
42
+ schema?: z.ZodType<OUTPUT>;
43
+ /** Defaults to 0 — the pipeline's determinism requirement. */
44
+ temperature?: number;
45
+ maxOutputTokens?: number;
46
+ /** Total attempts for transient failures (network, provider 429/5xx). */
47
+ maxAttempts?: number;
48
+ retryBaseDelayMs?: number;
49
+ };
50
+
51
+ export async function microCall<OUTPUT = undefined>(
52
+ args: MicroCallArgs<OUTPUT>,
53
+ ): Promise<{ output: OUTPUT; text: string }> {
54
+ const {
55
+ model,
56
+ system,
57
+ prompt,
58
+ messages,
59
+ schema,
60
+ temperature = 0,
61
+ maxOutputTokens = MICRO_CALL_MAX_OUTPUT_TOKENS,
62
+ maxAttempts = 3,
63
+ retryBaseDelayMs,
64
+ } = args;
65
+
66
+ return withRetry(
67
+ async () => {
68
+ const result = await generateText({
69
+ model,
70
+ temperature,
71
+ system,
72
+ prompt,
73
+ messages,
74
+ ...(schema ? { output: Output.object({ schema }) } : {}),
75
+ maxOutputTokens,
76
+ // withRetry owns retries. The SDK's internal retries on top of it
77
+ // tripled request volume per attempt while the provider was already
78
+ // rate-limiting.
79
+ maxRetries: 0,
80
+ providerOptions: microCallProviderOptions(model),
81
+ } as Parameters<typeof generateText>[0]);
82
+ // Read .output inside the retried closure — the SDK throws
83
+ // NoOutputGeneratedError from this getter, so reading it later would
84
+ // escape both the retry classification and the caller's per-call catch.
85
+ return {
86
+ output: (schema ? (result as { output: OUTPUT }).output : undefined) as OUTPUT,
87
+ text: result.text,
88
+ };
89
+ },
90
+ maxAttempts,
91
+ {
92
+ // Empty output is deterministic for identical params — retrying only
93
+ // added latency before the degraded path. Fail fast instead.
94
+ shouldRetry: (error) => !NoOutputGeneratedError.isInstance(error),
95
+ ...(retryBaseDelayMs !== undefined ? { baseDelayMs: retryBaseDelayMs } : {}),
96
+ },
97
+ );
98
+ }
@@ -1,6 +1,7 @@
1
1
  import { fuzzyPrefilter, exactTokenPrefilter, resolveIdentifierPins, clearPrefilterCaches } from "./prefilter";
2
2
 
3
3
  jest.mock("ai", () => ({
4
+ ...jest.requireActual("ai"),
4
5
  generateText: jest.fn(),
5
6
  Output: { object: jest.fn((o) => o) },
6
7
  }));
@@ -1,7 +1,6 @@
1
1
  import Fuse from "fuse.js";
2
- import { generateText, Output } from "ai";
3
2
  import { z } from "zod";
4
- import { withRetry } from "@SRC/utils/with-retry";
3
+ import { microCall } from "./micro-call";
5
4
  import { normalizeFileName } from "./text-utils";
6
5
  import { DEFAULT_PREFILTER_CUTOFF, type IdentifierSet, type KbKind } from "./config";
7
6
  import type { PhaseStep } from "./types";
@@ -322,24 +321,16 @@ export async function resolveIdentifierPins({
322
321
  identifierSets.map(async (set) => {
323
322
  if (!set.contexts.length) return;
324
323
  try {
325
- const { output } = await withRetry(
326
- () =>
327
- generateText({
328
- model,
329
- temperature: 0,
330
- system:
331
- set.strategy === "exact" ? EXACT_EXTRACTION_PROMPT(set) : FUZZY_EXTRACTION_PROMPT(set),
332
- messages: [{ role: "user", content: question }],
333
- output: Output.object({
334
- schema: z.object({
335
- hasMatches: z.boolean(),
336
- matches: z.array(z.string()).optional(),
337
- }),
338
- }),
339
- maxOutputTokens: 300,
340
- }),
341
- 3,
342
- );
324
+ const { output } = await microCall({
325
+ model,
326
+ system:
327
+ set.strategy === "exact" ? EXACT_EXTRACTION_PROMPT(set) : FUZZY_EXTRACTION_PROMPT(set),
328
+ messages: [{ role: "user", content: question }],
329
+ schema: z.object({
330
+ hasMatches: z.boolean(),
331
+ matches: z.array(z.string()).optional(),
332
+ }),
333
+ });
343
334
  if (!output?.hasMatches || !output.matches?.length) return;
344
335
  steps.push({ text: `Detected ${set.name} in the question: ${output.matches.join(", ")}` });
345
336
 
@@ -1,7 +1,10 @@
1
1
  // ee/agentic-retrieval/pipeline/routing.test.ts
2
2
  import { runRoutingPhase } from "./routing";
3
3
 
4
- jest.mock("ai", () => ({ generateText: jest.fn(), Output: { object: (x: any) => x } }));
4
+ jest.mock("ai", () => {
5
+ const actual = jest.requireActual("ai");
6
+ return { ...actual, generateText: jest.fn(), Output: { object: (x: any) => x } };
7
+ });
5
8
  jest.mock("./prefilter", () => ({ fuzzyPrefilter: jest.fn(async () => []) }));
6
9
  import { generateText } from "ai";
7
10
  import { fuzzyPrefilter } from "./prefilter";
@@ -141,4 +144,44 @@ describe("runRoutingPhase", () => {
141
144
  });
142
145
  expect(r.mainContexts).toEqual(["docs", "tickets"]);
143
146
  }, 30000); // withRetry backs off 2s+4s before exhausting
147
+
148
+ it("sends thinking-disable provider options and the raised token cap on every micro-call (gemini)", async () => {
149
+ (generateText as jest.Mock)
150
+ .mockResolvedValueOnce(noHints)
151
+ .mockResolvedValueOnce(noExplicit)
152
+ .mockResolvedValueOnce({ output: { ruleId: "t", reason: "r" } });
153
+ await runRoutingPhase({
154
+ question: "how do I fix the door?", enabledContexts: enabled, documentContexts: [],
155
+ routingRules: [{ id: "t", label: "T", description: "d", main: ["docs"], fallback: ["tickets"] }],
156
+ preselectedItems: new Map(), model: { modelId: "vertex-gemini-3.5-flash" },
157
+ });
158
+ const calls = (generateText as jest.Mock).mock.calls;
159
+ expect(calls.length).toBe(3);
160
+ for (const [args] of calls) {
161
+ expect(args.maxOutputTokens).toBe(2000);
162
+ expect(args.maxRetries).toBe(0);
163
+ expect(args.providerOptions).toEqual({ litellm: { reasoningEffort: "disable" } });
164
+ }
165
+ });
166
+
167
+ it("degrades only the doc/page step when the model returns empty output (thinking-starvation regression)", async () => {
168
+ const { NoOutputGeneratedError } = jest.requireActual("ai");
169
+ (generateText as jest.Mock)
170
+ .mockResolvedValueOnce({
171
+ text: "",
172
+ get output(): any {
173
+ throw new NoOutputGeneratedError({ message: "No output generated." });
174
+ },
175
+ })
176
+ .mockResolvedValueOnce(noExplicit);
177
+ const r = await runRoutingPhase({
178
+ question: "q", enabledContexts: enabled, documentContexts: [], routingRules: [],
179
+ preselectedItems: new Map(), model: {},
180
+ });
181
+ // Empty output is deterministic: no blind retry, and only the one step degrades.
182
+ expect(generateText).toHaveBeenCalledTimes(2);
183
+ expect(r.steps.some((s) => s.text.includes("Doc/page detection failed"))).toBe(true);
184
+ expect(r.steps.some((s) => s.text.includes("Routing failed"))).toBe(false);
185
+ expect(r.mainContexts).toEqual(["docs", "tickets"]);
186
+ });
144
187
  });