@exulu/backend 3.7.4 → 4.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.
Files changed (38) hide show
  1. package/dist/{chunk-27K2CO47.js → chunk-QMN6MVHQ.js} +1 -1
  2. package/dist/{chunk-AWMU6QXB.js → chunk-RBEWHG7I.js} +297 -39
  3. package/dist/cli/start-whisper.js +1 -1
  4. package/dist/{convert-exulu-tools-to-ai-sdk-tools-XNQ6Q3X6.js → convert-exulu-tools-to-ai-sdk-tools-6RU4IZMI.js} +1 -1
  5. package/dist/index.cjs +617 -223
  6. package/dist/index.d.cts +3 -1
  7. package/dist/index.d.ts +3 -1
  8. package/dist/index.js +300 -186
  9. package/dist/{python-setup-JZGHWQCG.js → python-setup-DRJ3QX5F.js} +1 -1
  10. package/ee/LICENSE.md +2 -2
  11. package/ee/agentic-retrieval/pipeline/config.test.ts +18 -1
  12. package/ee/agentic-retrieval/pipeline/config.ts +15 -0
  13. package/ee/agentic-retrieval/pipeline/index.test.ts +73 -0
  14. package/ee/agentic-retrieval/pipeline/index.ts +67 -13
  15. package/ee/agentic-retrieval/pipeline/memory.test.ts +59 -0
  16. package/ee/agentic-retrieval/pipeline/memory.ts +181 -11
  17. package/ee/agentic-retrieval/pipeline/pin-rerun.test.ts +17 -0
  18. package/ee/agentic-retrieval/pipeline/pin-rerun.ts +29 -0
  19. package/ee/agentic-retrieval/pipeline/routing.test.ts +34 -0
  20. package/ee/agentic-retrieval/pipeline/routing.ts +96 -5
  21. package/ee/agentic-retrieval/pipeline/search.ts +9 -6
  22. package/ee/agentic-retrieval/pipeline/timing.test.ts +24 -0
  23. package/ee/agentic-retrieval/pipeline/timing.ts +26 -0
  24. package/ee/agentic-retrieval/pipeline/types.ts +2 -0
  25. package/ee/python/documents/processing/README.md +2 -3
  26. package/ee/python/documents/processing/doc_processor.ts +21 -61
  27. package/ee/python/documents/processing/split_pdf.py +25 -30
  28. package/ee/python/documents/processing/tests/__init__.py +0 -0
  29. package/ee/python/documents/processing/tests/test_split_pdf.py +230 -0
  30. package/ee/python/requirements.txt +12 -2
  31. package/ee/python/setup.sh +40 -1
  32. package/ee/python/transcription/pipeline.py +109 -15
  33. package/ee/python/transcription/tests/test_align_model_licensing.py +184 -0
  34. package/ee/workers.ts +2 -7
  35. package/license.md +2 -2
  36. package/package.json +3 -4
  37. package/scripts/postinstall.cjs +52 -1
  38. package/ee/python/documents/processing/document_to_markdown.py +0 -413
@@ -6,7 +6,7 @@ import {
6
6
  isPythonEnvironmentSetup,
7
7
  setupPythonEnvironment,
8
8
  validatePythonEnvironment
9
- } from "./chunk-27K2CO47.js";
9
+ } from "./chunk-QMN6MVHQ.js";
10
10
  export {
11
11
  getPackageRoot,
12
12
  getPythonSetupInstructions,
package/ee/LICENSE.md CHANGED
@@ -5,7 +5,7 @@ Certain peripheral components of Exulu are subject to
5
5
  commercial licensing and governed by this Enterprise License.
6
6
  For clarity, this license does not cover the core of Exulu
7
7
  as defined in the license located at "/LICENSE" (distinct from
8
- this file at "ee/LICENSE"), which may be used and operated
8
+ this file at "ee/LICENSE.md"), which may be used and operated
9
9
  without violating this license or its licensed materials.
10
10
 
11
11
  Additionally, any functionality within the software that is
@@ -59,4 +59,4 @@ For any third-party components integrated into the Exulu Software, such
59
59
  components remain governed by the original license provided by the respective
60
60
  component owner.
61
61
 
62
- END OF LICENSE
62
+ END OF LICENSE
@@ -4,7 +4,8 @@ describe("parsePipelineConfig", () => {
4
4
  it("returns full defaults for an empty/missing config", () => {
5
5
  const cfg = parsePipelineConfig(undefined);
6
6
  expect(cfg.tuning).toEqual({ topK: 5, fallbackThreshold: 0.95, pinBoost: 0.15,
7
- identifierBoost: 0.15, pageWindow: 1, maxQueriesPerContext: 5 });
7
+ identifierBoost: 0.15, pageWindow: 1, maxQueriesPerContext: 5,
8
+ engine: "v1", v2: { mergedMemoryCall: true, mergedRoutingCall: true, parallelPins: true } });
8
9
  expect(cfg.memory).toEqual({ enabled: true, override: false, filePrioritization: false, queryAugmentation: true });
9
10
  expect(cfg.routing.rules).toEqual([]);
10
11
  expect(cfg.knowledgeBases).toEqual({});
@@ -94,3 +95,19 @@ describe("project_search option", () => {
94
95
  expect(parsePipelineConfig({ project_search: true }).projectSearch).toBe(true);
95
96
  });
96
97
  });
98
+
99
+ describe("tuning.engine — the per-agent switch between the v1 flow and the parallel v2 flow", () => {
100
+ it("defaults to v1 so existing agents keep today's behaviour", () => {
101
+ expect(parsePipelineConfig({ tuning: '{"topK": 8}' }).tuning.engine).toBe("v1");
102
+ });
103
+ it("enables v2 with every sub-feature on, and lets a single feature be switched off for bisecting", () => {
104
+ const cfg = parsePipelineConfig({ tuning: '{"engine": "v2", "v2": {"parallelPins": false}}' });
105
+ expect(cfg.tuning.engine).toBe("v2");
106
+ expect(cfg.tuning.v2).toEqual({ mergedMemoryCall: true, mergedRoutingCall: true, parallelPins: false });
107
+ });
108
+ it("falls back to v1 on an unknown engine value", () => {
109
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
110
+ expect(parsePipelineConfig({ tuning: '{"engine": "v9"}' }).tuning.engine).toBe("v1");
111
+ warn.mockRestore();
112
+ });
113
+ });
@@ -57,6 +57,21 @@ const tuningSchema = z.object({
57
57
  identifierBoost: z.number().min(0).max(1).default(0.15),
58
58
  pageWindow: z.number().int().min(0).default(1),
59
59
  maxQueriesPerContext: z.number().int().positive().default(5),
60
+ /**
61
+ * Orchestration engine. "v1" is the sequential flow every agent ran before 2026-09;
62
+ * "v2" merges the phase-1 LLM hops and runs identifier pins in parallel. Per agent,
63
+ * so a candidate agent can run v2 while the production agent stays on v1.
64
+ */
65
+ engine: z.enum(["v1", "v2"]).default("v1"),
66
+ /** v2 sub-features; each can be switched off on its own to bisect a regression. */
67
+ v2: z
68
+ .object({
69
+ mergedMemoryCall: z.boolean().default(true),
70
+ mergedRoutingCall: z.boolean().default(true),
71
+ parallelPins: z.boolean().default(true),
72
+ })
73
+ // zod's .default() returns the value as-is (inner defaults are not applied), so spell it out.
74
+ .default({ mergedMemoryCall: true, mergedRoutingCall: true, parallelPins: true }),
60
75
  });
61
76
 
62
77
  export type KbProfile = z.infer<typeof kbProfileSchema>;
@@ -223,3 +223,76 @@ describe("projectScope execute-level wiring", () => {
223
223
  expect(rerankCall.state.pinnedItemIds.has("item1")).toBe(true);
224
224
  });
225
225
  });
226
+
227
+ describe("phase timings", () => {
228
+ it("records how long the retrieval phases took as a step, so latency can be read from the stored tool result", async () => {
229
+ const run = makeTool({});
230
+ const out = await drain(run(inputs));
231
+ const last = JSON.parse(out[out.length - 1].result);
232
+ const timing = last.steps.find((s: any) => typeof s.text === "string" && s.text.startsWith("Timing:"));
233
+ expect(timing).toBeDefined();
234
+ expect(timing.text).toMatch(/memory\+routing \d+ms/);
235
+ expect(timing.text).toMatch(/search \d+ms/);
236
+ expect(timing.text).toMatch(/rerank \d+ms/);
237
+ expect(timing.text).toMatch(/total \d+ms/);
238
+ expect(last.timings).toEqual(expect.objectContaining({ totalMs: expect.any(Number), searchMs: expect.any(Number) }));
239
+ });
240
+ });
241
+
242
+ describe("engine v2 — identifier pins run alongside memory and routing", () => {
243
+ it("resolves pins on the original question in parallel, and only once when memory leaves the question unchanged", async () => {
244
+ const { resolveIdentifierPins } = jest.requireMock("./prefilter");
245
+ resolveIdentifierPins.mockClear();
246
+ const run = makeTool({ tuning: '{"engine": "v2"}' });
247
+ await drain(run(inputs));
248
+ expect(resolveIdentifierPins).toHaveBeenCalledTimes(1);
249
+ expect(resolveIdentifierPins.mock.calls[0][0].question).toBe("q");
250
+ });
251
+
252
+ it("keeps the parallel pins when memory only added synonyms to the question", async () => {
253
+ const { resolveIdentifierPins } = jest.requireMock("./prefilter");
254
+ const { runMemoryPhase } = jest.requireMock("./memory");
255
+ resolveIdentifierPins.mockClear();
256
+ runMemoryPhase.mockResolvedValueOnce({
257
+ memoryChunksForAnswer: [], memoryOverride: { active: false, chunks: [], reason: "" },
258
+ memoryPinnedItemIdsByContext: new Map(), updatedQuestion: "q plus synonym", updatedKeywords: ["k"],
259
+ updatedImportantKeyword: "k", steps: [] });
260
+ const run = makeTool({ tuning: '{"engine": "v2"}' });
261
+ await drain(run(inputs));
262
+ expect(resolveIdentifierPins).toHaveBeenCalledTimes(1);
263
+ });
264
+
265
+ it("re-resolves pins on the memory-augmented question when it introduces a new designation (v1 fidelity)", async () => {
266
+ const { resolveIdentifierPins } = jest.requireMock("./prefilter");
267
+ const { runMemoryPhase } = jest.requireMock("./memory");
268
+ resolveIdentifierPins.mockClear();
269
+ runMemoryPhase.mockResolvedValueOnce({
270
+ memoryChunksForAnswer: [], memoryOverride: { active: false, chunks: [], reason: "" },
271
+ memoryPinnedItemIdsByContext: new Map(), updatedQuestion: "q plus FST-2XT", updatedKeywords: ["k"],
272
+ updatedImportantKeyword: "k", steps: [] });
273
+ const run = makeTool({ tuning: '{"engine": "v2"}' });
274
+ await drain(run(inputs));
275
+ expect(resolveIdentifierPins).toHaveBeenCalledTimes(2);
276
+ expect(resolveIdentifierPins.mock.calls[1][0].question).toBe("q plus FST-2XT");
277
+ });
278
+
279
+ it("passes the merged-call flags to the memory and routing phases", async () => {
280
+ const { runMemoryPhase } = jest.requireMock("./memory");
281
+ const { runRoutingPhase } = jest.requireMock("./routing");
282
+ runMemoryPhase.mockClear(); runRoutingPhase.mockClear();
283
+ const run = makeTool({ tuning: '{"engine": "v2", "v2": {"mergedRoutingCall": false}}' });
284
+ await drain(run(inputs));
285
+ expect(runMemoryPhase.mock.calls[0][0].mergedCall).toBe(true);
286
+ expect(runRoutingPhase.mock.calls[0][0].mergedCall).toBe(false);
287
+ });
288
+
289
+ it("keeps v1 sequential pins by default", async () => {
290
+ const { resolveIdentifierPins } = jest.requireMock("./prefilter");
291
+ const { runMemoryPhase } = jest.requireMock("./memory");
292
+ resolveIdentifierPins.mockClear(); runMemoryPhase.mockClear();
293
+ const run = makeTool({});
294
+ await drain(run(inputs));
295
+ expect(resolveIdentifierPins).toHaveBeenCalledTimes(1);
296
+ expect(runMemoryPhase.mock.calls[0][0].mergedCall).toBeFalsy();
297
+ });
298
+ });
@@ -15,6 +15,8 @@ import { resolveIdentifierPins } from "./prefilter";
15
15
  import { searchContexts } from "./search";
16
16
  import { rerankResults } from "./rerank";
17
17
  import type { AgenticRetrievalOutput, RerankState, ChunkWithScore } from "./types";
18
+ import { withTiming } from "./timing";
19
+ import { needsPinRerun } from "./pin-rerun";
18
20
  import type { VectorSearchChunkResult } from "@SRC/graphql/resolvers/vector-search";
19
21
  import { parsePreselectedItems } from "./global-ids";
20
22
  export { parsePreselectedItems } from "./global-ids";
@@ -238,6 +240,16 @@ export function createAgenticRetrievalTool(opts: {
238
240
  usage: [],
239
241
  totalTokens: 0,
240
242
  };
243
+ // Phase timings (ms) — surfaced as a step and as `timings` on the result so latency
244
+ // can be analysed from stored tool results on deployments without tracing.
245
+ const t0 = Date.now();
246
+ const timings: Record<string, number> = {};
247
+ let tPhase = t0;
248
+ const lap = (name: string) => {
249
+ const now = Date.now();
250
+ timings[name] = (timings[name] ?? 0) + (now - tPhase);
251
+ tPhase = now;
252
+ };
241
253
 
242
254
  try {
243
255
  // ── Enabled contexts (knowledge_bases.enabled filter + restore-all) ───
@@ -340,8 +352,21 @@ export function createAgenticRetrievalTool(opts: {
340
352
  .filter(Boolean)
341
353
  .join("\n");
342
354
 
343
- const [memResult, routResult] = await Promise.all([
344
- runMemoryPhase({
355
+ const engineV2 = cfg.tuning.engine === "v2";
356
+ const v2 = cfg.tuning.v2;
357
+ const pinsFor = (question: string) =>
358
+ resolveIdentifierPins({
359
+ question,
360
+ identifierSets: cfg.vocabulary.identifiers,
361
+ contextsById,
362
+ kbKindById,
363
+ model: utilityModel,
364
+ });
365
+
366
+ const [memResult, routResult, parallelPins] = await Promise.all([
367
+ withTiming(timings, "memoryMs", () => runMemoryPhase({
368
+ timings,
369
+ mergedCall: engineV2 && v2.mergedMemoryCall,
345
370
  memoryChunks: memoryItems ?? [],
346
371
  memoryContext,
347
372
  question: userQuery,
@@ -353,8 +378,10 @@ export function createAgenticRetrievalTool(opts: {
353
378
  memoryConfig: cfg.memory,
354
379
  glossary: cfg.vocabulary.glossary,
355
380
  documentContexts,
356
- }),
357
- runRoutingPhase({
381
+ })),
382
+ withTiming(timings, "routingMs", () => runRoutingPhase({
383
+ timings,
384
+ mergedCall: engineV2 && v2.mergedRoutingCall,
358
385
  question: userQuery,
359
386
  enabledContexts,
360
387
  documentContexts,
@@ -365,9 +392,15 @@ export function createAgenticRetrievalTool(opts: {
365
392
  // detector must never treat these as filename hints.
366
393
  knownIdentifiers: cfg.vocabulary.identifiers.flatMap((i) => i.examples),
367
394
  model: utilityModel,
368
- }),
395
+ })),
396
+ // engine v2: identifier pins depend only on the question, so they run alongside
397
+ // memory and routing instead of after them (re-run below if memory rewrote the question).
398
+ engineV2 && v2.parallelPins
399
+ ? withTiming(timings, "pinsParallelMs", () => pinsFor(userQuery))
400
+ : Promise.resolve(null),
369
401
  ]);
370
402
 
403
+ lap("memoryRoutingMs");
371
404
  // Merge steps from both phases
372
405
  for (const step of [...memResult.steps, ...routResult.steps]) {
373
406
  result.steps.push({
@@ -431,14 +464,11 @@ export function createAgenticRetrievalTool(opts: {
431
464
 
432
465
  // ── Identifier pins (upfront, before Phase 2) ────────────────────────
433
466
  const { pinsByContext: identifierPinsByContext, exactPinsByContext, steps: pinSteps } =
434
- await resolveIdentifierPins({
435
- question: updatedQuestion,
436
- identifierSets: cfg.vocabulary.identifiers,
437
- contextsById,
438
- kbKindById,
439
- model: utilityModel,
440
- });
467
+ parallelPins && !needsPinRerun(userQuery, updatedQuestion)
468
+ ? parallelPins
469
+ : await pinsFor(updatedQuestion);
441
470
 
471
+ lap("pinsMs");
442
472
  for (const step of pinSteps) {
443
473
  result.steps.push({
444
474
  stepNumber: 1,
@@ -470,6 +500,8 @@ export function createAgenticRetrievalTool(opts: {
470
500
  rewrites: cfg.vocabulary.rewrites,
471
501
  styleHint: cfg.vocabulary.styleHint,
472
502
  maxQueries: cfg.tuning.maxQueriesPerContext,
503
+ timings,
504
+ timingPrefix: "search.main",
473
505
  skipPrefilter: false,
474
506
  }),
475
507
  fallbackContextsToSearch.length > 0 && !hasExplicitDocAndPage
@@ -491,11 +523,14 @@ export function createAgenticRetrievalTool(opts: {
491
523
  rewrites: cfg.vocabulary.rewrites,
492
524
  styleHint: cfg.vocabulary.styleHint,
493
525
  maxQueries: cfg.tuning.maxQueriesPerContext,
526
+ timings,
527
+ timingPrefix: "search.fallback",
494
528
  skipPrefilter: true,
495
529
  })
496
530
  : Promise.resolve({ chunks: [] }),
497
531
  ]);
498
532
 
533
+ lap("searchMs");
499
534
  // ── Build rerank state ────────────────────────────────────────────────
500
535
  // pinnedItemIds = memory ∪ exact identifier pins ∪ user pins ∪ project pins
501
536
  const pinnedItemIds = new Set<string>([
@@ -567,6 +602,7 @@ export function createAgenticRetrievalTool(opts: {
567
602
  tokens: 0,
568
603
  });
569
604
 
605
+ lap("rerankMs");
570
606
  // Accumulate main results (dedup by chunk_id, memory chunks already first)
571
607
  addChunks(result, mainRerank.limited_results);
572
608
  yield { result: serializeOutput(result) };
@@ -636,6 +672,7 @@ export function createAgenticRetrievalTool(opts: {
636
672
  });
637
673
  result.reasoning.push({ text: "Fallback results reranked", tools: [] });
638
674
  addChunks(result, fallbackRerank.limited_results);
675
+ lap("fallbackRerankMs");
639
676
  yield { result: serializeOutput(result) };
640
677
  }
641
678
 
@@ -667,10 +704,27 @@ export function createAgenticRetrievalTool(opts: {
667
704
  yield { result: serializeOutput(result) };
668
705
  }
669
706
 
707
+ timings.totalMs = Date.now() - t0;
708
+ result.timings = timings;
709
+ result.steps.push({
710
+ stepNumber: 1,
711
+ text:
712
+ `Timing: memory+routing ${timings.memoryRoutingMs ?? 0}ms (memory ${timings.memoryMs ?? 0}ms, routing ${timings.routingMs ?? 0}ms), pins ${timings.pinsMs ?? 0}ms, ` +
713
+ `search ${timings.searchMs ?? 0}ms, rerank ${timings.rerankMs ?? 0}ms` +
714
+ (timings.fallbackRerankMs !== undefined ? `, fallback rerank ${timings.fallbackRerankMs}ms` : "") +
715
+ `, total ${timings.totalMs}ms`,
716
+ toolCalls: [],
717
+ chunks: [],
718
+ tokens: 0,
719
+ });
720
+
670
721
  if (cfg.logging) {
671
- console.log("[EXULU pipeline] final result:", JSON.stringify({ steps: result.steps.length, chunks: result.chunks.length }));
722
+ console.log("[EXULU pipeline] final result:", JSON.stringify({ steps: result.steps.length, chunks: result.chunks.length, timings }));
672
723
  }
673
724
 
725
+ // The generator's return value is not consumed by the tool wrapper (for-await only sees
726
+ // yields), so the final payload — including the timing step — must be yielded.
727
+ yield { result: serializeOutput(result) };
674
728
  return { result: serializeOutput(result) };
675
729
  } catch (err) {
676
730
  console.warn("[EXULU pipeline] retrieval pipeline failed:", err);
@@ -105,3 +105,62 @@ describe("runMemoryPhase", () => {
105
105
  expect(r.updatedQuestion).toBe(baseOpts.question);
106
106
  });
107
107
  });
108
+
109
+ describe("recallMemoryByKeywords — fetching the chunks of keyword-matched memory items", () => {
110
+ const { singleSearch } = jest.requireMock("./multi-query") as { singleSearch: jest.Mock };
111
+ const { recallMemoryByKeywords, clearMemoryItemCache } = jest.requireActual("./memory") as typeof import("./memory");
112
+
113
+ it("uses the full-text method, not the hybrid one: the items are already chosen by keyword, so an embedding call would only add latency", async () => {
114
+ clearMemoryItemCache();
115
+ singleSearch.mockClear();
116
+ const memoryContext = {
117
+ id: "memory-ctx",
118
+ getItems: async () => [{ id: "item-1", name: "CBM2 Version", description: "Hinweis zur CBM2 Firmware", information: "" }],
119
+ };
120
+ await recallMemoryByKeywords({ keywords: ["CBM2"], importantKeyword: "CBM2", user: {}, role: {}, memoryContext });
121
+ expect(singleSearch).toHaveBeenCalledTimes(1);
122
+ expect(singleSearch.mock.calls[0][0].config.method).toBe("tsvector");
123
+ expect(singleSearch.mock.calls[0][0].pinnedItemIds).toEqual(["item-1"]);
124
+ });
125
+ });
126
+
127
+ describe("runMemoryPhase with mergedCall (engine v2)", () => {
128
+ const merged = (over: Partial<any> = {}) => ({ output: {
129
+ relevantChunkIds: ["1"],
130
+ override: { overrides: true, confidence: "high", authoritativeChunkIds: ["1"], reason: "direct answer" },
131
+ filePrioritization: { shouldPrioritizeFiles: false, fileNameHints: [] },
132
+ augmentation: { updatedUserQuestion: baseOpts.question + " (Türkontakt)", updatedRelevantKeywords: ["türkontakt"], updatedImportantKeyword: "FST-2XT" },
133
+ ...over,
134
+ } });
135
+
136
+ it("asks the model once and produces the same result shape as the four v1 hops", async () => {
137
+ (generateText as jest.Mock).mockResolvedValueOnce(merged());
138
+ const r = await runMemoryPhase({ ...baseOpts, mergedCall: true, memoryChunks: [memChunk("1", "hint"), memChunk("2", "other")],
139
+ memoryContext: undefined, memoryConfig: allOn });
140
+ expect(generateText).toHaveBeenCalledTimes(1);
141
+ expect(r.memoryChunksForAnswer.map((c) => c.chunk_id)).toEqual(["1"]);
142
+ expect(r.memoryOverride).toMatchObject({ active: true, reason: "direct answer" });
143
+ expect(r.memoryOverride.chunks.map((c) => c.chunk_id)).toEqual(["1"]);
144
+ expect(r.updatedQuestion).toBe(baseOpts.question + " (Türkontakt)");
145
+ expect(r.updatedKeywords).toEqual(["door", "türkontakt"]);
146
+ expect(r.updatedImportantKeyword).toBe("FST-2XT"); // original always preserved
147
+ });
148
+
149
+ it("ignores override/file/augmentation parts of the answer when those features are off", async () => {
150
+ (generateText as jest.Mock).mockResolvedValueOnce(merged());
151
+ const r = await runMemoryPhase({ ...baseOpts, mergedCall: true, memoryChunks: [memChunk("1", "hint")], memoryContext: undefined,
152
+ memoryConfig: { enabled: true, override: false, filePrioritization: false, queryAugmentation: false } });
153
+ expect(generateText).toHaveBeenCalledTimes(1);
154
+ expect(r.memoryOverride.active).toBe(false);
155
+ expect(r.updatedQuestion).toBe(baseOpts.question);
156
+ expect(r.updatedKeywords).toEqual(["door"]);
157
+ });
158
+
159
+ it("treats no relevant chunks as a neutral result even if the model filled the other parts", async () => {
160
+ (generateText as jest.Mock).mockResolvedValueOnce(merged({ relevantChunkIds: [] }));
161
+ const r = await runMemoryPhase({ ...baseOpts, mergedCall: true, memoryChunks: [memChunk("1", "hint")], memoryContext: undefined, memoryConfig: allOn });
162
+ expect(r.memoryChunksForAnswer).toEqual([]);
163
+ expect(r.memoryOverride.active).toBe(false);
164
+ expect(r.updatedQuestion).toBe(baseOpts.question);
165
+ });
166
+ });