[BUG] Prompt suggestions silently suppressed whenever the client-derived rate-limit status is allowed_warning — strict-equality gate in Vxy

Status Open
Reported on v2.1.218
Maintainer reply None cached
Activity 6 comments · opened Jun 30, 2026

Update 2026-07-23 — I have located the gate in the shipped binary, captured
the suppression live, and confirmed it with a pre-registered prediction (the
derived status cleared itself at the predicted time and suggestions resumed with
no user action — see "Prediction, then observation" below). Replacing my original
reproduction steps, which described only one of the two ways to reach the warned
state.

Root cause

From the 2.1.218 bundle (byte-identical in 2.1.216 and 2.1.217):

function Vxy(e){
  if(!e.promptSuggestionEnabled)                     return "disabled";
  if(e.pendingWorkerRequest||e.pendingSandboxRequest) return "pending_permission";
  if(e.elicitation.queue.length>0)                   return "elicitation_active";
  if(e.toolPermissionContext.mode==="plan")          return "plan_mode";
  if(mie().status!=="allowed")                       return "rate_limit";
  return null
}

mie().status is one of allowed | allowed_warning | rejected. The strict
inequality means the still-fully-usable allowed_warning state is treated
exactly like rejected.

Its only caller is the suggestion generator, which bails and emits telemetry:

let l=r(), c=Vxy(l);
if(c) return s7(c,void 0,o), null;   // tengu_prompt_suggestion {outcome:"suppressed", reason:c}

Both entry points reach it, so this affects the TUI ghost text and the SDK
prompt_suggestion message identically:

  • Mnd(e,t) — interactive, querySource.startsWith("repl_main_thread"), source "cli"
  • the print/SDK path — source "sdk"

Two routes into allowed_warning (this is what my original report missed)

Txu(headers) re-derives the status rather than passing the header through.
When the header is allowed or allowed_warning, it calls zzg(); if that
returns a warning object the derived status is allowed_warning, otherwise it is
normalized down to allowed.

zzg warns on either a -surpassed-threshold header, or this pace table:

Uzg=[
 {rateLimitType:"five_hour", claimAbbrev:"5h", windowSeconds:18000,
  thresholds:[{utilization:0.9, timePct:0.72}]},
 {rateLimitType:"seven_day", claimAbbrev:"7d", windowSeconds:604800,
  thresholds:[{utilization:0.75,timePct:0.6},
              {utilization:0.5, timePct:0.35},
              {utilization:0.25,timePct:0.15}]}
]

with Gzg(resetsAt, windowSeconds) returning the fraction of the window
elapsed, and the rule being
utilization >= f.utilization && elapsedPct <= f.timePct.

That is an ahead-of-pace test, not a near-the-limit test. On the weekly claim
it fires at 25% used in the first 15% of the week, 50% in the first 35%, or 75%
in the first 60%. A user who front-loads their week is suppressed for days while
every request still succeeds.

Live capture — 2026-07-23 08:04 EDT, v2.1.218, Linux

Server response headers on /v1/messages:

anthropic-ratelimit-unified-status              = allowed
anthropic-ratelimit-unified-7d-status           = allowed
anthropic-ratelimit-unified-7d-utilization      = 0.62
anthropic-ratelimit-unified-7d-reset            = 1785218400
anthropic-ratelimit-unified-5h-utilization      = 0.53
anthropic-ratelimit-unified-representative-claim = five_hour

Client-derived state, from the stream-json rate_limit_event:

{"status":"allowed_warning","resetsAt":1785218400,
 "rateLimitType":"seven_day","utilization":0.62,"isUsingOverage":false}

The server said allowed. The client stamped itself allowed_warning because
0.62 >= 0.5 and only 0.3219 of the week had elapsed (window opened
2026-07-21 02:00 EDT).

Client telemetry from the same run (base64 additional_metadata decoded):

tengu_prompt_suggestion_init -> {"subscription_type":"max","enabled":true,"source":"env"}
tengu_prompt_suggestion      -> {"subscription_type":"max","source":"sdk",
                                 "outcome":"suppressed","reason":"rate_limit",
                                 "prompt_id":"user_intent"}

Enablement passed. The runtime gate suppressed with reason: "rate_limit".
No prompt_suggestion message was emitted on any probe turn.

For completeness, my earlier report was based on a June capture in which the
header itself read allowed_warning — so both routes are real and both land
on the same gate.

Reproduction — headless, no TUI, no waiting for a usage window

CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=1 \
claude -p "say the word apple, nothing else" \
  --output-format stream-json --verbose --prompt-suggestions

CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=1 \
claude -p --continue "now say the word banana, nothing else" \
  --output-format stream-json --verbose --prompt-suggestions

Two turns are required — the generator early-returns early_conversation below
two assistant messages. Then read the second run's output:

  • rate_limit_event.rate_limit_info.status is the derived status
  • a prompt_suggestion message present = fired; absent while the derived status

is allowed_warning = suppressed by this gate

Prediction, then observation (2026-07-23)

Ahead of time I predicted: with the 7d claim ahead of pace, the rule
{utilization:0.5, timePct:0.35} stops matching the moment window-elapsed crosses
0.35 — 2026-07-23 12:48:00 EDT — after which the derived status returns to
allowed and suggestions resume with no user action, no restart, no settings
change.

Observed, via three unattended headless probes on the same machine and session:

| probe (EDT) | derived status | representative claim | 7d utilization | prompt_suggestion emitted |
|---|---|---|---|---|
| 12:35 (before) | allowed_warning | seven_day | 0.70 | no |
| 12:55 (after) | allowed | five_hour | — | yes |
| 13:30 (confirm)| allowed | five_hour | — | yes |

The derived status flipped across the predicted 12:48 boundary and suggestions
returned on the next turn — nothing on my end changed between 12:35 and 12:55.
That is the gate clearing itself as the pace rule stopped matching, exactly as the
code path predicts.

Note that 7d utilization actually rose from 0.62 (08:04) to 0.70 (12:35) over
the morning, yet suppression still cleared — because 0.70 never reached rule 1's
0.75 threshold, so only the {0.5, 0.35} rule was ever armed, and that rule is
released by elapsed time (crossing 0.35 of the window), not by usage falling. This
is the clearest possible confirmation that the trigger is pace, not proximity to
the limit: I was using more quota when it came back.

What I think should change

  1. allowed_warning is a fully usable state — requests succeed. Suppressing on it

should be a deliberate, documented decision, not a strict-equality fall-through.
If it is deliberate, gate on rejected (or on the derived warning only above a
much higher utilization).

  1. If it stays, surface it. The suppression is completely silent — the reason

exists in telemetry (reason: "rate_limit") but is shown nowhere to the user.
Five closed issues here are people chasing env vars and settings for a state
the client already knows the reason for.

  1. Consider whether a pace heuristic — 62% used at day 2.3 of 7 — should govern

an ambient UI hint at all, when the header itself says allowed.

Tracked separately as a feature request in #72497.

Related: #57822 (same strict-equality class, fixed), #79919 and #77144 (may be
this gate or may be the separate enablement gate — see my comment there for a
one-command discriminator), #30699 (tengu_crystal_beam has zero occurrences in
2.1.218, so the budgetTokens: 0 theory there no longer applies).

View original on GitHub ↗

3 Comments

inth3shadows · 1 month ago

Update 2026-07-28 — still present in 2.1.220, plus a live capture of the suppression and the missing second route.

Three things below: (1) the gate is intact two releases later, with the full call chain re-derived from the shipped 2.1.220 binary; (2) a mechanical capture of the suppression happening, including the route my original report did not cover; (3) a note on the cross-reference in the comment above, which I believe points at a different bug.

---

1. The gate is intact in 2.1.220

The function was renamed (Vxy -> dOy) but the logic is unchanged. Verbatim from ~/.local/share/claude/versions/2.1.220:

function dOy(e){
  if(!e.promptSuggestionEnabled)                      return "disabled";
  if(e.pendingWorkerRequest||e.pendingSandboxRequest)  return "pending_permission";
  if(e.elicitation.queue.length>0)                     return "elicitation_active";
  if(e.toolPermissionContext.mode==="plan")            return "plan_mode";
  if(Gie().status!=="allowed")                         return "rate_limit";
  return null
}

Gie() returns the module-level status object (wpe, initialised to {status:"allowed",...}). The strict inequality is still what does the damage: allowed_warning is a fully usable state and is treated identically to rejected.

The suppression telemetry is also unchanged:

function R7(e,t,r){
  O("tengu_prompt_suggestion",{...r&&{source:fe(r)},outcome:Ee("suppressed"),
                               reason:fe(e),prompt_id:Ee("user_intent"),...!1})
}

So every occurrence is already being reported server-side as outcome:"suppressed", reason:"rate_limit". That telemetry should make the population size directly measurable without needing to reproduce anything.

Reported against 2.1.207; confirmed on 2.1.216, 2.1.217, 2.1.218, and now 2.1.220. Sixteen patch releases.

2. There are two routes to allowed_warning, and they are checked in order

My original write-up described the pace table but not the header route, and the ordering matters for anyone trying to reproduce this. Re-derived from 2.1.220:

function OZg(e,t){
  let r=PZg(e,t); if(r) return r;              // route A: header, early return
  for(let n of IZg){ let o=MZg(e,n,t); if(o) return o }   // route B: pace table
  return null
}

OZg is only consulted when the server's own status is non-fatal:

if(t==="allowed"||t==="allowed_warning"){ let T=OZg(e,o); if(T) return {...T, ...} }

Route A — PZg, the -surpassed-threshold header. If the server sends anthropic-ratelimit-unified-<claim>-surpassed-threshold, the client returns allowed_warning immediately, carrying a surpassedThreshold field.

Route B — MZg, the client-side pace table. Unchanged from what I quoted before:

IZg=[
 {rateLimitType:"five_hour", claimAbbrev:"5h", windowSeconds:18000,
  thresholds:[{utilization:0.9, timePct:0.72}]},
 {rateLimitType:"seven_day", claimAbbrev:"7d", windowSeconds:604800,
  thresholds:[{utilization:0.75,timePct:0.6},
              {utilization:0.5, timePct:0.35},
              {utilization:0.25,timePct:0.15}]}
]

with the rule c>=f.utilization && d<=f.timePct, where d=LZg(u,i) is the fraction of the window elapsed. This is an ahead-of-pace test, not a near-the-limit test — front-load your week and you are suppressed for days while every request still succeeds.

A field-level tell for triage: route A's return value includes surpassedThreshold; route B's does not. That single field distinguishes "the server warned us" from "the client decided we were ahead of pace" in any captured rate_limit_event, with no extra instrumentation.

3. Live capture of the suppression — 2026-07-28 21:31:51 EDT, 2.1.220, Ubuntu 24.04 (WSL2)

Non-interactive, one turn:

claude -p "say ok" --prompt-suggestions --output-format stream-json --verbose \
       --model claude-haiku-4-5-20251001

The emitted rate_limit_event:

{"status":"allowed_warning","utilization":0.95,"surpassedThreshold":0.9,
 "rateLimitType":"five_hour","resetsAt":1785296400,"isUsingOverage":false}

Full census of stream message types in that run:

system: 14, assistant: 2, rate_limit_event: 1, result: 1

prompt_suggestion: 0. Every request in the run succeeded; status is allowed_warning, not rejected.

Decoding the window against the capture time:

window start : 2026-07-28T18:40:00-04:00   (resetsAt - 18000s)
capture      : 2026-07-28T21:31:51-04:00
reset        : 2026-07-28T23:40:00-04:00
elapsedPct   : 0.5729

Both routes were satisfied simultaneously here: route A because the server sent surpassedThreshold: 0.9, and route B because utilization 0.95 >= 0.90 and elapsedPct 0.5729 <= 0.72. Route A returned first by the ordering in OZg, which is why surpassedThreshold is present in the payload. Worth noting that the two are not mutually exclusive, so a fix must address both.

What this run does and does not establish. It establishes that the client entered allowed_warning while fully usable, and that no suggestion was emitted. It does not by itself isolate the gate as the cause, because a run in allowed_warning cannot distinguish "suppressed by dOy" from "print mode never emits". Separating those needs a control run at status: "allowed", which I cannot produce until the current window resets. I will post the control rather than leave the inference dangling.

That said, the print/SDK path clearly does emit this message type when not gated — prompt_suggestion is a first-class stream message in the bundle:

Pny=new Set(["prompt_suggestion","away_summary","agent_summary"]);

and the stream consumer branches on it explicitly (if(l.type==="prompt_suggestion")continue;). The --prompt-suggestions flag is documented as "In print/SDK mode, emits a prompt_suggestion message after each turn", and the CLI rejects it unless --print --output-format=stream-json are also passed.

4. On the cross-reference above

The comment above points to #79919's server-rollout analysis. I do not think that explains this issue, for a reason visible in that thread's own evidence: the rate_limit_event posted there reads status: "allowed". dOy only returns "rate_limit" when the status is not allowed, so that reporter was never on this path. That reading is also no longer the position of its author, who subsequently replaced it with an expired-OAuth-credential theory in the same thread.

For completeness, on the credential theory: I can find no branch in either gate that consults token state. dOy tests disabled, pending_permission, elicitation_active, plan_mode, rate_limit; the downstream generator adds aborted, early_conversation, and last_response_error. No expiry or refresh-token check appears in either. That may still be a real third bug, but it is not this one, and merging the three would lose the distinct fixes.

5. Suggested fix, unchanged

Treat allowed_warning as usable:

if(Gie().status==="rejected") return "rate_limit";

The state is not rate-limited by any behavioural definition — every request in the capture above succeeded. If suppression at high utilisation is deliberate, it should be a distinct reason code so the telemetry can tell an intentional throttle apart from this fall-through, and the pace-table route should be reconsidered independently, since firing at 25% of the weekly claim inside the first 15% of the week is not a proximity-to-limit signal at all.

Correction to an earlier version of this comment: I previously wrote that the user at least sees a "You're close to your usage limit" banner in this state. That is wrong below 70% utilisation, and the reason is worth stating on its own — see the fLu / dOy asymmetry in my follow-up comment below. At 26% weekly there is no banner and no suggestion, i.e. no signal of any kind.

---

Environment: Ubuntu 24.04 (WSL2), Anthropic API, 2.1.220 native install, promptSuggestionEnabled: true, not in plan mode, defaultMode: auto. All symbol names above are from the 2.1.220 build (BuildID 788318c9115981678ca1a25f40cdb3b39df71403) and are minifier-assigned, so they will differ between builds; the string literals and numeric thresholds are stable and greppable. No emoji or non-ASCII glyphs in any quoted output.

inth3shadows · 1 month ago

Edited: this comment previously led with a probe-methodology digression. The claim in this issue does not depend on any probe, and centring one obscured that. Rewritten to state what is verifiable from the shipped binary, plus a cross-issue prediction that others can check.

The claim is a code fact, not a measurement

dOy in 2.1.220, verbatim:

function dOy(e){
  if(!e.promptSuggestionEnabled)                      return "disabled";
  if(e.pendingWorkerRequest||e.pendingSandboxRequest)  return "pending_permission";
  if(e.elicitation.queue.length>0)                     return "elicitation_active";
  if(e.toolPermissionContext.mode==="plan")            return "plan_mode";
  if(Gie().status!=="allowed")                         return "rate_limit";   // <--
  return null
}

Gie() returns the client-derived rate-limit state. Once that state is allowed_warning, suggestions are off. Deterministically, for every caller. This needs no reproduction — it is what the shipped code does.

There is no user-facing way to turn it back on

This is the part I want to put on the record, because it is what makes the issue actionable rather than cosmetic.

CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION resolves through qwo():

function qwo(){
  let e=process.env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION;
  if(su(e)) return O("tengu_prompt_suggestion_init",{enabled:!1,source:Ee("env")}),!1;
  if(Yt(e)) return O("tengu_prompt_suggestion_init",{enabled:!0,source:Ee("env")}),!0;
  if(!Ke("tengu_chomp_inflection",!1))
            return O("tengu_prompt_suggestion_init",{enabled:!1,source:Ee("growthbook")}),!1;
  ...
}

Setting it to 1 forces the result true. But that value feeds promptSuggestionEnabled, which is gate 1 of dOy. The rate-limit check is gate 5, and its expression — Gie().status!=="allowed" — reads nothing the user can set. No env var, no settings.json key, no CLI flag, no /config toggle appears anywhere in that condition.

So a user can explicitly force the feature on and still get silence, with no error and no indication why. That is by construction, not by accident.

allowed_warning is reached at genuinely low usage

Measured on this machine, 2.1.220, and independent of anything to do with suggestions:

{"status":"allowed_warning","rateLimitType":"seven_day",
 "utilization":0.26,"resetsAt":1785823200,"isUsingOverage":false}

No surpassedThreshold field, so this came from the client-side pace table, not a server header. Exactly one threshold can produce it:

| threshold | util >= X | elapsed <= T | fires |
|---|---|---|---|
| 0.75 / 0.60 | 0.26 >= 0.75 no | -- | no |
| 0.50 / 0.35 | 0.26 >= 0.50 no | -- | no |
| 0.25 / 0.15 | 0.26 >= 0.25 yes | 0.12937 <= 0.15 yes | yes |

26% of the weekly claim consumed, 12.9% of the week elapsed, every request succeeding, status allowed_warning. The pace rule is an ahead-of-pace test, not a near-the-limit test, so front-loading a week trips it at a quarter of the claim and keeps it tripped for days.

A prediction that connects this to #77144 and #79919

Both of those threads have users reporting that suggestions vanished and stayed gone, that CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION was explicitly set to 1 or true, that promptSuggestionEnabled: true was in settings.json, and that restarts and reinstalls changed nothing. The working theory there is a server-side rollout drop.

Gate ordering above predicts exactly that symptom without any rollout: the env var satisfies gate 1, gate 5 still returns "rate_limit", and the user sees a silently dead feature that no local setting revives.

Two ways to tell the explanations apart, neither requiring code access:

  1. Telemetry. R7 emits tengu_prompt_suggestion {outcome:"suppressed", reason:...} with the specific gate name. rate_limit, early_conversation, disabled, and empty are distinct reason codes. For any affected account, the reason is already recorded server-side — no reproduction needed from anyone.
  2. Timing. If the cause is this gate, suggestions should return on their own early in a fresh weekly window at low utilisation, with no login, no reinstall, and no setting change — and disappear again once the pace rule re-arms. A rollout drop would not track the usage window.

For @Neur0Support and @HarryMuc: if suggestions ever come back without you changing anything, that is this bug rather than a rollout, and worth noting on #77144.

Suggested fix, unchanged

if(Gie().status==="rejected") return "rate_limit";

allowed_warning is a fully usable state — every request in the capture above succeeded. If suppression at high utilisation is deliberate, it needs (a) a distinct reason code so it is separable from this fall-through in telemetry, and (b) some user-visible signal — today there is none at all below 70% utilisation, for the reason set out in the addendum below. The pace-table route deserves separate reconsideration regardless: firing at 25% of the weekly claim inside the first 15% of the week is not a proximity-to-limit signal.

---

Environment: Ubuntu 24.04 (WSL2), Anthropic API, 2.1.220 native install (BuildID 788318c9115981678ca1a25f40cdb3b39df71403). Minified symbol names are build-specific; string literals and numeric thresholds are stable and greppable. No emoji or non-ASCII glyphs in any quoted output.

---

Addendum: why this produces sharp cutoffs, at window resets

The pace rule is utilization >= X && elapsedPct <= T, where elapsedPct is the fraction of the window already gone. elapsedPct is at its smallest immediately after a window resets — so the ahead-of-pace test is easiest to satisfy right after the rollover, which is precisely when a user has the most headroom and expects the fewest restrictions.

Translating the weekly thresholds into wall-clock:

| weekly threshold | suppressed for the first... |
|---|---|
| utilization >= 0.25 | 25.2 h of the window (elapsed <= 0.15) |
| utilization >= 0.50 | 58.8 h (elapsed <= 0.35) |
| utilization >= 0.75 | 100.8 h (elapsed <= 0.60) |

A user who consumes a quarter of the weekly claim in the first day is suppressed for that day, then it clears on its own. Nothing about that is visible to them.

The gate is not new. I checked the shipped binaries for 2.1.200, 2.1.207, 2.1.218, 2.1.219 and 2.1.220: all five contain the same if(<status>!=="allowed")return"rate_limit" check and a byte-identical threshold table (0.9/0.72 on 5h; 0.75/0.6, 0.5/0.35, 0.25/0.15 on 7d). So a sudden onset is not explained by the rule appearing in an update — the rule was already there, and what changes is which side of it your usage falls on.

A falsifiable prediction, and what would settle it

If this gate is the cause of a "suggestions died and never came back" report, the suppression is periodic, not permanent:

  • It should clear by itself partway into the window -- around 25 h in for a user sitting in the 0.25-0.50 band -- with no login, no reinstall, no settings change.
  • It should return at the next reset, if usage is again front-loaded.

A server-side rollout drop is monotonic. An expired credential is monotonic. Only this one cycles. So a single observation of suggestions returning on their own, mid-window, with nothing changed, distinguishes them.

This matters for #77144 and #79919, where the reported pattern is a sharp cutoff plus CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION set explicitly and having no effect -- which is exactly what gate ordering predicts, since that variable can only satisfy gate 1.

What I have not established, stated plainly: I cannot confirm those reports are this bug. I do not know their utilisation at cutoff, and I cannot see their window boundaries. Two specific cautions against over-reading the correlation:

  1. Weekly windows are per-account, not calendar-aligned. My own 7d window starts Tuesday 02:00 local, not Monday. So "it broke Monday morning" is not evidence by itself -- the relevant boundary is each account's own reset, which only that user or Anthropic can see.
  2. A before/after observation tied to a /login cannot separate "the credential fix worked" from "the window happened to cross" -- if the true cause is window-crossing, any action taken near the ~25 h mark correlates perfectly.

Three things would settle it, in descending order of ease for Anthropic:

  1. The telemetry already collected. R7 emits tengu_prompt_suggestion {outcome:"suppressed", reason:...}, and rate_limit, early_conversation, disabled, empty are distinct codes. For any affected account the answer is already recorded -- no reproduction required from anyone.
  2. One user observation: do suggestions ever come back mid-week without you changing anything? @Neur0Support @HarryMuc -- if yes, this is the gate, not a rollout.
  3. A forced-header test. Injecting anthropic-ratelimit-unified-5h-surpassed-threshold into a response would drive the client to allowed_warning on demand and A/B the gate directly, rather than waiting on real usage. Trivial for anyone with control of the response path; I have not run it.

---

The strongest form of this: the banner and the suggestion gate disagree

fLu renders the usage banner from the same rate-limit state object that dOy gates suggestions on:

if(e.status==="allowed_warning"){
  if(e.utilization!==void 0 && e.utilization<0.7) return null;   // <-- no banner below 70%
  ...
}

The banner has a 70% sanity floor. The suggestion gate has none.

| path | input | result |
|---|---|---|
| fLu (usage banner) | allowed_warning, utilization: 0.26 | 0.26 < 0.7 -> no banner |
| dOy (suggestions) | allowed_warning | -> suppressed |

So at the 26% weekly reading captured above, the user gets no banner and no suggestions -- no signal of any kind that a feature was turned off, and nothing to connect it to usage.

The codebase has already made the judgement that allowed_warning below 70% is not worth surfacing to the user, because a "you're close to your usage limit" warning at 26% would be absurd. dOy acts on that same state anyway. Whatever the right threshold for suppression is, these two functions reading one variable should not disagree about whether it means anything.

A minimal fix that respects the existing judgement, if suppression at genuinely high utilisation is intended:

if(Gie().status==="rejected") return "rate_limit";

or, if the warning state should still suppress, at least apply the same floor fLu already uses, so the two agree.

On measuring this from outside

For anyone trying to reproduce: do not use the presence or absence of a prompt_suggestion event as your detector. It has a high false-negative rate independent of this bug. Two further gates sit ahead of dOy in the same chain -- early_conversation (below two assistant messages) and cache_cold (input + cache_creation + output > 10000, which fires on the first turn after any idle gap once the prompt cache has expired) -- and downstream, generated suggestions are dropped by a content filter that reports them as empty. In four consecutive headless runs at status: "allowed" with everything else clear, I observed zero suggestions.

The rate_limit_event line, by contrast, is emitted on every run and is a direct read of the state variable this issue is about. That is the thing worth reporting.

Showing cached comments. Read the full discussion on GitHub ↗