@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.
- package/docs/activation-funnel-telemetry.md +310 -0
- package/openapi.yaml +16 -115
- package/package.json +1 -1
- package/src/__tests__/activation-early-marking.test.ts +120 -0
- package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
- package/src/__tests__/anthropic-provider.test.ts +23 -8
- package/src/__tests__/approval-cascade.test.ts +1 -1
- package/src/__tests__/compaction-direct.test.ts +32 -18
- package/src/__tests__/compaction-events.test.ts +2 -2
- package/src/__tests__/compaction.benchmark.test.ts +1 -1
- package/src/__tests__/context-overflow-reducer.test.ts +5 -5
- package/src/__tests__/context-window-manager-compact-retry.test.ts +121 -15
- package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
- package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
- package/src/__tests__/conversation-error.test.ts +15 -1
- package/src/__tests__/conversation-history-web-search.test.ts +5 -0
- package/src/__tests__/conversation-media-retry.test.ts +1 -1
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
- package/src/__tests__/conversation-process-callsite.test.ts +1 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
- package/src/__tests__/conversation-queue.test.ts +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
- package/src/__tests__/conversation-slash-queue.test.ts +1 -1
- package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
- package/src/__tests__/conversation-speed-override.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
- package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
- package/src/__tests__/conversation-undo.test.ts +2 -2
- package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
- package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/cu-unified-flow.test.ts +36 -0
- package/src/__tests__/history-repair-hook.test.ts +2 -0
- package/src/__tests__/llm-resolver.test.ts +73 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
- package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
- package/src/__tests__/persist-unsendable-image.test.ts +97 -1
- package/src/__tests__/plugin-external-api.test.ts +68 -0
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
- package/src/__tests__/published-app-updater.test.ts +138 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
- package/src/__tests__/title-generate-hook.test.ts +2 -0
- package/src/__tests__/web-fetch.test.ts +45 -0
- package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
- package/src/acp/resolve-agent.test.ts +0 -56
- package/src/acp/resolve-agent.ts +10 -38
- package/src/agent/loop.ts +13 -27
- package/src/api/responses/memory-v3-selection-log.ts +19 -10
- package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
- package/src/cli/commands/memory-v3.ts +57 -199
- package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
- package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
- package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
- package/src/cli/lib/install-from-github.ts +258 -41
- package/src/cli/lib/plugin-details.ts +20 -13
- package/src/cli/lib/plugin-marketplace.ts +23 -5
- package/src/cli/lib/search-plugins.ts +14 -8
- package/src/config/acp-defaults.ts +3 -3
- package/src/config/acp-schema.ts +1 -7
- package/src/config/bundled-skills/acp/SKILL.md +4 -17
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/call-site-defaults.ts +0 -1
- package/src/config/feature-flag-registry.json +3 -18
- package/src/config/llm-resolver.ts +39 -7
- package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
- package/src/config/schemas/call-site-catalog.ts +0 -7
- package/src/config/schemas/llm.ts +17 -1
- package/src/config/schemas/memory-v3.ts +58 -8
- package/src/config/seed-inference-profiles.ts +18 -0
- package/src/context/post-turn-tool-result-truncation.ts +39 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
- package/src/daemon/conversation-agent-loop.ts +87 -95
- package/src/daemon/conversation-error.ts +31 -4
- package/src/daemon/conversation-history.ts +1 -1
- package/src/daemon/conversation-media-retry.ts +19 -6
- package/src/daemon/conversation-messaging.ts +17 -0
- package/src/daemon/conversation-process.ts +14 -5
- package/src/daemon/conversation-queue-manager.ts +8 -0
- package/src/daemon/conversation-runtime-assembly.ts +37 -1
- package/src/daemon/conversation-surfaces.ts +141 -3
- package/src/daemon/conversation.ts +48 -13
- package/src/daemon/external-plugins-bootstrap.ts +8 -3
- package/src/daemon/persist-unsendable-image.ts +62 -25
- package/src/daemon/process-message.ts +1 -1
- package/src/daemon/tool-side-effects.ts +15 -0
- package/src/memory/__tests__/activation-session-store.test.ts +41 -0
- package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
- package/src/memory/activation-session-store.ts +43 -0
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
- package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/onboarding-events-store.ts +66 -18
- package/src/memory/schema/infrastructure.ts +13 -0
- package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
- package/src/memory/v2/__tests__/page-store.test.ts +22 -0
- package/src/memory/v2/consolidation-job.ts +2 -72
- package/src/memory/v2/types.ts +5 -0
- package/src/messaging/providers/telegram-bot/api.ts +14 -5
- package/src/notifications/adapters/telegram.ts +7 -1
- package/src/plugin-api/constants.ts +2 -2
- package/src/plugin-api/index.ts +2 -2
- package/src/plugin-api/types.ts +19 -5
- package/src/plugins/defaults/compaction/compact.ts +24 -15
- package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
- package/src/plugins/defaults/compaction/manager-store.ts +1 -1
- package/src/{context → plugins/defaults/compaction}/window-manager.ts +68 -12
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
- package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
- package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
- package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
- package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
- package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
- package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
- package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
- package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
- package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
- package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
- package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
- package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
- package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
- package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
- package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
- package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
- package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
- package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
- package/src/plugins/external-api.ts +114 -0
- package/src/prompts/system-prompt.ts +61 -10
- package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
- package/src/providers/anthropic/client.ts +9 -10
- package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
- package/src/providers/inference/logit-bias.ts +55 -0
- package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
- package/src/providers/openai/chat-completions-provider.ts +44 -1
- package/src/providers/retry.ts +22 -0
- package/src/providers/types.ts +6 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
- package/src/runtime/routes/acp-routes.test.ts +3 -20
- package/src/runtime/routes/app-management-routes.ts +3 -0
- package/src/runtime/routes/conversation-routes.ts +2 -0
- package/src/runtime/routes/memory-v3-routes.ts +64 -314
- package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
- package/src/runtime/routes/stt-routes.ts +45 -12
- package/src/runtime/routes/workspace-routes.ts +50 -15
- package/src/services/published-app-updater.ts +30 -8
- package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
- package/src/telemetry/activation-funnel.ts +167 -0
- package/src/telemetry/types.ts +13 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
- package/src/telemetry/usage-telemetry-reporter.ts +26 -1
- package/src/tools/acp/list-agents.test.ts +2 -18
- package/src/tools/acp/list-agents.ts +3 -15
- package/src/tools/acp/spawn.test.ts +0 -10
- package/src/tools/browser/browser-execution.ts +12 -2
- package/src/tools/network/web-fetch.ts +65 -24
- package/src/tools/skills/load.ts +1 -1
- package/src/tools/ui-surface/definitions.ts +7 -0
- package/src/acp/feature-gate.test.ts +0 -48
- package/src/acp/feature-gate.ts +0 -34
- package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
- package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
- package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
- package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
- package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
- package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
- package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
- package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
- package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
- package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
- package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
- package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
- package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
- package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
- package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
- package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
- package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
- package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
- package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
- package/src/util/map-limit.ts +0 -27
|
@@ -1,36 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for `
|
|
2
|
+
* Tests for `orchestrate.ts` (section-lane pipeline).
|
|
3
3
|
*
|
|
4
|
-
* The orchestrator composes
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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)
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
22
|
-
import
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
//
|
|
33
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
72
|
+
// Fixtures: a tiny corpus of pages with bodies + `links:` frontmatter.
|
|
56
73
|
// ---------------------------------------------------------------------------
|
|
57
74
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
/**
|
|
132
|
-
function
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
150
|
+
return [];
|
|
142
151
|
}
|
|
143
152
|
|
|
144
153
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
//
|
|
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 —
|
|
179
|
-
test("
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
236
|
+
denseConfig: config,
|
|
237
|
+
edgeGraph,
|
|
238
|
+
workingSet: new WorkingSet(),
|
|
218
239
|
});
|
|
219
240
|
|
|
220
|
-
|
|
221
|
-
|
|
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("
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
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 (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
//
|
|
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 —
|
|
267
|
-
test("
|
|
268
|
-
const
|
|
269
|
-
//
|
|
270
|
-
//
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
needle:
|
|
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
|
-
|
|
290
|
-
|
|
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("
|
|
294
|
-
|
|
295
|
-
//
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
315
|
-
//
|
|
316
|
-
expect(
|
|
317
|
-
|
|
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
|
-
//
|
|
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 —
|
|
327
|
-
test("
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
363
|
-
expect(result.
|
|
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
|
|
368
|
-
providerStub = providerForTurn(TURNS[0]); // pins page-a
|
|
611
|
+
const lanes = await buildLanes();
|
|
369
612
|
const ws = new WorkingSet();
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
needle:
|
|
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("
|
|
378
|
-
expect(ws.union().has("page-b")).toBe(true);
|
|
621
|
+
expect(ws.union().has("topic-a")).toBe(true);
|
|
379
622
|
});
|
|
623
|
+
});
|
|
380
624
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
414
|
-
expect(result.
|
|
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("
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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(
|
|
658
|
+
expect(new Set(result.finalInjection)).toEqual(
|
|
659
|
+
new Set(["topic-a", "topic-b", "topic-d"]),
|
|
660
|
+
);
|
|
438
661
|
});
|
|
439
662
|
});
|