Bladeren bron

Add plateau detection for issue #874 and board-churn scenario

Alvin Unreal 1 week geleden
bovenliggende
commit
a831c0198f
4 gewijzigde bestanden met toevoegingen van 304 en 10 verwijderingen
  1. 27 7
      docs/cache-verification.md
  2. 143 3
      scripts/cache-smoke.ts
  3. 87 0
      src/hooks/cache-monitor/index.test.ts
  4. 47 0
      src/hooks/cache-monitor/index.ts

+ 27 - 7
docs/cache-verification.md

@@ -65,6 +65,8 @@ Each scenario is designed to fire specific plugin payload machinery:
 | `todos` | todowrite churn (create/update/complete across turns) | extensive |
 | `todos` | todowrite churn (create/update/complete across turns) | extensive |
 | `long` | sliding cache breakpoints over a six-turn history | extensive |
 | `long` | sliding cache breakpoints over a six-turn history | extensive |
 | `board` | job-board trailing injection, injected completion, reconcile | extensive |
 | `board` | job-board trailing injection, injected completion, reconcile | extensive |
+| `board-churn` | board strip/re-append across many turns; plateau detection (issue #874) | extensive |
+| `running-lane` | running task tool_result mid-history across consecutive requests (PR #871) | extensive |
 | `agents` | repeated delegation, task-session reuse, subagent caching | extensive |
 | `agents` | repeated delegation, task-session reuse, subagent caching | extensive |
 
 
 ```bash
 ```bash
@@ -73,20 +75,32 @@ bun run cache:smoke -- --scenario extensive  # full matrix, several minutes
 bun run cache:smoke -- --scenario board,agents
 bun run cache:smoke -- --scenario board,agents
 bun run cache:smoke -- --provider anthropic --model claude-sonnet-5
 bun run cache:smoke -- --provider anthropic --model claude-sonnet-5
 bun run cache:smoke -- --server http://127.0.0.1:4096   # reuse a running server
 bun run cache:smoke -- --server http://127.0.0.1:4096   # reuse a running server
+
+# Issue #874 A/B: pin the board strategy via a scratch project config
+bun run cache:smoke -- --scenario board-churn --board-strategy latest
+bun run cache:smoke -- --scenario board-churn --board-strategy checkpoint-compatible
 ```
 ```
 
 
-A request is flagged **SUSPECT** when it is not the first request of its
-session, its input is ≥4096 tokens (above every provider's minimum cacheable
-prefix), and it read zero cached tokens. Exit codes: `0` caching works, `1`
-bust detected (any suspect request), `2` inconclusive (provider reported no
-cache telemetry), `3` setup error. Providers with read-only telemetry
-(OpenAI-style `cached_tokens`) show `cache-write 0` — normal, not a failure.
+Two failure signatures are detected, plus an inconclusive case:
+
+- A request is flagged **SUSPECT** when it is not the first request of its
+  session, its input is ≥4096 tokens (above every provider's minimum
+  cacheable prefix), and it read zero cached tokens — the bust signature.
+- A session is flagged **plateau** when `cache-read` stays frozen at the
+  same nonzero value for 3+ consecutive requests while ≥6144 uncached input
+  tokens accumulate — the issue #874 signature, where the reusable prefix
+  stops growing even though nothing reads zero.
+
+Exit codes: `0` caching works, `1` bust or plateau detected, `2` inconclusive
+(provider reported no cache telemetry), `3` setup error. Providers with
+read-only telemetry (OpenAI-style `cached_tokens`) show `cache-write 0` —
+normal, not a failure.
 
 
 ## Runtime cache monitoring
 ## Runtime cache monitoring
 
 
 `src/hooks/cache-monitor/` observes `message.updated` events and the
 `src/hooks/cache-monitor/` observes `message.updated` events and the
 provider-reported `tokens.cache.read` / `tokens.cache.write` counters. It
 provider-reported `tokens.cache.read` / `tokens.cache.write` counters. It
-logs two warning shapes (observation only, to the plugin log):
+logs three warning shapes (observation only, to the plugin log):
 
 
 - `possible prompt-cache bust` — a session that previously hit the cache
 - `possible prompt-cache bust` — a session that previously hit the cache
   reports zero cache-read tokens on a sizeable request (once per bust
   reports zero cache-read tokens on a sizeable request (once per bust
@@ -99,6 +113,12 @@ logs two warning shapes (observation only, to the plugin log):
   provider telemetry to zeros, so a cache-less provider is indistinguishable
   provider telemetry to zeros, so a cache-less provider is indistinguishable
   from this; the thresholds and hedged wording keep that ambiguity from
   from this; the thresholds and hedged wording keep that ambiguity from
   becoming noise, and modest sessions on cache-less providers stay silent.
   becoming noise, and modest sessions on cache-less providers stay silent.
+- `cache-read plateau` — reads frozen at the same nonzero value for 4+
+  consecutive requests while ≥50K uncached input tokens accumulate (once per
+  plateau; re-arms when the read value changes). The issue #874 signature:
+  the reusable prefix has stopped growing even though nothing reads zero.
+  The warning suggests trying `backgroundJobs.strategy`
+  `"checkpoint-compatible"`.
 
 
 This is the field safety net for provider-side behavior no offline test can
 This is the field safety net for provider-side behavior no offline test can
 model.
 model.

+ 143 - 3
scripts/cache-smoke.ts

@@ -28,6 +28,9 @@
  *                       (default: plain,tools — the cheap probe).
  *                       (default: plain,tools — the cheap probe).
  *   --turn-timeout-ms N Per-turn timeout (default 300000).
  *   --turn-timeout-ms N Per-turn timeout (default 300000).
  *   --keep-sessions     Don't delete the probe sessions afterwards.
  *   --keep-sessions     Don't delete the probe sessions afterwards.
+ *   --board-strategy S  Pin backgroundJobs.strategy ("latest" or
+ *                       "checkpoint-compatible") via a scratch project
+ *                       config — for A/B-ing issue #874. Spawned server only.
  *
  *
  * Exit codes: 0 caching works · 1 bust detected · 2 inconclusive (provider
  * Exit codes: 0 caching works · 1 bust detected · 2 inconclusive (provider
  * reported no cache telemetry) · 3 setup/runtime error.
  * reported no cache telemetry) · 3 setup/runtime error.
@@ -39,7 +42,7 @@
  */
  */
 
 
 import { spawn } from 'node:child_process';
 import { spawn } from 'node:child_process';
-import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
 import { createServer } from 'node:http';
 import { createServer } from 'node:http';
 import { tmpdir } from 'node:os';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
 import path from 'node:path';
@@ -52,6 +55,8 @@ interface Args {
   scenarios: string[];
   scenarios: string[];
   turnTimeoutMs: number;
   turnTimeoutMs: number;
   keepSessions: boolean;
   keepSessions: boolean;
+  /** Force backgroundJobs.strategy via a scratch project config (issue #874 A/B). */
+  boardStrategy?: 'latest' | 'checkpoint-compatible';
 }
 }
 
 
 interface Turn {
 interface Turn {
@@ -81,7 +86,7 @@ interface SessionReport {
   rows: RequestRow[];
   rows: RequestRow[];
 }
 }
 
 
-type Verdict = 'ok' | 'bust' | 'inconclusive';
+type Verdict = 'ok' | 'plateau' | 'bust' | 'inconclusive';
 
 
 /**
 /**
  * Requests at or above this input size with zero cache reads (after the
  * Requests at or above this input size with zero cache reads (after the
@@ -90,6 +95,23 @@ type Verdict = 'ok' | 'bust' | 'inconclusive';
  */
  */
 const SUSPECT_INPUT_THRESHOLD = 4096;
 const SUSPECT_INPUT_THRESHOLD = 4096;
 
 
+/**
+ * Cache-read plateau detection (issue #874 signature): the reusable prefix
+ * stops growing — `cache-read` stays frozen at the same nonzero value for
+ * consecutive requests while sizeable uncached input keeps accumulating.
+ * Distinct from a bust (reads never drop to zero). Small frozen streaks are
+ * normal (providers round reads to ~128-token boundaries), so both
+ * thresholds must be met.
+ */
+const PLATEAU_STREAK_THRESHOLD = 3;
+const PLATEAU_INPUT_THRESHOLD = 6144;
+
+interface PlateauFinding {
+  frozenAt: number;
+  requests: number;
+  accumulatedInput: number;
+}
+
 const NO_TOOLS = 'Do not use any tools.';
 const NO_TOOLS = 'Do not use any tools.';
 
 
 const SCENARIOS: Scenario[] = [
 const SCENARIOS: Scenario[] = [
@@ -183,6 +205,42 @@ const SCENARIOS: Scenario[] = [
       },
       },
     ],
     ],
   },
   },
+  {
+    name: 'board-churn',
+    description:
+      'many turns while background launches churn the job board between requests (issue #874 workload)',
+    triggers:
+      'board strip/re-append across consecutive requests; detects cache-read plateaus where the reusable prefix stops growing',
+    turns: (nonce) => [
+      {
+        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".`,
+        pauseAfterMs: 20_000,
+      },
+      {
+        text: `Reply with exactly "ack churn 2" and nothing else. ${NO_TOOLS}`,
+      },
+      {
+        text: 'Launch exactly one background @explorer task that counts the lines in package.json. Do not wait — reply immediately with exactly "ack churn 3".',
+        pauseAfterMs: 20_000,
+      },
+      {
+        text: `Reply with exactly "ack churn 4" and nothing else. ${NO_TOOLS}`,
+      },
+      {
+        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".',
+        pauseAfterMs: 20_000,
+      },
+      {
+        text: 'Reconcile all completed background tasks now, then reply with exactly "ack reconciled".',
+      },
+      {
+        text: `Reply with exactly "ack churn 7" and nothing else. ${NO_TOOLS}`,
+      },
+      {
+        text: `Reply with exactly "ack churn 8" and nothing else. ${NO_TOOLS}`,
+      },
+    ],
+  },
   {
   {
     name: 'running-lane',
     name: 'running-lane',
     description:
     description:
@@ -286,6 +344,14 @@ function parseArgs(argv: string[]): Args {
       case '--keep-sessions':
       case '--keep-sessions':
         args.keepSessions = true;
         args.keepSessions = true;
         break;
         break;
+      case '--board-strategy': {
+        const strategy = value();
+        if (strategy !== 'latest' && strategy !== 'checkpoint-compatible') {
+          fail('--board-strategy must be "latest" or "checkpoint-compatible"');
+        }
+        args.boardStrategy = strategy;
+        break;
+      }
       default:
       default:
         fail(`unknown flag ${flag}`);
         fail(`unknown flag ${flag}`);
     }
     }
@@ -293,6 +359,11 @@ function parseArgs(argv: string[]): Args {
   if (!!args.provider !== !!args.model) {
   if (!!args.provider !== !!args.model) {
     fail('--provider and --model must be given together');
     fail('--provider and --model must be given together');
   }
   }
+  if (args.boardStrategy && args.server) {
+    fail(
+      '--board-strategy requires a spawned server (it writes a scratch project config); drop --server',
+    );
+  }
   if (!Number.isFinite(args.turnTimeoutMs) || args.turnTimeoutMs <= 0) {
   if (!Number.isFinite(args.turnTimeoutMs) || args.turnTimeoutMs <= 0) {
     fail('--turn-timeout-ms must be a positive number');
     fail('--turn-timeout-ms must be a positive number');
   }
   }
@@ -423,6 +494,39 @@ function suspectRows(rows: RequestRow[]): RequestRow[] {
     );
     );
 }
 }
 
 
+/**
+ * Issue #874 signature: cache-read frozen at the same nonzero value across
+ * consecutive requests while sizeable uncached input accumulates — the
+ * reusable prefix has stopped growing even though nothing reads zero.
+ */
+function findPlateau(rows: RequestRow[]): PlateauFinding | undefined {
+  let worst: PlateauFinding | undefined;
+  let streak = 1;
+  let accumulatedInput = 0;
+  for (let i = 1; i < rows.length; i += 1) {
+    const row = rows[i];
+    if (row.cacheRead > 0 && row.cacheRead === rows[i - 1].cacheRead) {
+      streak += 1;
+      accumulatedInput += row.input;
+      if (
+        streak >= PLATEAU_STREAK_THRESHOLD &&
+        accumulatedInput >= PLATEAU_INPUT_THRESHOLD &&
+        (!worst || accumulatedInput > worst.accumulatedInput)
+      ) {
+        worst = {
+          frozenAt: row.cacheRead,
+          requests: streak,
+          accumulatedInput,
+        };
+      }
+    } else {
+      streak = 1;
+      accumulatedInput = 0;
+    }
+  }
+  return worst;
+}
+
 function judge(reports: SessionReport[]): Verdict {
 function judge(reports: SessionReport[]): Verdict {
   const allRows = reports.flatMap((report) => report.rows);
   const allRows = reports.flatMap((report) => report.rows);
   if (allRows.length < 2) return 'inconclusive';
   if (allRows.length < 2) return 'inconclusive';
@@ -431,7 +535,9 @@ function judge(reports: SessionReport[]): Verdict {
   );
   );
   if (!anyTelemetry) return 'inconclusive';
   if (!anyTelemetry) return 'inconclusive';
   const suspects = reports.flatMap((report) => suspectRows(report.rows));
   const suspects = reports.flatMap((report) => suspectRows(report.rows));
-  return suspects.length > 0 ? 'bust' : 'ok';
+  if (suspects.length > 0) return 'bust';
+  const plateaued = reports.some((report) => findPlateau(report.rows));
+  return plateaued ? 'plateau' : 'ok';
 }
 }
 
 
 function coverage(rows: RequestRow[]): string {
 function coverage(rows: RequestRow[]): string {
@@ -468,6 +574,12 @@ function printSessionTable(report: SessionReport): void {
   console.log(
   console.log(
     `    cache-read coverage after first request: ${coverage(report.rows)}`,
     `    cache-read coverage after first request: ${coverage(report.rows)}`,
   );
   );
+  const plateau = findPlateau(report.rows);
+  if (plateau) {
+    console.log(
+      `    ⚠ plateau: cache-read frozen at ${plateau.frozenAt} for ${plateau.requests} consecutive requests while ${plateau.accumulatedInput} uncached input tokens accumulated`,
+    );
+  }
 }
 }
 
 
 function printScenarioReport(
 function printScenarioReport(
@@ -482,6 +594,8 @@ function printScenarioReport(
   }
   }
   const labels: Record<Verdict, string> = {
   const labels: Record<Verdict, string> = {
     ok: '✅ every sizeable follow-up request read the provider cache',
     ok: '✅ every sizeable follow-up request read the provider cache',
+    plateau:
+      '⚠️ cache-read plateaued — reads never dropped to zero, but the reusable prefix stopped growing while input accumulated (issue #874 signature)',
     bust: '❌ SUSPECT requests above read 0 cached tokens — the prompt prefix changed between requests',
     bust: '❌ SUSPECT requests above read 0 cached tokens — the prompt prefix changed between requests',
     inconclusive:
     inconclusive:
       '⚠️ provider reported no cache telemetry — cannot verify (provider may not support or report caching)',
       '⚠️ provider reported no cache telemetry — cannot verify (provider may not support or report caching)',
@@ -571,6 +685,27 @@ async function main(): Promise<void> {
       path.join(scratch, 'package.json'),
       path.join(scratch, 'package.json'),
       `${JSON.stringify({ name: 'cache-smoke-fixture', version: '0.0.0' }, null, 2)}\n`,
       `${JSON.stringify({ name: 'cache-smoke-fixture', version: '0.0.0' }, null, 2)}\n`,
     );
     );
+    if (args.boardStrategy) {
+      // Project-local plugin config overrides the user config, pinning the
+      // board strategy for this run regardless of global settings.
+      mkdirSync(path.join(scratch, '.opencode'), { recursive: true });
+      writeFileSync(
+        path.join(scratch, '.opencode', 'oh-my-opencode-slim.json'),
+        `${JSON.stringify(
+          {
+            backgroundJobs: {
+              strategy: args.boardStrategy,
+              maxRetainedSnapshots: 20,
+            },
+          },
+          null,
+          2,
+        )}\n`,
+      );
+      console.log(
+        `board strategy pinned via project config: ${args.boardStrategy}`,
+      );
+    }
     const port = await getFreePort();
     const port = await getFreePort();
     base = `http://127.0.0.1:${port}`;
     base = `http://127.0.0.1:${port}`;
     console.log(`starting opencode serve on ${base} (cwd: ${scratch})`);
     console.log(`starting opencode serve on ${base} (cwd: ${scratch})`);
@@ -618,6 +753,11 @@ async function main(): Promise<void> {
         'RESULT: ❌ cache bust detected. Cross-check the plugin build (bun run build), then use docs/cache-verification.md to localize the changing prefix byte.',
         'RESULT: ❌ cache bust detected. Cross-check the plugin build (bun run build), then use docs/cache-verification.md to localize the changing prefix byte.',
       );
       );
       process.exitCode = 1;
       process.exitCode = 1;
+    } else if (verdicts.includes('plateau')) {
+      console.log(
+        'RESULT: ⚠️ cache-read plateau detected — the reusable prefix stopped growing (issue #874). Compare board strategies with --board-strategy latest vs checkpoint-compatible.',
+      );
+      process.exitCode = 1;
     } else if (verdicts.every((verdict) => verdict === 'inconclusive')) {
     } else if (verdicts.every((verdict) => verdict === 'inconclusive')) {
       console.log(
       console.log(
         'RESULT: ⚠️ inconclusive — the provider reported no cache telemetry for any request.',
         'RESULT: ⚠️ inconclusive — the provider reported no cache telemetry for any request.',

+ 87 - 0
src/hooks/cache-monitor/index.test.ts

@@ -168,6 +168,93 @@ describe('createCacheMonitorHook', () => {
     expect(warnings[0].message).toContain('prompt-cache bust');
     expect(warnings[0].message).toContain('prompt-cache bust');
   });
   });
 
 
+  test('warns when cache-read plateaus while sizeable input accumulates', async () => {
+    const { hook, warnings } = createHarness();
+
+    // Issue #874 signature: read frozen at one boundary while uncached
+    // input keeps growing turn over turn.
+    await hook.event(
+      assistantMessageEvent({ messageID: 'p1', input: 7000, cacheRead: 42496 }),
+    );
+    for (const [index, id] of ['p2', 'p3', 'p4', 'p5'].entries()) {
+      await hook.event(
+        assistantMessageEvent({
+          messageID: id,
+          input: 15000 + index,
+          cacheRead: 42496,
+        }),
+      );
+    }
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0].message).toContain('cache-read plateau');
+    expect(warnings[0].data).toMatchObject({
+      sessionID: 'ses_monitor',
+      frozenCacheRead: 42496,
+      consecutiveFrozenRequests: 4,
+    });
+
+    // Once per plateau, even as the streak keeps growing.
+    await hook.event(
+      assistantMessageEvent({
+        messageID: 'p6',
+        input: 20000,
+        cacheRead: 42496,
+      }),
+    );
+    expect(warnings).toHaveLength(1);
+
+    // Growth ends the plateau and re-arms the warning for a new one.
+    await hook.event(
+      assistantMessageEvent({ messageID: 'p7', input: 900, cacheRead: 60416 }),
+    );
+    for (const id of ['p8', 'p9', 'p10', 'p11']) {
+      await hook.event(
+        assistantMessageEvent({
+          messageID: id,
+          input: 16000,
+          cacheRead: 60416,
+        }),
+      );
+    }
+    expect(warnings).toHaveLength(2);
+  });
+
+  test('stays silent on frozen reads with small accumulated input', async () => {
+    const { hook, warnings } = createHarness();
+
+    // Providers round reads to coarse boundaries; identical reads across
+    // small turns are normal and must not warn.
+    await hook.event(
+      assistantMessageEvent({ messageID: 'q1', input: 8000, cacheRead: 17920 }),
+    );
+    for (const id of ['q2', 'q3', 'q4', 'q5', 'q6']) {
+      await hook.event(
+        assistantMessageEvent({ messageID: id, input: 400, cacheRead: 17920 }),
+      );
+    }
+
+    expect(warnings).toHaveLength(0);
+  });
+
+  test('stays silent while cache-read keeps growing', async () => {
+    const { hook, warnings } = createHarness();
+
+    let read = 17920;
+    for (const [index, id] of ['r1', 'r2', 'r3', 'r4', 'r5'].entries()) {
+      await hook.event(
+        assistantMessageEvent({
+          messageID: `${id}-${index}`,
+          input: 20000,
+          cacheRead: read,
+        }),
+      );
+      read += 1024;
+    }
+
+    expect(warnings).toHaveLength(0);
+  });
+
   test('stays silent on the first request and on tiny prompts', async () => {
   test('stays silent on the first request and on tiny prompts', async () => {
     const { hook, warnings } = createHarness();
     const { hook, warnings } = createHarness();
 
 

+ 47 - 0
src/hooks/cache-monitor/index.ts

@@ -41,6 +41,17 @@ const MAX_TRACKED_MESSAGES_PER_SESSION = 512;
 const NEVER_CACHED_STREAK_FOR_WARNING = 3;
 const NEVER_CACHED_STREAK_FOR_WARNING = 3;
 const NEVER_CACHED_INPUT_TOKENS_FOR_WARNING = 100_000;
 const NEVER_CACHED_INPUT_TOKENS_FOR_WARNING = 100_000;
 
 
+/**
+ * Cache-read plateau (issue #874 signature): `cache.read` stays frozen at
+ * the same nonzero value across consecutive requests while sizeable uncached
+ * input accumulates — the provider's reusable prefix has stopped growing
+ * even though nothing reads zero. Providers round reads to coarse
+ * boundaries, so short frozen streaks with small inputs are normal; both
+ * thresholds must be met before warning.
+ */
+const PLATEAU_STREAK_FOR_WARNING = 4;
+const PLATEAU_INPUT_TOKENS_FOR_WARNING = 50_000;
+
 interface SessionCacheState {
 interface SessionCacheState {
   completedRequests: number;
   completedRequests: number;
   everReportedCache: boolean;
   everReportedCache: boolean;
@@ -49,6 +60,9 @@ interface SessionCacheState {
   neverCachedStreak: number;
   neverCachedStreak: number;
   neverCachedInputTokens: number;
   neverCachedInputTokens: number;
   neverCachedWarned: boolean;
   neverCachedWarned: boolean;
+  plateauStreak: number;
+  plateauInputTokens: number;
+  plateauWarned: boolean;
   processedMessageIDs: Set<string>;
   processedMessageIDs: Set<string>;
 }
 }
 
 
@@ -140,6 +154,9 @@ export function createCacheMonitorHook(options: CacheMonitorOptions = {}) {
       neverCachedStreak: 0,
       neverCachedStreak: 0,
       neverCachedInputTokens: 0,
       neverCachedInputTokens: 0,
       neverCachedWarned: false,
       neverCachedWarned: false,
+      plateauStreak: 0,
+      plateauInputTokens: 0,
+      plateauWarned: false,
       processedMessageIDs: new Set(),
       processedMessageIDs: new Set(),
     };
     };
     sessions.set(sessionID, state);
     sessions.set(sessionID, state);
@@ -207,6 +224,36 @@ export function createCacheMonitorHook(options: CacheMonitorOptions = {}) {
       }
       }
     }
     }
 
 
+    // Cache-read plateau (issue #874): reads frozen at the same nonzero
+    // boundary while uncached input keeps accumulating — the reusable
+    // prefix has stopped growing. Reads changing (any direction) end the
+    // streak and re-arm the warning.
+    if (message.cacheRead > 0 && message.cacheRead === state.lastCacheRead) {
+      state.plateauStreak += 1;
+      state.plateauInputTokens += message.inputTokens;
+      if (
+        !state.plateauWarned &&
+        state.plateauStreak >= PLATEAU_STREAK_FOR_WARNING &&
+        state.plateauInputTokens >= PLATEAU_INPUT_TOKENS_FOR_WARNING
+      ) {
+        state.plateauWarned = true;
+        logger(
+          '[cache-monitor] cache-read plateau: the provider is reusing the same frozen prefix while sizeable uncached input accumulates — the reusable prefix has stopped growing (issue #874 signature). Consider backgroundJobs.strategy "checkpoint-compatible" — see docs/cache-verification.md.',
+          {
+            sessionID: message.sessionID,
+            requestNumber: state.completedRequests,
+            frozenCacheRead: message.cacheRead,
+            consecutiveFrozenRequests: state.plateauStreak,
+            uncachedInputTokensDuringPlateau: state.plateauInputTokens,
+          },
+        );
+      }
+    } else {
+      state.plateauStreak = 0;
+      state.plateauInputTokens = 0;
+      state.plateauWarned = false;
+    }
+
     if (message.cacheRead > 0) state.warnedSinceLastHit = false;
     if (message.cacheRead > 0) state.warnedSinceLastHit = false;
     state.everReportedCache =
     state.everReportedCache =
       state.everReportedCache ||
       state.everReportedCache ||