explore-allocation-e2e.test.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. /**
  2. * Score-proportional explore allocation, end to end (CG-14 / epic CG-1 / #1500).
  3. *
  4. * `explore-proportional-allocation.test.ts` pins `allocateExploreBudget` in
  5. * isolation; `explore-allocation-1500.test.ts` pins the reporter's Go shape.
  6. * What is left — and what this file owns — is everything the allocator only
  7. * *promises*: the render loop has to spend those reservations, the hard ceiling
  8. * has to catch the overshoot, and a degenerate or diffuse result set has to come
  9. * back usable rather than empty. Each of those is invisible to a unit test,
  10. * because the failure mode is not an exception — it is a response the agent
  11. * quietly abandons in favour of Read.
  12. *
  13. * Two halves:
  14. *
  15. * 1. **The self-query fixture's shape.** CG-6 declared a second regression
  16. * fixture beside payroll-go: this repo, asked "how does explore allocate its
  17. * output budget across files", spending 63% of its envelope on
  18. * `scripts/agent-eval/*.mjs` files that merely mention `explore` and
  19. * `BUDGET`, while `src/mcp/tools.ts` — the file that actually answers — sat
  20. * clipped at the flat `maxCharsPerFile`. That fixture reads THIS repo's live
  21. * index, so it belongs to the out-of-band probe
  22. * (`node scripts/agent-eval/probe-allocation.mjs self-query`) where its
  23. * numbers can move with the repo. Reproduced here as a synthetic project so
  24. * `npm test` owns the MECHANISM deterministically: a large relevant file, a
  25. * small genuinely-relevant helper, and an incidental name-collision script.
  26. *
  27. * 2. **Degenerate and diffuse result sets.** One file, no files, all files
  28. * scoring alike, a survey question. The proportional split divides by a total
  29. * weight and concentrates on a leader — both of which have a degenerate case
  30. * that ends in a division by zero or a starved response.
  31. *
  32. * Nothing here is platform-gated: fixtures are written through `path.join`, and
  33. * every path ASSERTED against is an indexed relative path, which extraction
  34. * normalizes to forward slashes on every platform (`normalizePath`, utils.ts).
  35. * A literal like `src/mcp/allocator.ts` is therefore correct on Windows too —
  36. * gate a new assertion with `it.runIf` only if it reaches for a real filesystem
  37. * path or a platform-specific separator.
  38. */
  39. import { describe, it, expect, beforeAll, afterAll } from 'vitest';
  40. import * as fs from 'fs';
  41. import * as path from 'path';
  42. import * as os from 'os';
  43. import CodeGraph from '../src/index';
  44. import { ToolHandler, getExploreOutputBudget, EXPLORE_ALLOCATION } from '../src/mcp/tools';
  45. import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
  46. import type { ExploreDiagnosticReport } from '../src/mcp/explore-diagnostics';
  47. /** The host's inline tool-result limit — above it the response is externalized. */
  48. const INLINE_CAP = 25000;
  49. const DEBUG_ENV = 'CODEGRAPH_EXPLORE_DEBUG';
  50. interface Project {
  51. dir: string;
  52. cg: CodeGraph;
  53. handler: ToolHandler;
  54. }
  55. /** Build + index a throwaway project from a `{ relPath: source }` map. */
  56. async function buildProject(prefix: string, files: Record<string, string>): Promise<Project> {
  57. const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
  58. for (const [rel, body] of Object.entries(files)) {
  59. const abs = path.join(dir, rel);
  60. fs.mkdirSync(path.dirname(abs), { recursive: true });
  61. fs.writeFileSync(abs, body.trimStart());
  62. }
  63. const cg = CodeGraph.initSync(dir);
  64. await cg.indexAll();
  65. return { dir, cg, handler: new ToolHandler(cg) };
  66. }
  67. function destroyProject(project?: Project): void {
  68. if (!project) return;
  69. project.cg.destroy();
  70. if (fs.existsSync(project.dir)) fs.rmSync(project.dir, { recursive: true, force: true });
  71. }
  72. /**
  73. * One explore call, reduced to what the allocation assertions need — plus the
  74. * CG-4 per-file diagnostic, which is where the SCORE and the RESERVATION live.
  75. * The instrument is observational (byte-identical output either way), so reading
  76. * it here measures the same response the agent would have received.
  77. */
  78. async function explore(project: Project, query: string) {
  79. // Outside the project root on purpose: a sidecar written INTO the indexed tree
  80. // is a new file the watcher can pick up mid-suite.
  81. const sidecar = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-alloc-diag-')), 'report.jsonl');
  82. const previous = process.env[DEBUG_ENV];
  83. process.env[DEBUG_ENV] = sidecar;
  84. let result;
  85. try {
  86. result = await project.handler.execute('codegraph_explore', { query });
  87. } finally {
  88. if (previous === undefined) delete process.env[DEBUG_ENV];
  89. else process.env[DEBUG_ENV] = previous;
  90. }
  91. const text = result.content?.[0]?.text ?? '';
  92. const bytes = attributeSourceBytes(text);
  93. const lines = fs.existsSync(sidecar)
  94. ? fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean)
  95. : [];
  96. const report = JSON.parse(lines[lines.length - 1]!) as ExploreDiagnosticReport;
  97. fs.rmSync(path.dirname(sidecar), { recursive: true, force: true });
  98. const fileOf = (file: string) => report.files.find((f) => f.path === file);
  99. return {
  100. text,
  101. bytes,
  102. report,
  103. isError: result.isError === true,
  104. /** Relevance score the ranking pass gave this file. */
  105. score: (file: string) => fileOf(file)?.score ?? 0,
  106. /** Chars of source the allocator RESERVED for it, before anything rendered. */
  107. allowance: (file: string) => fileOf(file)?.allowance ?? 0,
  108. /** Fraction of the WHOLE response this file's source occupies. */
  109. share: (file: string) => (bytes.get(file) ?? 0) / (text.length || 1),
  110. shareUnder: (prefix: string) => {
  111. let total = 0;
  112. for (const [file, n] of bytes) if (file.startsWith(prefix)) total += n;
  113. return total / (text.length || 1);
  114. },
  115. };
  116. }
  117. // ── 1. The self-query fixture's shape ───────────────────────────────────────
  118. describe('#1500 fixture 2 — allocation followed FILE SIZE, not relevance', () => {
  119. /**
  120. * The three roles from the real fixture, at synthetic scale:
  121. *
  122. * - `src/mcp/allocator.ts` — stands in for `src/mcp/tools.ts`. Carries the
  123. * query's terms on real functions with real call edges, and is deliberately
  124. * too big to ship whole, so under the old rule it was clipped at the flat
  125. * `maxCharsPerFile` no matter how far it outscored its peers.
  126. * - `src/util/budget-math.ts` — stands in for `src/resolution/memory-budget.ts`.
  127. * Genuinely relevant (the allocator calls it) but scoring about half as
  128. * well — and small enough to ship WHOLE, which under the old rule was worth
  129. * more than being right.
  130. * - `scripts/eval-harness.mjs` — stands in for `scripts/agent-eval/*.mjs`. Its
  131. * only claim on the query is a file-scope `explore` and `BUDGET` that nothing
  132. * reads: the incidental collision CG-10 demoted.
  133. *
  134. * Measured on this fixture, reverting the render loop to the pre-CG-12 rules
  135. * (`fileBudget = maxCharsPerFile`, whole-file bound `maxCharsPerFile * 3`)
  136. * reproduces the report exactly — and every gate below goes red:
  137. *
  138. * | file | score | pre-CG-12 | CG-12 |
  139. * |-------------------------|-------|----------------|----------------|
  140. * | `src/mcp/allocator.ts` | 77.5 | 4,843 (39.7%) | 9,335 (80.1%) |
  141. * | `src/util/budget-math.ts` | 36.0 | 6,079 (49.8%) | 1,037 ( 8.9%) |
  142. *
  143. * The half-as-relevant file taking the larger share, purely on size, IS #1500.
  144. */
  145. const QUERY = 'how does explore allocate its output budget across files';
  146. const ALLOCATOR = 'src/mcp/allocator.ts';
  147. const HELPER = 'src/util/budget-math.ts';
  148. const INCIDENTAL = 'scripts/eval-harness.mjs';
  149. const allocatorPass = (index: number, name: string) => `
  150. /** ${name}: one pass of the explore output split. */
  151. export function ${name}(
  152. candidates: AllocationCandidate[],
  153. budget: ExploreOutputBudget,
  154. ): Map<string, number> {
  155. const allowances = new Map<string, number>();
  156. const pool = clampOutputBudget(budget.maxOutputChars - ${index} * 200);
  157. const total = candidates.reduce((sum, candidate) => sum + candidate.score, 0);
  158. if (total <= 0) {
  159. return allowances;
  160. }
  161. const floors = Math.min(pool, 700 * candidates.length);
  162. const remainder = budgetRemainderAfterFloors(pool, floors);
  163. for (const candidate of candidates) {
  164. const floor = Math.floor(floors / candidates.length);
  165. const proportional = splitOutputEvenly(remainder, total, candidate.score);
  166. const boosted = candidate.spine ? proportional * 2 : proportional;
  167. const share = Math.min(floor + boosted, budget.maxCharsPerFile * 3);
  168. if (share <= 0) {
  169. continue;
  170. }
  171. allowances.set(candidate.path, share);
  172. }
  173. return allowances;
  174. }
  175. `;
  176. /**
  177. * Neutral bulk for the helper file: real symbols that match NOTHING in the
  178. * query, so the file grows in BYTES without gaining relevance. That asymmetry
  179. * is the fixture — the real `memory-budget.ts` won 51% of the envelope against
  180. * a file scoring twice its score purely by being small enough to ship whole.
  181. */
  182. const helperFiller = (n: number) => `
  183. export function normalizeLedgerRow${n}(row: string[], fallback: string): string[] {
  184. const trimmed = row.map((cell) => cell.trim()).filter((cell) => cell.length > 0);
  185. return trimmed.length > 0 ? trimmed : [fallback];
  186. }
  187. `;
  188. const ALLOCATOR_SOURCE = `
  189. /** Explore budget allocation: splits the output envelope across relevant files. */
  190. export interface ExploreOutputBudget {
  191. maxOutputChars: number;
  192. maxCharsPerFile: number;
  193. defaultMaxFiles: number;
  194. }
  195. export interface AllocationCandidate {
  196. path: string;
  197. score: number;
  198. spine: boolean;
  199. }
  200. ${[
  201. 'allocateExploreBudget',
  202. 'reserveOutputPerFile',
  203. 'distributeOutputBudget',
  204. 'planExploreOutput',
  205. 'spendExploreBudget',
  206. 'balanceOutputAcrossFiles',
  207. 'concentrateExploreOutput',
  208. 'settleExploreAllocation',
  209. 'apportionExploreBudget',
  210. 'rationOutputAcrossFiles',
  211. 'tallyExploreOutputBudget',
  212. 'weighExploreAllocation',
  213. ].map((name, i) => allocatorPass(i + 1, name)).join('')}
  214. import {
  215. clampOutputBudget,
  216. splitOutputEvenly,
  217. budgetRemainderAfterFloors,
  218. } from '../util/budget-math';
  219. `;
  220. let project: Project;
  221. let run: Awaited<ReturnType<typeof explore>>;
  222. beforeAll(async () => {
  223. project = await buildProject('codegraph-alloc-selfquery-', {
  224. [ALLOCATOR]: ALLOCATOR_SOURCE,
  225. [HELPER]: `
  226. /** Budget arithmetic the explore output allocator leans on. */
  227. export function clampOutputBudget(value: number): number {
  228. if (value < 0) return 0;
  229. return Math.floor(value);
  230. }
  231. export function splitOutputEvenly(pool: number, total: number, score: number): number {
  232. if (total <= 0) return 0;
  233. return Math.floor((pool * score) / total);
  234. }
  235. export function budgetRemainderAfterFloors(pool: number, floors: number): number {
  236. const remainder = pool - floors;
  237. return remainder > 0 ? remainder : 0;
  238. }
  239. export function splitBudgetAcrossFiles(pool: number, fileCount: number): number {
  240. return fileCount > 0 ? Math.floor(pool / fileCount) : pool;
  241. }
  242. export function describeOutputBudget(pool: number, perFile: number): string {
  243. return \`explore budget pool of \${pool} chars, \${perFile} per file\`;
  244. }
  245. ${Array.from({ length: 22 }, (_, i) => helperFiller(i + 1)).join('')}`,
  246. [INCIDENTAL]: `
  247. // Eval harness. Mentions explore and BUDGET incidentally; nothing here allocates.
  248. const explore = 'explore';
  249. const BUDGET = 24000;
  250. export function runHarness(repo) {
  251. const rows = [];
  252. for (const line of repo.split('\\n')) {
  253. rows.push(line.trim());
  254. }
  255. return rows;
  256. }
  257. export function summarizeRun(rows) {
  258. return { count: rows.length, first: rows[0] };
  259. }
  260. `,
  261. 'src/mcp/server.ts': `
  262. import { allocateExploreBudget } from './allocator';
  263. export function serve(candidates: any[]) {
  264. return allocateExploreBudget(candidates, { maxOutputChars: 13000, maxCharsPerFile: 3800, defaultMaxFiles: 4 });
  265. }
  266. `,
  267. 'src/util/logger.ts': `
  268. export function log(message: string): void {
  269. console.log(message);
  270. }
  271. `,
  272. });
  273. run = await explore(project, QUERY);
  274. }, 120_000);
  275. afterAll(() => destroyProject(project));
  276. describe('fixture shape', () => {
  277. it('indexes all three roles, so a zero share means demoted and not missing', () => {
  278. // Without this the incidental assertion below could pass vacuously — a file
  279. // that was never indexed also delivers 0 bytes.
  280. for (const rel of [ALLOCATOR, HELPER, INCIDENTAL]) {
  281. expect(project.cg.getFile(rel), `${rel} indexed`).toBeTruthy();
  282. }
  283. });
  284. it('sizes the two files so the size-driven render split actually bites', () => {
  285. // The mechanism the epic is about. The answer file must be too big to ship
  286. // whole (so the old flat cap clipped it), and the helper small enough that
  287. // shipping it whole was always affordable under the old `maxCharsPerFile * 3`
  288. // bound. Without that asymmetry the fixture stops reproducing anything.
  289. const budget = getExploreOutputBudget(project.cg.getFiles().length);
  290. const answer = fs.readFileSync(path.join(project.dir, ALLOCATOR), 'utf-8');
  291. const helper = fs.readFileSync(path.join(project.dir, HELPER), 'utf-8');
  292. expect(answer.split('\n').length).toBeGreaterThan(280);
  293. expect(answer.length).toBeGreaterThan(budget.maxCharsPerFile * 3);
  294. expect(helper.split('\n').length).toBeLessThan(220);
  295. expect(helper.length).toBeLessThan(budget.maxCharsPerFile * 3);
  296. });
  297. it('scores the answer file well above the helper it calls', () => {
  298. // The other half of the asymmetry: the reversal below only means something
  299. // if the file that used to WIN the envelope was the less relevant one.
  300. expect(run.score(ALLOCATOR)).toBeGreaterThan(run.score(HELPER) * 1.5);
  301. });
  302. });
  303. describe('budget allocation', () => {
  304. it('gives the file that answers the question the majority of the envelope', () => {
  305. // The epic's acceptance bar for this fixture: >50%, from 18.5% at baseline.
  306. // Pre-CG-12 this file took 39.7% — behind the helper it calls.
  307. expect(run.share(ALLOCATOR)).toBeGreaterThan(0.5);
  308. });
  309. it('lets the answer file spend multiples of the flat cap it used to be clipped at', () => {
  310. // The mechanism as a byte count rather than a share: this file is too big
  311. // to ship whole, so under the old rule its source was truncated at
  312. // `maxCharsPerFile` however far it outscored its peers. Its reservation is
  313. // now several times that cap. A build that re-imposes a flat per-file cap
  314. // fails HERE first — it delivered 4,843 against a 3,800 cap.
  315. const budget = getExploreOutputBudget(project.cg.getFiles().length);
  316. expect(run.bytes.get(ALLOCATOR) ?? 0).toBeGreaterThan(budget.maxCharsPerFile * 2);
  317. });
  318. it('stops the smaller file winning on size — it no longer ships whole', () => {
  319. // The reversal, from the other side. The helper scores about half the
  320. // answer file and is small enough that the old whole-file bound shipped it
  321. // ENTIRE (6,079 chars, 49.8% of the envelope — more than the file that
  322. // answered the question). It now clusters inside its proportional share.
  323. const helperSource = fs.readFileSync(path.join(project.dir, HELPER), 'utf-8');
  324. const delivered = run.bytes.get(HELPER) ?? 0;
  325. expect(delivered).toBeGreaterThan(0);
  326. expect(delivered).toBeLessThan(helperSource.length);
  327. });
  328. it('orders per-file shares by relevance, not by file size', () => {
  329. // Both files deliver — this is not concentration by elimination — but the
  330. // one that answers the question gets several times the bytes of the helper
  331. // it calls. Pre-CG-12 this ratio was 0.8, i.e. inverted.
  332. const answer = run.share(ALLOCATOR);
  333. const helper = run.share(HELPER);
  334. expect(helper).toBeGreaterThan(0);
  335. expect(answer).toBeGreaterThan(helper * 3);
  336. });
  337. it('spends nothing on the incidental name collision', () => {
  338. expect(run.bytes.get(INCIDENTAL) ?? 0).toBe(0);
  339. expect(run.shareUnder('scripts/')).toBe(0);
  340. });
  341. it('reserves in proportion to score, before anything renders', () => {
  342. // The reservations are the contract the render loop then spends. Asserting
  343. // them directly — not just the bytes that came out — separates "allocation
  344. // is proportional" from "the render loop happened to emit these sizes".
  345. const answerReserved = run.allowance(ALLOCATOR);
  346. const helperReserved = run.allowance(HELPER);
  347. expect(answerReserved).toBeGreaterThan(helperReserved);
  348. expect(answerReserved / helperReserved).toBeGreaterThan(run.score(ALLOCATOR) / run.score(HELPER) * 0.5);
  349. // Nothing is over-promised: the sum of reservations fits the pool, and the
  350. // pool fits the envelope. This is the invariant the whole epic rests on.
  351. expect(run.report.allocation.reserved).toBeLessThanOrEqual(run.report.allocation.pool);
  352. expect(run.report.allocation.pool).toBeLessThanOrEqual(run.report.budget.maxOutputChars);
  353. });
  354. it('keeps the response inside the hard ceiling and under the inline cap', () => {
  355. // Two different bounds, and it matters which is which. `maxOutputChars`
  356. // bounds the RESERVATIONS (asserted above); the RESPONSE is bounded by
  357. // `hardCeiling` — 1.5x the envelope, capped at 25K — because the render
  358. // loop is allowed a bounded overshoot for the whole-file grace and an
  359. // oversize first cluster. The 25K is the one that must never move: past it
  360. // the host writes the result to a file the agent Reads back.
  361. const budget = getExploreOutputBudget(project.cg.getFiles().length);
  362. const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), INLINE_CAP);
  363. expect(run.text.length).toBeLessThanOrEqual(hardCeiling);
  364. expect(run.text.length).toBeLessThan(INLINE_CAP);
  365. });
  366. it('records the shape of the split so a regression is legible', () => {
  367. // Not a gate — a snapshot, so a change that shifts the split shows up in the
  368. // diff rather than silently flipping a threshold.
  369. expect({
  370. answerWinsEnvelope: run.share(ALLOCATOR) > run.share(HELPER),
  371. helperStillDelivers: (run.bytes.get(HELPER) ?? 0) > 0,
  372. incidentalDelivers: (run.bytes.get(INCIDENTAL) ?? 0) > 0,
  373. }).toEqual({
  374. answerWinsEnvelope: true,
  375. helperStillDelivers: true,
  376. incidentalDelivers: false,
  377. });
  378. });
  379. });
  380. });
  381. // ── 2. Degenerate and diffuse result sets ───────────────────────────────────
  382. describe('allocation on degenerate result sets', () => {
  383. let project: Project;
  384. beforeAll(async () => {
  385. // Four modules that are deliberate COPIES of each other, plus one unrelated
  386. // file. Copies are the pathological input for a proportional split: every
  387. // candidate carries the same weight, so the split divides by a denominator
  388. // that is entirely made of ties.
  389. const twin = (n: number) => `
  390. export class InventoryLedger${n} {
  391. private rows: number[] = [];
  392. public recordInventoryMovement(quantity: number): void {
  393. this.rows.push(quantity);
  394. }
  395. public settleInventoryLedger(): number {
  396. return this.rows.reduce((sum, row) => sum + row, 0);
  397. }
  398. }
  399. `;
  400. project = await buildProject('codegraph-alloc-degenerate-', {
  401. 'src/ledger/one.ts': twin(1),
  402. 'src/ledger/two.ts': twin(2),
  403. 'src/ledger/three.ts': twin(3),
  404. 'src/ledger/four.ts': twin(4),
  405. 'src/unrelated/colors.ts': `
  406. export const PALETTE = ['oxblood', 'paper', 'ink'];
  407. export function pickPaletteEntry(index: number): string {
  408. return PALETTE[index % PALETTE.length]!;
  409. }
  410. `,
  411. });
  412. }, 120_000);
  413. afterAll(() => destroyProject(project));
  414. it('does not starve anyone when every file scores identically', async () => {
  415. // The all-ties case, end to end: no division by zero, nobody cliffed for
  416. // being relatively weak (nothing IS relatively weak), and no single copy
  417. // sweeping the envelope on an arbitrary tiebreak.
  418. const run = await explore(project, 'how does the inventory ledger record and settle movements');
  419. expect(run.isError).toBe(false);
  420. const ledger = [...run.bytes].filter(([file]) => file.startsWith('src/ledger/'));
  421. expect(ledger.length).toBeGreaterThanOrEqual(2);
  422. const shares = ledger.map(([, n]) => n);
  423. expect(Math.max(...shares) / Math.min(...shares)).toBeLessThan(3);
  424. for (const [file, n] of ledger) {
  425. expect(n, `${file} starved`).toBeGreaterThan(0);
  426. }
  427. // The reservations behind those bytes divided cleanly too.
  428. expect(run.report.allocation.reserved).toBeLessThanOrEqual(run.report.allocation.pool);
  429. });
  430. it('answers a single-file question without over-spending the envelope on it', async () => {
  431. const run = await explore(project, 'pickPaletteEntry');
  432. expect(run.isError).toBe(false);
  433. expect(run.bytes.get('src/unrelated/colors.ts') ?? 0).toBeGreaterThan(0);
  434. const budget = getExploreOutputBudget(project.cg.getFiles().length);
  435. // One dominant file still cannot exceed the share ceiling, and the response
  436. // as a whole still fits the envelope's hard ceiling.
  437. expect(run.bytes.get('src/unrelated/colors.ts')!)
  438. .toBeLessThanOrEqual(Math.round(budget.maxOutputChars * EXPLORE_ALLOCATION.MAX_SHARE));
  439. expect(run.text.length).toBeLessThan(INLINE_CAP);
  440. });
  441. it('returns guidance rather than an error when nothing matches', async () => {
  442. // An `isError` response teaches the agent to abandon codegraph for the rest
  443. // of the session, so a zero-result allocation must stay success-shaped.
  444. const run = await explore(project, 'quantumFluxCapacitorHandshake');
  445. expect(run.isError).toBe(false);
  446. expect(run.text.length).toBeGreaterThan(0);
  447. expect(run.bytes.size).toBe(0);
  448. });
  449. });
  450. describe('the diffuse-query control', () => {
  451. let project: Project;
  452. beforeAll(async () => {
  453. // Six genuinely distinct subsystems, each a legitimate partial answer to a
  454. // survey question. Concentration is the epic's goal, but over-correcting here
  455. // costs a round-trip: the agent's fallback for an under-served survey is
  456. // Grep, not a second explore.
  457. const subsystem = (name: string, verb: string) => `
  458. export interface ${name}Options {
  459. retries: number;
  460. }
  461. export class ${name}Service {
  462. constructor(private readonly options: ${name}Options) {}
  463. public ${verb}Request(payload: string): string {
  464. return this.describe${name}() + ':' + payload;
  465. }
  466. public describe${name}(): string {
  467. return '${name} with ' + this.options.retries + ' retries';
  468. }
  469. }
  470. `;
  471. project = await buildProject('codegraph-alloc-diffuse-', {
  472. 'src/services/auth.ts': subsystem('Auth', 'authorize'),
  473. 'src/services/billing.ts': subsystem('Billing', 'charge'),
  474. 'src/services/search.ts': subsystem('Search', 'query'),
  475. 'src/services/notify.ts': subsystem('Notify', 'publish'),
  476. 'src/services/report.ts': subsystem('Report', 'render'),
  477. 'src/services/audit.ts': subsystem('Audit', 'record'),
  478. });
  479. }, 120_000);
  480. afterAll(() => destroyProject(project));
  481. it('still returns a spread for a survey-style question', async () => {
  482. // The over-correction guard for CG-10's floor and CG-12's cliff together: a
  483. // question with no single right answer must come back as several usable
  484. // sections, not one file plus a pointer list.
  485. const run = await explore(project, 'what services does this project expose and what does each one do');
  486. expect(run.isError).toBe(false);
  487. const services = [...run.bytes].filter(([file]) => file.startsWith('src/services/'));
  488. expect(services.length).toBeGreaterThanOrEqual(3);
  489. const total = services.reduce((sum, [, n]) => sum + n, 0);
  490. expect(total).toBeGreaterThan(0);
  491. for (const [file, n] of services) {
  492. // Nobody is reduced to a fragment, and nobody swallows the response.
  493. expect(n, `${file} fragment`).toBeGreaterThan(200);
  494. expect(n / total, `${file} hogged the envelope`).toBeLessThan(0.8);
  495. }
  496. });
  497. it('names whatever it could not show, so the spread stays completable', async () => {
  498. const run = await explore(project, 'what services does this project expose and what does each one do');
  499. const shown = [...run.bytes.keys()].filter((f) => f.startsWith('src/services/'));
  500. const missing = ['auth', 'billing', 'search', 'notify', 'report', 'audit']
  501. .map((n) => `src/services/${n}.ts`)
  502. .filter((f) => !shown.includes(f));
  503. for (const file of missing) {
  504. expect(run.text, `${file} dropped without a pointer`).toContain(file);
  505. }
  506. });
  507. });