@vellumai/assistant 0.8.9-staging.2 → 0.8.9-staging.4

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 (196) hide show
  1. package/docs/activation-funnel-telemetry.md +310 -0
  2. package/openapi.yaml +16 -115
  3. package/package.json +1 -1
  4. package/src/__tests__/activation-early-marking.test.ts +120 -0
  5. package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
  6. package/src/__tests__/anthropic-provider.test.ts +23 -8
  7. package/src/__tests__/approval-cascade.test.ts +1 -1
  8. package/src/__tests__/compaction-direct.test.ts +32 -18
  9. package/src/__tests__/compaction-events.test.ts +2 -2
  10. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  11. package/src/__tests__/context-overflow-reducer.test.ts +5 -5
  12. package/src/__tests__/context-window-manager-compact-retry.test.ts +121 -15
  13. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  14. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  15. package/src/__tests__/conversation-error.test.ts +15 -1
  16. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  17. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  18. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
  19. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  20. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  21. package/src/__tests__/conversation-queue.test.ts +1 -1
  22. package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
  23. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  24. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  25. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  26. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  27. package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
  28. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  29. package/src/__tests__/conversation-undo.test.ts +2 -2
  30. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  31. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  32. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  33. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  34. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  35. package/src/__tests__/history-repair-hook.test.ts +2 -0
  36. package/src/__tests__/llm-resolver.test.ts +73 -0
  37. package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
  38. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  39. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  40. package/src/__tests__/plugin-external-api.test.ts +68 -0
  41. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  42. package/src/__tests__/published-app-updater.test.ts +138 -0
  43. package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
  44. package/src/__tests__/title-generate-hook.test.ts +2 -0
  45. package/src/__tests__/web-fetch.test.ts +45 -0
  46. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  47. package/src/acp/resolve-agent.test.ts +0 -56
  48. package/src/acp/resolve-agent.ts +10 -38
  49. package/src/agent/loop.ts +13 -27
  50. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  51. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  52. package/src/cli/commands/memory-v3.ts +57 -199
  53. package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
  54. package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
  55. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
  56. package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
  57. package/src/cli/lib/install-from-github.ts +258 -41
  58. package/src/cli/lib/plugin-details.ts +20 -13
  59. package/src/cli/lib/plugin-marketplace.ts +23 -5
  60. package/src/cli/lib/search-plugins.ts +14 -8
  61. package/src/config/acp-defaults.ts +3 -3
  62. package/src/config/acp-schema.ts +1 -7
  63. package/src/config/bundled-skills/acp/SKILL.md +4 -17
  64. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  65. package/src/config/call-site-defaults.ts +0 -1
  66. package/src/config/feature-flag-registry.json +3 -18
  67. package/src/config/llm-resolver.ts +39 -7
  68. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  69. package/src/config/schemas/call-site-catalog.ts +0 -7
  70. package/src/config/schemas/llm.ts +17 -1
  71. package/src/config/schemas/memory-v3.ts +58 -8
  72. package/src/config/seed-inference-profiles.ts +18 -0
  73. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  74. package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
  75. package/src/daemon/conversation-agent-loop.ts +87 -95
  76. package/src/daemon/conversation-error.ts +31 -4
  77. package/src/daemon/conversation-history.ts +1 -1
  78. package/src/daemon/conversation-media-retry.ts +19 -6
  79. package/src/daemon/conversation-messaging.ts +17 -0
  80. package/src/daemon/conversation-process.ts +14 -5
  81. package/src/daemon/conversation-queue-manager.ts +8 -0
  82. package/src/daemon/conversation-runtime-assembly.ts +37 -1
  83. package/src/daemon/conversation-surfaces.ts +141 -3
  84. package/src/daemon/conversation.ts +48 -13
  85. package/src/daemon/external-plugins-bootstrap.ts +8 -3
  86. package/src/daemon/persist-unsendable-image.ts +62 -25
  87. package/src/daemon/process-message.ts +1 -1
  88. package/src/daemon/tool-side-effects.ts +15 -0
  89. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  90. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  91. package/src/memory/activation-session-store.ts +43 -0
  92. package/src/memory/db-init.ts +4 -0
  93. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  94. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  95. package/src/memory/migrations/index.ts +2 -0
  96. package/src/memory/onboarding-events-store.ts +66 -18
  97. package/src/memory/schema/infrastructure.ts +13 -0
  98. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  99. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  100. package/src/memory/v2/consolidation-job.ts +2 -72
  101. package/src/memory/v2/types.ts +5 -0
  102. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  103. package/src/notifications/adapters/telegram.ts +7 -1
  104. package/src/plugin-api/constants.ts +2 -2
  105. package/src/plugin-api/index.ts +2 -2
  106. package/src/plugin-api/types.ts +19 -5
  107. package/src/plugins/defaults/compaction/compact.ts +24 -15
  108. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
  109. package/src/plugins/defaults/compaction/manager-store.ts +1 -1
  110. package/src/{context → plugins/defaults/compaction}/window-manager.ts +68 -12
  111. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
  112. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
  113. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  114. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  115. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
  116. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
  117. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
  118. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  119. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
  120. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  121. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  122. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  123. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
  124. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  125. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
  126. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
  127. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
  128. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  129. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  130. package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
  131. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
  132. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
  133. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
  134. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  135. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
  136. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  137. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  138. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  139. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
  140. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
  141. package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
  142. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
  143. package/src/plugins/external-api.ts +114 -0
  144. package/src/prompts/system-prompt.ts +61 -10
  145. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
  146. package/src/providers/anthropic/client.ts +9 -10
  147. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  148. package/src/providers/inference/logit-bias.ts +55 -0
  149. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  150. package/src/providers/openai/chat-completions-provider.ts +44 -1
  151. package/src/providers/retry.ts +22 -0
  152. package/src/providers/types.ts +6 -0
  153. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  154. package/src/runtime/routes/acp-routes.test.ts +3 -20
  155. package/src/runtime/routes/app-management-routes.ts +3 -0
  156. package/src/runtime/routes/conversation-routes.ts +2 -0
  157. package/src/runtime/routes/memory-v3-routes.ts +64 -314
  158. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  159. package/src/runtime/routes/stt-routes.ts +45 -12
  160. package/src/runtime/routes/workspace-routes.ts +50 -15
  161. package/src/services/published-app-updater.ts +30 -8
  162. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  163. package/src/telemetry/activation-funnel.ts +167 -0
  164. package/src/telemetry/types.ts +13 -0
  165. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  166. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  167. package/src/tools/acp/list-agents.test.ts +2 -18
  168. package/src/tools/acp/list-agents.ts +3 -15
  169. package/src/tools/acp/spawn.test.ts +0 -10
  170. package/src/tools/browser/browser-execution.ts +12 -2
  171. package/src/tools/network/web-fetch.ts +65 -24
  172. package/src/tools/skills/load.ts +1 -1
  173. package/src/tools/ui-surface/definitions.ts +7 -0
  174. package/src/acp/feature-gate.test.ts +0 -48
  175. package/src/acp/feature-gate.ts +0 -34
  176. package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
  177. package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
  178. package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
  179. package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
  180. package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
  181. package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
  182. package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
  183. package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
  184. package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
  185. package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
  186. package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
  187. package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
  188. package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
  189. package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
  190. package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
  191. package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
  192. package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
  193. package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
  194. package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
  195. package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
  196. package/src/util/map-limit.ts +0 -27
@@ -1,36 +1,37 @@
1
1
  /**
2
- * Tests for `assistant/src/memory/v3/orchestrate.ts`.
2
+ * Tests for `orchestrate.ts` (section-lane pipeline).
3
3
  *
4
- * The orchestrator composes four lanes: L1 routing + BM25 needle (parallel) →
5
- * open set (routed ∪ core ∪ needle-owning leaves) → bounded per-leaf L2
6
- * selection → carry-forward working set (record + evict) → final injection.
4
+ * The orchestrator composes three deterministic candidate lanes — the
5
+ * section-grain BM25 needle, the dense lane, and link-graph edge expansion —
6
+ * into ONE unified pool, runs a SINGLE forced-tool select over the pool, then
7
+ * carries forward the working set. Synthetic capability pages (skills / CLI
8
+ * commands) are indexed like any other page, so they enter the pool through the
9
+ * lanes (e.g. the needle) rather than being always-added.
7
10
  *
8
- * The provider is stubbed (no network). A single stub answers BOTH the L1
9
- * `open_leaves` call and the per-leaf L2 `select_pages` calls by inspecting the
10
- * forced tool name and, for L2, the `<leaf>...</leaf>` tag in the prompt. The
11
- * needle is a hand-built fake so we control its hits directly.
11
+ * The select provider is stubbed (no network); a single stub answers the one
12
+ * `select_pages` call per turn by reading the numbered `<candidates>` block.
13
+ * The dense lane is stubbed at the module boundary. The needle and edge graph
14
+ * are real, built from tiny inline fixtures so the pool union is exercised
15
+ * end-to-end.
12
16
  */
13
17
 
14
- import { beforeEach, describe, expect, mock, test } from "bun:test";
18
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
15
19
 
20
+ import type { PageIndexEntry } from "../../../../memory/v2/page-index.js";
16
21
  import type {
17
22
  Message,
18
23
  Provider,
19
24
  ProviderResponse,
20
25
  } from "../../../../providers/types.js";
21
- import type { NeedleIndex } from "../needle.js";
22
- import type {
23
- LeafNode,
24
- LeafPath,
25
- LeafTree,
26
- MemoryRoutingTurn,
27
- Slug,
28
- } from "../types.js";
29
- import evalTurns from "./fixtures/eval-turns.json" with { type: "json" };
26
+ import type { EdgeGraph } from "../edge.js";
27
+ import { buildEdgeGraph } from "../edge.js";
28
+ import { buildSectionNeedle } from "../section-needle.js";
29
+ import { buildSectionIndex } from "../sections.js";
30
+ import type { MemoryRoutingTurn, SectionIndex, Slug } from "../types.js";
30
31
 
31
32
  // ---------------------------------------------------------------------------
32
- // Provider mock installed BEFORE the orchestrator import so router.ts and
33
- // selector.ts observe it at load time.
33
+ // Module stubs installed BEFORE the orchestrator import so pool-select and the
34
+ // dense lane observe them at load time.
34
35
  // ---------------------------------------------------------------------------
35
36
 
36
37
  let providerStub: Provider | null = null;
@@ -48,61 +49,69 @@ mock.module("../../../../util/logger.js", () => ({
48
49
  }),
49
50
  }));
50
51
 
51
- const { orchestrate, DEFAULT_NEEDLE_K } = await import("../orchestrate.js");
52
+ // The dense lane is stubbed: each test sets `denseHits` to control which
53
+ // articles (+ matched ordinals) the dense lane returns. The stub DELEGATES to
54
+ // the real `denseLane` unless this file's tests are running (`denseMockActive`),
55
+ // so the process-global `mock.module` cannot leak fake behavior into
56
+ // dense.test.ts (which exercises the real lane). Spread the real module so
57
+ // every other export (`OVERSAMPLE`) stays present.
58
+ const realDense = { ...(await import("../dense.js")) };
59
+ let denseMockActive = false;
60
+ let denseHits: Array<{ article: Slug; section: number }> = [];
61
+ mock.module("../dense.js", () => ({
62
+ ...realDense,
63
+ denseLane: async (...args: Parameters<typeof realDense.denseLane>) =>
64
+ denseMockActive ? denseHits : realDense.denseLane(...args),
65
+ }));
66
+
67
+ const { orchestrate, DEFAULT_NEEDLE_K, DEFAULT_DENSE_K } =
68
+ await import("../orchestrate.js");
52
69
  const { WorkingSet } = await import("../working-set.js");
53
70
 
54
71
  // ---------------------------------------------------------------------------
55
- // Fixture types + helpers.
72
+ // Fixtures: a tiny corpus of pages with bodies + `links:` frontmatter.
56
73
  // ---------------------------------------------------------------------------
57
74
 
58
- interface LeafSelection {
59
- ids: number[];
60
- pinned_ids: number[];
75
+ const PAGES: Record<Slug, string> = {
76
+ "topic-a": "lead for topic a\n## Details\napple banana about topic a",
77
+ "topic-b": "lead for topic b\n## More\ncherry date about topic b",
78
+ "topic-c": "lead for topic c\n## Notes\nelderberry fig about topic c",
79
+ "topic-d": "lead for topic d\n## Notes\ngrape about topic d",
80
+ };
81
+
82
+ /** Raw page = frontmatter (with `links:`) + body. */
83
+ const RAW: Record<Slug, string> = {
84
+ "topic-a": `---\nlinks:\n - "topic-d — the curated edge from a to d"\n---\n${PAGES["topic-a"]}`,
85
+ "topic-b": `---\nedges: []\n---\n${PAGES["topic-b"]}`,
86
+ "topic-c": `---\nedges: []\n---\n${PAGES["topic-c"]}`,
87
+ "topic-d": `---\nedges: []\n---\n${PAGES["topic-d"]}`,
88
+ };
89
+
90
+ const SLUGS = Object.keys(PAGES);
91
+
92
+ function makeEntries(): PageIndexEntry[] {
93
+ return SLUGS.map((slug, i) => ({
94
+ id: i + 1,
95
+ slug,
96
+ summary: `summary of ${slug}`,
97
+ edges: [],
98
+ leaves: [],
99
+ modifiedAt: 0,
100
+ }));
61
101
  }
62
- interface EvalTurn {
63
- name: string;
64
- currentMessage: string;
65
- routeIds: number[];
66
- leafSelections: Record<LeafPath, LeafSelection>;
67
- expectedOpenedLeaves: LeafPath[];
68
- expectedFinalInjection: Slug[];
69
- }
70
- const TURNS = (evalTurns as { turns: EvalTurn[] }).turns;
71
102
 
72
- function makeLeaf(path: LeafPath, members: Slug[]): LeafNode {
73
- return {
74
- path,
75
- frontmatter: { path, in_core: false },
76
- description: `description for ${path}`,
77
- members,
78
- domain: path.split("/")[0],
79
- };
80
- }
81
-
82
- /**
83
- * Synthetic tree. Sorted leaf order is [domain-a/topic-x, domain-a/topic-y],
84
- * so L1 id 1 → topic-x, id 2 → topic-y.
85
- */
86
- function makeTree(): LeafTree {
87
- const leaves: LeafNode[] = [
88
- makeLeaf("domain-a/topic-x", ["page-a", "page-b"]),
89
- makeLeaf("domain-a/topic-y", ["page-c"]),
90
- ];
91
- const byPage = new Map<Slug, LeafPath[]>();
92
- for (const leaf of leaves) {
93
- for (const slug of leaf.members) {
94
- byPage.set(slug, [...(byPage.get(slug) ?? []), leaf.path]);
95
- }
96
- }
97
- return { leaves: new Map(leaves.map((n) => [n.path, n])), byPage };
103
+ async function buildLanes(): Promise<{
104
+ sectionIndex: SectionIndex;
105
+ needle: ReturnType<typeof buildSectionNeedle>;
106
+ edgeGraph: EdgeGraph;
107
+ }> {
108
+ const sectionIndex = await buildSectionIndex(SLUGS, async (s) => PAGES[s]!);
109
+ const needle = buildSectionNeedle(sectionIndex);
110
+ const edgeGraph = await buildEdgeGraph(makeEntries(), async (s) => RAW[s]!);
111
+ return { sectionIndex, needle, edgeGraph };
98
112
  }
99
113
 
100
- const summaryOf = async (slug: Slug): Promise<string> => `summary of ${slug}`;
101
-
102
- /** Needle that returns a fixed slug list, ignoring the query. */
103
- function fakeNeedle(hits: Slug[]): NeedleIndex {
104
- return { query: (_text, k) => hits.slice(0, k) };
105
- }
114
+ const config = {} as never;
106
115
 
107
116
  function makeTurn(
108
117
  turnNumber: number,
@@ -116,324 +125,538 @@ function makeTurn(
116
125
  };
117
126
  }
118
127
 
119
- function toolUseResponse(
120
- name: string,
121
- input: Record<string, unknown>,
122
- ): ProviderResponse {
128
+ function toolUseResponse(input: Record<string, unknown>): ProviderResponse {
123
129
  return {
124
130
  model: "stub-model",
125
131
  stopReason: "tool_use",
126
132
  usage: { inputTokens: 0, outputTokens: 0 },
127
- content: [{ type: "tool_use", id: "tu-1", name, input }],
133
+ content: [{ type: "tool_use", id: "tu-1", name: "select_pages", input }],
128
134
  };
129
135
  }
130
136
 
131
- /** Last `<leaf>X</leaf>` tag found in an L2 request, or undefined. */
132
- function leafFromMessages(messages: Message[]): LeafPath | undefined {
137
+ /** Parse the numbered `<candidates>` block back into an ordered slug list. */
138
+ function candidateSlugs(messages: Message[]): Slug[] {
133
139
  for (const msg of messages) {
134
140
  for (const block of msg.content) {
135
- if (block.type === "text") {
136
- const m = /<leaf>([^<]+)<\/leaf>/.exec(block.text);
137
- if (m) return m[1];
138
- }
141
+ if (block.type !== "text") continue;
142
+ const m = /<candidates>\n([\s\S]*?)\n<\/candidates>/.exec(block.text);
143
+ if (!m) continue;
144
+ return m[1]
145
+ .split("\n")
146
+ .map((line) => /^\[\d+\] (\S+) —/.exec(line)?.[1])
147
+ .filter((s): s is string => !!s);
139
148
  }
140
149
  }
141
- return undefined;
150
+ return [];
142
151
  }
143
152
 
144
153
  /**
145
- * Build a provider that answers L1 `open_leaves` with `routeIds` and each L2
146
- * `select_pages` call with the per-leaf selection from the fixture turn.
154
+ * Provider that selects the pool candidates whose slug is in `keep` (mapping
155
+ * each back to its 1-based id), pinning those in `pin`. Captures the rendered
156
+ * candidate list for pool assertions.
147
157
  */
148
- function providerForTurn(turn: EvalTurn): Provider {
158
+ let lastPool: Slug[] = [];
159
+ let selectCalls = 0;
160
+ function selectProvider(keep: Slug[], pin: Slug[] = []): Provider {
149
161
  return {
150
162
  name: "stub",
151
- sendMessage: async (messages, options) => {
152
- const toolName = options?.tools?.[0]?.name;
153
- if (toolName === "open_leaves") {
154
- return toolUseResponse("open_leaves", { ids: turn.routeIds });
155
- }
156
- // L2 select_pages — pick the selection for the leaf under selection.
157
- const leaf = leafFromMessages(messages);
158
- const sel = (leaf ? turn.leafSelections[leaf] : undefined) ?? {
159
- ids: [],
160
- pinned_ids: [],
161
- };
162
- return toolUseResponse("select_pages", {
163
- ids: sel.ids,
164
- pinned_ids: sel.pinned_ids,
163
+ sendMessage: async (messages) => {
164
+ selectCalls++;
165
+ const pool = candidateSlugs(messages);
166
+ lastPool = pool;
167
+ const ids: number[] = [];
168
+ const pinned_ids: number[] = [];
169
+ pool.forEach((slug, i) => {
170
+ if (keep.includes(slug)) ids.push(i + 1);
171
+ if (pin.includes(slug)) pinned_ids.push(i + 1);
165
172
  });
173
+ return toolUseResponse({ ids, pinned_ids });
166
174
  },
167
175
  };
168
176
  }
169
177
 
170
178
  beforeEach(() => {
179
+ denseMockActive = true;
171
180
  providerStub = null;
181
+ denseHits = [];
182
+ lastPool = [];
183
+ selectCalls = 0;
184
+ });
185
+
186
+ afterAll(() => {
187
+ denseMockActive = false;
172
188
  });
173
189
 
174
190
  // ---------------------------------------------------------------------------
175
- // Integration: drive the whole fixture sequence through ONE shared WorkingSet.
191
+ // Pool composition: the candidate pool is the union of the lanes. Synthetic
192
+ // capability pages are no longer always-added — they enter through a lane (see
193
+ // the dedicated test below).
176
194
  // ---------------------------------------------------------------------------
177
195
 
178
- describe("orchestrate — fixture sequence (carry-forward)", () => {
179
- test("each turn's finalInjection matches the fixture", async () => {
180
- const tree = makeTree();
181
- const workingSet = new WorkingSet();
182
- const needle = fakeNeedle([]);
183
-
184
- for (const turn of TURNS) {
185
- providerStub = providerForTurn(turn);
186
- const result = await orchestrate(
187
- makeTurn(TURNS.indexOf(turn) + 1, turn.currentMessage),
188
- { tree, core: new Set(), needle, workingSet, pageSummary: summaryOf },
189
- );
190
- expect(result.openedLeaves).toEqual(turn.expectedOpenedLeaves);
191
- expect(result.finalInjection).toEqual(turn.expectedFinalInjection);
192
- }
193
- });
194
-
195
- test("a slug pinned in turn 1 carries into turn 2 without re-selection", async () => {
196
- const tree = makeTree();
197
- const workingSet = new WorkingSet();
198
- const needle = fakeNeedle([]);
199
-
200
- // Turn 1 selects+pins page-a.
201
- providerStub = providerForTurn(TURNS[0]);
202
- await orchestrate(makeTurn(1, TURNS[0].currentMessage), {
203
- tree,
204
- core: new Set(),
205
- needle,
206
- workingSet,
207
- pageSummary: summaryOf,
196
+ describe("orchestrate — candidate pool composition", () => {
197
+ test("pool unions needle ∪ dense ∪ edge; one select runs", async () => {
198
+ const lanes = await buildLanes();
199
+ // "apple" hits topic-a (needle). Dense returns topic-b. topic-a links to
200
+ // topic-d (edge).
201
+ denseHits = [{ article: "topic-b", section: 0 }];
202
+ providerStub = selectProvider([]); // selection is irrelevant to pool union
203
+
204
+ await orchestrate(makeTurn(1, "apple"), {
205
+ sectionIndex: lanes.sectionIndex,
206
+ needle: lanes.needle,
207
+ denseConfig: config,
208
+ edgeGraph: lanes.edgeGraph,
209
+ workingSet: new WorkingSet(),
208
210
  });
209
211
 
210
- // Turn 2 opens a DIFFERENT leaf and never re-selects page-a.
211
- providerStub = providerForTurn(TURNS[1]);
212
- const t2 = await orchestrate(makeTurn(2, TURNS[1].currentMessage), {
213
- tree,
214
- core: new Set(),
212
+ expect(selectCalls).toBe(1);
213
+ expect(new Set(lastPool)).toEqual(
214
+ new Set(["topic-a", "topic-b", "topic-d"]),
215
+ );
216
+ });
217
+
218
+ test("a synthetic capability page enters the pool via the needle lane", async () => {
219
+ // A capability slug indexed with body content (the section index treats it
220
+ // like any other page) is ranked by the real needle when the query matches
221
+ // its text — this is how synthetic pages reach the pool now that they are no
222
+ // longer always-added.
223
+ const CAP: Slug = "skills/example";
224
+ const sectionIndex = await buildSectionIndex([...SLUGS, CAP], async (s) =>
225
+ s === CAP
226
+ ? "# Skill: example\nuse the kumquat skill to do the thing"
227
+ : PAGES[s]!,
228
+ );
229
+ const needle = buildSectionNeedle(sectionIndex);
230
+ const edgeGraph = await buildEdgeGraph(makeEntries(), async (s) => RAW[s]!);
231
+
232
+ providerStub = selectProvider([]); // selection irrelevant to pool union
233
+ await orchestrate(makeTurn(1, "kumquat"), {
234
+ sectionIndex,
215
235
  needle,
216
- workingSet,
217
- pageSummary: summaryOf,
236
+ denseConfig: config,
237
+ edgeGraph,
238
+ workingSet: new WorkingSet(),
218
239
  });
219
240
 
220
- expect(t2.currentSelections.map((s) => s.slug)).not.toContain("page-a");
221
- expect(t2.finalInjection).toContain("page-a");
241
+ // The needle ranked the capability page on the "kumquat" term, so it is in
242
+ // the candidate pool.
243
+ expect(lastPool).toContain(CAP);
222
244
  });
223
245
 
224
- test("carry-forward survives a turn whose selections fill the cap", async () => {
225
- const tree = makeTree();
226
- // Cap of 1: under a naive record-then-cap order this turn's own selection
227
- // would evict the carried page before injection. Snapshotting the carry
228
- // BEFORE recording this turn keeps the earlier page in the injection.
229
- const workingSet = new WorkingSet(1);
230
- const needle = fakeNeedle([]);
231
- const stub = (selectIds: number[]): Provider => ({
246
+ test("edge curated link description becomes the edge candidate's descriptor", async () => {
247
+ const lanes = await buildLanes();
248
+ let descriptorLine = "";
249
+ providerStub = {
232
250
  name: "stub",
233
- sendMessage: async (_messages, options) =>
234
- options?.tools?.[0]?.name === "open_leaves"
235
- ? toolUseResponse("open_leaves", { ids: [1] })
236
- : toolUseResponse("select_pages", { ids: selectIds, pinned_ids: [] }),
237
- });
251
+ sendMessage: async (messages) => {
252
+ for (const msg of messages) {
253
+ for (const block of msg.content) {
254
+ if (block.type !== "text") continue;
255
+ const m = /\[\d+\] topic-d — ([^\n]+)/.exec(block.text);
256
+ if (m) descriptorLine = m[1]!;
257
+ }
258
+ }
259
+ return toolUseResponse({ ids: [] });
260
+ },
261
+ };
238
262
 
239
- providerStub = stub([1]); // turn 1 → page-a
240
- await orchestrate(makeTurn(1, "page a"), {
241
- tree,
242
- core: new Set(),
243
- needle,
244
- workingSet,
245
- pageSummary: summaryOf,
263
+ await orchestrate(makeTurn(1, "apple"), {
264
+ sectionIndex: lanes.sectionIndex,
265
+ needle: lanes.needle,
266
+ denseConfig: config,
267
+ edgeGraph: lanes.edgeGraph,
268
+ workingSet: new WorkingSet(),
246
269
  });
247
270
 
248
- providerStub = stub([2]); // turn 2 → page-b, never re-selects page-a
249
- const t2 = await orchestrate(makeTurn(2, "page b"), {
250
- tree,
251
- core: new Set(),
271
+ expect(descriptorLine).toContain("the curated edge from a to d");
272
+ });
273
+
274
+ test("needleK and denseK default to their constants", async () => {
275
+ const lanes = await buildLanes();
276
+ let needleK = -1;
277
+ const needle = {
278
+ query: (_t: string, k: number) => {
279
+ needleK = k;
280
+ return [];
281
+ },
282
+ bestSection: () => -1,
283
+ };
284
+ providerStub = selectProvider([]);
285
+ await orchestrate(makeTurn(1, "x"), {
286
+ sectionIndex: lanes.sectionIndex,
252
287
  needle,
253
- workingSet,
254
- pageSummary: summaryOf,
288
+ denseConfig: config,
289
+ edgeGraph: lanes.edgeGraph,
290
+ workingSet: new WorkingSet(),
255
291
  });
292
+ expect(needleK).toBe(DEFAULT_NEEDLE_K);
293
+ expect(DEFAULT_DENSE_K).toBe(100);
294
+ });
256
295
 
257
- expect(t2.currentSelections.map((s) => s.slug)).toEqual(["page-b"]);
258
- expect(t2.finalInjection).toContain("page-a"); // carried despite the cap
296
+ test("sectionBySlug is populated from matched lane sections", async () => {
297
+ const lanes = await buildLanes();
298
+ denseHits = [];
299
+ providerStub = selectProvider(["topic-a"]);
300
+ const result = await orchestrate(makeTurn(1, "apple"), {
301
+ sectionIndex: lanes.sectionIndex,
302
+ needle: lanes.needle,
303
+ denseConfig: config,
304
+ edgeGraph: lanes.edgeGraph,
305
+ workingSet: new WorkingSet(),
306
+ });
307
+ // topic-a matched "apple" in its `## Details` section.
308
+ expect(result.sectionBySlug.get("topic-a")?.article).toBe("topic-a");
309
+ expect(result.sectionBySlug.get("topic-a")?.text).toContain("apple");
259
310
  });
260
311
  });
261
312
 
262
313
  // ---------------------------------------------------------------------------
263
- // Lane composition: needle and core fold into the open set.
314
+ // Edge-only injection: a page surfaced ONLY by the edge lane (the query did not
315
+ // lexically hit it) records NO matched section, so injection falls back to the
316
+ // FULL page (the curated `links` description — not the often-empty lead the
317
+ // query never hit — is what made the candidate relevant). The best-section text
318
+ // is kept only as the select-pool descriptor fallback.
264
319
  // ---------------------------------------------------------------------------
265
320
 
266
- describe("orchestrate — open set composition", () => {
267
- test("needle hits add their owning leaves to the open set", async () => {
268
- const tree = makeTree();
269
- // L1 routes only topic-x; the needle hit page-c lives in topic-y, so the
270
- // open set must include topic-y too.
321
+ describe("orchestrate — edge-only injection", () => {
322
+ test("an edge-only page records NO sectionBySlug entry (→ full-page inject)", async () => {
323
+ const lanes = await buildLanes();
324
+ // "apple" hits topic-a (needle); topic-a links to topic-d (edge-only — the
325
+ // query never hits topic-d). Select topic-d so it is in the result.
326
+ denseHits = [];
327
+ providerStub = selectProvider(["topic-d"]);
328
+ const result = await orchestrate(makeTurn(1, "apple"), {
329
+ sectionIndex: lanes.sectionIndex,
330
+ needle: lanes.needle,
331
+ denseConfig: config,
332
+ edgeGraph: lanes.edgeGraph,
333
+ workingSet: new WorkingSet(),
334
+ });
335
+
336
+ // topic-d was selected and injected, but with NO matched section — so
337
+ // `renderV3SectionContent(slug, undefined)` falls back to the full page.
338
+ expect(result.finalInjection).toContain("topic-d");
339
+ expect(result.sectionBySlug.has("topic-d")).toBe(false);
340
+ });
341
+
342
+ test("an edge-only page still carries the curated links description as its descriptor", async () => {
343
+ const lanes = await buildLanes();
344
+ let descriptorLine = "";
271
345
  providerStub = {
272
346
  name: "stub",
273
- sendMessage: async (messages, options) => {
274
- if (options?.tools?.[0]?.name === "open_leaves") {
275
- return toolUseResponse("open_leaves", { ids: [1] });
347
+ sendMessage: async (messages) => {
348
+ for (const msg of messages) {
349
+ for (const block of msg.content) {
350
+ if (block.type !== "text") continue;
351
+ const m = /\[\d+\] topic-d — ([^\n]+)/.exec(block.text);
352
+ if (m) descriptorLine = m[1]!;
353
+ }
276
354
  }
277
- const leaf = leafFromMessages(messages);
278
- const ids = leaf === "domain-a/topic-y" ? [1] : [];
279
- return toolUseResponse("select_pages", { ids, pinned_ids: [] });
355
+ return toolUseResponse({ ids: [] });
280
356
  },
281
357
  };
282
- const result = await orchestrate(makeTurn(1, "anything"), {
283
- tree,
284
- core: new Set(),
285
- needle: fakeNeedle(["page-c"]),
358
+
359
+ await orchestrate(makeTurn(1, "apple"), {
360
+ sectionIndex: lanes.sectionIndex,
361
+ needle: lanes.needle,
362
+ denseConfig: config,
363
+ edgeGraph: lanes.edgeGraph,
286
364
  workingSet: new WorkingSet(),
287
- pageSummary: summaryOf,
288
365
  });
289
- expect(result.openedLeaves).toContain("domain-a/topic-y");
290
- expect(result.finalInjection).toContain("page-c");
366
+
367
+ // The curated `links` description (not the lead text) is the descriptor.
368
+ expect(descriptorLine).toContain("the curated edge from a to d");
291
369
  });
292
370
 
293
- test("core leaves are always opened even when routing abstains", async () => {
294
- const tree = makeTree();
295
- // L1 abstains (empty ids); only the core leaf should open.
371
+ test("an edge-only page with no curated description falls back to bestSection text as the descriptor", async () => {
372
+ // A bare `links:` entry (no ` — `) carries NO description, so the edge
373
+ // candidate's descriptor falls back to the page's best section against the
374
+ // query. The query never hits dst-page, so bestSection returns its lead;
375
+ // that lead text becomes the descriptor (and the page is still injected in
376
+ // full, with no sectionBySlug entry).
377
+ const pages: Record<Slug, string> = {
378
+ "src-page": "lead for src\n## Body\nalpha bravo about src",
379
+ "dst-page": "lead content for dst page\n## Extra\nnothing relevant here",
380
+ };
381
+ const raw: Record<Slug, string> = {
382
+ // bare slug — no ` — ` separator → undefined curated description.
383
+ "src-page": `---\nlinks:\n - "dst-page"\n---\n${pages["src-page"]}`,
384
+ "dst-page": `---\nedges: []\n---\n${pages["dst-page"]}`,
385
+ };
386
+ const slugs = Object.keys(pages);
387
+ const entries: PageIndexEntry[] = slugs.map((slug, i) => ({
388
+ id: i + 1,
389
+ slug,
390
+ summary: `summary of ${slug}`,
391
+ edges: [],
392
+ leaves: [],
393
+ modifiedAt: 0,
394
+ }));
395
+ const sectionIndex = await buildSectionIndex(slugs, async (s) => pages[s]!);
396
+ const needle = buildSectionNeedle(sectionIndex);
397
+ const edgeGraph = await buildEdgeGraph(entries, async (s) => raw[s]!);
398
+
399
+ let descriptorLine = "";
296
400
  providerStub = {
297
401
  name: "stub",
298
- sendMessage: async (messages, options) => {
299
- if (options?.tools?.[0]?.name === "open_leaves") {
300
- return toolUseResponse("open_leaves", { ids: [] });
402
+ sendMessage: async (messages) => {
403
+ for (const msg of messages) {
404
+ for (const block of msg.content) {
405
+ if (block.type !== "text") continue;
406
+ const m = /\[\d+\] dst-page — ([^\n]+)/.exec(block.text);
407
+ if (m) descriptorLine = m[1]!;
408
+ }
301
409
  }
302
- const leaf = leafFromMessages(messages);
303
- const ids = leaf === "domain-a/topic-y" ? [1] : [];
304
- return toolUseResponse("select_pages", { ids, pinned_ids: [] });
410
+ return toolUseResponse({ ids: [] });
305
411
  },
306
412
  };
307
- const result = await orchestrate(makeTurn(1, "nothing topical"), {
308
- tree,
309
- core: new Set(["domain-a/topic-y"]),
310
- needle: fakeNeedle([]),
413
+
414
+ // "alpha" hits src-page (needle); src-page links to dst-page (edge-only, no
415
+ // curated description).
416
+ const result = await orchestrate(makeTurn(1, "alpha"), {
417
+ sectionIndex,
418
+ needle,
419
+ denseConfig: config,
420
+ edgeGraph,
311
421
  workingSet: new WorkingSet(),
312
- pageSummary: summaryOf,
313
422
  });
314
- expect(result.openedLeaves).toEqual(["domain-a/topic-y"]);
315
- // page-c (topic-y member) is core, so the working set must NOT retain it.
316
- expect(result.workingSetUnion.has("page-c")).toBe(false);
317
- // It still injects this turn via the current selection.
318
- expect(result.finalInjection).toContain("page-c");
423
+
424
+ // Descriptor fell back to dst-page's lead text; still no matched section.
425
+ expect(descriptorLine).toContain("lead content for dst page");
426
+ expect(result.sectionBySlug.has("dst-page")).toBe(false);
319
427
  });
320
428
  });
321
429
 
322
430
  // ---------------------------------------------------------------------------
323
- // Edge cases.
431
+ // Dense liveness: a dense hit whose article is no longer in the live section
432
+ // index (its page was deleted; its Qdrant points linger) is dropped from the
433
+ // pool, so a deleted page can never be surfaced via the dense lane.
324
434
  // ---------------------------------------------------------------------------
325
435
 
326
- describe("orchestrate — edge cases", () => {
327
- test("empty needle results do not break orchestration", async () => {
328
- const tree = makeTree();
329
- providerStub = providerForTurn(TURNS[0]);
330
- const result = await orchestrate(makeTurn(1, "tell me about page a"), {
331
- tree,
332
- core: new Set(),
333
- needle: fakeNeedle([]),
436
+ describe("orchestrate — dense liveness filter", () => {
437
+ test("a dense hit absent from sectionIndex.byArticle is dropped from the pool", async () => {
438
+ const lanes = await buildLanes();
439
+ // Dense returns a live page (topic-b) AND a deleted page (gone-page) whose
440
+ // points still linger in Qdrant but which is absent from the section index.
441
+ denseHits = [
442
+ { article: "topic-b", section: 0 },
443
+ { article: "gone-page", section: 0 },
444
+ ];
445
+ providerStub = selectProvider([]); // selection irrelevant to pool membership
446
+ const result = await orchestrate(makeTurn(1, "apple"), {
447
+ sectionIndex: lanes.sectionIndex,
448
+ needle: lanes.needle,
449
+ denseConfig: config,
450
+ edgeGraph: lanes.edgeGraph,
334
451
  workingSet: new WorkingSet(),
335
- pageSummary: summaryOf,
336
452
  });
337
- expect(result.openedLeaves).toEqual(["domain-a/topic-x"]);
338
- expect(result.finalInjection).toEqual(["page-a", "page-b"]);
453
+
454
+ // The live dense hit is pooled; the deleted page is dropped entirely.
455
+ expect(lastPool).toContain("topic-b");
456
+ expect(lastPool).not.toContain("gone-page");
457
+ expect(result.laneBySlug.has("gone-page")).toBe(false);
339
458
  });
459
+ });
340
460
 
341
- test("omitted L1 ids opens only the deterministic lanes, not the whole tree", async () => {
342
- const tree = makeTree();
343
- // L1 omits ids → routeL1 opens NO routed leaves; only the needle/core lanes
344
- // drive the open set, so the whole tree is never fanned out (topic-y, which
345
- // nothing routes or needles to, stays closed).
346
- providerStub = {
347
- name: "stub",
348
- sendMessage: async (_messages, options) => {
349
- if (options?.tools?.[0]?.name === "open_leaves") {
350
- return toolUseResponse("open_leaves", {}); // omitted ids → []
351
- }
352
- return toolUseResponse("select_pages", {}); // omitted → all members
353
- },
354
- };
355
- const result = await orchestrate(makeTurn(1, "x"), {
356
- tree,
357
- core: new Set(),
358
- needle: fakeNeedle(["page-a"]), // needle opens domain-a/topic-x only
461
+ // ---------------------------------------------------------------------------
462
+ // Lane provenance: each pooled slug records the candgen lane that FIRST
463
+ // surfaced it (needle → dense → edge precedence), exposed via
464
+ // `result.laneBySlug` so the selection telemetry can attribute true sources.
465
+ // ---------------------------------------------------------------------------
466
+
467
+ describe("orchestrate — lane provenance (laneBySlug)", () => {
468
+ test("laneBySlug tags each pooled slug with its surfacing lane", async () => {
469
+ const lanes = await buildLanes();
470
+ // "apple" hits topic-a (needle). Dense returns topic-b. topic-a links to
471
+ // topic-d (edge). So each lane contributes exactly one distinct slug.
472
+ denseHits = [{ article: "topic-b", section: 0 }];
473
+ providerStub = selectProvider([]); // selection irrelevant to pool provenance
474
+ const result = await orchestrate(makeTurn(1, "apple"), {
475
+ sectionIndex: lanes.sectionIndex,
476
+ needle: lanes.needle,
477
+ denseConfig: config,
478
+ edgeGraph: lanes.edgeGraph,
359
479
  workingSet: new WorkingSet(),
360
- pageSummary: summaryOf,
361
480
  });
362
- expect(result.openedLeaves).toEqual(["domain-a/topic-x"]);
363
- expect(result.finalInjection).toEqual(["page-a", "page-b"]);
481
+
482
+ expect(result.laneBySlug.get("topic-a")).toBe("needle");
483
+ expect(result.laneBySlug.get("topic-b")).toBe("dense");
484
+ expect(result.laneBySlug.get("topic-d")).toBe("edge");
485
+ });
486
+
487
+ test("a slug surfaced by needle AND dense keeps the needle lane (first wins)", async () => {
488
+ const lanes = await buildLanes();
489
+ // topic-a is surfaced by the needle on "apple"; dense ALSO returns topic-a.
490
+ // Needle runs first (step 2a before 2b), so the recorded lane stays needle.
491
+ denseHits = [{ article: "topic-a", section: 0 }];
492
+ providerStub = selectProvider([]);
493
+ const result = await orchestrate(makeTurn(1, "apple"), {
494
+ sectionIndex: lanes.sectionIndex,
495
+ needle: lanes.needle,
496
+ denseConfig: config,
497
+ edgeGraph: lanes.edgeGraph,
498
+ workingSet: new WorkingSet(),
499
+ });
500
+
501
+ expect(result.laneBySlug.get("topic-a")).toBe("needle");
502
+ });
503
+ });
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Carry-forward: same working-set semantics as the prior tree pipeline.
507
+ // ---------------------------------------------------------------------------
508
+
509
+ describe("orchestrate — carry-forward", () => {
510
+ test("a page selected+pinned in turn 1 carries into turn 2 without re-selection", async () => {
511
+ const lanes = await buildLanes();
512
+ const workingSet = new WorkingSet();
513
+
514
+ // Turn 1 selects+pins topic-a.
515
+ providerStub = selectProvider(["topic-a"], ["topic-a"]);
516
+ await orchestrate(makeTurn(1, "apple"), {
517
+ sectionIndex: lanes.sectionIndex,
518
+ needle: lanes.needle,
519
+ denseConfig: config,
520
+ edgeGraph: lanes.edgeGraph,
521
+ workingSet,
522
+ });
523
+
524
+ // Turn 2 selects a DIFFERENT page and never re-selects topic-a.
525
+ denseHits = [{ article: "topic-b", section: 0 }];
526
+ providerStub = selectProvider(["topic-b"]);
527
+ const t2 = await orchestrate(makeTurn(2, "cherry"), {
528
+ sectionIndex: lanes.sectionIndex,
529
+ needle: lanes.needle,
530
+ denseConfig: config,
531
+ edgeGraph: lanes.edgeGraph,
532
+ workingSet,
533
+ });
534
+
535
+ expect(t2.currentSelections.map((s) => s.slug)).not.toContain("topic-a");
536
+ expect(t2.finalInjection).toContain("topic-a");
537
+ });
538
+
539
+ test("carry-forward survives a turn whose selections fill the cap", async () => {
540
+ const lanes = await buildLanes();
541
+ // Cap of 1: under a naive record-then-cap order this turn's own selection
542
+ // would evict the carried page before injection. Snapshotting the carry
543
+ // BEFORE recording this turn keeps the earlier page in the injection.
544
+ const workingSet = new WorkingSet(1);
545
+
546
+ providerStub = selectProvider(["topic-a"]); // turn 1 → topic-a
547
+ await orchestrate(makeTurn(1, "apple"), {
548
+ sectionIndex: lanes.sectionIndex,
549
+ needle: lanes.needle,
550
+ denseConfig: config,
551
+ edgeGraph: lanes.edgeGraph,
552
+ workingSet,
553
+ });
554
+
555
+ denseHits = [{ article: "topic-b", section: 0 }];
556
+ providerStub = selectProvider(["topic-b"]); // turn 2 → topic-b
557
+ const t2 = await orchestrate(makeTurn(2, "cherry"), {
558
+ sectionIndex: lanes.sectionIndex,
559
+ needle: lanes.needle,
560
+ denseConfig: config,
561
+ edgeGraph: lanes.edgeGraph,
562
+ workingSet,
563
+ });
564
+
565
+ expect(t2.currentSelections.map((s) => s.slug)).toEqual(["topic-b"]);
566
+ expect(t2.finalInjection).toContain("topic-a"); // carried despite the cap
567
+ });
568
+
569
+ test("a stale non-pinned page ages out of the carry-forward window", async () => {
570
+ const lanes = await buildLanes();
571
+ // window 2: a non-pinned entry unseen for >2 turns evicts.
572
+ const workingSet = new WorkingSet(150, 2);
573
+
574
+ providerStub = selectProvider(["topic-a"]); // turn 1 selects topic-a
575
+ const t1 = await orchestrate(makeTurn(1, "apple"), {
576
+ sectionIndex: lanes.sectionIndex,
577
+ needle: lanes.needle,
578
+ denseConfig: config,
579
+ edgeGraph: lanes.edgeGraph,
580
+ workingSet,
581
+ });
582
+ expect(t1.finalInjection).toContain("topic-a");
583
+
584
+ // Turns 2–4 never re-select topic-a. By turn 4 (4-1=3 > 2) it ages out.
585
+ providerStub = selectProvider([]);
586
+ await orchestrate(makeTurn(2, "x"), {
587
+ sectionIndex: lanes.sectionIndex,
588
+ needle: lanes.needle,
589
+ denseConfig: config,
590
+ edgeGraph: lanes.edgeGraph,
591
+ workingSet,
592
+ });
593
+ await orchestrate(makeTurn(3, "x"), {
594
+ sectionIndex: lanes.sectionIndex,
595
+ needle: lanes.needle,
596
+ denseConfig: config,
597
+ edgeGraph: lanes.edgeGraph,
598
+ workingSet,
599
+ });
600
+ const t4 = await orchestrate(makeTurn(4, "x"), {
601
+ sectionIndex: lanes.sectionIndex,
602
+ needle: lanes.needle,
603
+ denseConfig: config,
604
+ edgeGraph: lanes.edgeGraph,
605
+ workingSet,
606
+ });
607
+ expect(t4.finalInjection).not.toContain("topic-a");
364
608
  });
365
609
 
366
610
  test("pinned current-turn selections land in the working set", async () => {
367
- const tree = makeTree();
368
- providerStub = providerForTurn(TURNS[0]); // pins page-a
611
+ const lanes = await buildLanes();
369
612
  const ws = new WorkingSet();
370
- await orchestrate(makeTurn(1, "tell me about page a"), {
371
- tree,
372
- core: new Set(),
373
- needle: fakeNeedle([]),
613
+ providerStub = selectProvider(["topic-a"], ["topic-a"]);
614
+ await orchestrate(makeTurn(1, "apple"), {
615
+ sectionIndex: lanes.sectionIndex,
616
+ needle: lanes.needle,
617
+ denseConfig: config,
618
+ edgeGraph: lanes.edgeGraph,
374
619
  workingSet: ws,
375
- pageSummary: summaryOf,
376
620
  });
377
- expect(ws.union().has("page-a")).toBe(true);
378
- expect(ws.union().has("page-b")).toBe(true);
621
+ expect(ws.union().has("topic-a")).toBe(true);
379
622
  });
623
+ });
380
624
 
381
- test("a page in multiple opened leaves is deduped with pinned ORed", async () => {
382
- // Build a tree where page-shared belongs to BOTH leaves; pin it in one.
383
- const leaves: LeafNode[] = [
384
- makeLeaf("domain-a/topic-x", ["page-shared"]),
385
- makeLeaf("domain-a/topic-y", ["page-shared"]),
386
- ];
387
- const byPage = new Map<Slug, LeafPath[]>([
388
- ["page-shared", ["domain-a/topic-x", "domain-a/topic-y"]],
389
- ]);
390
- const tree: LeafTree = {
391
- leaves: new Map(leaves.map((n) => [n.path, n])),
392
- byPage,
393
- };
394
- providerStub = {
395
- name: "stub",
396
- sendMessage: async (messages, options) => {
397
- if (options?.tools?.[0]?.name === "open_leaves") {
398
- return toolUseResponse("open_leaves", { ids: [1, 2] });
399
- }
400
- const leaf = leafFromMessages(messages);
401
- // Pin only in topic-x; select (unpinned) in topic-y.
402
- const pinned_ids = leaf === "domain-a/topic-x" ? [1] : [];
403
- return toolUseResponse("select_pages", { ids: [1], pinned_ids });
404
- },
405
- };
406
- const result = await orchestrate(makeTurn(1, "x"), {
407
- tree,
408
- core: new Set(),
409
- needle: fakeNeedle([]),
625
+ // ---------------------------------------------------------------------------
626
+ // Degradation: an empty pool and a provider-unavailable path are recall-safe.
627
+ // ---------------------------------------------------------------------------
628
+
629
+ describe("orchestrate — degradation", () => {
630
+ test("an empty pool yields no selections and an empty injection", async () => {
631
+ const lanes = await buildLanes();
632
+ providerStub = selectProvider([]);
633
+ const result = await orchestrate(makeTurn(1, "zzzzz no-match"), {
634
+ sectionIndex: lanes.sectionIndex,
635
+ needle: { query: () => [], bestSection: () => -1 },
636
+ denseConfig: config,
637
+ edgeGraph: lanes.edgeGraph,
410
638
  workingSet: new WorkingSet(),
411
- pageSummary: summaryOf,
412
639
  });
413
- // One deduped selection, pinned because it was pinned in topic-x.
414
- expect(result.currentSelections).toEqual([
415
- { slug: "page-shared", pinned: true },
416
- ]);
417
- expect(result.finalInjection).toEqual(["page-shared"]);
640
+ expect(result.currentSelections).toEqual([]);
641
+ expect(result.finalInjection).toEqual([]);
418
642
  });
419
643
 
420
- test("needleK defaults to DEFAULT_NEEDLE_K", async () => {
421
- const tree = makeTree();
422
- let seenK = -1;
423
- const needle: NeedleIndex = {
424
- query: (_text, k) => {
425
- seenK = k;
426
- return [];
427
- },
644
+ test("omitted ids keeps ALL pooled candidates (recall-safe)", async () => {
645
+ const lanes = await buildLanes();
646
+ denseHits = [{ article: "topic-b", section: 0 }];
647
+ providerStub = {
648
+ name: "stub",
649
+ sendMessage: async () => toolUseResponse({}), // omitted ids → keep all
428
650
  };
429
- providerStub = providerForTurn(TURNS[0]);
430
- await orchestrate(makeTurn(1, "x"), {
431
- tree,
432
- core: new Set(),
433
- needle,
651
+ const result = await orchestrate(makeTurn(1, "apple"), {
652
+ sectionIndex: lanes.sectionIndex,
653
+ needle: lanes.needle,
654
+ denseConfig: config,
655
+ edgeGraph: lanes.edgeGraph,
434
656
  workingSet: new WorkingSet(),
435
- pageSummary: summaryOf,
436
657
  });
437
- expect(seenK).toBe(DEFAULT_NEEDLE_K);
658
+ expect(new Set(result.finalInjection)).toEqual(
659
+ new Set(["topic-a", "topic-b", "topic-d"]),
660
+ );
438
661
  });
439
662
  });