auto-model-router 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,12 +47,32 @@ export interface ContextResolveInput {
47
47
  * the prompt already holds.
48
48
  */
49
49
  firstFetch: boolean;
50
+ /**
51
+ * Group-context scope (rendered first) from `X-Agentdox-Group`. Empty ⇒ no
52
+ * group layer is requested and nothing new is sent.
53
+ */
54
+ group: string;
55
+ /** Personal scope (rendered last) from `X-Agentdox-Personal`. Empty ⇒ no personal layer. */
56
+ personal: string;
57
+ /**
58
+ * The member this turn belongs to — the harness id, which a team front door
59
+ * sets per user. agentdox filters the project layer's recent tail to that
60
+ * member's own turns. Sent only when `group` or `personal` is also named:
61
+ * a lone router has a harness id too and must not filter its own tail.
62
+ */
63
+ user: string;
50
64
  }
51
65
 
52
66
  /** One settled turn, recorded to agentdox with the model that served it. */
53
67
  export interface TurnRecord {
54
68
  scope: string;
55
69
  conversationKey: string;
70
+ /**
71
+ * The harness id, which a team front door sets per user. Non-empty ⇒ every
72
+ * recorded message also carries a `user:<harnessId>` ref, so the member's
73
+ * own turns can be told apart in a shared project session.
74
+ */
75
+ harnessId: string;
56
76
  /** Title used if this is the first turn and a session must be created. */
57
77
  title: string;
58
78
  userText: string;
@@ -50,6 +50,9 @@ function requestOf(req: AdviseRequest): NormRequest {
50
50
  harnessId: req.harnessId,
51
51
  ompSessionId: req.ompSessionId,
52
52
  agentdoxScope: "",
53
+ agentdoxGroup: "",
54
+ agentdoxPersonal: "",
55
+ agentdoxOrigin: "",
53
56
  isSubagent: false,
54
57
  requestedModel: "auto",
55
58
  messages: [
@@ -99,6 +99,9 @@ function syntheticRequest(req: DigestRequest, promptText: string): NormRequest {
99
99
  harnessId: req.harnessId,
100
100
  ompSessionId: req.ompSessionId,
101
101
  agentdoxScope: "",
102
+ agentdoxGroup: "",
103
+ agentdoxPersonal: "",
104
+ agentdoxOrigin: "",
102
105
  isSubagent: true,
103
106
  requestedModel: "digest",
104
107
  messages: [
@@ -225,6 +225,11 @@ export async function runTurn(
225
225
  retrying: attempt > 0,
226
226
  query: relevanceQuery(req),
227
227
  firstFetch: state.contextVersion === null,
228
+ // Layer names come from the front door's headers; the member is the
229
+ // harness id it set. All empty on a router without a team.
230
+ group: req.agentdoxGroup,
231
+ personal: req.agentdoxPersonal,
232
+ user: req.harnessId,
228
233
  });
229
234
  if (pin !== null) {
230
235
  contextBlock = pin.block;
@@ -673,6 +678,7 @@ export async function runTurn(
673
678
  bridge.recordTurn({
674
679
  scope: doxScope,
675
680
  conversationKey: req.conversationKey,
681
+ harnessId: req.harnessId,
676
682
  title: sessionTitle(req),
677
683
  userText,
678
684
  assistantText,
@@ -1,4 +1,4 @@
1
- import { acceptScope } from "../../context/scope.ts";
1
+ import { acceptOrigin, acceptScope } from "../../context/scope.ts";
2
2
  import type {
3
3
  RequestPolicy,
4
4
  CompactionEdit,
@@ -335,6 +335,17 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
335
335
  // back to its configured default scope.
336
336
  const agentdoxScope = acceptScope(headers.get("x-agentdox-scope"));
337
337
 
338
+ // Layered project memory (team edition): the group-context scope rendered
339
+ // first and the member's personal scope rendered last. A lone router never
340
+ // sees these headers; empty means "not sent", so the block is unchanged.
341
+ const agentdoxGroup = acceptScope(headers.get("x-agentdox-group"));
342
+ const agentdoxPersonal = acceptScope(headers.get("x-agentdox-personal"));
343
+
344
+ // The workspace's repository fingerprint, for a front door that keeps a
345
+ // project registry. Same sentinel rule as the scope: omp sends the env-var
346
+ // NAME when the variable is unset, and a name is never a fingerprint.
347
+ const agentdoxOrigin = acceptOrigin(headers.get("x-agentdox-origin"));
348
+
338
349
  // Subagent marker from the embed extension (sessions without a UI).
339
350
  const isSubagent = (headers.get("x-omp-subagent") ?? "").trim() === "1";
340
351
 
@@ -401,6 +412,9 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
401
412
  harnessId,
402
413
  ompSessionId,
403
414
  agentdoxScope,
415
+ agentdoxGroup,
416
+ agentdoxPersonal,
417
+ agentdoxOrigin,
404
418
  isSubagent,
405
419
  ...(policy === undefined ? {} : { policy }),
406
420
  requestedModel,
package/src/wire/types.ts CHANGED
@@ -96,6 +96,28 @@ export interface NormRequest {
96
96
  * and if that is empty too the bridge stays inert for this request.
97
97
  */
98
98
  agentdoxScope: string;
99
+ /**
100
+ * agentdox group-context scope from the `X-Agentdox-Group` request header,
101
+ * set by a front door such as the team edition: the scope whose brief and
102
+ * top memory render FIRST in the block. Empty when absent or not a slug,
103
+ * which sends nothing, so a lone router's block is unchanged.
104
+ */
105
+ agentdoxGroup: string;
106
+ /**
107
+ * agentdox personal scope from the `X-Agentdox-Personal` request header:
108
+ * one member's own thread in the project (their handoff note first), which
109
+ * renders LAST. Empty when absent or not a slug ⇒ no personal layer.
110
+ */
111
+ agentdoxPersonal: string;
112
+ /**
113
+ * The workspace's repository fingerprint from the `X-Agentdox-Origin`
114
+ * request header (`<host>/<path>` of its git remote `origin`, see
115
+ * `src/context/scope.ts`). Validated here, in one place, for a front door
116
+ * with a project registry — the team edition — which uses it to find the
117
+ * project two same-named folders are really about. The router's own bridge
118
+ * has no registry and never reads it. Empty when absent or not a fingerprint.
119
+ */
120
+ agentdoxOrigin: string;
99
121
  /** `X-Omp-Subagent: 1`: the caller is an omp subagent (a session without a UI). */
100
122
  isSubagent: boolean;
101
123
  /**
@@ -138,6 +138,28 @@ describe("parseMessagesRequest", () => {
138
138
  expect(anthropicIdentityHeaders({}, new Headers({ "user-agent": "python-requests" })).get("x-omp-harness")).toBe("anthropic");
139
139
  });
140
140
 
141
+ test("the agentdox scope and layer headers reach the request on the Anthropic path too", () => {
142
+ const plain = parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "user-agent": "claude-cli/2.1.263" }));
143
+ expect(plain.agentdoxScope).toBe("");
144
+ expect(plain.agentdoxGroup).toBe("");
145
+ expect(plain.agentdoxPersonal).toBe("");
146
+ const team = parseMessagesRequest(
147
+ CLAUDE_CODE_BODY,
148
+ new Headers({ "user-agent": "claude-cli/2.1.263", "x-omp-harness": "u_ada", "x-agentdox-scope": "proj", "x-agentdox-group": "group.g1", "x-agentdox-personal": "proj.u.u_ada" }),
149
+ );
150
+ expect(team.harnessId).toBe("u_ada");
151
+ expect(team.agentdoxScope).toBe("proj");
152
+ expect(team.agentdoxGroup).toBe("group.g1");
153
+ expect(team.agentdoxPersonal).toBe("proj.u.u_ada");
154
+ expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-group": "Not A Slug" })).agentdoxGroup).toBe("");
155
+ });
156
+
157
+ test("the origin fingerprint reaches the request on the Anthropic path too", () => {
158
+ expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "user-agent": "claude-cli/2.1.263" })).agentdoxOrigin).toBe("");
159
+ expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-origin": "github.com/drewappling/omp-router" })).agentdoxOrigin).toBe("github.com/drewappling/omp-router");
160
+ expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-origin": "https://github.com/a/b" })).agentdoxOrigin).toBe("");
161
+ });
162
+
141
163
  test("a request captured from Claude Code 2.1: system inside messages, JSON user_id, adaptive thinking with effort, 23 custom tools", () => {
142
164
  const fixture = JSON.parse(readFileSync("test/fixtures/harness/claude-code.json", "utf8")) as { headers: Record<string, string>; body: Record<string, unknown> };
143
165
  const norm = parseMessagesRequest(fixture.body, new Headers(fixture.headers));
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import type { AgentDoxClient, AssembleLimits } from "../src/context/agentdox.ts";
3
+ import { type AgentDoxClient, type AssembleLayers, type AssembleLimits, createAgentDoxClient } from "../src/context/agentdox.ts";
4
4
  import { createContextBridge } from "../src/context/bridge.ts";
5
5
  import { createContextStore } from "../src/context/store.ts";
6
6
  import type { ContextResolveInput, TurnRecord } from "../src/context/types.ts";
@@ -16,19 +16,24 @@ interface FakeClient extends AgentDoxClient {
16
16
  sessionsCreated: number;
17
17
  prompt: string;
18
18
  lastLimits: AssembleLimits | null;
19
+ /** The layers of the last assemble: undefined when the bridge sent none. */
20
+ lastLayers: AssembleLayers | undefined;
19
21
  }
20
22
 
21
23
  function mkClient(prompt = "MEMORY: player digs in 3/4 top-down"): FakeClient {
22
24
  const c: FakeClient = {
23
25
  assembleCalls: 0,
24
26
  lastLimits: null,
27
+ lastLayers: undefined,
25
28
  appended: [],
26
29
  sessionsCreated: 0,
27
30
  prompt,
28
- async assemble(_scope, _query, limits) {
31
+ async assemble(scope, _query, limits, layers) {
29
32
  c.assembleCalls++;
30
33
  c.lastLimits = limits;
31
- return c.prompt;
34
+ c.lastLayers = layers;
35
+ // Like the server: a personal layer renders last, so members differ.
36
+ return layers !== undefined && layers.personal !== "" ? `${c.prompt}\n\n# Your thread in ${scope}\n${layers.personal}` : c.prompt;
32
37
  },
33
38
  async createSession() {
34
39
  c.sessionsCreated++;
@@ -56,6 +61,7 @@ function mkBridge(client: AgentDoxClient, over: Partial<BridgeOpts> = {}) {
56
61
  docsLimit: 2,
57
62
  sessionLimit: 6,
58
63
  briefChars: 0,
64
+ layers: true,
59
65
  recordTurns: true,
60
66
  maxQueue: 64,
61
67
  ...over,
@@ -72,6 +78,9 @@ function input(over: Partial<ContextResolveInput> = {}): ContextResolveInput {
72
78
  modelSwitching: false,
73
79
  retrying: false,
74
80
  query: "movement rules",
81
+ group: "",
82
+ personal: "",
83
+ user: "",
75
84
  firstFetch: true,
76
85
  ...over,
77
86
  };
@@ -258,6 +267,7 @@ describe("context bridge refresh policy", () => {
258
267
  docsLimit: 2,
259
268
  sessionLimit: 6,
260
269
  briefChars: 0,
270
+ layers: true,
261
271
  recordTurns: true,
262
272
  maxQueue: 64,
263
273
  };
@@ -282,6 +292,7 @@ describe("context bridge write-back", () => {
282
292
  bridge.recordTurn({
283
293
  scope: "ashlands",
284
294
  conversationKey: "k1",
295
+ harnessId: "",
285
296
  title: "movement fix",
286
297
  userText: "fix movement",
287
298
  assistantText: "done",
@@ -292,6 +303,7 @@ describe("context bridge write-back", () => {
292
303
  bridge.recordTurn({
293
304
  scope: "ashlands",
294
305
  conversationKey: "k1",
306
+ harnessId: "",
295
307
  title: "movement fix",
296
308
  userText: "now the camera",
297
309
  assistantText: "ok",
@@ -318,6 +330,7 @@ describe("context bridge write-back", () => {
318
330
  bridge.recordTurn({
319
331
  scope: "ashlands",
320
332
  conversationKey: "k1",
333
+ harnessId: "",
321
334
  title: "t",
322
335
  userText: "u",
323
336
  assistantText: "a",
@@ -337,6 +350,7 @@ describe("context bridge write-back", () => {
337
350
  return {
338
351
  scope: "ashlands",
339
352
  conversationKey: "k1",
353
+ harnessId: "",
340
354
  title: "movement fix",
341
355
  userText: "fix movement",
342
356
  assistantText: "",
@@ -423,6 +437,137 @@ describe("context bridge write-back", () => {
423
437
  });
424
438
  });
425
439
 
440
+ describe("context layers a team names (project memory, phase one)", () => {
441
+ // A router without a team has no group or personal scope and no harness id:
442
+ // the bridge must then send exactly what it did before, so a block on a
443
+ // lone install is byte-identical to 0.15. A team front door names the
444
+ // layers in headers and the member in the harness id.
445
+ test("resolve passes the group, personal and user layers to assemble; a lone router passes empties", async () => {
446
+ const client = mkClient();
447
+ const { bridge, db } = mkBridge(client);
448
+ try {
449
+ await bridge.resolve(input({ group: "group.g1", personal: "ashlands.u.u1", user: "u1" }));
450
+ expect(client.lastLayers).toEqual({ group: "group.g1", personal: "ashlands.u.u1", user: "u1" });
451
+ await bridge.resolve(input({ conversationKey: "k2" }));
452
+ expect(client.lastLayers).toEqual({ group: "", personal: "", user: "" });
453
+ // Either layer alone is a front door; the member goes with it.
454
+ await bridge.resolve(input({ conversationKey: "k3", group: "group.g1", user: "u1" }));
455
+ expect(client.lastLayers).toEqual({ group: "group.g1", personal: "", user: "u1" });
456
+ } finally {
457
+ db.close();
458
+ }
459
+ });
460
+
461
+ test("a router on its own sends no user, even though it has a harness id", async () => {
462
+ // Claude Code's wire path derives a harness id and omp sends one, so a
463
+ // lone router is NOT harness-less. Sending it as `user` would make a new
464
+ // agentdox filter the project's recent tail to that harness, dropping
465
+ // every pre-0.16 message and every other harness's turns.
466
+ const client = mkClient();
467
+ const { bridge, db } = mkBridge(client);
468
+ try {
469
+ await bridge.resolve(input({ user: "claude-code" }));
470
+ expect(client.lastLayers).toEqual({ group: "", personal: "", user: "" });
471
+ } finally {
472
+ db.close();
473
+ }
474
+ });
475
+
476
+ test("layers: false sends none, whatever the request names", async () => {
477
+ const client = mkClient();
478
+ const { bridge, db } = mkBridge(client, { layers: false });
479
+ try {
480
+ const pin = await bridge.resolve(input({ group: "group.g1", personal: "ashlands.u.u1", user: "u1" }));
481
+ expect(pin).not.toBeNull();
482
+ expect(client.assembleCalls).toBe(1);
483
+ expect(client.lastLayers).toBeUndefined();
484
+ } finally {
485
+ db.close();
486
+ }
487
+ });
488
+
489
+ test("a personal layer pins a different block per member, and the same block for the same member", async () => {
490
+ // The version is a hash of the block's content, so two members' blocks
491
+ // never share a version even in one scope; the shared store keys on it.
492
+ const client = mkClient();
493
+ const { bridge, db } = mkBridge(client);
494
+ try {
495
+ const ada = await bridge.resolve(input({ conversationKey: "ada", personal: "ashlands.u.ada", user: "ada" }));
496
+ const bob = await bridge.resolve(input({ conversationKey: "bob", personal: "ashlands.u.bob", user: "bob" }));
497
+ const adaAgain = await bridge.resolve(input({ conversationKey: "ada-2", personal: "ashlands.u.ada", user: "ada" }));
498
+ const nobody = await bridge.resolve(input({ conversationKey: "solo" }));
499
+ expect(ada?.block).toContain("ashlands.u.ada");
500
+ expect(bob?.block).toContain("ashlands.u.bob");
501
+ expect(ada?.version).not.toBe(bob?.version);
502
+ expect(adaAgain?.version).toBe(ada?.version ?? "");
503
+ expect(nobody?.version).not.toBe(ada?.version);
504
+ expect(nobody?.block).not.toContain("Your thread");
505
+ } finally {
506
+ db.close();
507
+ }
508
+ });
509
+
510
+ test("the recorded turn carries user:<harnessId> on both messages only when the harness is set", async () => {
511
+ const client = mkClient();
512
+ const { bridge, db } = mkBridge(client);
513
+ try {
514
+ const rec = {
515
+ scope: "ashlands",
516
+ title: "movement fix",
517
+ userText: "fix movement",
518
+ assistantText: "done",
519
+ slug: "anthropic/claude-haiku-4.5",
520
+ tier: "simple",
521
+ turnEnded: true,
522
+ };
523
+ bridge.recordTurn({ ...rec, conversationKey: "member", harnessId: "u_ada" });
524
+ bridge.recordTurn({ ...rec, conversationKey: "solo", harnessId: "" });
525
+ await bridge.flush();
526
+
527
+ const member = client.appended.filter((m) => m.sessionId === "ses_1");
528
+ expect(member.map((m) => m.role)).toEqual(["user", "assistant"]);
529
+ expect(member[0]?.refs).toEqual(["user:u_ada"]);
530
+ expect(member[1]?.refs).toEqual(["model:anthropic/claude-haiku-4.5", "tier:simple", "user:u_ada"]);
531
+
532
+ // No harness id: the refs are exactly what 0.15 wrote.
533
+ const solo = client.appended.filter((m) => m.sessionId === "ses_2");
534
+ expect(solo[0]?.refs).toEqual([]);
535
+ expect(solo[1]?.refs).toEqual(["model:anthropic/claude-haiku-4.5", "tier:simple"]);
536
+ } finally {
537
+ db.close();
538
+ }
539
+ });
540
+
541
+ test("the REST client posts the layer keys only when they are non-empty", async () => {
542
+ // An older agentdox ignores unknown keys, so sending them is harmless —
543
+ // but a lone router must post the same body as before, key for key, and
544
+ // a half-named layer set must not post empty strings the server would
545
+ // have to special-case.
546
+ const bodies: Record<string, unknown>[] = [];
547
+ const realFetch = globalThis.fetch;
548
+ globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
549
+ bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
550
+ return new Response(JSON.stringify({ prompt: "P" }), { status: 200, headers: { "content-type": "application/json" } });
551
+ }) as unknown as typeof fetch;
552
+ try {
553
+ const client = createAgentDoxClient({ baseUrl: "http://agentdox.test", token: "t", timeoutMs: 1000, log });
554
+ const limits = { memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0 };
555
+ await client.assemble("ashlands", "q", limits);
556
+ await client.assemble("ashlands", "q", limits, { group: "", personal: "", user: "" });
557
+ await client.assemble("ashlands", "q", limits, { group: "group.g1", personal: "", user: "u1" });
558
+ await client.assemble("ashlands", "q", limits, { group: "group.g1", personal: "ashlands.u.u1", user: "u1" });
559
+
560
+ const before = { scope: "ashlands", query: "q", ...limits };
561
+ expect(bodies[0]).toEqual(before);
562
+ expect(bodies[1]).toEqual(before);
563
+ expect(bodies[2]).toEqual({ ...before, group: "group.g1", user: "u1" });
564
+ expect(bodies[3]).toEqual({ ...before, group: "group.g1", personal: "ashlands.u.u1", user: "u1" });
565
+ } finally {
566
+ globalThis.fetch = realFetch;
567
+ }
568
+ });
569
+ });
570
+
426
571
  describe("context injection into the wire body", () => {
427
572
  test("appends to the last system message, leaving breakpoint indices valid", () => {
428
573
  const body = {
@@ -1,11 +1,12 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  import {
7
7
  buildProviderConfig,
8
8
  deriveAgentdoxScope,
9
+ deriveWorkspaceOrigin,
9
10
  EMBED_PORT_FILE,
10
11
  EMBED_PROVIDER_ID,
11
12
  embedPortPath,
@@ -207,3 +208,87 @@ describe("agentdox scope", () => {
207
208
  expect(buildProviderConfig(1234, cfg, "/x/ashlands").agentdoxScope).toBeUndefined();
208
209
  });
209
210
  });
211
+
212
+ describe("workspace origin", () => {
213
+ const CONFIG = ['[core]', '\trepositoryformatversion = 0', '[remote "origin"]', '\turl = https://github.com/DrewAppling/omp-router.git', '\tfetch = +refs/heads/*:refs/remotes/origin/*', '[branch "main"]', '\tremote = origin', ''].join("\n");
214
+ let root: string;
215
+ beforeAll(() => {
216
+ root = mkdtempSync(join(tmpdir(), "amr-origin-"));
217
+ });
218
+ afterAll(() => {
219
+ rmSync(root, { recursive: true, force: true });
220
+ });
221
+
222
+ test("a plain repository: the remote from .git/config, also from a subdirectory", () => {
223
+ const repo = join(root, "repo");
224
+ mkdirSync(join(repo, ".git"), { recursive: true });
225
+ writeFileSync(join(repo, ".git", "config"), CONFIG);
226
+ mkdirSync(join(repo, "src", "deep"), { recursive: true });
227
+ expect(deriveWorkspaceOrigin(repo)).toBe("github.com/drewappling/omp-router");
228
+ expect(deriveWorkspaceOrigin(join(repo, "src", "deep"))).toBe("github.com/drewappling/omp-router");
229
+ // The scope is still the folder, untouched by any of this.
230
+ expect(deriveAgentdoxScope(repo)).toBe("repo");
231
+ });
232
+
233
+ test("a worktree: .git is a file naming the git dir, whose shared config is one hop further", () => {
234
+ // The main checkout holds the config; the worktree's own dir only points at it.
235
+ const main = join(root, "main");
236
+ mkdirSync(join(main, ".git", "worktrees", "wt"), { recursive: true });
237
+ writeFileSync(join(main, ".git", "config"), CONFIG.replace("github.com/DrewAppling/omp-router.git", "gitlab.example.com:2222/team/api/").replace("https://", "ssh://git@"));
238
+ writeFileSync(join(main, ".git", "worktrees", "wt", "commondir"), "../..\n");
239
+ const wt = join(root, "wt");
240
+ mkdirSync(wt, { recursive: true });
241
+ writeFileSync(join(wt, ".git"), `gitdir: ${join(main, ".git", "worktrees", "wt")}\n`);
242
+ expect(deriveWorkspaceOrigin(wt)).toBe("gitlab.example.com/team/api");
243
+ // A submodule-style pointer: the named dir has its own config, relative to the .git file.
244
+ const sub = join(root, "sub");
245
+ mkdirSync(join(sub, "modules", "lib"), { recursive: true });
246
+ mkdirSync(join(sub, "lib"), { recursive: true });
247
+ writeFileSync(join(sub, "modules", "lib", "config"), CONFIG.replace("DrewAppling/omp-router", "org/lib"));
248
+ writeFileSync(join(sub, "lib", ".git"), "gitdir: ../modules/lib\n");
249
+ expect(deriveWorkspaceOrigin(join(sub, "lib"))).toBe("github.com/org/lib");
250
+ });
251
+
252
+ test("no remote, a local remote, no repository, or nothing at all: no fingerprint, never a throw", () => {
253
+ const bare = join(root, "bare");
254
+ mkdirSync(join(bare, ".git"), { recursive: true });
255
+ writeFileSync(join(bare, ".git", "config"), "[core]\n\tbare = false\n");
256
+ expect(deriveWorkspaceOrigin(bare)).toBe("");
257
+ const local = join(root, "local");
258
+ mkdirSync(join(local, ".git"), { recursive: true });
259
+ writeFileSync(join(local, ".git", "config"), '[remote "origin"]\n\turl = /srv/git/local.git\n');
260
+ expect(deriveWorkspaceOrigin(local)).toBe("");
261
+ // A `.git` file that leads nowhere is the boundary: the walk stops there.
262
+ const dangling = join(root, "repo", "dangling");
263
+ mkdirSync(dangling, { recursive: true });
264
+ writeFileSync(join(dangling, ".git"), "gitdir: /nowhere/at/all\n");
265
+ expect(deriveWorkspaceOrigin(dangling)).toBe("");
266
+ expect(deriveWorkspaceOrigin(join(root, "not-a-repo", "missing"))).toBe("");
267
+ expect(deriveWorkspaceOrigin("")).toBe("");
268
+ expect(
269
+ deriveWorkspaceOrigin("/anywhere", () => {
270
+ throw new Error("disk on fire");
271
+ }),
272
+ ).toBe("");
273
+ });
274
+
275
+ test("the first url under [remote \"origin\"] wins; other remotes do not count", () => {
276
+ const read = () => '[remote "upstream"]\n\turl = https://github.com/other/thing.git\n[remote "origin"]\n\turl = git@github.com:me/thing.git\n\turl = https://github.com/me/second.git\n';
277
+ expect(deriveWorkspaceOrigin("/x/repo", read)).toBe("github.com/me/thing");
278
+ expect(deriveWorkspaceOrigin("/x/repo", () => '[remote "upstream"]\n\turl = https://github.com/other/thing.git\n')).toBe("");
279
+ });
280
+
281
+ test("buildProviderConfig carries the origin beside the scope, only where the scope goes", () => {
282
+ const base = {
283
+ server: { host: "127.0.0.1" },
284
+ profiles: [],
285
+ ledger: { fallbackBlend: { inputPerMtok: 1, outputPerMtok: 1 } },
286
+ };
287
+ const on = buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands", "github.com/me/ashlands");
288
+ expect(on.agentdoxScope).toBe("ashlands");
289
+ expect(on.agentdoxOrigin).toBe("github.com/me/ashlands");
290
+ expect(buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands", "").agentdoxOrigin).toBeUndefined();
291
+ expect(buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands").agentdoxOrigin).toBeUndefined();
292
+ expect(buildProviderConfig(1234, { ...base, context: { enabled: false, defaultScope: "" } }, "/x/ashlands", "github.com/me/ashlands").agentdoxOrigin).toBeUndefined();
293
+ });
294
+ });
@@ -24,6 +24,9 @@ function req(messages: NormMessage[] = [], over: Partial<NormRequest> = {}): Nor
24
24
  harnessId: "",
25
25
  ompSessionId: "",
26
26
  agentdoxScope: "",
27
+ agentdoxGroup: "",
28
+ agentdoxPersonal: "",
29
+ agentdoxOrigin: "",
27
30
  isSubagent: false,
28
31
  requestedModel: "auto",
29
32
  messages,
@@ -74,7 +74,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
74
74
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1, confirmUpgradesBelowConfidence: 0.6 },
75
75
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
76
76
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
77
- context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
77
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, layers: true, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
78
78
  compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
79
79
  budget: { onExceeded: "downgrade" },
80
80
  report: { baselines: [], dailySummary: false },
@@ -96,6 +96,9 @@ function mkReq(): NormRequest {
96
96
  harnessId: "",
97
97
  ompSessionId: "",
98
98
  agentdoxScope: "",
99
+ agentdoxGroup: "",
100
+ agentdoxPersonal: "",
101
+ agentdoxOrigin: "",
99
102
  isSubagent: false,
100
103
  requestedModel: "auto",
101
104
  messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { parse as parseYaml } from "yaml";
3
3
 
4
4
  import { loadConfig } from "../src/config/load.ts";
5
+ import { ORIGIN_ENV, SCOPE_ENV } from "../src/context/scope.ts";
5
6
  import {
6
7
  BEGIN_GUARD,
7
8
  END_GUARD,
@@ -68,6 +69,15 @@ describe("renderProviderBlock", () => {
68
69
  expect(BLOCK).toContain("auth: none");
69
70
  expect(BLOCK).toContain("/v1");
70
71
  });
72
+
73
+ test("with the bridge on, the scope and origin headers name the variables the extension sets", () => {
74
+ // The file is machine-wide, so neither can be a literal: omp resolves a
75
+ // header value that names an env var per request (src/context/scope.ts).
76
+ const bridged = renderProviderBlock(loadConfig({ overrides: { context: { enabled: true, baseUrl: "http://agentdox:3003", token: "t" } } }), null);
77
+ expect(bridged).toContain(`X-Agentdox-Scope: ${SCOPE_ENV}`);
78
+ expect(bridged).toContain(`X-Agentdox-Origin: ${ORIGIN_ENV}`);
79
+ expect(BLOCK).not.toContain("X-Agentdox-");
80
+ });
71
81
  });
72
82
 
73
83
  describe("spliceProviderBlock", () => {
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import { addExtensions, codexBlock, connectRemote, setDotenv, type ConnectOptions } from "../src/cli/connect.ts";
7
7
  import { parseRemoteRouter, readRemoteRouter, remoteProviderRegistration } from "../omp-extension/remote-logic.ts";
8
8
  import { existingBlockScope, hasForeignRouterProvider, mergeModelsYml, renderRemoteModelsYml } from "../src/cli/connect.ts";
9
- import { SCOPE_ENV } from "../src/context/scope.ts";
9
+ import { ORIGIN_ENV, SCOPE_ENV } from "../src/context/scope.ts";
10
10
  import { refreshAndRewrite, refreshCredential, RefreshError, resolveRefreshToken, shouldRefresh } from "../src/cli/refresh.ts";
11
11
  import { loadRefreshToken, pickStore, removeRefreshToken, saveRefreshToken } from "../src/cli/credential-store.ts";
12
12
  import { hasRefresh, refreshAccountOf } from "../omp-extension/remote-logic.ts";
@@ -31,6 +31,9 @@ describe("remote-logic", () => {
31
31
  // the machine with the right context; the remote may still override it.
32
32
  expect(reg.headers["X-Agentdox-Scope"]).toBeUndefined();
33
33
  expect(remoteProviderRegistration(t, "", false, { inputPerMtok: 1, outputPerMtok: 4 }, "omp-router").headers).toEqual({ "X-Agentdox-Scope": "omp-router" });
34
+ // The repository fingerprint rides beside the scope, and only when there is one.
35
+ expect(remoteProviderRegistration(t, "", false, { inputPerMtok: 1, outputPerMtok: 4 }, "omp-router", "github.com/drewappling/omp-router").headers).toEqual({ "X-Agentdox-Scope": "omp-router", "X-Agentdox-Origin": "github.com/drewappling/omp-router" });
36
+ expect(remoteProviderRegistration(t, "", false, { inputPerMtok: 1, outputPerMtok: 4 }, "", "github.com/drewappling/omp-router").headers).toEqual({ "X-Agentdox-Origin": "github.com/drewappling/omp-router" });
34
37
  const dir = mkdtempSync(join(tmpdir(), "amr-remote-"));
35
38
  expect(readRemoteRouter(dir)).toBeNull();
36
39
  writeFileSync(join(dir, "remote.json"), JSON.stringify({ url: "https://t", key: "k" }));
@@ -126,12 +129,16 @@ describe("omp models.yml for a remote router", () => {
126
129
  // Machine-wide file: the header names the env var the extension sets per
127
130
  // workspace, so the MAIN model's turns carry each repo's own scope.
128
131
  expect(block).toContain(`X-Agentdox-Scope: ${SCOPE_ENV}`);
132
+ // The origin names its own variable the same way, on every block.
133
+ expect(block).toContain(`X-Agentdox-Origin: ${ORIGIN_ENV}`);
129
134
  expect(existingBlockScope(mergeModelsYml("", block))).toBe("");
130
135
  // --scope pins one slug for the whole machine.
131
136
  const pinned = renderRemoteModelsYml("https://team.example", "k", BLEND, "omp-router");
132
137
  expect(pinned).toContain("X-Agentdox-Scope: omp-router");
133
138
  expect(pinned).not.toContain(SCOPE_ENV);
134
139
  expect(existingBlockScope(mergeModelsYml("", pinned))).toBe("omp-router");
140
+ // A pinned scope does not pin an origin: the repository is still the workspace's.
141
+ expect(pinned).toContain(`X-Agentdox-Origin: ${ORIGIN_ENV}`);
135
142
  });
136
143
 
137
144
  test("merging keeps other providers, replaces our own block, and is idempotent", () => {