auto-model-router 0.4.2 → 0.4.3
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 +21 -2
- package/omp-extension/report-logic.ts +46 -0
- package/omp-extension/router-configure.ts +55 -3
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +7 -1
- package/src/config/defaults.ts +7 -0
- package/src/config/schema.ts +4 -1
- package/src/config/types.ts +27 -0
- package/src/cost/ledger.ts +75 -8
- package/src/cost/report.ts +8 -3
- package/src/cost/summary.ts +231 -0
- package/src/cost/types.ts +31 -3
- package/src/router/candidates.ts +1 -1
- package/src/router/classify.ts +2 -2
- package/src/router/compaction.ts +1 -0
- package/src/router/learned.ts +11 -1
- package/src/router/select.ts +5 -1
- package/src/server/compaction-digest.ts +127 -0
- package/src/server/digest.ts +13 -3
- package/src/server/http.ts +29 -1
- package/src/server/turn.ts +27 -1
- package/src/util/sqlite.ts +7 -0
- package/src/wire/openai/request.ts +4 -0
- package/src/wire/types.ts +7 -0
- package/test/compaction.test.ts +40 -3
- package/test/digest.test.ts +22 -0
- package/test/failover.test.ts +3 -3
- package/test/learned.test.ts +21 -1
- package/test/report-logic.test.ts +11 -1
- package/test/select.test.ts +2 -2
- package/test/summary.test.ts +171 -0
- package/test/tokens.test.ts +44 -0
- package/test/trust-attribution.test.ts +37 -0
- package/test/turn.test.ts +68 -3
- package/tools/train-classifier.ts +75 -20
|
@@ -199,6 +199,10 @@ function applyCompaction(messages: Record<string, unknown>[], edits: readonly Co
|
|
|
199
199
|
if (msg === undefined) continue;
|
|
200
200
|
const content = msg.content;
|
|
201
201
|
if (typeof content !== "string") continue;
|
|
202
|
+
if (edit.digest !== undefined) {
|
|
203
|
+
msg.content = edit.digest;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
202
206
|
if (edit.mode === "stub") {
|
|
203
207
|
msg.content = `[omp-router: ${edit.note} elided to save context; re-run the tool to restore]`;
|
|
204
208
|
continue;
|
package/src/wire/types.ts
CHANGED
|
@@ -158,6 +158,13 @@ export interface CompactionEdit {
|
|
|
158
158
|
* the edit instead of corrupting the prompt.
|
|
159
159
|
*/
|
|
160
160
|
bytes: number;
|
|
161
|
+
/**
|
|
162
|
+
* A cheap-model digest of the original content (marker line first), set
|
|
163
|
+
* by summarising compaction. When present it replaces the content outright
|
|
164
|
+
* instead of the head/tail or stub breadcrumb, and persists with the plan
|
|
165
|
+
* so the dispatched bytes stay identical turn to turn.
|
|
166
|
+
*/
|
|
167
|
+
digest?: string;
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
export type FinishReason = "stop" | "length" | "tool_calls" | "content_filter" | "error";
|
package/test/compaction.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
3
|
import type { CompactionConfig } from "../src/config/types.ts";
|
|
4
|
-
import { planCompaction, validatePlan } from "../src/router/compaction.ts";
|
|
4
|
+
import { compactedBytes, planCompaction, validatePlan } from "../src/router/compaction.ts";
|
|
5
5
|
import { parseChatRequest } from "../src/wire/openai/request.ts";
|
|
6
6
|
import type { NormMessage } from "../src/wire/types.ts";
|
|
7
7
|
|
|
@@ -16,7 +16,7 @@ const CFG: CompactionConfig = {
|
|
|
16
16
|
keepHeadBytes: 10,
|
|
17
17
|
keepTailBytes: 10,
|
|
18
18
|
elideSupersededReads: true,
|
|
19
|
-
collapseDuplicateResults: true,
|
|
19
|
+
collapseDuplicateResults: true, digestToolResults: false, digestMaxPerTurn: 2,
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
function user(text: string): NormMessage {
|
|
@@ -78,7 +78,7 @@ describe("planCompaction", () => {
|
|
|
78
78
|
toolMsg("c2", "read", big("V2")), // different content, same path → supersedes c1
|
|
79
79
|
...PAD,
|
|
80
80
|
];
|
|
81
|
-
const { edits } = planCompaction(msgs, { ...CFG, collapseDuplicateResults: false }, 10_000, 10_000);
|
|
81
|
+
const { edits } = planCompaction(msgs, { ...CFG, collapseDuplicateResults: false, digestToolResults: false, digestMaxPerTurn: 2, }, 10_000, 10_000);
|
|
82
82
|
expect(edits.map((e) => e.index)).toEqual([2]);
|
|
83
83
|
expect(edits[0]?.mode).toBe("stub");
|
|
84
84
|
});
|
|
@@ -270,3 +270,40 @@ describe("plan byte-stability across turns", () => {
|
|
|
270
270
|
expect(next.edits.filter((e) => e.index === 2)).toEqual(carried);
|
|
271
271
|
});
|
|
272
272
|
});
|
|
273
|
+
|
|
274
|
+
describe("summarising compaction (edit.digest)", () => {
|
|
275
|
+
const bodyWith = (messages: unknown[]): Record<string, unknown> => ({ model: "auto", messages });
|
|
276
|
+
const MUT = { slug: "x/y", fallbacks: [], sessionId: "s", cacheBreakpointMessageIndices: [], reasoning: undefined, maxTokens: undefined, stripAssistantReasoning: false };
|
|
277
|
+
const DIGEST = "[digest: read output 208 bytes → 40 chars by cheap/model. Full output: re-run read {}]\nA: two hundred x's.";
|
|
278
|
+
|
|
279
|
+
test("a digested edit replaces the content with the digest, whatever its mode", () => {
|
|
280
|
+
const raw = [
|
|
281
|
+
{ role: "user", content: "go" },
|
|
282
|
+
{ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: "{}" } }] },
|
|
283
|
+
{ role: "tool", tool_call_id: "c1", content: "HEAD" + "x".repeat(500) + "TAIL" },
|
|
284
|
+
];
|
|
285
|
+
const req = parseChatRequest(bodyWith(raw), new Headers());
|
|
286
|
+
for (const mode of ["truncate", "stub"] as const) {
|
|
287
|
+
const out = req.renderUpstreamBody({ ...MUT, compactionPlan: [{ index: 2, mode, keepHead: 4, keepTail: 4, note: "large read result", bytes: 508, digest: DIGEST }] });
|
|
288
|
+
expect((out.messages as { content: string }[])[2]?.content).toBe(DIGEST);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("compactedBytes sizes a digested edit by its digest, never above the original", () => {
|
|
293
|
+
const plain = { index: 2, mode: "truncate" as const, keepHead: 10, keepTail: 10, note: "n", bytes: 5_000 };
|
|
294
|
+
expect(compactedBytes(5_000, { ...plain, digest: DIGEST })).toBe(Buffer.byteLength(DIGEST));
|
|
295
|
+
expect(compactedBytes(5_000, { ...plain, digest: "y".repeat(9_000) })).toBe(5_000);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("the digest survives validation and a re-plan, so the bytes stay stable", () => {
|
|
299
|
+
const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
|
|
300
|
+
const { edits } = planCompaction(msgs, CFG, 1, 10_000);
|
|
301
|
+
const digested = edits.map((e) => ({ ...e, digest: DIGEST }));
|
|
302
|
+
expect(validatePlan(digested, msgs)[0]?.digest).toBe(DIGEST);
|
|
303
|
+
const replanned = planCompaction(msgs, CFG, 1, 10_000, validatePlan(digested, msgs));
|
|
304
|
+
expect(replanned.edits).toHaveLength(1);
|
|
305
|
+
expect(replanned.edits[0]?.digest).toBe(DIGEST);
|
|
306
|
+
// Savings count the digest's size, not the head+tail the plain edit would have kept.
|
|
307
|
+
expect(replanned.savedBytes).toBe(Buffer.byteLength(big("A")) - Buffer.byteLength(DIGEST));
|
|
308
|
+
});
|
|
309
|
+
});
|
package/test/digest.test.ts
CHANGED
|
@@ -162,6 +162,28 @@ describe("createDigester", () => {
|
|
|
162
162
|
db2.close();
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
+
test("a compaction-sourced digest is gated on compaction.digestToolResults and judges the given tier", async () => {
|
|
166
|
+
const cfg = cfgWith({ enabled: false });
|
|
167
|
+
cfg.compaction.digestToolResults = true;
|
|
168
|
+
const db = openDb(":memory:");
|
|
169
|
+
const ledger = createLedger(db, cfg);
|
|
170
|
+
// No session rows at all: the tier comes from the request.
|
|
171
|
+
const { upstream, calls } = fakeUpstream(() => "Condensed.");
|
|
172
|
+
const d = createDigester({ cfg, catalog, ledger, upstream, log });
|
|
173
|
+
const base = { ompSessionId: "omp-9", harnessId: "", toolName: "read", input: { path: "x.ts" }, content: BIG, query: "q" };
|
|
174
|
+
// digest.enabled is off, so the tool_result path declines...
|
|
175
|
+
expect(await d.digest(base)).toMatchObject({ digested: false, reason: expect.stringContaining("disabled") });
|
|
176
|
+
// ...but compaction only needs compaction.digestToolResults, plus a tier at or above fromTier.
|
|
177
|
+
expect(await d.digest({ ...base, tier: "simple", source: "compaction" })).toMatchObject({ digested: false, reason: expect.stringContaining("below digest.fromTier") });
|
|
178
|
+
const r = await d.digest({ ...base, tier: "hard", source: "compaction" });
|
|
179
|
+
expect(r.digested).toBe(true);
|
|
180
|
+
expect(calls).toHaveLength(1);
|
|
181
|
+
expect(ledger.recentEntries(5).find((e) => e.requestedModel === "digest")?.reasons[0]).toContain("digest (compaction)");
|
|
182
|
+
cfg.compaction.digestToolResults = false;
|
|
183
|
+
expect(await d.digest({ ...base, tier: "hard", source: "compaction" })).toMatchObject({ digested: false });
|
|
184
|
+
db.close();
|
|
185
|
+
});
|
|
186
|
+
|
|
165
187
|
test("a pinned digest model is used as-is", async () => {
|
|
166
188
|
const pinned = MODELS.find((m) => m.price.prompt > 0)!.slug;
|
|
167
189
|
const cfg = cfgWith({ model: pinned });
|
package/test/failover.test.ts
CHANGED
|
@@ -46,7 +46,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
46
46
|
data: { axis: "intelligence", minQuality: 0 },
|
|
47
47
|
chat: { axis: "intelligence", minQuality: 0 },
|
|
48
48
|
},
|
|
49
|
-
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
|
|
49
|
+
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
|
|
50
50
|
classifier: {
|
|
51
51
|
ambiguityThreshold: 0,
|
|
52
52
|
model: "test/adjudicator", learnedModelPath: "",
|
|
@@ -74,9 +74,9 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
74
74
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
75
75
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
76
76
|
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, maxQueue: 64 },
|
|
77
|
-
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 },
|
|
77
|
+
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 },
|
|
78
78
|
budget: { onExceeded: "downgrade" },
|
|
79
|
-
report: { baselines: [] },
|
|
79
|
+
report: { baselines: [], dailySummary: false },
|
|
80
80
|
digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
|
|
81
81
|
profiles: [],
|
|
82
82
|
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
|
package/test/learned.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import { auc, FEATURE_NAMES, learnedVector, predictRisk, trainLogistic, type LearnedModel } from "../src/router/learned.ts";
|
|
3
|
+
import { auc, FEATURE_NAMES, LEARNED_MODEL_VERSION, learnedRiskName, learnedVector, loadLearnedModel, predictRisk, resetLearnedModels, trainLogistic, type LearnedModel } from "../src/router/learned.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The learned escalation-risk model: a deterministic logistic regression that
|
|
@@ -59,3 +59,23 @@ describe("trainLogistic", () => {
|
|
|
59
59
|
expect(() => trainLogistic([], [])).toThrow();
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
|
+
|
|
63
|
+
describe("learned label", () => {
|
|
64
|
+
test("a feedback-labelled model loads with its label and names its risk p(bad)", async () => {
|
|
65
|
+
const d = FEATURE_NAMES.length;
|
|
66
|
+
const base: LearnedModel = { version: LEARNED_MODEL_VERSION, trainedAtMs: 0, rows: 100, positives: 10, names: [...FEATURE_NAMES], means: new Array(d).fill(0), stds: new Array(d).fill(1), weights: new Array(d).fill(0), bias: 0, auc: 0.5 };
|
|
67
|
+
expect(learnedRiskName(base)).toBe("escalate");
|
|
68
|
+
expect(learnedRiskName({ ...base, label: "feedback" })).toBe("bad");
|
|
69
|
+
const path = `${import.meta.dir}/../.tmp-learned-feedback.json`;
|
|
70
|
+
await Bun.write(path, JSON.stringify({ ...base, label: "feedback" }));
|
|
71
|
+
try {
|
|
72
|
+
resetLearnedModels();
|
|
73
|
+
const loaded = await loadLearnedModel(path);
|
|
74
|
+
expect(loaded?.label).toBe("feedback");
|
|
75
|
+
expect(learnedRiskName(loaded!)).toBe("bad");
|
|
76
|
+
} finally {
|
|
77
|
+
resetLearnedModels();
|
|
78
|
+
await Bun.file(path).delete();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import { fetchReport, parseReportArgs, renderStatus, type HealthSnapshot } from "../omp-extension/report-logic.ts";
|
|
3
|
+
import { fetchReport, parseReportArgs, renderSoftFailureSpikes, renderStatus, type HealthSnapshot } from "../omp-extension/report-logic.ts";
|
|
4
4
|
|
|
5
5
|
describe("parseReportArgs", () => {
|
|
6
6
|
test("defaults to 7 days scoped to the harness", () => {
|
|
@@ -71,8 +71,11 @@ describe("renderStatus", () => {
|
|
|
71
71
|
costBias: { configured: 0.1, effective: 0.1, biasUntilUsage: 0.9 },
|
|
72
72
|
},
|
|
73
73
|
catalog: { models: 240, ageMs: 5 * 60_000, keyScoped: true, shrink: { fromModels: 300, toModels: 120, atMs: now } },
|
|
74
|
+
softFailures: { recentMs: 3_600_000, baselineDays: 7, spikes: [{ slug: "z-ai/glm-5.3", recentDispatches: 12, recentFailures: 5, recentRate: 5 / 12, baselineDispatches: 340, baselineRate: 0.08 }] },
|
|
74
75
|
};
|
|
75
76
|
const text = renderStatus("http://127.0.0.1:8788", h, now);
|
|
77
|
+
expect(text).toContain("soft failures SPIKING (1):");
|
|
78
|
+
expect(text).toContain(" z-ai/glm-5.3: 42% of 12 failed in the last 1h (7d baseline 8% of 340)");
|
|
76
79
|
expect(text).toContain("configured (omp)");
|
|
77
80
|
expect(text).toContain("240 models");
|
|
78
81
|
expect(text).toContain("refreshed 5m ago");
|
|
@@ -86,6 +89,13 @@ describe("renderStatus", () => {
|
|
|
86
89
|
expect(text).toContain("recording turns");
|
|
87
90
|
});
|
|
88
91
|
|
|
92
|
+
test("reports a quiet hour and omits the line for routers that predate the check", () => {
|
|
93
|
+
expect(renderStatus("http://h", { status: "ok", softFailures: { spikes: [] } })).toContain("soft failures: no model spiking in the last hour");
|
|
94
|
+
expect(renderStatus("http://h", { status: "ok" })).not.toContain("soft failures");
|
|
95
|
+
expect(renderSoftFailureSpikes(null)).toEqual([]);
|
|
96
|
+
expect(renderSoftFailureSpikes([{ slug: "a/b", recentRate: 0.5, recentDispatches: 6 }], 30 * 60_000, 7)).toEqual(["a/b: 50% of 6 failed in the last 30m (7d baseline 0% of 0)"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
89
99
|
test("degrades cleanly when sections are absent", () => {
|
|
90
100
|
const text = renderStatus("http://h", { status: "ok", apiKeyConfigured: false });
|
|
91
101
|
expect(text).toContain("key MISSING");
|
package/test/select.test.ts
CHANGED
|
@@ -637,7 +637,7 @@ describe("context compaction", () => {
|
|
|
637
637
|
keepHeadBytes: 20,
|
|
638
638
|
keepTailBytes: 20,
|
|
639
639
|
elideSupersededReads: true,
|
|
640
|
-
collapseDuplicateResults: true,
|
|
640
|
+
collapseDuplicateResults: true, digestToolResults: false, digestMaxPerTurn: 2,
|
|
641
641
|
},
|
|
642
642
|
};
|
|
643
643
|
|
|
@@ -954,7 +954,7 @@ describe("compaction.replanGrowthRatio (review 2026-09-05 §7)", () => {
|
|
|
954
954
|
keepHeadBytes: 20,
|
|
955
955
|
keepTailBytes: 20,
|
|
956
956
|
elideSupersededReads: false,
|
|
957
|
-
collapseDuplicateResults: false,
|
|
957
|
+
collapseDuplicateResults: false, digestToolResults: false, digestMaxPerTurn: 2,
|
|
958
958
|
},
|
|
959
959
|
};
|
|
960
960
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
4
|
+
import { createLedger } from "../src/cost/ledger.ts";
|
|
5
|
+
import { buildDailySummary, countTierChanges, createKv, DAILY_SUMMARY_INTERVAL_MS, markSummaryShown, renderDailySummary, summaryDue, summaryHasNews } from "../src/cost/summary.ts";
|
|
6
|
+
import type { LedgerEntry } from "../src/cost/types.ts";
|
|
7
|
+
import { openDb } from "../src/util/sqlite.ts";
|
|
8
|
+
import { fetchSummary } from "../omp-extension/report-logic.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The daily summary: the last 24h against the day before, top models, tier
|
|
12
|
+
* moves, the once-a-day gate in router_kv, and the text the transcript gets.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const HOUR = 3_600_000;
|
|
16
|
+
const NOW = Date.UTC(2026, 8, 7, 9, 0, 0);
|
|
17
|
+
|
|
18
|
+
function entry(over: Partial<LedgerEntry>): LedgerEntry {
|
|
19
|
+
return {
|
|
20
|
+
id: crypto.randomUUID(),
|
|
21
|
+
createdAtMs: NOW - HOUR,
|
|
22
|
+
conversationKey: "k",
|
|
23
|
+
sessionId: "s",
|
|
24
|
+
turn: 1,
|
|
25
|
+
requestedModel: "auto",
|
|
26
|
+
harnessId: "",
|
|
27
|
+
ompSessionId: "",
|
|
28
|
+
slug: "vendor/model",
|
|
29
|
+
servedSlug: "vendor/model",
|
|
30
|
+
tier: "simple",
|
|
31
|
+
classificationSource: "heuristic",
|
|
32
|
+
reasons: [],
|
|
33
|
+
features: null,
|
|
34
|
+
score: null,
|
|
35
|
+
confidence: null,
|
|
36
|
+
task: null,
|
|
37
|
+
classifierReasons: null,
|
|
38
|
+
exploredFrom: null,
|
|
39
|
+
holdArm: null,
|
|
40
|
+
predictedUsd: 0.01,
|
|
41
|
+
reportedUsd: 0.01,
|
|
42
|
+
usage: { promptTokens: 1000, cachedTokens: 500, cacheWriteTokens: 0, completionTokens: 100, reasoningTokens: 0, images: 0 },
|
|
43
|
+
attempt: 0,
|
|
44
|
+
escalationSignal: null,
|
|
45
|
+
latencyMs: 1_100,
|
|
46
|
+
ttftMs: 100,
|
|
47
|
+
finishReason: "stop",
|
|
48
|
+
wasted: false,
|
|
49
|
+
upstreamGenerationId: null,
|
|
50
|
+
error: null,
|
|
51
|
+
promptTokensSaved: null,
|
|
52
|
+
...over,
|
|
53
|
+
} as LedgerEntry;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function seeded() {
|
|
57
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
58
|
+
cfg.ledger.path = ":memory:";
|
|
59
|
+
const db = openDb(":memory:");
|
|
60
|
+
const ledger = createLedger(db, cfg);
|
|
61
|
+
return { db, ledger };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("buildDailySummary", () => {
|
|
65
|
+
test("compares the last 24h with the day before and names the top models and tier moves", () => {
|
|
66
|
+
const { db, ledger } = seeded();
|
|
67
|
+
try {
|
|
68
|
+
// Yesterday: one conversation climbing simple → hard, then dropping to moderate; a second model.
|
|
69
|
+
ledger.record(entry({ createdAtMs: NOW - 5 * HOUR, tier: "simple", turn: 1 }));
|
|
70
|
+
ledger.record(entry({ createdAtMs: NOW - 4 * HOUR, tier: "hard", turn: 2, slug: "vendor/big", servedSlug: "vendor/big", reportedUsd: 0.5 }));
|
|
71
|
+
ledger.record(entry({ createdAtMs: NOW - 3 * HOUR, tier: "moderate", turn: 3, escalationSignal: "empty_completion", wasted: true }));
|
|
72
|
+
ledger.record(entry({ createdAtMs: NOW - 3 * HOUR + 1, tier: "moderate", turn: 3, attempt: 1 }));
|
|
73
|
+
ledger.record(entry({ createdAtMs: NOW - 2 * HOUR, requestedModel: "digest", slug: "vendor/tiny", servedSlug: "vendor/tiny", conversationKey: "d", reportedUsd: 0.001 }));
|
|
74
|
+
// The day before: pricier.
|
|
75
|
+
for (let i = 0; i < 4; i++) ledger.record(entry({ createdAtMs: NOW - 30 * HOUR - i, conversationKey: "old", reportedUsd: 0.4 }));
|
|
76
|
+
const s = buildDailySummary(db, { nowMs: NOW, baselines: [{ slug: "anthropic/claude-opus-5", prompt: 15e-6, completion: 75e-6, cacheRead: 1.5e-6 }] });
|
|
77
|
+
expect(s.current.dispatches).toBe(5);
|
|
78
|
+
expect(s.current.spendUsd).toBeCloseTo(0.531, 6);
|
|
79
|
+
expect(s.current.escalations).toBe(1);
|
|
80
|
+
expect(s.current.digests).toBe(1);
|
|
81
|
+
expect(s.current.modelSwitches).toBe(2);
|
|
82
|
+
expect(s.previous.dispatches).toBe(4);
|
|
83
|
+
expect(s.previous.spendUsd).toBeCloseTo(1.6, 6);
|
|
84
|
+
expect(s.topModels[0]).toMatchObject({ slug: "vendor/big", dispatches: 1 });
|
|
85
|
+
expect(s.topModels[0]!.share).toBeCloseTo(0.5 / 0.531, 3);
|
|
86
|
+
expect(s.tierChanges).toEqual({ up: 1, down: 1 });
|
|
87
|
+
expect(s.baseline?.slug).toBe("anthropic/claude-opus-5");
|
|
88
|
+
expect(summaryHasNews(s)).toBe(true);
|
|
89
|
+
|
|
90
|
+
const text = renderDailySummary({ ...s, spikes: [{ slug: "vendor/big", recentDispatches: 8, recentFailures: 4, recentRate: 0.5, baselineDispatches: 90, baselineFailures: 3, baselineRate: 3 / 90 }], ollama: { plan: "pro", usedUsd: 25.2, creditsUsd: 60, runwayDays: 23.4 } });
|
|
91
|
+
expect(text).toContain("auto-model-router daily summary — last 24h (all harnesses)");
|
|
92
|
+
expect(text).toContain("spend $0.531 (prev 24h $1.60, −67%) · 5 turns · 2 conversations · $0.106/turn");
|
|
93
|
+
expect(text).toContain("cache hit 50% · 1 escalations · 0 errors · 2 model switches (1 tier up, 1 down)");
|
|
94
|
+
expect(text).toContain("top models: vendor/big $0.500 (94%, 1 turns)");
|
|
95
|
+
// Tiny seeded turns cost more than Opus would have at list price: the honest branch renders.
|
|
96
|
+
expect(text).toContain("cost 574% MORE than anthropic/claude-opus-5 ($0.079 at list)");
|
|
97
|
+
expect(renderDailySummary({ ...s, baseline: { slug: "anthropic/claude-opus-5", usd: 2.5, savedShare: 0.7876 } })).toContain("saved 79% vs anthropic/claude-opus-5 ($2.50 at list)");
|
|
98
|
+
expect(text).toContain("1 digests for $0.001");
|
|
99
|
+
expect(text).toContain("soft failures SPIKING (1):\n vendor/big: 50% of 8 failed in the last 1h (7d baseline 3% of 90)");
|
|
100
|
+
expect(text).toContain("ollama: pro plan $25.20 of $60 · ~23 days of credits left");
|
|
101
|
+
} finally {
|
|
102
|
+
db.close();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("an empty day renders as such and carries no news unless a model is spiking", () => {
|
|
107
|
+
const { db } = seeded();
|
|
108
|
+
try {
|
|
109
|
+
const s = buildDailySummary(db, { nowMs: NOW });
|
|
110
|
+
expect(summaryHasNews(s)).toBe(false);
|
|
111
|
+
const text = renderDailySummary(s);
|
|
112
|
+
expect(text).toContain("no routed turns in the last 24h");
|
|
113
|
+
expect(text).toContain("soft failures: no model spiking in the last hour");
|
|
114
|
+
expect(text).not.toContain("ollama:");
|
|
115
|
+
expect(summaryHasNews({ ...s, spikes: [{ slug: "a/b", recentDispatches: 5, recentFailures: 3, recentRate: 0.6, baselineDispatches: 0, baselineFailures: 0, baselineRate: 0 }] })).toBe(true);
|
|
116
|
+
expect(countTierChanges(db, 0, "")).toEqual({ up: 0, down: 0 });
|
|
117
|
+
} finally {
|
|
118
|
+
db.close();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("harness scope narrows both windows", () => {
|
|
123
|
+
const { db, ledger } = seeded();
|
|
124
|
+
try {
|
|
125
|
+
ledger.record(entry({ harnessId: "a" }));
|
|
126
|
+
ledger.record(entry({ harnessId: "b", reportedUsd: 5 }));
|
|
127
|
+
expect(buildDailySummary(db, { nowMs: NOW, harnessId: "a" }).current).toMatchObject({ dispatches: 1, spendUsd: 0.01 });
|
|
128
|
+
expect(buildDailySummary(db, { nowMs: NOW }).current.dispatches).toBe(2);
|
|
129
|
+
} finally {
|
|
130
|
+
db.close();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("once-a-day gate", () => {
|
|
136
|
+
test("router_kv marks the summary shown per harness for 20h, surviving a reopen", () => {
|
|
137
|
+
const db = openDb(":memory:");
|
|
138
|
+
try {
|
|
139
|
+
const kv = createKv(db);
|
|
140
|
+
expect(summaryDue(kv, "", NOW)).toBe(true);
|
|
141
|
+
markSummaryShown(kv, "", NOW);
|
|
142
|
+
expect(summaryDue(kv, "", NOW + HOUR)).toBe(false);
|
|
143
|
+
expect(summaryDue(kv, "other", NOW + HOUR)).toBe(true);
|
|
144
|
+
expect(summaryDue(kv, "", NOW + DAILY_SUMMARY_INTERVAL_MS)).toBe(true);
|
|
145
|
+
// Same table, fresh handle: the marker is durable.
|
|
146
|
+
expect(summaryDue(createKv(db), "", NOW + HOUR)).toBe(false);
|
|
147
|
+
kv.set("k", "v1");
|
|
148
|
+
kv.set("k", "v2");
|
|
149
|
+
expect(kv.get("k")).toBe("v2");
|
|
150
|
+
expect(kv.get("missing")).toBeNull();
|
|
151
|
+
} finally {
|
|
152
|
+
db.close();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("fetchSummary", () => {
|
|
158
|
+
test("passes harness and auto through and surfaces the router's verdict", async () => {
|
|
159
|
+
const calls: string[] = [];
|
|
160
|
+
const fetchImpl = async (url: string): Promise<Response> => {
|
|
161
|
+
calls.push(url);
|
|
162
|
+
return new Response(JSON.stringify({ due: false, reason: "posted in the last 20h", summary: null }), { status: 200 });
|
|
163
|
+
};
|
|
164
|
+
const r = await fetchSummary("http://h", "omp", true, {}, fetchImpl);
|
|
165
|
+
expect(r).toEqual({ due: false, reason: "posted in the last 20h", summary: null });
|
|
166
|
+
expect(calls).toEqual(["http://h/v1/router/summary?harness=omp&auto=1"]);
|
|
167
|
+
await fetchSummary("http://h", "", false, {}, fetchImpl);
|
|
168
|
+
expect(calls[1]).toBe("http://h/v1/router/summary");
|
|
169
|
+
await expect(fetchSummary("http://h", "", false, {}, async () => new Response("no", { status: 503 }))).rejects.toThrow("router returned 503");
|
|
170
|
+
});
|
|
171
|
+
});
|
package/test/tokens.test.ts
CHANGED
|
@@ -237,3 +237,47 @@ describe("ledger.escalationCost", () => {
|
|
|
237
237
|
});
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
+
|
|
241
|
+
describe("ledger.softFailureSpikes", () => {
|
|
242
|
+
test("flags a model whose last-hour failure rate is a spike against its own 7-day baseline", () => {
|
|
243
|
+
const db = openDb(":memory:");
|
|
244
|
+
try {
|
|
245
|
+
const ledger = createLedger(db, cfg);
|
|
246
|
+
const now = 1_800_000_000_000;
|
|
247
|
+
const H = 3_600_000;
|
|
248
|
+
// Baseline: 100 dispatches over the prior week at 5% soft failures.
|
|
249
|
+
for (let i = 0; i < 100; i++) {
|
|
250
|
+
ledger.record(entry({ createdAtMs: now - 2 * H - i * 60 * 60_000, escalationSignal: i % 20 === 0 ? "empty_completion" : null, wasted: i % 20 === 0 }));
|
|
251
|
+
}
|
|
252
|
+
// Last hour: 10 dispatches, 4 soft failures (40%): a spike.
|
|
253
|
+
for (let i = 0; i < 10; i++) {
|
|
254
|
+
ledger.record(entry({ createdAtMs: now - 5 * 60_000 - i * 60_000, escalationSignal: i < 4 ? "repeat_tool_call" : null, wasted: i < 4 }));
|
|
255
|
+
}
|
|
256
|
+
// A second model with plenty of failures but a matching baseline is not spiking.
|
|
257
|
+
for (let i = 0; i < 100; i++) {
|
|
258
|
+
ledger.record(entry({ slug: "x/steady", servedSlug: "x/steady", createdAtMs: now - 2 * H - i * 60 * 60_000, error: i % 2 === 0 ? "upstream_error: 502" : null }));
|
|
259
|
+
}
|
|
260
|
+
for (let i = 0; i < 10; i++) {
|
|
261
|
+
ledger.record(entry({ slug: "x/steady", servedSlug: "x/steady", createdAtMs: now - 5 * 60_000 - i * 60_000, error: i % 2 === 0 ? "upstream_error: 502" : null }));
|
|
262
|
+
}
|
|
263
|
+
// Aborted and quota errors are not attributable; digest rows are side calls.
|
|
264
|
+
for (let i = 0; i < 10; i++) {
|
|
265
|
+
ledger.record(entry({ slug: "x/aborted", servedSlug: "x/aborted", createdAtMs: now - 5 * 60_000 - i * 60_000, error: "aborted: client closed" }));
|
|
266
|
+
ledger.record(entry({ slug: "x/digest", servedSlug: "x/digest", requestedModel: "digest", createdAtMs: now - 5 * 60_000 - i * 60_000, error: "upstream_error: 500" }));
|
|
267
|
+
}
|
|
268
|
+
const spikes = ledger.softFailureSpikes?.(now) ?? [];
|
|
269
|
+
expect(spikes.map((s) => s.slug)).toEqual(["openai/gpt-5-mini"]);
|
|
270
|
+
const s = spikes[0]!;
|
|
271
|
+
expect(s.recentDispatches).toBe(10);
|
|
272
|
+
expect(s.recentFailures).toBe(4);
|
|
273
|
+
expect(s.recentRate).toBeCloseTo(0.4, 6);
|
|
274
|
+
expect(s.baselineDispatches).toBe(100);
|
|
275
|
+
expect(s.baselineFailures).toBe(5);
|
|
276
|
+
expect(s.baselineRate).toBeCloseTo(0.05, 6);
|
|
277
|
+
// Too few recent dispatches: nothing spikes, however high the rate.
|
|
278
|
+
expect(ledger.softFailureSpikes?.(now, 3 * 60_000)).toEqual([]);
|
|
279
|
+
} finally {
|
|
280
|
+
db.close();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -463,3 +463,40 @@ describe("feedback in trust", () => {
|
|
|
463
463
|
db.close();
|
|
464
464
|
});
|
|
465
465
|
});
|
|
466
|
+
|
|
467
|
+
describe("task-scoped feedback (filters.feedbackByTask)", () => {
|
|
468
|
+
test("a verdict counts only for its task type; an untasked verdict counts everywhere", () => {
|
|
469
|
+
const db = openDb(":memory:");
|
|
470
|
+
try {
|
|
471
|
+
const c = structuredClone(cfg);
|
|
472
|
+
c.filters.feedbackWeight = 3;
|
|
473
|
+
c.filters.feedbackByTask = true;
|
|
474
|
+
const ledger = createLedger(db, c);
|
|
475
|
+
const fb = createFeedbackStore(db);
|
|
476
|
+
for (let i = 0; i < 10; i++) ledger.record(entry({ error: null, task: "coding" }));
|
|
477
|
+
const prose = entry({ error: null, task: "documentation" });
|
|
478
|
+
ledger.record(prose);
|
|
479
|
+
const untasked = entry({ error: null, task: null });
|
|
480
|
+
ledger.record(untasked);
|
|
481
|
+
fb.record({ ledgerId: prose.id, ompSessionId: "s", slug: "vendor/model", tier: "simple", verdict: "bad", note: "" });
|
|
482
|
+
// 12 clean attempts, weight 3, one bad verdict on a documentation turn.
|
|
483
|
+
const pooled = (12 - 0 + 1) / (12 + 2);
|
|
484
|
+
const withBad = (15 - 3 + 1) / (15 + 2);
|
|
485
|
+
expect(ledger.trust("vendor/model", undefined, "coding")!.successRate).toBeCloseTo(pooled, 9);
|
|
486
|
+
expect(ledger.trust("vendor/model", undefined, "documentation")!.successRate).toBeCloseTo(withBad, 9);
|
|
487
|
+
// No task given (allTrust, reports): pooled behaviour, the verdict counts.
|
|
488
|
+
expect(ledger.trust("vendor/model")!.successRate).toBeCloseTo(withBad, 9);
|
|
489
|
+
expect(ledger.allTrust()[0]!.successRate).toBeCloseTo(withBad, 9);
|
|
490
|
+
// signals() honours the task the same way.
|
|
491
|
+
expect(ledger.signals?.(["vendor/model"], undefined, "coding").get("vendor/model")!.trust!.successRate).toBeCloseTo(pooled, 9);
|
|
492
|
+
// A verdict on a turn that recorded no task counts for every task.
|
|
493
|
+
fb.record({ ledgerId: untasked.id, ompSessionId: "s", slug: "vendor/model", tier: "simple", verdict: "bad", note: "" });
|
|
494
|
+
expect(ledger.trust("vendor/model", undefined, "coding")!.successRate).toBeCloseTo(withBad, 9);
|
|
495
|
+
// Off: task is ignored and every verdict pools.
|
|
496
|
+
c.filters.feedbackByTask = false;
|
|
497
|
+
expect(ledger.trust("vendor/model", undefined, "coding")!.successRate).toBeCloseTo((18 - 6 + 1) / (18 + 2), 9);
|
|
498
|
+
} finally {
|
|
499
|
+
db.close();
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
});
|
package/test/turn.test.ts
CHANGED
|
@@ -46,7 +46,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
46
46
|
data: { axis: "intelligence", minQuality: 0 },
|
|
47
47
|
chat: { axis: "intelligence", minQuality: 0 },
|
|
48
48
|
},
|
|
49
|
-
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
|
|
49
|
+
filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, feedbackWeight: 0, feedbackByTask: false, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, cacheReliabilityMinSamples: 10, latencyMinSamples: 20, escalationCostWeight: 0 },
|
|
50
50
|
classifier: {
|
|
51
51
|
ambiguityThreshold: 0,
|
|
52
52
|
model: "test/adjudicator", learnedModelPath: "",
|
|
@@ -74,9 +74,9 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
74
74
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
75
75
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
76
76
|
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, maxQueue: 64 },
|
|
77
|
-
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 },
|
|
77
|
+
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 },
|
|
78
78
|
budget: { onExceeded: "downgrade" },
|
|
79
|
-
report: { baselines: [] },
|
|
79
|
+
report: { baselines: [], dailySummary: false },
|
|
80
80
|
digest: { enabled: false, minBytes: 12_000, maxBytes: 400_000, tools: ["read"], fromTier: "moderate", tier: "simple", model: "", maxOutputTokens: 700, maxCostUsd: 0.02, timeoutMs: 25_000 },
|
|
81
81
|
profiles: [],
|
|
82
82
|
ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
|
|
@@ -930,3 +930,68 @@ describe("latency measurement covers the work the router actually does", () => {
|
|
|
930
930
|
});
|
|
931
931
|
|
|
932
932
|
});
|
|
933
|
+
|
|
934
|
+
describe("summarising compaction in a turn", () => {
|
|
935
|
+
function reqWithTool(): NormRequest {
|
|
936
|
+
const content = "line\n".repeat(400);
|
|
937
|
+
const req = mkReq();
|
|
938
|
+
req.messages = [
|
|
939
|
+
{ role: "user", text: "fix it", images: 0, textBytes: 6, toolCalls: [] },
|
|
940
|
+
{ role: "assistant", text: "", images: 0, textBytes: 0, toolCalls: [{ id: "c1", name: "read", argsJson: '{"path":"a.ts"}' }] },
|
|
941
|
+
{ role: "tool", text: content, images: 0, textBytes: Buffer.byteLength(content), toolCalls: [], toolCallId: "c1" },
|
|
942
|
+
{ role: "user", text: "go on", images: 0, textBytes: 5, toolCalls: [] },
|
|
943
|
+
];
|
|
944
|
+
req.promptBytes = req.messages.reduce((s, m) => s + m.textBytes, 0);
|
|
945
|
+
return req;
|
|
946
|
+
}
|
|
947
|
+
function decisionWithPlan(): Decision {
|
|
948
|
+
const d = mkDecision("hard", "dear/model", { escalateTo: null });
|
|
949
|
+
d.compactionPlan = [{ index: 2, mode: "truncate", keepHead: 100, keepTail: 100, note: "large read result", bytes: 2_000 }];
|
|
950
|
+
d.compactionSavedBytes = 1_700;
|
|
951
|
+
return d;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
test("new edits are digested with the turn's tier, persisted with the plan, and not paid twice on a retry", async () => {
|
|
955
|
+
const seen: { toolName: string; tier?: string; source?: string; content: string; input: Record<string, unknown> }[] = [];
|
|
956
|
+
const digester = {
|
|
957
|
+
digest: async (r: { toolName: string; tier?: string; source?: string; content: string; input: Record<string, unknown> }) => {
|
|
958
|
+
seen.push(r);
|
|
959
|
+
return { digested: true as const, text: "[digest] the file", model: "cheap/model", usd: 0.0001, inputBytes: r.content.length, outputChars: 17, ms: 5 };
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
// First attempt is probe-rejected and escalates; the second dispatches. Both decisions carry the same new edit.
|
|
963
|
+
const { router } = mkRouter([decisionWithPlan(), decisionWithPlan()]);
|
|
964
|
+
const { upstream } = mkUpstream([
|
|
965
|
+
{ kind: "chunks", chunks: [startChunk("dear/model"), textChunk("I'm sorry, but I can't help with that request."), finishChunk("stop")] },
|
|
966
|
+
{ kind: "chunks", chunks: [startChunk("dear/model"), textChunk("done"), finishChunk("stop"), usageChunk({ promptTokens: 50, completionTokens: 2 }, 0.001)] },
|
|
967
|
+
]);
|
|
968
|
+
const { ledger } = mkLedger();
|
|
969
|
+
const { store, map } = mkConversations();
|
|
970
|
+
const { sink, errors } = mkSink();
|
|
971
|
+
const cfg = mkConfig();
|
|
972
|
+
cfg.compaction.digestToolResults = true;
|
|
973
|
+
cfg.escalation.maxAttempts = 2;
|
|
974
|
+
|
|
975
|
+
await runTurn(reqWithTool(), sink, { config: cfg, router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge(), digester }, new AbortController().signal);
|
|
976
|
+
|
|
977
|
+
expect(errors).toHaveLength(0);
|
|
978
|
+
expect(seen).toHaveLength(1);
|
|
979
|
+
expect(seen[0]).toMatchObject({ toolName: "read", tier: "hard", source: "compaction", input: { path: "a.ts" } });
|
|
980
|
+
expect(seen[0]!.content.startsWith("line\n")).toBe(true);
|
|
981
|
+
const plan = map.get("conv-test")!.compactionPlan!;
|
|
982
|
+
expect(plan[0]?.digest).toBe("[digest] the file");
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
test("without compaction.digestToolResults the digester is never consulted", async () => {
|
|
986
|
+
let calls = 0;
|
|
987
|
+
const digester = { digest: async () => { calls++; return { digested: false as const, reason: "n/a" }; } };
|
|
988
|
+
const { router } = mkRouter([decisionWithPlan()]);
|
|
989
|
+
const { upstream } = mkUpstream([{ kind: "chunks", chunks: [startChunk("dear/model"), textChunk("done"), finishChunk("stop")] }]);
|
|
990
|
+
const { ledger } = mkLedger();
|
|
991
|
+
const { store, map } = mkConversations();
|
|
992
|
+
const { sink } = mkSink();
|
|
993
|
+
await runTurn(reqWithTool(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge(), digester }, new AbortController().signal);
|
|
994
|
+
expect(calls).toBe(0);
|
|
995
|
+
expect(map.get("conv-test")!.compactionPlan![0]?.digest).toBeUndefined();
|
|
996
|
+
});
|
|
997
|
+
});
|