auto-model-router 0.2.2 → 0.2.8

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 (51) hide show
  1. package/.claude/skills/agentdox/SKILL.md +143 -0
  2. package/.mcp.json +11 -0
  3. package/.omp-plugin/marketplace.json +2 -2
  4. package/CLAUDE.md +129 -0
  5. package/README.md +64 -0
  6. package/docs/AGENTDOX-BRIDGE.md +132 -0
  7. package/docs/context-optimization.md +362 -0
  8. package/omp-extension/embed-logic.ts +31 -0
  9. package/omp-extension/router-embed.ts +7 -1
  10. package/package.json +1 -1
  11. package/src/cli/config-cmd.ts +20 -5
  12. package/src/cli/explain.ts +1 -0
  13. package/src/config/defaults.ts +33 -1
  14. package/src/config/load.ts +13 -0
  15. package/src/config/schema.ts +27 -0
  16. package/src/config/types.ts +79 -5
  17. package/src/context/agentdox.ts +113 -0
  18. package/src/context/bridge.ts +166 -0
  19. package/src/context/index.ts +33 -0
  20. package/src/context/store.ts +82 -0
  21. package/src/context/types.ts +78 -0
  22. package/src/cost/ledger.ts +53 -14
  23. package/src/cost/types.ts +15 -4
  24. package/src/router/candidates.ts +19 -8
  25. package/src/router/classify.ts +26 -12
  26. package/src/router/compaction.ts +163 -0
  27. package/src/router/features.ts +26 -13
  28. package/src/router/select.ts +37 -4
  29. package/src/router/state.ts +12 -2
  30. package/src/router/types.ts +33 -1
  31. package/src/server/http.ts +18 -1
  32. package/src/server/turn.ts +88 -1
  33. package/src/upstream/openrouter.ts +8 -1
  34. package/src/util/sqlite.ts +34 -1
  35. package/src/wire/openai/request.ts +86 -1
  36. package/src/wire/types.ts +36 -0
  37. package/test/classify.test.ts +63 -5
  38. package/test/compaction.test.ts +148 -0
  39. package/test/context-bridge.test.ts +337 -0
  40. package/test/embed-logic.test.ts +32 -0
  41. package/test/escalate.test.ts +1 -0
  42. package/test/exploration.test.ts +6 -2
  43. package/test/failover.test.ts +51 -6
  44. package/test/features.test.ts +45 -0
  45. package/test/helpers/inject.ts +23 -0
  46. package/test/hold-exploration.test.ts +4 -2
  47. package/test/select.test.ts +86 -3
  48. package/test/tokens.test.ts +1 -0
  49. package/test/trust-attribution.test.ts +49 -9
  50. package/test/turn.test.ts +20 -10
  51. package/tools/agentdox-e2e.ts +123 -0
@@ -0,0 +1,148 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import type { CompactionConfig } from "../src/config/types.ts";
4
+ import { planCompaction } from "../src/router/compaction.ts";
5
+ import { parseChatRequest } from "../src/wire/openai/request.ts";
6
+ import type { NormMessage } from "../src/wire/types.ts";
7
+
8
+ const CFG: CompactionConfig = {
9
+ enabled: true,
10
+ budgetTokens: 1,
11
+ fitToWindow: false,
12
+ protectRecentTurns: 2,
13
+ maxToolResultBytes: 50,
14
+ keepHeadBytes: 10,
15
+ keepTailBytes: 10,
16
+ elideSupersededReads: true,
17
+ collapseDuplicateResults: true,
18
+ };
19
+
20
+ function user(text: string): NormMessage {
21
+ return { role: "user", text, images: 0, textBytes: Buffer.byteLength(text), toolCalls: [] };
22
+ }
23
+ function asst(id: string, name: string, args: string): NormMessage {
24
+ return { role: "assistant", text: "", images: 0, textBytes: 0, toolCalls: [{ id, name, argsJson: args }] };
25
+ }
26
+ function toolMsg(id: string, name: string, content: string): NormMessage {
27
+ return { role: "tool", text: content, images: 0, textBytes: Buffer.byteLength(content), toolCalls: [], toolCallId: id, toolName: name };
28
+ }
29
+
30
+ // Two recent turns of padding so early tool results fall outside the protected window.
31
+ const PAD: NormMessage[] = [asst("z1", "bash", '{"command":"ls"}'), toolMsg("z1", "bash", "recent"), user("continue")];
32
+ const big = (marker: string): string => `${marker}:${"x".repeat(200)}`;
33
+
34
+ describe("planCompaction", () => {
35
+ test("is a no-op when disabled", () => {
36
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a"}'), toolMsg("c1", "read", big("A")), ...PAD];
37
+ expect(planCompaction(msgs, { ...CFG, enabled: false }, 0, 10_000).edits).toEqual([]);
38
+ });
39
+
40
+ test("truncates a large stale tool result, protecting recent turns", () => {
41
+ const msgs = [
42
+ user("go"),
43
+ asst("c1", "read", '{"path":"a.ts"}'),
44
+ toolMsg("c1", "read", big("OLD")), // eligible: outside the protected window
45
+ asst("c2", "read", '{"path":"b.ts"}'),
46
+ toolMsg("c2", "read", big("RECENT")), // within protectRecentTurns=2 → protected
47
+ user("next"),
48
+ ];
49
+ const { edits } = planCompaction(msgs, CFG, 1, 10_000);
50
+ expect(edits).toHaveLength(1);
51
+ expect(edits[0]?.index).toBe(2);
52
+ expect(edits[0]?.mode).toBe("truncate");
53
+ });
54
+
55
+ test("collapses byte-identical duplicate results, keeping the last", () => {
56
+ const msgs = [
57
+ user("go"),
58
+ asst("c1", "read", '{"path":"a.ts"}'),
59
+ toolMsg("c1", "read", big("DUP")),
60
+ asst("c2", "read", '{"path":"a.ts"}'),
61
+ toolMsg("c2", "read", big("DUP")),
62
+ ...PAD,
63
+ ];
64
+ const { edits } = planCompaction(msgs, { ...CFG, elideSupersededReads: false }, 10_000, 10_000);
65
+ // Only the earlier identical copy is elided; the later one survives.
66
+ expect(edits.map((e) => e.index)).toEqual([2]);
67
+ expect(edits[0]?.mode).toBe("stub");
68
+ });
69
+
70
+ test("elides a read superseded by a newer call to the same resource", () => {
71
+ const msgs = [
72
+ user("go"),
73
+ asst("c1", "read", '{"path":"a.ts"}'),
74
+ toolMsg("c1", "read", big("V1")),
75
+ asst("c2", "read", '{"path":"a.ts"}'),
76
+ toolMsg("c2", "read", big("V2")), // different content, same path → supersedes c1
77
+ ...PAD,
78
+ ];
79
+ const { edits } = planCompaction(msgs, { ...CFG, collapseDuplicateResults: false }, 10_000, 10_000);
80
+ expect(edits.map((e) => e.index)).toEqual([2]);
81
+ expect(edits[0]?.mode).toBe("stub");
82
+ });
83
+
84
+ test("different resources are not superseded", () => {
85
+ const msgs = [
86
+ user("go"),
87
+ asst("c1", "read", '{"path":"a.ts"}'),
88
+ toolMsg("c1", "read", "small"),
89
+ asst("c2", "read", '{"path":"b.ts"}'),
90
+ toolMsg("c2", "read", "small"),
91
+ ...PAD,
92
+ ];
93
+ expect(planCompaction(msgs, CFG, 10_000, 10_000).edits).toEqual([]);
94
+ });
95
+
96
+ test("is deterministic and idempotent on stable input", () => {
97
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("OLD")), ...PAD];
98
+ const a = planCompaction(msgs, CFG, 1, 10_000);
99
+ const b = planCompaction(msgs, CFG, 1, 10_000);
100
+ expect(a).toEqual(b);
101
+ });
102
+ });
103
+
104
+ describe("renderUpstreamBody applies compaction", () => {
105
+ function bodyWith(messages: unknown[]): Record<string, unknown> {
106
+ return { model: "auto", messages };
107
+ }
108
+ const MUT = {
109
+ slug: "x/y",
110
+ fallbacks: [],
111
+ sessionId: "s",
112
+ cacheBreakpointMessageIndices: [],
113
+ reasoning: undefined,
114
+ maxTokens: undefined,
115
+ stripAssistantReasoning: false,
116
+ };
117
+
118
+ test("truncates content in place with a recoverable breadcrumb, preserving pairing", () => {
119
+ const raw = [
120
+ { role: "user", content: "go" },
121
+ { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: "{}" } }] },
122
+ { role: "tool", tool_call_id: "c1", content: "HEAD" + "x".repeat(500) + "TAIL" },
123
+ ];
124
+ const req = parseChatRequest(bodyWith(raw), new Headers());
125
+ const out = req.renderUpstreamBody({ ...MUT, compactionPlan: [{ index: 2, mode: "truncate", keepHead: 4, keepTail: 4, note: "large read result" }] });
126
+ const messages = out.messages as { role: string; content: unknown }[];
127
+ expect(messages).toHaveLength(3); // no message removed → pairing intact
128
+ const content = messages[2]?.content;
129
+ expect(typeof content).toBe("string");
130
+ expect(content as string).toContain("elided");
131
+ expect(content as string).toContain("re-run the tool to restore");
132
+ expect((content as string).length).toBeLessThan(510);
133
+ expect(content as string).toStartWith("HEAD");
134
+ expect(content as string).toEndWith("TAIL");
135
+ });
136
+
137
+ test("stub replaces the whole content with a breadcrumb", () => {
138
+ const raw = [
139
+ { role: "user", content: "go" },
140
+ { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: "{}" } }] },
141
+ { role: "tool", tool_call_id: "c1", content: "a".repeat(300) },
142
+ ];
143
+ const req = parseChatRequest(bodyWith(raw), new Headers());
144
+ const out = req.renderUpstreamBody({ ...MUT, compactionPlan: [{ index: 2, mode: "stub", keepHead: 0, keepTail: 0, note: "identical repeated read result" }] });
145
+ const messages = out.messages as { content: string }[];
146
+ expect(messages[2]?.content).toBe("[omp-router: identical repeated read result elided to save context; re-run the tool to restore]");
147
+ });
148
+ });
@@ -0,0 +1,337 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import type { AgentDoxClient } from "../src/context/agentdox.ts";
4
+ import { createContextBridge } from "../src/context/bridge.ts";
5
+ import { createContextStore } from "../src/context/store.ts";
6
+ import type { ContextResolveInput } from "../src/context/types.ts";
7
+ import { createLogger } from "../src/util/log.ts";
8
+ import { openDb } from "../src/util/sqlite.ts";
9
+ import { injectForTest } from "./helpers/inject.ts";
10
+
11
+ const log = createLogger("silent");
12
+
13
+ interface FakeClient extends AgentDoxClient {
14
+ assembleCalls: number;
15
+ appended: { sessionId: string; role: string; content: string; refs: string[] }[];
16
+ sessionsCreated: number;
17
+ prompt: string;
18
+ }
19
+
20
+ function mkClient(prompt = "MEMORY: player digs in 3/4 top-down"): FakeClient {
21
+ const c: FakeClient = {
22
+ assembleCalls: 0,
23
+ appended: [],
24
+ sessionsCreated: 0,
25
+ prompt,
26
+ async assemble() {
27
+ c.assembleCalls++;
28
+ return c.prompt;
29
+ },
30
+ async createSession() {
31
+ c.sessionsCreated++;
32
+ return `ses_${c.sessionsCreated}`;
33
+ },
34
+ async append(sessionId, role, content, refs) {
35
+ c.appended.push({ sessionId, role, content, refs });
36
+ return true;
37
+ },
38
+ };
39
+ return c;
40
+ }
41
+
42
+ type BridgeOpts = Parameters<typeof createContextBridge>[0];
43
+
44
+ function mkBridge(client: AgentDoxClient, over: Partial<BridgeOpts> = {}) {
45
+ const db = openDb(":memory:");
46
+ const opts: BridgeOpts = {
47
+ client,
48
+ store: createContextStore(db),
49
+ log,
50
+ maxStalenessMs: 900_000,
51
+ maxBlockChars: 24_000,
52
+ recordTurns: true,
53
+ maxQueue: 64,
54
+ ...over,
55
+ };
56
+ return { db, bridge: createContextBridge(opts) };
57
+ }
58
+
59
+ function input(over: Partial<ContextResolveInput> = {}): ContextResolveInput {
60
+ return {
61
+ scope: "ashlands",
62
+ conversationKey: "k1",
63
+ pinnedVersion: null,
64
+ pinnedFetchedAtMs: 0,
65
+ modelSwitching: false,
66
+ retrying: false,
67
+ query: "movement rules",
68
+ ...over,
69
+ };
70
+ }
71
+
72
+ describe("context bridge refresh policy", () => {
73
+ test("fetches on the first turn, then pins without re-fetching", async () => {
74
+ const client = mkClient();
75
+ const { bridge, db } = mkBridge(client);
76
+ try {
77
+ const first = await bridge.resolve(input());
78
+ expect(first).not.toBeNull();
79
+ expect(client.assembleCalls).toBe(1);
80
+
81
+ // Steady state: same model, not retrying, not stale => no fetch, same bytes.
82
+ const second = await bridge.resolve(
83
+ input({ pinnedVersion: first?.version ?? null, pinnedFetchedAtMs: first?.fetchedAtMs ?? 0 }),
84
+ );
85
+ expect(client.assembleCalls).toBe(1);
86
+ expect(second?.block).toBe(first?.block ?? "");
87
+ } finally {
88
+ db.close();
89
+ }
90
+ });
91
+
92
+ test("refreshes when the model switches, because the cache is already forfeit", async () => {
93
+ const client = mkClient();
94
+ const { bridge, db } = mkBridge(client);
95
+ try {
96
+ const first = await bridge.resolve(input());
97
+ await bridge.resolve(
98
+ input({
99
+ pinnedVersion: first?.version ?? null,
100
+ pinnedFetchedAtMs: first?.fetchedAtMs ?? 0,
101
+ modelSwitching: true,
102
+ }),
103
+ );
104
+ expect(client.assembleCalls).toBe(2);
105
+ } finally {
106
+ db.close();
107
+ }
108
+ });
109
+
110
+ test("refreshes on a retry", async () => {
111
+ const client = mkClient();
112
+ const { bridge, db } = mkBridge(client);
113
+ try {
114
+ const first = await bridge.resolve(input());
115
+ await bridge.resolve(
116
+ input({
117
+ pinnedVersion: first?.version ?? null,
118
+ pinnedFetchedAtMs: first?.fetchedAtMs ?? 0,
119
+ retrying: true,
120
+ }),
121
+ );
122
+ expect(client.assembleCalls).toBe(2);
123
+ } finally {
124
+ db.close();
125
+ }
126
+ });
127
+
128
+ test("refreshes once the staleness TTL elapses", async () => {
129
+ const client = mkClient();
130
+ const { bridge, db } = mkBridge(client, { maxStalenessMs: 1_000 });
131
+ try {
132
+ const first = await bridge.resolve(input());
133
+ await bridge.resolve(input({ pinnedVersion: first?.version ?? null, pinnedFetchedAtMs: Date.now() - 5_000 }));
134
+ expect(client.assembleCalls).toBe(2);
135
+ } finally {
136
+ db.close();
137
+ }
138
+ });
139
+
140
+ test("version is a content hash, so an unchanged re-assembly keeps the cache warm", async () => {
141
+ const client = mkClient();
142
+ const { bridge, db } = mkBridge(client);
143
+ try {
144
+ const first = await bridge.resolve(input());
145
+ // Force a refetch; agentdox returns byte-identical content.
146
+ const second = await bridge.resolve(
147
+ input({
148
+ pinnedVersion: first?.version ?? null,
149
+ pinnedFetchedAtMs: first?.fetchedAtMs ?? 0,
150
+ modelSwitching: true,
151
+ }),
152
+ );
153
+ expect(client.assembleCalls).toBe(2);
154
+ expect(second?.version).toBe(first?.version ?? "");
155
+ expect(second?.block).toBe(first?.block ?? "");
156
+ } finally {
157
+ db.close();
158
+ }
159
+ });
160
+
161
+ test("changed content yields a new version", async () => {
162
+ const client = mkClient();
163
+ const { bridge, db } = mkBridge(client);
164
+ try {
165
+ const first = await bridge.resolve(input());
166
+ client.prompt = "MEMORY: player digs in 3/4 top-down; hard edges only";
167
+ const second = await bridge.resolve(
168
+ input({
169
+ pinnedVersion: first?.version ?? null,
170
+ pinnedFetchedAtMs: first?.fetchedAtMs ?? 0,
171
+ modelSwitching: true,
172
+ }),
173
+ );
174
+ expect(second?.version).not.toBe(first?.version ?? "");
175
+ } finally {
176
+ db.close();
177
+ }
178
+ });
179
+
180
+ test("an unreachable agentdox keeps serving the pinned block", async () => {
181
+ const client = mkClient();
182
+ const { bridge, db } = mkBridge(client);
183
+ try {
184
+ const first = await bridge.resolve(input());
185
+ client.assemble = async () => null; // agentdox goes down
186
+ const second = await bridge.resolve(
187
+ input({
188
+ pinnedVersion: first?.version ?? null,
189
+ pinnedFetchedAtMs: first?.fetchedAtMs ?? 0,
190
+ modelSwitching: true,
191
+ }),
192
+ );
193
+ expect(second?.block).toBe(first?.block ?? "");
194
+ } finally {
195
+ db.close();
196
+ }
197
+ });
198
+
199
+ test("an empty scope is inert", async () => {
200
+ const client = mkClient();
201
+ const { bridge, db } = mkBridge(client);
202
+ try {
203
+ expect(await bridge.resolve(input({ scope: "" }))).toBeNull();
204
+ expect(client.assembleCalls).toBe(0);
205
+ } finally {
206
+ db.close();
207
+ }
208
+ });
209
+
210
+ test("blocks survive a restart, so the same bytes are re-injected", async () => {
211
+ const client = mkClient();
212
+ const db = openDb(":memory:");
213
+ try {
214
+ const opts: BridgeOpts = {
215
+ client,
216
+ store: createContextStore(db),
217
+ log,
218
+ maxStalenessMs: 900_000,
219
+ maxBlockChars: 24_000,
220
+ recordTurns: true,
221
+ maxQueue: 64,
222
+ };
223
+ const first = await createContextBridge(opts).resolve(input());
224
+ // A "restart": brand-new bridge over the same store.
225
+ const after = await createContextBridge(opts).resolve(
226
+ input({ pinnedVersion: first?.version ?? null, pinnedFetchedAtMs: first?.fetchedAtMs ?? 0 }),
227
+ );
228
+ expect(after?.block).toBe(first?.block ?? "");
229
+ expect(client.assembleCalls).toBe(1);
230
+ } finally {
231
+ db.close();
232
+ }
233
+ });
234
+ });
235
+
236
+ describe("context bridge write-back", () => {
237
+ test("creates one session per conversation and attributes the model", async () => {
238
+ const client = mkClient();
239
+ const { bridge, db } = mkBridge(client);
240
+ try {
241
+ bridge.recordTurn({
242
+ scope: "ashlands",
243
+ conversationKey: "k1",
244
+ title: "movement fix",
245
+ userText: "fix movement",
246
+ assistantText: "done",
247
+ slug: "anthropic/claude-haiku-4.5",
248
+ tier: "simple",
249
+ });
250
+ bridge.recordTurn({
251
+ scope: "ashlands",
252
+ conversationKey: "k1",
253
+ title: "movement fix",
254
+ userText: "now the camera",
255
+ assistantText: "ok",
256
+ slug: "anthropic/claude-opus-4.5",
257
+ tier: "hard",
258
+ });
259
+ await bridge.flush();
260
+
261
+ expect(client.sessionsCreated).toBe(1);
262
+ expect(client.appended).toHaveLength(4);
263
+ const assistants = client.appended.filter((m) => m.role === "assistant");
264
+ expect(assistants[0]?.refs).toEqual(["model:anthropic/claude-haiku-4.5", "tier:simple"]);
265
+ expect(assistants[1]?.refs).toEqual(["model:anthropic/claude-opus-4.5", "tier:hard"]);
266
+ } finally {
267
+ db.close();
268
+ }
269
+ });
270
+
271
+ test("recordTurns=false writes nothing", async () => {
272
+ const client = mkClient();
273
+ const { bridge, db } = mkBridge(client, { recordTurns: false });
274
+ try {
275
+ bridge.recordTurn({
276
+ scope: "ashlands",
277
+ conversationKey: "k1",
278
+ title: "t",
279
+ userText: "u",
280
+ assistantText: "a",
281
+ slug: "x",
282
+ tier: "simple",
283
+ });
284
+ await bridge.flush();
285
+ expect(client.appended).toHaveLength(0);
286
+ } finally {
287
+ db.close();
288
+ }
289
+ });
290
+ });
291
+
292
+ describe("context injection into the wire body", () => {
293
+ test("appends to the last system message, leaving breakpoint indices valid", () => {
294
+ const body = {
295
+ model: "auto",
296
+ messages: [
297
+ { role: "system", content: "you are omp" },
298
+ { role: "user", content: "hi" },
299
+ ],
300
+ };
301
+ const out = injectForTest(body, "BLOCK", [0]);
302
+ const msgs = out.messages as Record<string, unknown>[];
303
+ // No new message: the indices the core computed stay correct.
304
+ expect(msgs).toHaveLength(2);
305
+ const content = msgs[0]?.content;
306
+ const text = Array.isArray(content) ? JSON.stringify(content) : String(content);
307
+ expect(text).toContain("you are omp");
308
+ expect(text).toContain("BLOCK");
309
+ // The breakpoint landed on the system message that now carries the block.
310
+ expect(text).toContain("cache_control");
311
+ });
312
+
313
+ test("prepends a system message and shifts breakpoints when there is none", () => {
314
+ const body = { model: "auto", messages: [{ role: "user", content: "hi" }] };
315
+ const out = injectForTest(body, "BLOCK", [0]);
316
+ const msgs = out.messages as Record<string, unknown>[];
317
+ expect(msgs).toHaveLength(2);
318
+ expect(msgs[0]?.role).toBe("system");
319
+ // The user message that was index 0 is now index 1, and the breakpoint
320
+ // followed it — otherwise the marker would land on the injected block.
321
+ expect(JSON.stringify(msgs[1]?.content ?? "")).toContain("cache_control");
322
+ });
323
+
324
+ test("no contextBlock leaves the body untouched", () => {
325
+ const body = {
326
+ model: "auto",
327
+ messages: [
328
+ { role: "system", content: "sys" },
329
+ { role: "user", content: "hi" },
330
+ ],
331
+ };
332
+ const out = injectForTest(body, "", [0]);
333
+ const msgs = out.messages as Record<string, unknown>[];
334
+ expect(msgs).toHaveLength(2);
335
+ expect(JSON.stringify(msgs)).not.toContain("project-context");
336
+ });
337
+ });
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
 
6
6
  import {
7
7
  buildProviderConfig,
8
+ deriveAgentdoxScope,
8
9
  EMBED_PORT_FILE,
9
10
  EMBED_PROVIDER_ID,
10
11
  embedPortPath,
@@ -105,3 +106,34 @@ describe("embed constants", () => {
105
106
  expect(EMBED_PORT_FILE).toBe("embed.port");
106
107
  });
107
108
  });
109
+
110
+ describe("agentdox scope", () => {
111
+ test("derives a slug from the workspace basename", () => {
112
+ expect(deriveAgentdoxScope("E:/projects/Ashlands/Ashlands")).toBe("ashlands");
113
+ expect(deriveAgentdoxScope("/home/drew/omp-router")).toBe("omp-router");
114
+ expect(deriveAgentdoxScope("E:\\projects\\My Game\\")).toBe("my-game");
115
+ expect(deriveAgentdoxScope("")).toBe("");
116
+ });
117
+
118
+ test("an explicit defaultScope wins over the derived one", () => {
119
+ const base = {
120
+ server: { host: "127.0.0.1" },
121
+ profiles: [],
122
+ ledger: { fallbackBlend: { inputPerMtok: 1, outputPerMtok: 1 } },
123
+ };
124
+ const derived = buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands");
125
+ expect(derived.agentdoxScope).toBe("ashlands");
126
+ const explicit = buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "pinned" } }, "/x/ashlands");
127
+ expect(explicit.agentdoxScope).toBe("pinned");
128
+ });
129
+
130
+ test("no scope header when the bridge is off", () => {
131
+ const cfg = {
132
+ server: { host: "127.0.0.1" },
133
+ profiles: [],
134
+ ledger: { fallbackBlend: { inputPerMtok: 1, outputPerMtok: 1 } },
135
+ context: { enabled: false, defaultScope: "ashlands" },
136
+ };
137
+ expect(buildProviderConfig(1234, cfg, "/x/ashlands").agentdoxScope).toBeUndefined();
138
+ });
139
+ });
@@ -23,6 +23,7 @@ function req(messages: NormMessage[] = [], over: Partial<NormRequest> = {}): Nor
23
23
  conversationKey: "k",
24
24
  harnessId: "",
25
25
  ompSessionId: "",
26
+ agentdoxScope: "",
26
27
  requestedModel: "auto",
27
28
  messages,
28
29
  tools: [],
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import { normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
4
4
  import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
5
- import { loadConfig } from "../src/config/load.ts";
5
+ import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
6
6
  import type { ExplorationConfig, ProfileConfig, RouterConfig } from "../src/config/types.ts";
7
7
  import { scoreHeuristic } from "../src/router/classify.ts";
8
8
  import { extractFeatures } from "../src/router/features.ts";
@@ -15,7 +15,9 @@ const FIXTURE = (await Bun.file("test/fixtures/openrouter-models.json").json())
15
15
  const MODELS: CatalogModel[] = FIXTURE.data.map(normalizeCatalogModel).filter((m): m is CatalogModel => m !== null);
16
16
  const SNAPSHOT: CatalogSnapshot = { models: MODELS, fetchedAtMs: Date.now() };
17
17
 
18
- const BASE = loadConfig({});
18
+ // Shipped defaults, not loadConfig({}) — the latter merges the live home
19
+ // config.yml and makes this suite depend on the developer's local settings.
20
+ const BASE = DEFAULT_CONFIG;
19
21
  const PROFILE: ProfileConfig = {
20
22
  id: "auto",
21
23
  name: "Auto",
@@ -52,6 +54,8 @@ function state(over: Partial<ConversationState> = {}): ConversationState {
52
54
  lastPromptTokens: 0,
53
55
  cacheWarmSlug: null,
54
56
  cacheWarmAtMs: 0,
57
+ contextVersion: null,
58
+ contextFetchedAtMs: 0,
55
59
  updatedAtMs: NOW,
56
60
  ...over,
57
61
  };