cache-smoke.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. /**
  2. * Cache smoke — live end-to-end probe answering "is provider prompt caching
  3. * working in my setup right now?"
  4. *
  5. * Starts a real `opencode serve` using your normal global config, auth, and
  6. * plugin, runs scripted conversations against your default (or given)
  7. * provider/model, and reads the provider-reported cache telemetry that
  8. * OpenCode stores on every assistant message (`tokens.cache.read/write`,
  9. * normalized from Anthropic's cache_control usage fields and OpenAI's
  10. * `prompt_tokens_details.cached_tokens`).
  11. *
  12. * The scenarios are designed to trigger this plugin's payload-touching
  13. * machinery on purpose — phase reminders, the post-file-tool nudge, todo
  14. * churn, background job board injection/reconciliation, repeated specialist
  15. * delegation with session reuse — so each injection path is validated
  16. * against a real provider, including the subagent child sessions it spawns.
  17. *
  18. * Usage:
  19. * bun run cache:smoke [-- options]
  20. *
  21. * Options:
  22. * --server URL Use an already-running OpenCode server instead of
  23. * starting one (skips spawn/cleanup of the server).
  24. * --provider ID Route turns to this provider (requires --model).
  25. * --model ID Route turns to this model (requires --provider).
  26. * --agent NAME Agent for each turn (default: server default).
  27. * --scenario LIST Comma list of scenario names, or "extensive"/"all"
  28. * (default: plain,tools — the cheap probe).
  29. * --turn-timeout-ms N Per-turn timeout (default 300000).
  30. * --keep-sessions Don't delete the probe sessions afterwards.
  31. *
  32. * Exit codes: 0 caching works · 1 bust detected · 2 inconclusive (provider
  33. * reported no cache telemetry) · 3 setup/runtime error.
  34. *
  35. * Each run costs real requests against your provider; the extensive set also
  36. * spawns background specialist sessions and takes several minutes. This is a
  37. * manual/operational probe, not a CI test — the CI-side guarantees live in
  38. * the cache-safety suites (see docs/cache-verification.md).
  39. */
  40. import { spawn } from 'node:child_process';
  41. import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
  42. import { createServer } from 'node:http';
  43. import { tmpdir } from 'node:os';
  44. import path from 'node:path';
  45. interface Args {
  46. server?: string;
  47. provider?: string;
  48. model?: string;
  49. agent?: string;
  50. scenarios: string[];
  51. turnTimeoutMs: number;
  52. keepSessions: boolean;
  53. }
  54. interface Turn {
  55. text: string;
  56. /** Wait after the turn completes (lets background work land). */
  57. pauseAfterMs?: number;
  58. }
  59. interface Scenario {
  60. name: string;
  61. description: string;
  62. /** Plugin machinery this scenario is designed to fire. */
  63. triggers: string;
  64. turns: (nonce: string) => Turn[];
  65. }
  66. interface RequestRow {
  67. messageID: string;
  68. input: number;
  69. output: number;
  70. cacheRead: number;
  71. cacheWrite: number;
  72. }
  73. interface SessionReport {
  74. label: string;
  75. rows: RequestRow[];
  76. }
  77. type Verdict = 'ok' | 'bust' | 'inconclusive';
  78. /**
  79. * Requests at or above this input size with zero cache reads (after the
  80. * first request of their session) count as suspect — comfortably above
  81. * every provider's minimum cacheable prefix (OpenAI 1024, Anthropic ≤4096).
  82. */
  83. const SUSPECT_INPUT_THRESHOLD = 4096;
  84. const NO_TOOLS = 'Do not use any tools.';
  85. const SCENARIOS: Scenario[] = [
  86. {
  87. name: 'plain',
  88. description: 'multi-turn conversation, no tools',
  89. triggers: 'phase reminder, skills filter, system transform',
  90. turns: (nonce) => [
  91. {
  92. text: `Cache smoke probe ${nonce}. Reply with exactly "ack 1" and nothing else. ${NO_TOOLS}`,
  93. },
  94. { text: `Reply with exactly "ack 2" and nothing else. ${NO_TOOLS}` },
  95. { text: `Reply with exactly "ack 3" and nothing else. ${NO_TOOLS}` },
  96. ],
  97. },
  98. {
  99. name: 'tools',
  100. description: 'tool loop within and across turns',
  101. triggers: 'tool-result growth across steps',
  102. turns: (nonce) => [
  103. {
  104. text: `Cache smoke probe ${nonce}. Read the file package.json in the current directory and reply with only the value of its "name" field.`,
  105. },
  106. { text: `Reply with exactly "ack done" and nothing else. ${NO_TOOLS}` },
  107. ],
  108. },
  109. {
  110. name: 'nudge',
  111. description: 'direct file read arms the post-file-tool nudge',
  112. triggers: 'post-file-tool-nudge injection, then phase-reminder equilibrium',
  113. turns: (nonce) => [
  114. {
  115. text: `Cache smoke probe ${nonce}. Do not delegate: use your read tool yourself on package.json and reply with only its "name" value.`,
  116. },
  117. { text: `Reply with exactly "ack nudged" and nothing else. ${NO_TOOLS}` },
  118. {
  119. text: `Reply with exactly "ack settled" and nothing else. ${NO_TOOLS}`,
  120. },
  121. ],
  122. },
  123. {
  124. name: 'todos',
  125. description: 'todo list created, updated, and completed across turns',
  126. triggers: 'todowrite churn, todo hygiene',
  127. turns: (nonce) => [
  128. {
  129. text: `Cache smoke probe ${nonce}. Use the todowrite tool to create exactly three todos named alpha, beta, gamma. Then reply with exactly "ack todos".`,
  130. },
  131. {
  132. text: 'Mark the todo alpha as completed and add a new todo named delta. Then reply with exactly "ack updated".',
  133. },
  134. {
  135. text: 'Mark every remaining todo as completed. Then reply with exactly "ack cleared".',
  136. },
  137. { text: `Reply with exactly "ack final" and nothing else. ${NO_TOOLS}` },
  138. ],
  139. },
  140. {
  141. name: 'long',
  142. description: 'six-turn conversation, growing history',
  143. triggers: 'sliding cache breakpoints over a long same-session history',
  144. turns: (nonce) => [
  145. {
  146. text: `Cache smoke probe ${nonce}. Reply with exactly "ack 1" and nothing else. ${NO_TOOLS}`,
  147. },
  148. { text: `Name one prime number below 10. One word only. ${NO_TOOLS}` },
  149. { text: `Name one planet. One word only. ${NO_TOOLS}` },
  150. { text: `Name one color. One word only. ${NO_TOOLS}` },
  151. { text: `Name one weekday. One word only. ${NO_TOOLS}` },
  152. {
  153. text: `Reply with exactly "ack long done" and nothing else. ${NO_TOOLS}`,
  154. },
  155. ],
  156. },
  157. {
  158. name: 'board',
  159. description:
  160. 'background task launched without waiting; board appears, completion lands, reconcile',
  161. triggers:
  162. 'background job board trailing injection, injected completion message, reconciliation',
  163. turns: (nonce) => [
  164. {
  165. text: `Cache smoke probe ${nonce}. Launch exactly one background @explorer task that lists the files in the current directory. Do not wait for it — reply immediately with exactly "ack launched".`,
  166. pauseAfterMs: 30_000,
  167. },
  168. {
  169. text: 'Reconcile any completed background tasks now, then reply with exactly "ack reconciled".',
  170. },
  171. {
  172. text: `Reply with exactly "ack board done" and nothing else. ${NO_TOOLS}`,
  173. },
  174. ],
  175. },
  176. {
  177. name: 'running-lane',
  178. description:
  179. 'parent keeps talking while a background lane is still running (PR #871 window)',
  180. triggers:
  181. 'running task tool_result sits mid-history across consecutive requests; byte churn there busts the cache tail',
  182. turns: (nonce) => [
  183. {
  184. text: `Cache smoke probe ${nonce}. Launch exactly one background @explorer task with this prompt: "Produce a very thorough report of at least 600 words describing every file in the current directory, its likely purpose, and recommendations." Do not wait for it — reply immediately with exactly "ack lane started".`,
  185. },
  186. {
  187. text: `Reply with exactly "ack while running" and nothing else. ${NO_TOOLS}`,
  188. },
  189. {
  190. text: `Reply with exactly "ack still running" and nothing else. ${NO_TOOLS}`,
  191. pauseAfterMs: 45_000,
  192. },
  193. {
  194. text: 'Reconcile any completed background tasks, then reply with exactly "ack lane done".',
  195. },
  196. ],
  197. },
  198. {
  199. name: 'agents',
  200. description:
  201. 'repeated delegation: same specialist twice (session reuse), then a second specialist',
  202. triggers:
  203. 'board churn across multiple tasks, task session reuse by alias, @mention rewriting, subagent session caching',
  204. turns: (nonce) => [
  205. {
  206. text: `Cache smoke probe ${nonce}. Launch a background @explorer task to list the files in the current directory. Wait for it to complete, then reply with exactly "ack explorer 1".`,
  207. },
  208. {
  209. text: 'Give @explorer one more task: report how many lines package.json has. Wait for completion, then reply with exactly "ack explorer 2".',
  210. },
  211. {
  212. text: 'Now launch a background @fixer task to create a file named hello.txt containing the single word "hi". Wait for completion, then reply with exactly "ack fixer".',
  213. },
  214. {
  215. text: `Reply with exactly "ack agents done" and nothing else. ${NO_TOOLS}`,
  216. },
  217. ],
  218. },
  219. ];
  220. const CHEAP_SET = ['plain', 'tools'];
  221. const EXTENSIVE_SET = SCENARIOS.map((scenario) => scenario.name);
  222. function fail(message: string): never {
  223. console.error(`\ncache-smoke: ${message}`);
  224. process.exit(3);
  225. }
  226. function parseArgs(argv: string[]): Args {
  227. const args: Args = {
  228. scenarios: CHEAP_SET,
  229. turnTimeoutMs: 300_000,
  230. keepSessions: false,
  231. };
  232. for (let i = 0; i < argv.length; i += 1) {
  233. const flag = argv[i];
  234. const value = () => {
  235. const next = argv[i + 1];
  236. if (next === undefined) fail(`missing value for ${flag}`);
  237. i += 1;
  238. return next;
  239. };
  240. switch (flag) {
  241. case '--server':
  242. args.server = value().replace(/\/$/, '');
  243. break;
  244. case '--provider':
  245. args.provider = value();
  246. break;
  247. case '--model':
  248. args.model = value();
  249. break;
  250. case '--agent':
  251. args.agent = value();
  252. break;
  253. case '--scenario': {
  254. const requested = value();
  255. if (requested === 'all' || requested === 'extensive') {
  256. args.scenarios = EXTENSIVE_SET;
  257. break;
  258. }
  259. const names = requested.split(',');
  260. for (const name of names) {
  261. if (!SCENARIOS.some((scenario) => scenario.name === name)) {
  262. fail(
  263. `unknown scenario "${name}" (${EXTENSIVE_SET.join(' | ')} | extensive | all)`,
  264. );
  265. }
  266. }
  267. args.scenarios = names;
  268. break;
  269. }
  270. case '--turn-timeout-ms':
  271. args.turnTimeoutMs = Number(value());
  272. break;
  273. case '--keep-sessions':
  274. args.keepSessions = true;
  275. break;
  276. default:
  277. fail(`unknown flag ${flag}`);
  278. }
  279. }
  280. if (!!args.provider !== !!args.model) {
  281. fail('--provider and --model must be given together');
  282. }
  283. if (!Number.isFinite(args.turnTimeoutMs) || args.turnTimeoutMs <= 0) {
  284. fail('--turn-timeout-ms must be a positive number');
  285. }
  286. return args;
  287. }
  288. function getFreePort(): Promise<number> {
  289. return new Promise((resolve, reject) => {
  290. const server = createServer();
  291. server.once('error', reject);
  292. server.listen(0, '127.0.0.1', () => {
  293. const address = server.address();
  294. if (!address || typeof address === 'string') {
  295. server.close();
  296. reject(new Error('failed to allocate a port'));
  297. return;
  298. }
  299. server.close((error) => (error ? reject(error) : resolve(address.port)));
  300. });
  301. });
  302. }
  303. function isRecord(value: unknown): value is Record<string, unknown> {
  304. return !!value && typeof value === 'object' && !Array.isArray(value);
  305. }
  306. function finiteNumber(value: unknown): number {
  307. return typeof value === 'number' && Number.isFinite(value) ? value : 0;
  308. }
  309. async function request(
  310. base: string,
  311. method: string,
  312. route: string,
  313. body?: unknown,
  314. timeoutMs = 30_000,
  315. ): Promise<unknown> {
  316. const response = await fetch(`${base}${route}`, {
  317. method,
  318. headers: body === undefined ? {} : { 'content-type': 'application/json' },
  319. body: body === undefined ? undefined : JSON.stringify(body),
  320. signal: AbortSignal.timeout(timeoutMs),
  321. });
  322. if (!response.ok) {
  323. throw new Error(`${method} ${route} returned HTTP ${response.status}`);
  324. }
  325. const text = await response.text();
  326. if (!text) return undefined;
  327. try {
  328. return JSON.parse(text);
  329. } catch {
  330. return undefined;
  331. }
  332. }
  333. async function waitForHealth(base: string): Promise<void> {
  334. const deadline = Date.now() + 40_000;
  335. while (Date.now() < deadline) {
  336. try {
  337. const response = await fetch(`${base}/global/health`, {
  338. signal: AbortSignal.timeout(2_000),
  339. });
  340. if (response.ok) return;
  341. } catch {
  342. // keep polling
  343. }
  344. await new Promise((resolve) => setTimeout(resolve, 400));
  345. }
  346. throw new Error('server did not become healthy within 40s');
  347. }
  348. function extractAssistantRows(rawMessages: unknown): RequestRow[] {
  349. if (!Array.isArray(rawMessages)) return [];
  350. const rows: RequestRow[] = [];
  351. for (const raw of rawMessages) {
  352. const info = isRecord(raw) && isRecord(raw.info) ? raw.info : undefined;
  353. if (info?.role !== 'assistant') continue;
  354. const tokens = isRecord(info.tokens) ? info.tokens : undefined;
  355. if (!tokens) continue;
  356. const cache = isRecord(tokens.cache) ? tokens.cache : undefined;
  357. rows.push({
  358. messageID: typeof info.id === 'string' ? info.id : '?',
  359. input: finiteNumber(tokens.input),
  360. output: finiteNumber(tokens.output),
  361. cacheRead: finiteNumber(cache?.read),
  362. cacheWrite: finiteNumber(cache?.write),
  363. });
  364. }
  365. return rows;
  366. }
  367. async function fetchSessionRows(
  368. base: string,
  369. sessionID: string,
  370. ): Promise<RequestRow[]> {
  371. const rawMessages = await request(
  372. base,
  373. 'GET',
  374. `/session/${encodeURIComponent(sessionID)}/message`,
  375. );
  376. return extractAssistantRows(rawMessages);
  377. }
  378. async function listChildSessions(
  379. base: string,
  380. parentID: string,
  381. ): Promise<Array<{ id: string; title: string }>> {
  382. const raw = await request(base, 'GET', '/session').catch(() => undefined);
  383. if (!Array.isArray(raw)) return [];
  384. const children: Array<{ id: string; title: string }> = [];
  385. for (const item of raw) {
  386. if (!isRecord(item)) continue;
  387. if (item.parentID !== parentID || typeof item.id !== 'string') continue;
  388. children.push({
  389. id: item.id,
  390. title: typeof item.title === 'string' ? item.title : item.id,
  391. });
  392. }
  393. return children;
  394. }
  395. /** Suspect = non-first request with a sizeable prompt and zero cache reads. */
  396. function suspectRows(rows: RequestRow[]): RequestRow[] {
  397. return rows
  398. .slice(1)
  399. .filter(
  400. (row) => row.cacheRead === 0 && row.input >= SUSPECT_INPUT_THRESHOLD,
  401. );
  402. }
  403. function judge(reports: SessionReport[]): Verdict {
  404. const allRows = reports.flatMap((report) => report.rows);
  405. if (allRows.length < 2) return 'inconclusive';
  406. const anyTelemetry = allRows.some(
  407. (row) => row.cacheRead > 0 || row.cacheWrite > 0,
  408. );
  409. if (!anyTelemetry) return 'inconclusive';
  410. const suspects = reports.flatMap((report) => suspectRows(report.rows));
  411. return suspects.length > 0 ? 'bust' : 'ok';
  412. }
  413. function coverage(rows: RequestRow[]): string {
  414. const later = rows.slice(1);
  415. const read = later.reduce((sum, row) => sum + row.cacheRead, 0);
  416. const input = later.reduce((sum, row) => sum + row.input, 0);
  417. const denominator = read + input;
  418. return denominator > 0
  419. ? `${((read / denominator) * 100).toFixed(1)}%`
  420. : 'n/a';
  421. }
  422. function printSessionTable(report: SessionReport): void {
  423. console.log(` ${report.label}`);
  424. if (report.rows.length === 0) {
  425. console.log(' no assistant requests with token telemetry recorded');
  426. return;
  427. }
  428. console.log(
  429. ' req input output cache-read cache-write read-coverage',
  430. );
  431. const suspects = new Set(suspectRows(report.rows));
  432. report.rows.forEach((row, index) => {
  433. const denominator = row.input + row.cacheRead;
  434. const rowCoverage =
  435. denominator > 0
  436. ? `${((row.cacheRead / denominator) * 100).toFixed(1)}%`
  437. : 'n/a';
  438. const marker = suspects.has(row) ? ' ← SUSPECT' : '';
  439. console.log(
  440. ` #${String(index + 1).padEnd(3)}${String(row.input).padEnd(11)}${String(row.output).padEnd(9)}${String(row.cacheRead).padEnd(12)}${String(row.cacheWrite).padEnd(13)}${rowCoverage}${marker}`,
  441. );
  442. });
  443. console.log(
  444. ` cache-read coverage after first request: ${coverage(report.rows)}`,
  445. );
  446. }
  447. function printScenarioReport(
  448. scenario: Scenario,
  449. reports: SessionReport[],
  450. verdict: Verdict,
  451. ): void {
  452. console.log(`\n━━ scenario: ${scenario.name} (${scenario.description})`);
  453. console.log(` triggers: ${scenario.triggers}`);
  454. for (const report of reports) {
  455. printSessionTable(report);
  456. }
  457. const labels: Record<Verdict, string> = {
  458. ok: '✅ every sizeable follow-up request read the provider cache',
  459. bust: '❌ SUSPECT requests above read 0 cached tokens — the prompt prefix changed between requests',
  460. inconclusive:
  461. '⚠️ provider reported no cache telemetry — cannot verify (provider may not support or report caching)',
  462. };
  463. console.log(` verdict: ${labels[verdict]}`);
  464. }
  465. async function runScenario(
  466. base: string,
  467. args: Args,
  468. scenario: Scenario,
  469. ): Promise<{ reports: SessionReport[]; verdict: Verdict }> {
  470. const created = await request(base, 'POST', '/session', {});
  471. const sessionID = isRecord(created) ? String(created.id ?? '') : '';
  472. if (!sessionID) throw new Error('POST /session returned no session id');
  473. const cleanupIDs = [sessionID];
  474. try {
  475. const nonce = crypto.randomUUID();
  476. for (const turn of scenario.turns(nonce)) {
  477. await request(
  478. base,
  479. 'POST',
  480. `/session/${encodeURIComponent(sessionID)}/message`,
  481. {
  482. ...(args.agent ? { agent: args.agent } : {}),
  483. ...(args.provider && args.model
  484. ? { model: { providerID: args.provider, modelID: args.model } }
  485. : {}),
  486. parts: [{ type: 'text', text: turn.text }],
  487. },
  488. args.turnTimeoutMs,
  489. );
  490. if (turn.pauseAfterMs) {
  491. console.log(
  492. ` (waiting ${Math.round(turn.pauseAfterMs / 1000)}s for background work…)`,
  493. );
  494. await new Promise((resolve) => setTimeout(resolve, turn.pauseAfterMs));
  495. }
  496. }
  497. const reports: SessionReport[] = [
  498. {
  499. label: `session ${sessionID} (main)`,
  500. rows: await fetchSessionRows(base, sessionID),
  501. },
  502. ];
  503. for (const child of await listChildSessions(base, sessionID)) {
  504. cleanupIDs.push(child.id);
  505. reports.push({
  506. label: `session ${child.id} (subagent: ${child.title})`,
  507. rows: await fetchSessionRows(base, child.id),
  508. });
  509. }
  510. return { reports, verdict: judge(reports) };
  511. } finally {
  512. if (!args.keepSessions) {
  513. for (const id of cleanupIDs.reverse()) {
  514. await request(
  515. base,
  516. 'DELETE',
  517. `/session/${encodeURIComponent(id)}`,
  518. ).catch(() => {});
  519. }
  520. } else {
  521. console.log(` sessions kept: ${cleanupIDs.join(', ')}`);
  522. }
  523. }
  524. }
  525. async function main(): Promise<void> {
  526. const args = parseArgs(process.argv.slice(2));
  527. let base = args.server;
  528. let child: ReturnType<typeof spawn> | undefined;
  529. let scratch: string | undefined;
  530. if (!base) {
  531. const binary = process.env.OPENCODE_BIN ?? Bun.which('opencode');
  532. if (!binary) {
  533. fail(
  534. 'opencode binary not found — install opencode or set OPENCODE_BIN, or pass --server URL',
  535. );
  536. }
  537. scratch = mkdtempSync(path.join(tmpdir(), 'cache-smoke-'));
  538. writeFileSync(
  539. path.join(scratch, 'package.json'),
  540. `${JSON.stringify({ name: 'cache-smoke-fixture', version: '0.0.0' }, null, 2)}\n`,
  541. );
  542. const port = await getFreePort();
  543. base = `http://127.0.0.1:${port}`;
  544. console.log(`starting opencode serve on ${base} (cwd: ${scratch})`);
  545. child = spawn(
  546. binary,
  547. ['serve', '--hostname', '127.0.0.1', '--port', String(port)],
  548. {
  549. cwd: scratch,
  550. stdio: ['ignore', 'pipe', 'pipe'],
  551. },
  552. );
  553. const stderrChunks: string[] = [];
  554. child.stderr?.on('data', (chunk: Buffer) => {
  555. stderrChunks.push(String(chunk));
  556. });
  557. child.once('exit', (code) => {
  558. if (code !== null && code !== 0) {
  559. console.error(stderrChunks.join('').slice(-2000));
  560. fail(`opencode serve exited early with code ${code}`);
  561. }
  562. });
  563. await waitForHealth(base);
  564. }
  565. const cleanup = () => {
  566. child?.kill('SIGTERM');
  567. if (scratch) rmSync(scratch, { recursive: true, force: true });
  568. };
  569. try {
  570. const scenarios = SCENARIOS.filter((scenario) =>
  571. args.scenarios.includes(scenario.name),
  572. );
  573. const verdicts: Verdict[] = [];
  574. for (const scenario of scenarios) {
  575. console.log(`\nrunning scenario: ${scenario.name}…`);
  576. const { reports, verdict } = await runScenario(base, args, scenario);
  577. printScenarioReport(scenario, reports, verdict);
  578. verdicts.push(verdict);
  579. }
  580. console.log('');
  581. if (verdicts.includes('bust')) {
  582. console.log(
  583. 'RESULT: ❌ cache bust detected. Cross-check the plugin build (bun run build), then use docs/cache-verification.md to localize the changing prefix byte.',
  584. );
  585. process.exitCode = 1;
  586. } else if (verdicts.every((verdict) => verdict === 'inconclusive')) {
  587. console.log(
  588. 'RESULT: ⚠️ inconclusive — the provider reported no cache telemetry for any request.',
  589. );
  590. process.exitCode = 2;
  591. } else {
  592. console.log(
  593. 'RESULT: ✅ provider prompt caching is working across the tested scenarios.',
  594. );
  595. }
  596. } finally {
  597. cleanup();
  598. }
  599. }
  600. main().catch((error) => {
  601. fail(error instanceof Error ? error.message : String(error));
  602. });