@ludi-uni/ludi-agent-kit 0.1.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.
Files changed (172) hide show
  1. package/AGENTS.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/adapters/codex/README.md +24 -0
  5. package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
  6. package/adapters/pi/README.md +88 -0
  7. package/adapters/pi/browser/agent-browser.mjs +193 -0
  8. package/adapters/pi/lib/invoke.mjs +55 -0
  9. package/adapters/pi/lib/list-models.mjs +29 -0
  10. package/adapters/pi/lib/settings-proposal.mjs +34 -0
  11. package/adapters/pi/lib/subagent.mjs +175 -0
  12. package/adapters/pi/loop-guard/index.js +51 -0
  13. package/adapters/pi/maintenance-policy.json +36 -0
  14. package/adapters/pi/mcp.template.json +4 -0
  15. package/adapters/pi/model-catalog.json +97 -0
  16. package/adapters/pi/models.json +13 -0
  17. package/adapters/pi/models.local.example.json +14 -0
  18. package/adapters/pi/orchestrator-ext/command.mjs +14 -0
  19. package/adapters/pi/orchestrator-ext/index.js +150 -0
  20. package/adapters/pi/settings.template.json +7 -0
  21. package/adapters/pi/shell-gate/index.js +70 -0
  22. package/adapters/pi/sync-pi.ps1 +137 -0
  23. package/agents/README.md +26 -0
  24. package/agents/browser.md +64 -0
  25. package/agents/coder.md +31 -0
  26. package/agents/orchestrator.md +37 -0
  27. package/agents/reviewer.md +32 -0
  28. package/agents/scout.md +35 -0
  29. package/agents/tester.md +28 -0
  30. package/agents/visual.md +28 -0
  31. package/context-pack/SPEC.md +101 -0
  32. package/context-pack/context-pack.schema.json +79 -0
  33. package/context-pack/examples/example-fix.md +44 -0
  34. package/docs/architecture.md +55 -0
  35. package/docs/migration-from-codex-setting.md +44 -0
  36. package/docs/model-maintenance.md +401 -0
  37. package/docs/orchestrator.md +155 -0
  38. package/docs/phase2-report.md +39 -0
  39. package/docs/roadmap.md +27 -0
  40. package/docs/third-party.md +15 -0
  41. package/lib/agents.mjs +79 -0
  42. package/lib/context-pack.mjs +215 -0
  43. package/lib/job.mjs +312 -0
  44. package/lib/language-policy.mjs +27 -0
  45. package/lib/maintenance-exec.mjs +377 -0
  46. package/lib/maintenance-runner.mjs +266 -0
  47. package/lib/maintenance.mjs +422 -0
  48. package/lib/normalize.mjs +101 -0
  49. package/lib/observe/differ.mjs +185 -0
  50. package/lib/observe/observation.mjs +147 -0
  51. package/lib/observe/observers.mjs +134 -0
  52. package/lib/observe/sources.mjs +154 -0
  53. package/lib/orchestrator/activity.mjs +249 -0
  54. package/lib/orchestrator/api.mjs +151 -0
  55. package/lib/orchestrator/contract.mjs +68 -0
  56. package/lib/orchestrator/escalation.mjs +84 -0
  57. package/lib/orchestrator/evaluator.mjs +92 -0
  58. package/lib/orchestrator/failures.mjs +88 -0
  59. package/lib/orchestrator/health.mjs +53 -0
  60. package/lib/orchestrator/orchestrator.mjs +483 -0
  61. package/lib/orchestrator/permissions.mjs +64 -0
  62. package/lib/orchestrator/planner.mjs +194 -0
  63. package/lib/orchestrator/policy.mjs +134 -0
  64. package/lib/orchestrator/router.mjs +45 -0
  65. package/lib/orchestrator/runner.mjs +278 -0
  66. package/lib/orchestrator/shell-policy.mjs +52 -0
  67. package/lib/orchestrator/store.mjs +581 -0
  68. package/lib/orchestrator/task-store.mjs +79 -0
  69. package/lib/orchestrator/turn-budget.mjs +63 -0
  70. package/lib/orchestrator/worktree.mjs +72 -0
  71. package/lib/pipeline.mjs +279 -0
  72. package/lib/registry.mjs +63 -0
  73. package/lib/resolve.mjs +35 -0
  74. package/lib/routing.mjs +137 -0
  75. package/lib/telemetry.mjs +222 -0
  76. package/mcp/README.md +11 -0
  77. package/mcp/servers.json +13 -0
  78. package/orchestration/decision-policy.json +66 -0
  79. package/package.json +56 -0
  80. package/routing/README.md +24 -0
  81. package/routing/routing.json +81 -0
  82. package/routing/routing.schema.json +66 -0
  83. package/rules/README.md +10 -0
  84. package/rules/common.md +52 -0
  85. package/rules/loop-prevention.md +15 -0
  86. package/rules/repo-local.md +6 -0
  87. package/scripts/check-environment.ps1 +22 -0
  88. package/scripts/context-pack.mjs +17 -0
  89. package/scripts/e2e-investigate-repro.mjs +66 -0
  90. package/scripts/model-maintenance-job.mjs +59 -0
  91. package/scripts/observe-models.mjs +97 -0
  92. package/scripts/orchestrate.mjs +137 -0
  93. package/scripts/reevaluate-models.mjs +95 -0
  94. package/scripts/report-model-maintenance.mjs +70 -0
  95. package/scripts/resolve-capabilities.mjs +39 -0
  96. package/scripts/run-pipeline.mjs +56 -0
  97. package/scripts/sync-agents-md.ps1 +10 -0
  98. package/scripts/validate.mjs +71 -0
  99. package/skills/README.md +14 -0
  100. package/skills/pi-workflow/SKILL.md +26 -0
  101. package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
  102. package/skills/pi-workflow/references/research.md +14 -0
  103. package/skills/pi-workflow/references/review.md +11 -0
  104. package/skills/pi-workflow/references/visual-work.md +14 -0
  105. package/skills/project-management/SKILL.md +106 -0
  106. package/skills/project-management/references/operations.md +52 -0
  107. package/skills/visual-verification/SKILL.md +88 -0
  108. package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
  109. package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
  110. package/skills/visual-verification/scripts/common.ps1 +387 -0
  111. package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
  112. package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
  113. package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
  114. package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
  115. package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
  116. package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
  117. package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
  118. package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
  119. package/skills/visual-verification/scripts/record-av.ps1 +102 -0
  120. package/skills/visual-verification/scripts/record.ps1 +72 -0
  121. package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
  122. package/skills/visual-verification/scripts/waveform.ps1 +450 -0
  123. package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
  124. package/tests/activity.test.mjs +252 -0
  125. package/tests/attempt-budget.test.mjs +102 -0
  126. package/tests/browser.test.mjs +121 -0
  127. package/tests/context-pack.test.mjs +98 -0
  128. package/tests/dirty-gate.test.mjs +211 -0
  129. package/tests/e2e-browser.mjs +66 -0
  130. package/tests/e2e-real-orchestrator-resume.mjs +101 -0
  131. package/tests/e2e-real-orchestrator.mjs +41 -0
  132. package/tests/e2e-real-pi.mjs +27 -0
  133. package/tests/e2e-real-tool-orchestrator.mjs +66 -0
  134. package/tests/fixtures/browser-page/index.html +20 -0
  135. package/tests/fixtures/maintenance/availability.txt +5 -0
  136. package/tests/fixtures/maintenance/catalog.json +74 -0
  137. package/tests/fixtures/maintenance/events.json +13 -0
  138. package/tests/fixtures/math-repo/README.md +3 -0
  139. package/tests/fixtures/math-repo/package.json +7 -0
  140. package/tests/fixtures/math-repo/src/math.js +11 -0
  141. package/tests/fixtures/math-repo/test/math.test.js +7 -0
  142. package/tests/fixtures/observe/announcements.json +8 -0
  143. package/tests/fixtures/orch-concurrent-child.mjs +44 -0
  144. package/tests/fixtures/orch-persist-child.mjs +61 -0
  145. package/tests/job.test.mjs +230 -0
  146. package/tests/kit.test.mjs +79 -0
  147. package/tests/language-policy.test.mjs +93 -0
  148. package/tests/loop-guard.test.mjs +60 -0
  149. package/tests/maintenance-exec.test.mjs +218 -0
  150. package/tests/maintenance-runner.test.mjs +222 -0
  151. package/tests/maintenance.test.mjs +195 -0
  152. package/tests/observe.test.mjs +283 -0
  153. package/tests/observer-registry.test.mjs +157 -0
  154. package/tests/orchestrator-cleanup.test.mjs +358 -0
  155. package/tests/orchestrator-command.test.mjs +14 -0
  156. package/tests/orchestrator-persist.test.mjs +375 -0
  157. package/tests/orchestrator-tools.test.mjs +215 -0
  158. package/tests/orchestrator.test.mjs +396 -0
  159. package/tests/package.test.mjs +37 -0
  160. package/tests/pipeline.test.mjs +239 -0
  161. package/tests/planner-classification.test.mjs +81 -0
  162. package/tests/planner-split.test.mjs +67 -0
  163. package/tests/qoder-observer.test.mjs +266 -0
  164. package/tests/reassign-progression.test.mjs +104 -0
  165. package/tests/retry-escalation.test.mjs +120 -0
  166. package/tests/routing.test.mjs +110 -0
  167. package/tests/sqlite-concurrency.test.mjs +178 -0
  168. package/tests/task-global-e2e.test.mjs +63 -0
  169. package/tests/task-global-failed.test.mjs +134 -0
  170. package/tests/telemetry.test.mjs +173 -0
  171. package/tests/test-sync-pi.ps1 +56 -0
  172. package/tests/turn-budget.test.mjs +106 -0
@@ -0,0 +1,266 @@
1
+ // Qoder provider-metadata observer (qoder-models-cache.json priceFactor).
2
+ // Cases A–J. The cache is read-only; tests use temp fixture files. Nothing real is
3
+ // written — catalog/routing/settings untouched, only out/ artifacts + temp state.
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { resolve, dirname, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { fromQoderCache, fromFixture } from '../lib/observe/sources.mjs';
11
+ import { validateObservation, normalizeObservation, ingestObservations, loadObservationStore, productionObservations } from '../lib/observe/observation.mjs';
12
+ import { diffCatalog, buildCatalogProposal, applyProposalToCatalog, proposalToEvents } from '../lib/observe/differ.mjs';
13
+ import { runMaintenanceJob } from '../lib/job.mjs';
14
+ import { runMaintenancePlan, selectTierModel, DEFAULT_POLICY } from '../lib/maintenance-exec.mjs';
15
+ import { evaluateMaintenance, effectiveCatalog } from '../lib/maintenance.mjs';
16
+ import { loadRouting } from '../lib/routing.mjs';
17
+ import { loadAgents } from '../lib/agents.mjs';
18
+ import { mergeRegistries } from '../lib/registry.mjs';
19
+
20
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
21
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
22
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
23
+ const tmp = () => mkdtempSync(join(tmpdir(), 'qoder-'));
24
+
25
+ const cacheDoc = (priceFactor, extra = {}) => ({
26
+ version: 2, updatedAt: 1790224130520,
27
+ models: [
28
+ { id: 'Qwen3.8-Flash', name: 'Qwen3.8-Flash (0x)', priceFactor, api: 'qoder-api', provider: 'qoder', baseUrl: 'https://api3.qoder.sh/', contextWindow: 1000000, ...extra },
29
+ { id: 'Qwen3.8-Max', priceFactor: 0.5, api: 'qoder-api', provider: 'qoder' },
30
+ ],
31
+ });
32
+ const MODELS = [{ provider: 'qoder', model: 'Qwen3.8-Flash' }];
33
+ const CAT = {
34
+ version: 1, updatedAt: '2026-03-01',
35
+ models: [
36
+ { provider: 'qoder', model: 'Qwen3.8-Flash', status: 'free-campaign', cost: { free: true }, postCampaignCost: null, contextK: 1024, vision: false, toolUse: 'good', location: 'cloud', scores: { coding: 62, reasoning: 55, speed: 85 } },
37
+ { provider: 'cloudp', model: 'cheap-ok', status: 'active', cost: { usdPerMInput: 0.2, usdPerMOutput: 0.8 }, contextK: 256, vision: false, toolUse: 'good', location: 'cloud', scores: { coding: 68, reasoning: 62, speed: 75 } },
38
+ { provider: 'cloudp', model: 'pro-strong', status: 'active', cost: { usdPerMInput: 5, usdPerMOutput: 20 }, contextK: 400, vision: false, toolUse: 'good', location: 'cloud', scores: { coding: 90, reasoning: 88, speed: 60 } },
39
+ { provider: 'freetoken', model: 'Local-1', status: 'active', cost: { free: true }, location: 'local', local: { powerWatts: 450, taskMinutes: 4 }, contextK: 64, vision: false, toolUse: 'basic', scores: { coding: 40, reasoning: 35, speed: 70 } },
40
+ ],
41
+ };
42
+ const REG = mergeRegistries(null, { version: 1, backends: {
43
+ qoder: { provider: 'qoder', model: 'Qwen3.8-Flash' },
44
+ cheap: { provider: 'cloudp', model: 'cheap-ok' },
45
+ sol: { provider: 'cloudp', model: 'pro-strong' },
46
+ local: { provider: 'freetoken', model: 'Local-1' },
47
+ } });
48
+
49
+ const writeCache = (dir, doc) => { const p = join(dir, 'qoder-models-cache.json'); writeFileSync(p, JSON.stringify(doc)); return p; };
50
+
51
+ // --- A: priceFactor 0 -> free=true -------------------------------------------
52
+ test('A: priceFactor 0 -> free=true observation', () => {
53
+ const r = fromQoderCache(cacheDoc(0), { models: MODELS });
54
+ assert.equal(r.state, 'ok');
55
+ assert.equal(r.observations.length, 1);
56
+ assert.equal(r.observations[0].changes.free, true);
57
+ assert.equal(r.snapshot['Qwen3.8-Flash'], 0);
58
+ });
59
+
60
+ // --- B: priceFactor > 0 -> free=false ----------------------------------------
61
+ test('B: priceFactor 1 -> free=false observation', () => {
62
+ const r = fromQoderCache(cacheDoc(1), { models: MODELS });
63
+ assert.equal(r.observations[0].changes.free, false);
64
+ assert.equal(r.snapshot['Qwen3.8-Flash'], 1);
65
+ });
66
+
67
+ // --- C: 0 -> 1 = free-campaign-ended ------------------------------------------
68
+ test('C: transition 0 -> 1 emits free=false (campaign ended)', () => {
69
+ const r = fromQoderCache(cacheDoc(1), { models: MODELS, previous: { 'Qwen3.8-Flash': 0 } });
70
+ assert.equal(r.observations.length, 1);
71
+ assert.equal(r.observations[0].changes.free, false);
72
+ assert.match(r.observations[0].evidence[0], /was 0/);
73
+ });
74
+
75
+ // --- D: 1 -> 0 = free-campaign-started ----------------------------------------
76
+ test('D: transition 1 -> 0 emits free=true (campaign started)', () => {
77
+ const r = fromQoderCache(cacheDoc(0), { models: MODELS, previous: { 'Qwen3.8-Flash': 1 } });
78
+ assert.equal(r.observations.length, 1);
79
+ assert.equal(r.observations[0].changes.free, true);
80
+ });
81
+
82
+ // --- E: 0 -> 0 = no event ------------------------------------------------------
83
+ test('E: same priceFactor -> no observation', () => {
84
+ const r = fromQoderCache(cacheDoc(0), { models: MODELS, previous: { 'Qwen3.8-Flash': 0 } });
85
+ assert.equal(r.observations.length, 0);
86
+ const r2 = fromQoderCache(cacheDoc(1), { models: MODELS, previous: { 'Qwen3.8-Flash': 1 } });
87
+ assert.equal(r2.observations.length, 0);
88
+ });
89
+
90
+ // --- F: cache missing -> unknown, catalog unchanged ----------------------------
91
+ test('F: missing cache file -> state=missing, no observation, catalog unchanged', () => {
92
+ const dir = tmp();
93
+ const r = fromQoderCache(join(dir, 'nope.json'), { models: MODELS });
94
+ assert.equal(r.state, 'missing');
95
+ assert.equal(r.observations.length, 0);
96
+ assert.equal(CAT.models[0].cost.free, true); // untouched
97
+ rmSync(dir, { recursive: true, force: true });
98
+ });
99
+
100
+ // --- G: malformed JSON / schema -> unknown, job continues ----------------------
101
+ test('G: malformed JSON and unknown schema -> unknown, no crash', async () => {
102
+ const dir = tmp();
103
+ const bad = join(dir, 'bad.json'); writeFileSync(bad, '{ not json');
104
+ assert.equal(fromQoderCache(bad, { models: MODELS }).state, 'malformed');
105
+ assert.equal(fromQoderCache({ version: 2, models: 'nope' }, { models: MODELS }).state, 'no-schema');
106
+ assert.equal(fromQoderCache(cacheDoc(0), { models: [{ provider: 'qoder', model: 'Ghost-9' }] }).state, 'entry-missing');
107
+ assert.equal(fromQoderCache(cacheDoc(undefined, { priceFactor: undefined }), { models: MODELS }).state, 'priceFactor-missing');
108
+ // job continues on probe failure
109
+ const run = await runMaintenanceJob({ outDir: dir, adapterDir: join(kit, 'adapters/pi'), kit, catalog: CAT, routing, registry: REG, agents, policy: DEFAULT_POLICY, checkQoder: true, qoderListing: false });
110
+ assert.equal(run.status, 'ok');
111
+ assert.equal(run.ingest.probeFailed, true);
112
+ assert.equal(run.quiet, true);
113
+ rmSync(dir, { recursive: true, force: true });
114
+ });
115
+
116
+ // --- H: campaign end -> maintenance preview re-evaluates -----------------------
117
+ test('H: 0->non-zero -> diff proposes cost.free=false; preview re-evaluates cheapest-sufficient', () => {
118
+ const dir = tmp();
119
+ const store = join(dir, 'obs.jsonl');
120
+ const r = fromQoderCache(cacheDoc(1), { models: MODELS, previous: { 'Qwen3.8-Flash': 0 } });
121
+ ingestObservations(store, r.observations);
122
+ const observations = loadObservationStore(store).observations;
123
+ const proposal = buildCatalogProposal(CAT, diffCatalog(CAT, observations), observations);
124
+ assert.ok(proposal.updates.some(u => u.field === 'cost.free' && u.observedValue === false));
125
+ // proposal -> event -> maintenance preview on hypothetical catalog
126
+ const events = proposalToEvents(proposal);
127
+ assert.ok(events.some(e => e.type === 'free-campaign-ended' && e.model === 'Qwen3.8-Flash'));
128
+ const hypothetical = applyProposalToCatalog(CAT, proposal);
129
+ const run = runMaintenancePlan({ routing, registry: REG, agents, catalog: hypothetical, events, policy: DEFAULT_POLICY });
130
+ assert.equal(run.monitor.changed, true);
131
+ const qd = run.proposal.decisions.find(d => d.backend === 'qoder');
132
+ assert.ok(qd, 'qoder backend re-evaluated');
133
+ // postCampaignCost is null -> costUnknown; never treated as $0
134
+ const hq = effectiveCatalog(hypothetical, events).find(m => m.model === 'Qwen3.8-Flash');
135
+ assert.equal(hq.costUnknown, true);
136
+ assert.notEqual(hq.cost?.free, true);
137
+ // monitor tier can no longer pick it as a free cloud model
138
+ const after = selectTierModel(effectiveCatalog(hypothetical, events), 'monitor', DEFAULT_POLICY);
139
+ assert.notEqual(after.selectionPath, 'free-cloud');
140
+ rmSync(dir, { recursive: true, force: true });
141
+ });
142
+
143
+ // --- I: campaign start -> Qwen becomes free-eligible again ---------------------
144
+ test('I: non-zero -> 0 -> free=true; Qwen re-enters free-eligible pool in preview', () => {
145
+ const dir = tmp();
146
+ const store = join(dir, 'obs.jsonl');
147
+ // catalog currently shows paid (campaign previously ended)
148
+ const paidCat = structuredClone(CAT);
149
+ paidCat.models[0].status = 'active';
150
+ paidCat.models[0].cost = { free: false, usdPerMInput: null, usdPerMOutput: null };
151
+ paidCat.models[0].costUnknown = true;
152
+ const r = fromQoderCache(cacheDoc(0), { models: MODELS, previous: { 'Qwen3.8-Flash': 1 } });
153
+ ingestObservations(store, r.observations);
154
+ const observations = loadObservationStore(store).observations;
155
+ const proposal = buildCatalogProposal(paidCat, diffCatalog(paidCat, observations), observations);
156
+ assert.ok(proposal.updates.some(u => u.field === 'cost.free' && u.observedValue === true));
157
+ const events = proposalToEvents(proposal);
158
+ assert.ok(events.some(e => e.type === 'free-campaign-started' && e.model === 'Qwen3.8-Flash'));
159
+ const hypothetical = applyProposalToCatalog(paidCat, proposal);
160
+ const hq = effectiveCatalog(hypothetical, events).find(m => m.model === 'Qwen3.8-Flash');
161
+ assert.equal(hq.status, 'free-campaign');
162
+ assert.equal(hq.cost.free, true);
163
+ const sel = selectTierModel(effectiveCatalog(hypothetical, events), 'monitor', DEFAULT_POLICY);
164
+ assert.equal(sel.selectionPath, 'free-cloud');
165
+ assert.equal(sel.selected.model, 'qoder/Qwen3.8-Flash');
166
+ rmSync(dir, { recursive: true, force: true });
167
+ });
168
+
169
+ // --- J: costUnknown is never treated as $0 -------------------------------------
170
+ test('J: post-campaign costUnknown -> not free, not $0, flagged in proposal', () => {
171
+ const events = [{ type: 'free-campaign-ended', provider: 'qoder', model: 'Qwen3.8-Flash', asOf: '2026-03-01' }];
172
+ const eff = effectiveCatalog(CAT, events).find(m => m.model === 'Qwen3.8-Flash');
173
+ assert.equal(eff.costUnknown, true);
174
+ assert.equal(eff.cost, null);
175
+ // costPoints(scoreModel) treats null cost as unknown, not 0
176
+ const report = evaluateMaintenance({ routing, registry: REG, agents, catalog: CAT, events });
177
+ const qd = report.decisions.find(d => d.backend === 'qoder');
178
+ assert.ok(qd.currentScore.unknown.includes('cost'));
179
+ // decision reason / proposal carries the uncertainty (never a silent $0 switch)
180
+ if (qd.decision === 'propose') {
181
+ const change = report.changes.find(c => c.affected.backend === 'qoder');
182
+ assert.match(change.expectedCostImpact, /unknown/);
183
+ }
184
+ });
185
+
186
+ // --- combined probes: --check-pi + --check-qoder run together ----------------
187
+ test('combined: checkPi + checkQoder both contribute; a stale qoder cache does not block pi availability', async () => {
188
+ const dir = tmp();
189
+ const listing = { models: new Set(['qoder/Qwen3.8-Flash', 'cloudp/cheap-ok', 'cloudp/pro-strong', 'freetoken/Local-1']), providers: new Set(['qoder', 'cloudp', 'freetoken']), source: 'test' };
190
+ // observedAt must be ~now: a stale timestamp is correctly deduped as stale.
191
+ const qoderOk = fromQoderCache(cacheDoc(0), { models: MODELS, observedAt: new Date().toISOString() }); // free:true, first sighting
192
+ const run = await runMaintenanceJob({ outDir: dir, adapterDir: join(kit, 'adapters/pi'), kit, catalog: CAT, routing, registry: REG, agents, policy: DEFAULT_POLICY,
193
+ checkPi: true, listing, checkQoder: true, qoderListing: qoderOk });
194
+ assert.equal(run.status, 'ok');
195
+ assert.equal(run.ingest.probeFailed, false);
196
+ // both sources contributed: availability (pi) + free (qoder cache)
197
+ const store = loadObservationStore(join(dir, 'model-observations.jsonl')).observations;
198
+ assert.ok(store.some(o => o.changes?.availability === 'available'), 'pi availability observation stored');
199
+ assert.ok(store.some(o => o.changes?.free === true && o.source?.trust === 'provider_local_cache'), 'qoder free observation stored');
200
+ rmSync(dir, { recursive: true, force: true });
201
+ });
202
+
203
+ test('combined: qoder probe failure is quiet and does not suppress pi observations', async () => {
204
+ const dir = tmp();
205
+ const listing = { models: new Set(['qoder/Qwen3.8-Flash']), providers: new Set(['qoder']), source: 'test' };
206
+ const run = await runMaintenanceJob({ outDir: dir, adapterDir: join(kit, 'adapters/pi'), kit, catalog: CAT, routing, registry: REG, agents, policy: DEFAULT_POLICY,
207
+ checkPi: true, listing, checkQoder: true, qoderListing: false }); // qoder probe failed
208
+ assert.equal(run.status, 'ok');
209
+ assert.equal(run.ingest.probeFailed, true);
210
+ const store = loadObservationStore(join(dir, 'model-observations.jsonl')).observations;
211
+ assert.ok(store.some(o => o.changes?.availability === 'available'), 'pi observation still stored despite qoder failure');
212
+ rmSync(dir, { recursive: true, force: true });
213
+ });
214
+
215
+ // --- observation schema + trust ------------------------------------------------
216
+ test('qoder observation validates and carries provider_local_cache trust', () => {
217
+ const r = fromQoderCache(cacheDoc(0), { models: MODELS });
218
+ const o = normalizeObservation(r.observations[0]);
219
+ assert.deepEqual(validateObservation(o), []);
220
+ assert.equal(o.source.trust, 'provider_local_cache');
221
+ assert.equal(o.source.type, 'cli');
222
+ });
223
+
224
+ // --- fixture/production boundary ------------------------------------------------
225
+ test('fixture observations are tagged test and excluded from production proposals', async () => {
226
+ const dir = tmp();
227
+ // a fixture observation claiming qoder free:false (the contamination we just cleaned)
228
+ const fixtureObs = fromFixture({ announcements: [{ provider: 'qoder', model: 'Qwen3.8-Flash', type: 'free-campaign-ended', asOf: '2026-03-01', label: 'Qoder pricing page', url: 'https://example.invalid/x' }] });
229
+ assert.equal(fixtureObs[0].environment, 'test');
230
+ assert.equal(fixtureObs[0].sourceFixture, true);
231
+ // real production observation: qoder free:true from the cache
232
+ const prodObs = fromQoderCache(cacheDoc(0), { models: MODELS, observedAt: new Date().toISOString() }).observations;
233
+ const store = join(dir, 'obs.jsonl');
234
+ ingestObservations(store, [...fixtureObs, ...prodObs]);
235
+ const all = loadObservationStore(store).observations;
236
+ const prod = productionObservations(all);
237
+ assert.equal(all.length, 2);
238
+ assert.equal(prod.length, 1);
239
+ assert.equal(prod[0].changes.free, true); // fixture free:false excluded
240
+ // production proposal sees only the real free:true
241
+ const proposal = buildCatalogProposal(CAT, diffCatalog(CAT, prod), prod);
242
+ assert.equal(proposal.updates.filter(u => u.field === 'cost.free' && u.observedValue === false).length, 0);
243
+ rmSync(dir, { recursive: true, force: true });
244
+ });
245
+
246
+ test('job excludes fixture observations from the production diff even when stored', async () => {
247
+ const dir = tmp();
248
+ // seed the store with a fixture observation directly (simulating prior contamination)
249
+ const fixtureObs = fromFixture({ announcements: [{ provider: 'qoder', model: 'Qwen3.8-Flash', type: 'free-campaign-ended', asOf: '2026-03-01', label: 'fixture' }] });
250
+ ingestObservations(join(dir, 'model-observations.jsonl'), fixtureObs);
251
+ const run = await runMaintenanceJob({ ...{ outDir: dir, adapterDir: join(kit, 'adapters/pi'), kit, catalog: CAT, routing, registry: REG, agents, policy: DEFAULT_POLICY }, checkQoder: true, qoderListing: fromQoderCache(cacheDoc(0), { models: MODELS, observedAt: new Date().toISOString() }) });
252
+ assert.equal(run.status, 'ok');
253
+ assert.equal(run.excludedTestObservations, 1); // fixture record excluded from the diff
254
+ rmSync(dir, { recursive: true, force: true });
255
+ });
256
+
257
+ // --- real cache smoke (read-only) ----------------------------------------------
258
+ test('smoke: real ~/.pi/agent/qoder-models-cache.json parses and reports Qwen3.8-Flash state', () => {
259
+ const real = join(process.env.USERPROFILE ?? process.env.HOME, '.pi', 'agent', 'qoder-models-cache.json');
260
+ if (!existsSync(real)) { console.log(' (skipped: real cache not present)'); return; }
261
+ const r = fromQoderCache(real, { models: MODELS });
262
+ assert.equal(r.state, 'ok');
263
+ assert.equal(typeof r.snapshot['Qwen3.8-Flash'], 'number');
264
+ // first sighting emits the current free state (0 -> free:true on this machine)
265
+ assert.equal(r.observations[0].changes.free, r.snapshot['Qwen3.8-Flash'] === 0);
266
+ });
@@ -0,0 +1,104 @@
1
+ // REASSIGN failure (MODEL_FAILURE / BACKEND_LIMIT) candidate semantics: a dead
2
+ // candidate is never re-invoked on the same-capability retry; untried candidates
3
+ // (e.g. local) are reached; health skips never consume the attempt budget.
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { resolve, dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { loadRouting } from '../lib/routing.mjs';
9
+ import { loadAgents } from '../lib/agents.mjs';
10
+ import { DEFAULT_POLICY, mergePolicy } from '../lib/orchestrator/policy.mjs';
11
+ import { createAgentRunner } from '../lib/orchestrator/runner.mjs';
12
+ import { orchestrate } from '../lib/orchestrator/orchestrator.mjs';
13
+ import { createHealthMonitor } from '../lib/orchestrator/health.mjs';
14
+ import { createScriptedInvoker } from '../adapters/pi/lib/invoke.mjs';
15
+
16
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
18
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
19
+ // strong-code chain: devin -> qoder -> sol -> codex -> local
20
+ const REG = { version: 1, backends: {
21
+ local: { provider: 'pl', model: 'ml', thinking: 'off' },
22
+ cheap: { provider: 'pc', model: 'mc', thinking: 'low' },
23
+ sol: { provider: 'ps', model: 'ms', thinking: 'high' },
24
+ codex: { provider: 'px', model: 'mx', thinking: 'high' },
25
+ qoder: { provider: 'pq', model: 'mq', thinking: 'low' },
26
+ devin: { provider: 'pd', model: 'md', thinking: 'high' },
27
+ } };
28
+ const policyWith = over => mergePolicy(DEFAULT_POLICY, over ?? {});
29
+ const jsonReply = obj => `notes\n\n\`\`\`json\n${JSON.stringify(obj)}\n\`\`\``;
30
+ const okReply = jsonReply({ status: 'completed', summary: 'done', acceptance: [{ id: 'A1', met: true, evidence: 'e' }] });
31
+ const spec = (id, agent, extra = {}) => ({ id, title: `task ${id}`, goal: `goal ${id}`, agent, dependencies: [], acceptance: ['done'], ...extra });
32
+ const run = ({ policy, ...o }) => orchestrate({ request: 'r', agents, routing, registry: REG, policy: policyWith(policy), ...o });
33
+ // Unclassified backend error: NOT matched by classifyBackendFailure, so health never skips it.
34
+ const DEAD = new Error('pi exited 1: upstream returned status 402');
35
+ const count = (calls, id) => calls.filter(c => c.modelId === id).length;
36
+
37
+ function scripted(mapper, { health = null } = {}) {
38
+ const calls = [];
39
+ const invoke = createScriptedInvoker(mapper, calls);
40
+ return { calls, runner: createAgentRunner({ invoke, agents, routing, registry: REG, health, maxModelAttempts: 3 }) };
41
+ }
42
+
43
+ // A: strong-code (ladder end): devin/qoder/sol die with MODEL_FAILURE -> retry does
44
+ // not re-invoke any of them; codex (untried) is reached.
45
+ test('A: strong-code end of ladder: dead candidates are excluded on retry, untried candidate reached', async () => {
46
+ const { calls, runner } = scripted({ 'pd/md:high': DEAD, 'pq/mq:low': DEAD, 'ps/ms:high': DEAD, '*': okReply });
47
+ const r = await run({ plan: [spec('a', 'coder')], runner, policy: { limits: { max_retries: 2, model_attempts_per_task: 3, max_total_attempts_per_task: 8 } } });
48
+ assert.equal(r.status, 'completed', JSON.stringify(r.tasks[0]));
49
+ assert.deepEqual(calls.map(c => c.modelId), ['pd/md:high', 'pq/mq:low', 'ps/ms:high', 'px/mx:high']);
50
+ assert.equal(count(calls, 'ps/ms:high'), 1, 'the last dead candidate was not re-invoked');
51
+ assert.equal(r.tasks[0].attempts, 2);
52
+ const retry = r.trace.find(e => e.type === 'retry');
53
+ assert.equal(retry.failureClass, 'MODEL_FAILURE');
54
+ assert.ok(r.autoDecisions.some(d => /next untried candidate on strong-code/.test(d.choice)));
55
+ });
56
+
57
+ // B: unclassified quota error on every hosted candidate: the same model is never
58
+ // re-run "just because" it was last; the task fails cleanly once nothing is left.
59
+ test('B: unclassified quota error is not health-skipped, yet no model runs twice', async () => {
60
+ const { calls, runner } = scripted({ '*': DEAD });
61
+ const r = await run({ plan: [spec('a', 'coder')], runner, policy: { limits: { max_retries: 5, model_attempts_per_task: 3, max_total_attempts_per_task: 20 } } });
62
+ assert.equal(r.tasks[0].status, 'failed');
63
+ const ids = calls.map(c => c.modelId);
64
+ assert.equal(new Set(ids).size, ids.length, `a model was invoked twice: ${ids.join(',')}`);
65
+ assert.deepEqual(ids, ['pd/md:high', 'pq/mq:low', 'ps/ms:high', 'px/mx:high', 'pl/ml:off']);
66
+ assert.match(r.tasks[0].blockedReason, /no untried model candidate left on strong-code/);
67
+ assert.equal(r.tasks[0].attempts, 2, 'no extra rounds spent invoking nothing');
68
+ });
69
+
70
+ // C: local is reached even with the default budget (4) when the first pass consumes 3.
71
+ test('C: untried local candidate is reached with the default attempt budget', async () => {
72
+ const { calls, runner } = scripted({ 'pd/md:high': DEAD, 'pq/mq:low': DEAD, 'ps/ms:high': DEAD, 'px/mx:high': DEAD, '*': okReply });
73
+ const r = await run({ plan: [spec('a', 'coder')], runner, policy: { limits: { max_retries: 2, model_attempts_per_task: 4, max_total_attempts_per_task: 5 } } });
74
+ assert.equal(r.status, 'completed', JSON.stringify(r.tasks[0]));
75
+ assert.equal(calls.at(-1).modelId, 'pl/ml:off');
76
+ assert.equal(count(calls, 'px/mx:high'), 1);
77
+ });
78
+
79
+ // D: attempt budget counts real invocations only; health skips are free.
80
+ test('D: health-skipped candidates cost no budget; budget counts invocations only', async () => {
81
+ const policy = policyWith({ limits: { max_retries: 2, model_attempts_per_task: 3, max_total_attempts_per_task: 3 } });
82
+ const health = createHealthMonitor({ policy });
83
+ health.bindRun('run-x');
84
+ // devin and qoder are known-exhausted before the run.
85
+ health.report({ provider: 'pd', model: 'md' }, 'usage limit has been reached');
86
+ health.report({ provider: 'pq', model: 'mq' }, 'usage limit has been reached');
87
+ const { calls, runner } = scripted({ 'ps/ms:high': DEAD, 'px/mx:high': DEAD, '*': okReply }, { health });
88
+ const r = await run({ plan: [spec('a', 'coder')], runner, policy: policy });
89
+ assert.equal(r.status, 'completed', JSON.stringify(r.tasks[0]));
90
+ assert.deepEqual(calls.map(c => c.modelId), ['ps/ms:high', 'px/mx:high', 'pl/ml:off']);
91
+ assert.equal(r.tasks[0].totalModelAttempts, 3, 'exactly three real invocations counted');
92
+ const first = r.trace.find(e => e.type === 'result');
93
+ assert.equal(first.counters.candidatesSkipped, 2);
94
+ assert.equal(first.counters.invocationsStarted, 3);
95
+ });
96
+
97
+ // E: recoverable failure (TEST_FAILURE) still keeps the same model retryable (unchanged semantics).
98
+ test('E: recoverable failure keeps the last model retryable (feedback retry)', async () => {
99
+ let n = 0;
100
+ const { calls, runner } = scripted({ '*': () => (++n === 1 ? jsonReply({ status: 'failed', summary: 'tests failed', acceptance: [{ id: 'A1', met: false, evidence: '1 failing' }] }) : okReply) });
101
+ const r = await run({ plan: [spec('a', 'coder')], runner });
102
+ assert.equal(r.status, 'completed');
103
+ assert.deepEqual(calls.map(c => c.modelId), ['pd/md:high', 'pd/md:high']);
104
+ });
@@ -0,0 +1,120 @@
1
+ // Failure-aware retry/escalation: protocol-quality failures advance to the next
2
+ // candidate (or escalate capability) instead of burning same-model retries.
3
+ import test from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { resolve, dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { loadRouting } from '../lib/routing.mjs';
8
+ import { loadAgents } from '../lib/agents.mjs';
9
+ import { DEFAULT_POLICY, mergePolicy } from '../lib/orchestrator/policy.mjs';
10
+ import { createAgentRunner } from '../lib/orchestrator/runner.mjs';
11
+ import { orchestrate } from '../lib/orchestrator/orchestrator.mjs';
12
+ import { isProtocolFailure, classifyRun } from '../lib/orchestrator/failures.mjs';
13
+ import { createScriptedInvoker } from '../adapters/pi/lib/invoke.mjs';
14
+
15
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
16
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
17
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
18
+ // cheap-code chain resolves to cheap -> local -> sol (no qoder/devin in this registry)
19
+ const REG = { version: 1, backends: {
20
+ local: { provider: 'pl', model: 'ml', thinking: 'off' },
21
+ cheap: { provider: 'pc', model: 'mc', thinking: 'low' },
22
+ sol: { provider: 'ps', model: 'ms', thinking: 'high' },
23
+ astra: { provider: 'pa', model: 'ma', thinking: 'medium', vision: true },
24
+ codex: { provider: 'px', model: 'mx', thinking: 'high' },
25
+ } };
26
+ const policyWith = over => mergePolicy(DEFAULT_POLICY, over ?? {});
27
+ const jsonReply = obj => `notes\n\n\`\`\`json\n${JSON.stringify(obj)}\n\`\`\``;
28
+ const spec = (id, agent, dependencies = [], extra = {}) => ({ id, title: `task ${id}`, goal: `goal ${id}`, agent, dependencies, acceptance: ['done'], ...extra });
29
+ const run = ({ policy, ...o }) => orchestrate({ request: 'r', agents, routing, registry: REG, policy: policyWith(policy), ...o });
30
+ const okReply = jsonReply({ status: 'completed', summary: 'done', acceptance: [{ id: 'A1', met: true, evidence: 'e' }] });
31
+
32
+ function scripted(mapper) {
33
+ const calls = [];
34
+ const invoke = createScriptedInvoker(mapper, calls);
35
+ return { calls, runner: createAgentRunner({ invoke, agents, routing, registry: REG }) };
36
+ }
37
+
38
+ // A: MALFORMED_RESULT advances to the next candidate, not an infinite same-model retry.
39
+ test('A: malformed result -> next candidate within the same attempt', async () => {
40
+ const { calls, runner } = scripted({ '*': req => req.modelId.startsWith('pc/') ? 'Done' : okReply });
41
+ const r = await run({ plan: [spec('a', 'scout')], runner });
42
+ assert.equal(r.status, 'completed');
43
+ assert.equal(r.tasks[0].attempts, 1); // no wasted orchestrator retry
44
+ assert.deepEqual(calls.map(c => c.modelId), ['pc/mc:low', 'pl/ml:off']);
45
+ });
46
+
47
+ // B: turn-limit TIMEOUT with toolCalls=0 -> next candidate.
48
+ test('B: turn-limit timeout with no tool progress -> next candidate', async () => {
49
+ assert.equal(isProtocolFailure('TIMEOUT', { child: { toolCalls: 0 } }), true);
50
+ const { calls, runner } = scripted({ '*': req => req.modelId.startsWith('pc/') ? { ok: false, error: 'turn limit 12', failureClass: 'TIMEOUT', child: { toolCalls: 0 } } : okReply });
51
+ const r = await run({ plan: [spec('a', 'scout')], runner });
52
+ assert.equal(r.status, 'completed');
53
+ assert.deepEqual(calls.map(c => c.modelId), ['pc/mc:low', 'pl/ml:off']);
54
+ });
55
+
56
+ // C: empty response -> next candidate.
57
+ test('C: empty response -> next candidate', async () => {
58
+ assert.equal(classifyRun({ error: 'empty model response' }), 'EMPTY_RESPONSE');
59
+ const { calls, runner } = scripted({ '*': req => req.modelId.startsWith('pc/') ? { ok: false, error: 'empty model response', failureClass: 'EMPTY_RESPONSE' } : okReply });
60
+ const r = await run({ plan: [spec('a', 'scout')], runner });
61
+ assert.equal(r.status, 'completed');
62
+ assert.deepEqual(calls.map(c => c.modelId), ['pc/mc:low', 'pl/ml:off']);
63
+ });
64
+
65
+ // D: protocol failure on every cheap candidate -> escalate to strong-code.
66
+ test('D: cheap candidates exhausted by protocol failures -> escalate to strong-code', async () => {
67
+ // Every candidate fails protocol on the FIRST pass; on escalation to strong-code
68
+ // the codex candidate (px/mx, strong-only) succeeds.
69
+ const { calls, runner } = scripted({ '*': req => req.modelId.startsWith('px/') ? okReply : 'Done' });
70
+ const r = await run({ plan: [spec('a', 'scout')], runner, policy: { limits: { max_retries: 4, model_attempts_per_task: 3, max_total_attempts_per_task: 8 } } });
71
+ assert.equal(r.status, 'completed');
72
+ assert.ok(calls.some(c => c.modelId === 'px/mx:high'), 'reached a strong-code-only candidate');
73
+ assert.ok(r.tasks[0].capability === 'strong-code' || r.autoDecisions.some(d => /escalate to strong-code/.test(d.choice)));
74
+ });
75
+
76
+ // E: recoverable failure (test failure) -> same-model feedback retry allowed.
77
+ test('E: recoverable failure keeps the same model retryable', async () => {
78
+ let n = 0;
79
+ const { calls, runner } = scripted({ '*': () => (++n === 1
80
+ ? jsonReply({ status: 'failed', summary: 'tests failed', acceptance: [{ id: 'A1', met: false, evidence: 'x' }] })
81
+ : okReply) });
82
+ const r = await run({ plan: [spec('a', 'scout')], runner });
83
+ assert.equal(r.status, 'completed');
84
+ assert.equal(r.tasks[0].attempts, 2); // a real retry happened
85
+ assert.deepEqual(calls.map(c => c.modelId), ['pc/mc:low', 'pc/mc:low']); // same model retried
86
+ });
87
+
88
+ // F: a different candidate is reached before the per-task attempt budget is spent.
89
+ test('F: candidate progression happens before maxAttempts is exhausted', async () => {
90
+ const { calls, runner } = scripted({ '*': req => req.modelId.startsWith('pc/') ? 'Done' : okReply });
91
+ const r = await run({ plan: [spec('a', 'scout')], runner });
92
+ assert.equal(r.status, 'completed');
93
+ assert.ok(calls.length >= 2 && calls[0].modelId !== calls[1].modelId, 'tried a different candidate');
94
+ });
95
+
96
+ // G: every candidate fails -> task ultimately fails.
97
+ test('G: all candidates fail -> task failed', async () => {
98
+ const { runner } = scripted({ '*': () => 'Done' });
99
+ const r = await run({ plan: [spec('a', 'scout')], runner, policy: { limits: { max_retries: 2, model_attempts_per_task: 3, max_total_attempts_per_task: 4 } } });
100
+ assert.equal(r.tasks[0].status, 'failed');
101
+ assert.equal(r.status, 'incomplete');
102
+ });
103
+
104
+ // H: a timeout WITH real tool progress stays retryable on the same model.
105
+ test('H: turn-limit timeout WITH tool progress is recoverable (same-model retry)', async () => {
106
+ assert.equal(isProtocolFailure('TIMEOUT', { child: { toolCalls: 5 } }), false);
107
+ });
108
+
109
+ // Telemetry: protocol outcomes are recorded per provider/model (audit only).
110
+ test('telemetry: malformed/empty/turn_limit/structured_ok are counted per model', async () => {
111
+ const recorded = [];
112
+ const session = { recordProtocol: r => recorded.push(`${r.provider}/${r.model}:${r.kind}`) };
113
+ const calls = [];
114
+ const invoke = createScriptedInvoker({ '*': req => req.modelId.startsWith('pc/') ? 'Done' : okReply }, calls);
115
+ const runner = createAgentRunner({ invoke, agents, routing, registry: REG, session });
116
+ const r = await run({ plan: [spec('a', 'scout')], runner });
117
+ assert.equal(r.status, 'completed');
118
+ assert.ok(recorded.includes('pc/mc:malformed'), `malformed recorded: ${recorded}`);
119
+ assert.ok(recorded.includes('pl/ml:structured_ok'), `structured_ok recorded: ${recorded}`);
120
+ });
@@ -0,0 +1,110 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { resolve, dirname, join } from 'node:path';
4
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { loadRouting, mergeLocalRouting, validateRouting, resolveBackends, resolveModels } from '../lib/routing.mjs';
8
+ import { loadAgents } from '../lib/agents.mjs';
9
+
10
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
+
12
+ const base = () => ({
13
+ version: 1,
14
+ backends: { a: { tier: 'low' }, b: { tier: 'high', vision: true }, c: {} },
15
+ capabilities: { x: { primary: 'a', fallback: ['b'] }, see: { primary: 'b', fallback: ['a'], requires: { vision: true } } },
16
+ });
17
+
18
+ test('shipped routing.json validates', () => {
19
+ const r = loadRouting(resolve(kit, 'routing/routing.json'));
20
+ for (const c of ['cheap-code', 'strong-code', 'vision-reasoning', 'deep-review', 'browser']) assert.ok(r.capabilities[c], c);
21
+ for (const b of ['local', 'cheap', 'sol', 'astra', 'codex']) assert.ok(r.backends[b], b);
22
+ });
23
+
24
+ test('valid minimal config has no errors', () => assert.deepEqual(validateRouting(base()), []));
25
+
26
+ test('local routes override, add and disable capabilities without changing shared routing', () => {
27
+ const config = base();
28
+ config.escalation = { ladders: { code: ['x', 'see'] } };
29
+ const effective = mergeLocalRouting(config, { version: 1, capabilities: {
30
+ x: null,
31
+ see: { primary: 'b', fallback: ['c'] },
32
+ new: { primary: 'a', fallback: ['c'] },
33
+ } });
34
+ assert.equal(config.capabilities.x.primary, 'a');
35
+ assert.equal(effective.capabilities.x, undefined);
36
+ assert.deepEqual(effective.escalation.ladders.code, ['see']);
37
+ assert.deepEqual(resolveBackends(effective, 'new'), ['a', 'c']);
38
+ assert.deepEqual(resolveBackends(effective, 'see'), ['b', 'c']);
39
+ });
40
+
41
+ test('local routes reject broken references and malformed overrides', () => {
42
+ assert.throws(() => mergeLocalRouting(base(), { version: 1, capabilities: { x: { primary: 'missing' } } }), /not a defined backend/);
43
+ assert.throws(() => mergeLocalRouting(base(), { version: 1, capabilities: { x: { primary: 'a', fallback: ['a'] } } }), /repeats primary/);
44
+ assert.throws(() => mergeLocalRouting(base(), { version: 1, capabilities: { ghost: null } }), /unknown capability/);
45
+ });
46
+
47
+ test('loadRouting applies local file and excludes agents for disabled capabilities', () => {
48
+ const dir = mkdtempSync(join(tmpdir(), 'kit-routing-'));
49
+ try {
50
+ mkdirSync(join(dir, 'routing'));
51
+ writeFileSync(join(dir, 'routing', 'routing.json'), JSON.stringify(base()));
52
+ writeFileSync(join(dir, 'routing', 'routing.local.json'), JSON.stringify({ version: 1, capabilities: { x: null } }));
53
+ const routing = loadRouting(join(dir, 'routing', 'routing.json'));
54
+ assert.equal(routing.capabilities.x, undefined);
55
+ const { agents, errors } = loadAgents(join(kit, 'agents'), mergeLocalRouting(loadRouting(join(kit, 'routing', 'routing.json')), { version: 1, capabilities: { 'cheap-code': null } }));
56
+ assert.deepEqual(errors, []);
57
+ assert.ok(!agents.some(agent => agent.meta.capability === 'cheap-code'));
58
+ } finally {
59
+ rmSync(dir, { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ test('unknown primary / fallback backends are rejected', () => {
64
+ const c = base(); c.capabilities.x.primary = 'nope'; c.capabilities.x.fallback = ['zzz'];
65
+ const errs = validateRouting(c);
66
+ assert.ok(errs.some(e => e.includes('primary "nope"')));
67
+ assert.ok(errs.some(e => e.includes('fallback "zzz"')));
68
+ });
69
+
70
+ test('fallback may not repeat primary or itself', () => {
71
+ const c = base(); c.capabilities.x.fallback = ['a', 'b', 'b'];
72
+ const errs = validateRouting(c);
73
+ assert.ok(errs.some(e => e.includes('repeats primary')));
74
+ assert.ok(errs.some(e => e.includes('repeats "b"')));
75
+ });
76
+
77
+ test('vision requirement is enforced on primary only', () => {
78
+ const c = base(); c.capabilities.see.primary = 'a';
79
+ assert.ok(validateRouting(c).some(e => e.includes('requires vision')));
80
+ assert.deepEqual(validateRouting(base()), []);
81
+ });
82
+
83
+ test('version, names and tiers are checked', () => {
84
+ const c = base(); c.version = 2; c.backends['Bad Name'] = {}; c.backends.a.tier = 'ultra';
85
+ const errs = validateRouting(c);
86
+ assert.ok(errs.some(e => e.includes('version')));
87
+ assert.ok(errs.some(e => e.includes('invalid backend name')));
88
+ assert.ok(errs.some(e => e.includes('invalid tier')));
89
+ });
90
+
91
+ test('escalation ladders must reference known capabilities', () => {
92
+ const c = base(); c.escalation = { ladders: { l: ['x', 'ghost'] } };
93
+ assert.ok(validateRouting(c).some(e => e.includes('unknown capability "ghost"')));
94
+ });
95
+
96
+ test('resolveBackends returns primary then fallbacks in order', () => {
97
+ assert.deepEqual(resolveBackends(base(), 'x'), ['a', 'b']);
98
+ assert.throws(() => resolveBackends(base(), 'nope'));
99
+ });
100
+
101
+ test('resolveModels binds through a model map and skips unbound backends; no provider names in code', () => {
102
+ const map = { version: 1, backends: { b: { provider: 'p1', model: 'm1' } } };
103
+ const r = resolveModels(base(), map, 'x');
104
+ assert.deepEqual(r.candidates.map(c => c.backend), ['b']);
105
+ assert.deepEqual(r.unbound, ['a']);
106
+ const swapped = { version: 1, backends: { a: { provider: 'other', model: 'other-m' }, b: { provider: 'p1', model: 'm1' } } };
107
+ assert.equal(resolveModels(base(), swapped, 'x').candidates[0].provider, 'other');
108
+ const vis = resolveModels(base(), swapped, 'see');
109
+ assert.equal(vis.candidates[1].degraded, true);
110
+ });