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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +44 -1
- package/docs/AGENTDOX-BRIDGE.md +18 -1
- package/omp-extension/embed-logic.ts +91 -1
- package/omp-extension/remote-logic.ts +4 -1
- package/omp-extension/router-embed.ts +16 -4
- package/package.json +1 -1
- package/src/cli/config-cmd.ts +5 -2
- package/src/cli/config-wizard.ts +1 -0
- package/src/cli/connect.ts +6 -2
- package/src/config/defaults.ts +4 -0
- package/src/config/schema.ts +1 -0
- package/src/config/types.ts +9 -0
- package/src/context/agentdox.ts +25 -4
- package/src/context/bridge.ts +34 -9
- package/src/context/index.ts +1 -0
- package/src/context/scope.ts +63 -0
- package/src/context/types.ts +20 -0
- package/src/server/advise.ts +3 -0
- package/src/server/digest.ts +3 -0
- package/src/server/turn.ts +6 -0
- package/src/wire/openai/request.ts +15 -1
- package/src/wire/types.ts +22 -0
- package/test/anthropic-wire.test.ts +22 -0
- package/test/context-bridge.test.ts +148 -3
- package/test/embed-logic.test.ts +86 -1
- package/test/escalate.test.ts +3 -0
- package/test/failover.test.ts +4 -1
- package/test/models-yml.test.ts +10 -0
- package/test/remote.test.ts +8 -1
- package/test/scope.test.ts +74 -1
- package/test/turn.test.ts +40 -2
- package/tools/agentdox-e2e.ts +6 -0
- package/tools/replay.ts +3 -0
package/src/context/types.ts
CHANGED
|
@@ -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;
|
package/src/server/advise.ts
CHANGED
|
@@ -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: [
|
package/src/server/digest.ts
CHANGED
|
@@ -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: [
|
package/src/server/turn.ts
CHANGED
|
@@ -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
|
|
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(
|
|
31
|
+
async assemble(scope, _query, limits, layers) {
|
|
29
32
|
c.assembleCalls++;
|
|
30
33
|
c.lastLimits = limits;
|
|
31
|
-
|
|
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 = {
|
package/test/embed-logic.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/test/escalate.test.ts
CHANGED
|
@@ -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,
|
package/test/failover.test.ts
CHANGED
|
@@ -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: [] }],
|
package/test/models-yml.test.ts
CHANGED
|
@@ -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", () => {
|
package/test/remote.test.ts
CHANGED
|
@@ -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", () => {
|