cache-smoke.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778
  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. * --board-strategy S Pin backgroundJobs.strategy ("latest" or
  32. * "checkpoint-compatible") via a scratch project
  33. * config — for A/B-ing issue #874. Spawned server only.
  34. *
  35. * Exit codes: 0 caching works · 1 bust detected · 2 inconclusive (provider
  36. * reported no cache telemetry) · 3 setup/runtime error.
  37. *
  38. * Each run costs real requests against your provider; the extensive set also
  39. * spawns background specialist sessions and takes several minutes. This is a
  40. * manual/operational probe, not a CI test — the CI-side guarantees live in
  41. * the cache-safety suites (see docs/cache-verification.md).
  42. */
  43. import { spawn } from 'node:child_process';
  44. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
  45. import { createServer } from 'node:http';
  46. import { tmpdir } from 'node:os';
  47. import path from 'node:path';
  48. interface Args {
  49. server?: string;
  50. provider?: string;
  51. model?: string;
  52. agent?: string;
  53. scenarios: string[];
  54. turnTimeoutMs: number;
  55. keepSessions: boolean;
  56. /** Force backgroundJobs.strategy via a scratch project config (issue #874 A/B). */
  57. boardStrategy?: 'latest' | 'checkpoint-compatible';
  58. }
  59. interface Turn {
  60. text: string;
  61. /** Wait after the turn completes (lets background work land). */
  62. pauseAfterMs?: number;
  63. }
  64. interface Scenario {
  65. name: string;
  66. description: string;
  67. /** Plugin machinery this scenario is designed to fire. */
  68. triggers: string;
  69. turns: (nonce: string) => Turn[];
  70. }
  71. interface RequestRow {
  72. messageID: string;
  73. input: number;
  74. output: number;
  75. cacheRead: number;
  76. cacheWrite: number;
  77. }
  78. interface SessionReport {
  79. label: string;
  80. rows: RequestRow[];
  81. }
  82. type Verdict = 'ok' | 'plateau' | 'bust' | 'inconclusive';
  83. /**
  84. * Requests at or above this input size with zero cache reads (after the
  85. * first request of their session) count as suspect — comfortably above
  86. * every provider's minimum cacheable prefix (OpenAI 1024, Anthropic ≤4096).
  87. */
  88. const SUSPECT_INPUT_THRESHOLD = 4096;
  89. /**
  90. * Cache-read plateau detection (issue #874 signature): the reusable prefix
  91. * stops growing — `cache-read` stays frozen at the same nonzero value for
  92. * consecutive requests while sizeable uncached input keeps accumulating.
  93. * Distinct from a bust (reads never drop to zero). Small frozen streaks are
  94. * normal (providers round reads to ~128-token boundaries), so both
  95. * thresholds must be met.
  96. */
  97. const PLATEAU_STREAK_THRESHOLD = 3;
  98. const PLATEAU_INPUT_THRESHOLD = 6144;
  99. interface PlateauFinding {
  100. frozenAt: number;
  101. requests: number;
  102. accumulatedInput: number;
  103. }
  104. const NO_TOOLS = 'Do not use any tools.';
  105. const SCENARIOS: Scenario[] = [
  106. {
  107. name: 'plain',
  108. description: 'multi-turn conversation, no tools',
  109. triggers: 'phase reminder, skills filter, system transform',
  110. turns: (nonce) => [
  111. {
  112. text: `Cache smoke probe ${nonce}. Reply with exactly "ack 1" and nothing else. ${NO_TOOLS}`,
  113. },
  114. { text: `Reply with exactly "ack 2" and nothing else. ${NO_TOOLS}` },
  115. { text: `Reply with exactly "ack 3" and nothing else. ${NO_TOOLS}` },
  116. ],
  117. },
  118. {
  119. name: 'tools',
  120. description: 'tool loop within and across turns',
  121. triggers: 'tool-result growth across steps',
  122. turns: (nonce) => [
  123. {
  124. text: `Cache smoke probe ${nonce}. Read the file package.json in the current directory and reply with only the value of its "name" field.`,
  125. },
  126. { text: `Reply with exactly "ack done" and nothing else. ${NO_TOOLS}` },
  127. ],
  128. },
  129. {
  130. name: 'nudge',
  131. description: 'direct file read arms the post-file-tool nudge',
  132. triggers: 'post-file-tool-nudge injection, then phase-reminder equilibrium',
  133. turns: (nonce) => [
  134. {
  135. text: `Cache smoke probe ${nonce}. Do not delegate: use your read tool yourself on package.json and reply with only its "name" value.`,
  136. },
  137. { text: `Reply with exactly "ack nudged" and nothing else. ${NO_TOOLS}` },
  138. {
  139. text: `Reply with exactly "ack settled" and nothing else. ${NO_TOOLS}`,
  140. },
  141. ],
  142. },
  143. {
  144. name: 'todos',
  145. description: 'todo list created, updated, and completed across turns',
  146. triggers: 'todowrite churn, todo hygiene',
  147. turns: (nonce) => [
  148. {
  149. text: `Cache smoke probe ${nonce}. Use the todowrite tool to create exactly three todos named alpha, beta, gamma. Then reply with exactly "ack todos".`,
  150. },
  151. {
  152. text: 'Mark the todo alpha as completed and add a new todo named delta. Then reply with exactly "ack updated".',
  153. },
  154. {
  155. text: 'Mark every remaining todo as completed. Then reply with exactly "ack cleared".',
  156. },
  157. { text: `Reply with exactly "ack final" and nothing else. ${NO_TOOLS}` },
  158. ],
  159. },
  160. {
  161. name: 'long',
  162. description: 'six-turn conversation, growing history',
  163. triggers: 'sliding cache breakpoints over a long same-session history',
  164. turns: (nonce) => [
  165. {
  166. text: `Cache smoke probe ${nonce}. Reply with exactly "ack 1" and nothing else. ${NO_TOOLS}`,
  167. },
  168. { text: `Name one prime number below 10. One word only. ${NO_TOOLS}` },
  169. { text: `Name one planet. One word only. ${NO_TOOLS}` },
  170. { text: `Name one color. One word only. ${NO_TOOLS}` },
  171. { text: `Name one weekday. One word only. ${NO_TOOLS}` },
  172. {
  173. text: `Reply with exactly "ack long done" and nothing else. ${NO_TOOLS}`,
  174. },
  175. ],
  176. },
  177. {
  178. name: 'board',
  179. description:
  180. 'background task launched without waiting; board appears, completion lands, reconcile',
  181. triggers:
  182. 'background job board trailing injection, injected completion message, reconciliation',
  183. turns: (nonce) => [
  184. {
  185. 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".`,
  186. pauseAfterMs: 30_000,
  187. },
  188. {
  189. text: 'Reconcile any completed background tasks now, then reply with exactly "ack reconciled".',
  190. },
  191. {
  192. text: `Reply with exactly "ack board done" and nothing else. ${NO_TOOLS}`,
  193. },
  194. ],
  195. },
  196. {
  197. name: 'board-churn',
  198. description:
  199. 'many turns while background launches churn the job board between requests (issue #874 workload)',
  200. triggers:
  201. 'board strip/re-append across consecutive requests; detects cache-read plateaus where the reusable prefix stops growing',
  202. turns: (nonce) => [
  203. {
  204. text: `Cache smoke probe ${nonce}. Launch exactly one background @explorer task that lists the files in the current directory. Do not wait — reply immediately with exactly "ack churn 1".`,
  205. pauseAfterMs: 20_000,
  206. },
  207. {
  208. text: `Reply with exactly "ack churn 2" and nothing else. ${NO_TOOLS}`,
  209. },
  210. {
  211. text: 'Launch exactly one background @explorer task that counts the lines in package.json. Do not wait — reply immediately with exactly "ack churn 3".',
  212. pauseAfterMs: 20_000,
  213. },
  214. {
  215. text: `Reply with exactly "ack churn 4" and nothing else. ${NO_TOOLS}`,
  216. },
  217. {
  218. text: 'Launch exactly one background @explorer task that reports the largest file in the current directory. Do not wait — reply immediately with exactly "ack churn 5".',
  219. pauseAfterMs: 20_000,
  220. },
  221. {
  222. text: 'Reconcile all completed background tasks now, then reply with exactly "ack reconciled".',
  223. },
  224. {
  225. text: `Reply with exactly "ack churn 7" and nothing else. ${NO_TOOLS}`,
  226. },
  227. {
  228. text: `Reply with exactly "ack churn 8" and nothing else. ${NO_TOOLS}`,
  229. },
  230. ],
  231. },
  232. {
  233. name: 'running-lane',
  234. description:
  235. 'parent keeps talking while a background lane is still running (PR #871 window)',
  236. triggers:
  237. 'running task tool_result sits mid-history across consecutive requests; byte churn there busts the cache tail',
  238. turns: (nonce) => [
  239. {
  240. 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".`,
  241. },
  242. {
  243. text: `Reply with exactly "ack while running" and nothing else. ${NO_TOOLS}`,
  244. },
  245. {
  246. text: `Reply with exactly "ack still running" and nothing else. ${NO_TOOLS}`,
  247. pauseAfterMs: 45_000,
  248. },
  249. {
  250. text: 'Reconcile any completed background tasks, then reply with exactly "ack lane done".',
  251. },
  252. ],
  253. },
  254. {
  255. name: 'agents',
  256. description:
  257. 'repeated delegation: same specialist twice (session reuse), then a second specialist',
  258. triggers:
  259. 'board churn across multiple tasks, task session reuse by alias, @mention rewriting, subagent session caching',
  260. turns: (nonce) => [
  261. {
  262. 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".`,
  263. },
  264. {
  265. text: 'Give @explorer one more task: report how many lines package.json has. Wait for completion, then reply with exactly "ack explorer 2".',
  266. },
  267. {
  268. 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".',
  269. },
  270. {
  271. text: `Reply with exactly "ack agents done" and nothing else. ${NO_TOOLS}`,
  272. },
  273. ],
  274. },
  275. ];
  276. const CHEAP_SET = ['plain', 'tools'];
  277. const EXTENSIVE_SET = SCENARIOS.map((scenario) => scenario.name);
  278. function fail(message: string): never {
  279. console.error(`\ncache-smoke: ${message}`);
  280. process.exit(3);
  281. }
  282. function parseArgs(argv: string[]): Args {
  283. const args: Args = {
  284. scenarios: CHEAP_SET,
  285. turnTimeoutMs: 300_000,
  286. keepSessions: false,
  287. };
  288. for (let i = 0; i < argv.length; i += 1) {
  289. const flag = argv[i];
  290. const value = () => {
  291. const next = argv[i + 1];
  292. if (next === undefined) fail(`missing value for ${flag}`);
  293. i += 1;
  294. return next;
  295. };
  296. switch (flag) {
  297. case '--server':
  298. args.server = value().replace(/\/$/, '');
  299. break;
  300. case '--provider':
  301. args.provider = value();
  302. break;
  303. case '--model':
  304. args.model = value();
  305. break;
  306. case '--agent':
  307. args.agent = value();
  308. break;
  309. case '--scenario': {
  310. const requested = value();
  311. if (requested === 'all' || requested === 'extensive') {
  312. args.scenarios = EXTENSIVE_SET;
  313. break;
  314. }
  315. const names = requested.split(',');
  316. for (const name of names) {
  317. if (!SCENARIOS.some((scenario) => scenario.name === name)) {
  318. fail(
  319. `unknown scenario "${name}" (${EXTENSIVE_SET.join(' | ')} | extensive | all)`,
  320. );
  321. }
  322. }
  323. args.scenarios = names;
  324. break;
  325. }
  326. case '--turn-timeout-ms':
  327. args.turnTimeoutMs = Number(value());
  328. break;
  329. case '--keep-sessions':
  330. args.keepSessions = true;
  331. break;
  332. case '--board-strategy': {
  333. const strategy = value();
  334. if (strategy !== 'latest' && strategy !== 'checkpoint-compatible') {
  335. fail('--board-strategy must be "latest" or "checkpoint-compatible"');
  336. }
  337. args.boardStrategy = strategy;
  338. break;
  339. }
  340. default:
  341. fail(`unknown flag ${flag}`);
  342. }
  343. }
  344. if (!!args.provider !== !!args.model) {
  345. fail('--provider and --model must be given together');
  346. }
  347. if (args.boardStrategy && args.server) {
  348. fail(
  349. '--board-strategy requires a spawned server (it writes a scratch project config); drop --server',
  350. );
  351. }
  352. if (!Number.isFinite(args.turnTimeoutMs) || args.turnTimeoutMs <= 0) {
  353. fail('--turn-timeout-ms must be a positive number');
  354. }
  355. return args;
  356. }
  357. function getFreePort(): Promise<number> {
  358. return new Promise((resolve, reject) => {
  359. const server = createServer();
  360. server.once('error', reject);
  361. server.listen(0, '127.0.0.1', () => {
  362. const address = server.address();
  363. if (!address || typeof address === 'string') {
  364. server.close();
  365. reject(new Error('failed to allocate a port'));
  366. return;
  367. }
  368. server.close((error) => (error ? reject(error) : resolve(address.port)));
  369. });
  370. });
  371. }
  372. function isRecord(value: unknown): value is Record<string, unknown> {
  373. return !!value && typeof value === 'object' && !Array.isArray(value);
  374. }
  375. function finiteNumber(value: unknown): number {
  376. return typeof value === 'number' && Number.isFinite(value) ? value : 0;
  377. }
  378. async function request(
  379. base: string,
  380. method: string,
  381. route: string,
  382. body?: unknown,
  383. timeoutMs = 30_000,
  384. ): Promise<unknown> {
  385. const response = await fetch(`${base}${route}`, {
  386. method,
  387. headers: body === undefined ? {} : { 'content-type': 'application/json' },
  388. body: body === undefined ? undefined : JSON.stringify(body),
  389. signal: AbortSignal.timeout(timeoutMs),
  390. });
  391. if (!response.ok) {
  392. throw new Error(`${method} ${route} returned HTTP ${response.status}`);
  393. }
  394. const text = await response.text();
  395. if (!text) return undefined;
  396. try {
  397. return JSON.parse(text);
  398. } catch {
  399. return undefined;
  400. }
  401. }
  402. async function waitForHealth(base: string): Promise<void> {
  403. const deadline = Date.now() + 40_000;
  404. while (Date.now() < deadline) {
  405. try {
  406. const response = await fetch(`${base}/global/health`, {
  407. signal: AbortSignal.timeout(2_000),
  408. });
  409. if (response.ok) return;
  410. } catch {
  411. // keep polling
  412. }
  413. await new Promise((resolve) => setTimeout(resolve, 400));
  414. }
  415. throw new Error('server did not become healthy within 40s');
  416. }
  417. function extractAssistantRows(rawMessages: unknown): RequestRow[] {
  418. if (!Array.isArray(rawMessages)) return [];
  419. const rows: RequestRow[] = [];
  420. for (const raw of rawMessages) {
  421. const info = isRecord(raw) && isRecord(raw.info) ? raw.info : undefined;
  422. if (info?.role !== 'assistant') continue;
  423. const tokens = isRecord(info.tokens) ? info.tokens : undefined;
  424. if (!tokens) continue;
  425. const cache = isRecord(tokens.cache) ? tokens.cache : undefined;
  426. rows.push({
  427. messageID: typeof info.id === 'string' ? info.id : '?',
  428. input: finiteNumber(tokens.input),
  429. output: finiteNumber(tokens.output),
  430. cacheRead: finiteNumber(cache?.read),
  431. cacheWrite: finiteNumber(cache?.write),
  432. });
  433. }
  434. return rows;
  435. }
  436. async function fetchSessionRows(
  437. base: string,
  438. sessionID: string,
  439. ): Promise<RequestRow[]> {
  440. const rawMessages = await request(
  441. base,
  442. 'GET',
  443. `/session/${encodeURIComponent(sessionID)}/message`,
  444. );
  445. return extractAssistantRows(rawMessages);
  446. }
  447. async function listChildSessions(
  448. base: string,
  449. parentID: string,
  450. ): Promise<Array<{ id: string; title: string }>> {
  451. const raw = await request(base, 'GET', '/session').catch(() => undefined);
  452. if (!Array.isArray(raw)) return [];
  453. const children: Array<{ id: string; title: string }> = [];
  454. for (const item of raw) {
  455. if (!isRecord(item)) continue;
  456. if (item.parentID !== parentID || typeof item.id !== 'string') continue;
  457. children.push({
  458. id: item.id,
  459. title: typeof item.title === 'string' ? item.title : item.id,
  460. });
  461. }
  462. return children;
  463. }
  464. /** Suspect = non-first request with a sizeable prompt and zero cache reads. */
  465. function suspectRows(rows: RequestRow[]): RequestRow[] {
  466. return rows
  467. .slice(1)
  468. .filter(
  469. (row) => row.cacheRead === 0 && row.input >= SUSPECT_INPUT_THRESHOLD,
  470. );
  471. }
  472. /**
  473. * Issue #874 signature: cache-read frozen at the same nonzero value across
  474. * consecutive requests while sizeable uncached input accumulates — the
  475. * reusable prefix has stopped growing even though nothing reads zero.
  476. */
  477. function findPlateau(rows: RequestRow[]): PlateauFinding | undefined {
  478. let worst: PlateauFinding | undefined;
  479. let streak = 1;
  480. let accumulatedInput = 0;
  481. for (let i = 1; i < rows.length; i += 1) {
  482. const row = rows[i];
  483. if (row.cacheRead > 0 && row.cacheRead === rows[i - 1].cacheRead) {
  484. streak += 1;
  485. accumulatedInput += row.input;
  486. if (
  487. streak >= PLATEAU_STREAK_THRESHOLD &&
  488. accumulatedInput >= PLATEAU_INPUT_THRESHOLD &&
  489. (!worst || accumulatedInput > worst.accumulatedInput)
  490. ) {
  491. worst = {
  492. frozenAt: row.cacheRead,
  493. requests: streak,
  494. accumulatedInput,
  495. };
  496. }
  497. } else {
  498. streak = 1;
  499. accumulatedInput = 0;
  500. }
  501. }
  502. return worst;
  503. }
  504. function judge(reports: SessionReport[]): Verdict {
  505. const allRows = reports.flatMap((report) => report.rows);
  506. if (allRows.length < 2) return 'inconclusive';
  507. const anyTelemetry = allRows.some(
  508. (row) => row.cacheRead > 0 || row.cacheWrite > 0,
  509. );
  510. if (!anyTelemetry) return 'inconclusive';
  511. const suspects = reports.flatMap((report) => suspectRows(report.rows));
  512. if (suspects.length > 0) return 'bust';
  513. const plateaued = reports.some((report) => findPlateau(report.rows));
  514. return plateaued ? 'plateau' : 'ok';
  515. }
  516. function coverage(rows: RequestRow[]): string {
  517. const later = rows.slice(1);
  518. const read = later.reduce((sum, row) => sum + row.cacheRead, 0);
  519. const input = later.reduce((sum, row) => sum + row.input, 0);
  520. const denominator = read + input;
  521. return denominator > 0
  522. ? `${((read / denominator) * 100).toFixed(1)}%`
  523. : 'n/a';
  524. }
  525. function printSessionTable(report: SessionReport): void {
  526. console.log(` ${report.label}`);
  527. if (report.rows.length === 0) {
  528. console.log(' no assistant requests with token telemetry recorded');
  529. return;
  530. }
  531. console.log(
  532. ' req input output cache-read cache-write read-coverage',
  533. );
  534. const suspects = new Set(suspectRows(report.rows));
  535. report.rows.forEach((row, index) => {
  536. const denominator = row.input + row.cacheRead;
  537. const rowCoverage =
  538. denominator > 0
  539. ? `${((row.cacheRead / denominator) * 100).toFixed(1)}%`
  540. : 'n/a';
  541. const marker = suspects.has(row) ? ' ← SUSPECT' : '';
  542. console.log(
  543. ` #${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}`,
  544. );
  545. });
  546. console.log(
  547. ` cache-read coverage after first request: ${coverage(report.rows)}`,
  548. );
  549. const plateau = findPlateau(report.rows);
  550. if (plateau) {
  551. console.log(
  552. ` ⚠ plateau: cache-read frozen at ${plateau.frozenAt} for ${plateau.requests} consecutive requests while ${plateau.accumulatedInput} uncached input tokens accumulated`,
  553. );
  554. }
  555. }
  556. function printScenarioReport(
  557. scenario: Scenario,
  558. reports: SessionReport[],
  559. verdict: Verdict,
  560. ): void {
  561. console.log(`\n━━ scenario: ${scenario.name} (${scenario.description})`);
  562. console.log(` triggers: ${scenario.triggers}`);
  563. for (const report of reports) {
  564. printSessionTable(report);
  565. }
  566. const labels: Record<Verdict, string> = {
  567. ok: '✅ every sizeable follow-up request read the provider cache',
  568. plateau:
  569. '⚠️ cache-read plateaued — reads never dropped to zero, but the reusable prefix stopped growing while input accumulated (issue #874 signature)',
  570. bust: '❌ SUSPECT requests above read 0 cached tokens — the prompt prefix changed between requests',
  571. inconclusive:
  572. '⚠️ provider reported no cache telemetry — cannot verify (provider may not support or report caching)',
  573. };
  574. console.log(` verdict: ${labels[verdict]}`);
  575. }
  576. async function runScenario(
  577. base: string,
  578. args: Args,
  579. scenario: Scenario,
  580. ): Promise<{ reports: SessionReport[]; verdict: Verdict }> {
  581. const created = await request(base, 'POST', '/session', {});
  582. const sessionID = isRecord(created) ? String(created.id ?? '') : '';
  583. if (!sessionID) throw new Error('POST /session returned no session id');
  584. const cleanupIDs = [sessionID];
  585. try {
  586. const nonce = crypto.randomUUID();
  587. for (const turn of scenario.turns(nonce)) {
  588. await request(
  589. base,
  590. 'POST',
  591. `/session/${encodeURIComponent(sessionID)}/message`,
  592. {
  593. ...(args.agent ? { agent: args.agent } : {}),
  594. ...(args.provider && args.model
  595. ? { model: { providerID: args.provider, modelID: args.model } }
  596. : {}),
  597. parts: [{ type: 'text', text: turn.text }],
  598. },
  599. args.turnTimeoutMs,
  600. );
  601. if (turn.pauseAfterMs) {
  602. console.log(
  603. ` (waiting ${Math.round(turn.pauseAfterMs / 1000)}s for background work…)`,
  604. );
  605. await new Promise((resolve) => setTimeout(resolve, turn.pauseAfterMs));
  606. }
  607. }
  608. const reports: SessionReport[] = [
  609. {
  610. label: `session ${sessionID} (main)`,
  611. rows: await fetchSessionRows(base, sessionID),
  612. },
  613. ];
  614. for (const child of await listChildSessions(base, sessionID)) {
  615. cleanupIDs.push(child.id);
  616. reports.push({
  617. label: `session ${child.id} (subagent: ${child.title})`,
  618. rows: await fetchSessionRows(base, child.id),
  619. });
  620. }
  621. return { reports, verdict: judge(reports) };
  622. } finally {
  623. if (!args.keepSessions) {
  624. for (const id of cleanupIDs.reverse()) {
  625. await request(
  626. base,
  627. 'DELETE',
  628. `/session/${encodeURIComponent(id)}`,
  629. ).catch(() => {});
  630. }
  631. } else {
  632. console.log(` sessions kept: ${cleanupIDs.join(', ')}`);
  633. }
  634. }
  635. }
  636. async function main(): Promise<void> {
  637. const args = parseArgs(process.argv.slice(2));
  638. let base = args.server;
  639. let child: ReturnType<typeof spawn> | undefined;
  640. let scratch: string | undefined;
  641. if (!base) {
  642. const binary = process.env.OPENCODE_BIN ?? Bun.which('opencode');
  643. if (!binary) {
  644. fail(
  645. 'opencode binary not found — install opencode or set OPENCODE_BIN, or pass --server URL',
  646. );
  647. }
  648. scratch = mkdtempSync(path.join(tmpdir(), 'cache-smoke-'));
  649. writeFileSync(
  650. path.join(scratch, 'package.json'),
  651. `${JSON.stringify({ name: 'cache-smoke-fixture', version: '0.0.0' }, null, 2)}\n`,
  652. );
  653. if (args.boardStrategy) {
  654. // Project-local plugin config overrides the user config, pinning the
  655. // board strategy for this run regardless of global settings.
  656. mkdirSync(path.join(scratch, '.opencode'), { recursive: true });
  657. writeFileSync(
  658. path.join(scratch, '.opencode', 'oh-my-opencode-slim.json'),
  659. `${JSON.stringify(
  660. {
  661. backgroundJobs: {
  662. strategy: args.boardStrategy,
  663. maxRetainedSnapshots: 20,
  664. },
  665. },
  666. null,
  667. 2,
  668. )}\n`,
  669. );
  670. console.log(
  671. `board strategy pinned via project config: ${args.boardStrategy}`,
  672. );
  673. }
  674. const port = await getFreePort();
  675. base = `http://127.0.0.1:${port}`;
  676. console.log(`starting opencode serve on ${base} (cwd: ${scratch})`);
  677. child = spawn(
  678. binary,
  679. ['serve', '--hostname', '127.0.0.1', '--port', String(port)],
  680. {
  681. cwd: scratch,
  682. stdio: ['ignore', 'pipe', 'pipe'],
  683. },
  684. );
  685. const stderrChunks: string[] = [];
  686. child.stderr?.on('data', (chunk: Buffer) => {
  687. stderrChunks.push(String(chunk));
  688. });
  689. child.once('exit', (code) => {
  690. if (code !== null && code !== 0) {
  691. console.error(stderrChunks.join('').slice(-2000));
  692. fail(`opencode serve exited early with code ${code}`);
  693. }
  694. });
  695. await waitForHealth(base);
  696. }
  697. const cleanup = () => {
  698. child?.kill('SIGTERM');
  699. if (scratch) rmSync(scratch, { recursive: true, force: true });
  700. };
  701. try {
  702. const scenarios = SCENARIOS.filter((scenario) =>
  703. args.scenarios.includes(scenario.name),
  704. );
  705. const verdicts: Verdict[] = [];
  706. for (const scenario of scenarios) {
  707. console.log(`\nrunning scenario: ${scenario.name}…`);
  708. const { reports, verdict } = await runScenario(base, args, scenario);
  709. printScenarioReport(scenario, reports, verdict);
  710. verdicts.push(verdict);
  711. }
  712. console.log('');
  713. if (verdicts.includes('bust')) {
  714. console.log(
  715. 'RESULT: ❌ cache bust detected. Cross-check the plugin build (bun run build), then use docs/cache-verification.md to localize the changing prefix byte.',
  716. );
  717. process.exitCode = 1;
  718. } else if (verdicts.includes('plateau')) {
  719. console.log(
  720. 'RESULT: ⚠️ cache-read plateau detected — the reusable prefix stopped growing (issue #874). Compare board strategies with --board-strategy latest vs checkpoint-compatible.',
  721. );
  722. process.exitCode = 1;
  723. } else if (verdicts.every((verdict) => verdict === 'inconclusive')) {
  724. console.log(
  725. 'RESULT: ⚠️ inconclusive — the provider reported no cache telemetry for any request.',
  726. );
  727. process.exitCode = 2;
  728. } else {
  729. console.log(
  730. 'RESULT: ✅ provider prompt caching is working across the tested scenarios.',
  731. );
  732. }
  733. } finally {
  734. cleanup();
  735. }
  736. }
  737. main().catch((error) => {
  738. fail(error instanceof Error ? error.message : String(error));
  739. });