fix(credits): let the "grant spent" notice yield on the next prompt (#40367)

credits.grant_spent is a one-time "your monthly grant is used up, you're now on
top-up" heads-up, but it was sticky — it camped the TUI status bar until the grant
refilled, so a user with healthy top-up saw "Grant spent · $990 top-up left"
indefinitely. Treat it like the usage-band notice: flash once, then clear on the
next prompt (startMessage). Depletion stays sticky (you actually can't make
requests). The Python `active` latch keeps the key, so it won't re-fire next turn.
This commit is contained in:
Siddharth Balyan 2026-06-06 13:32:41 +05:30 committed by GitHub
parent fcb1944b4f
commit c79b6f23e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 25 additions and 10 deletions

View file

@ -4,10 +4,11 @@ import { turnController } from '../app/turnController.js'
import { resetTurnState } from '../app/turnStore.js'
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
// turnController.startMessage() treats the usage-band notice (credits.usage) as
// "show until next prompt": a 50/75/90 heads-up flashes, then yields when the next
// turn starts. Depletion (and other notices) are sticky until the policy clears them.
describe('turnController.startMessage — usage-band notice clears on next prompt', () => {
// turnController.startMessage() treats "flash and yield" notices (the usage-band
// credits.usage and the one-time credits.grant_spent transition) as "show until next
// prompt": they flash once, then yield when the next turn starts. Depletion (and
// other notices) are sticky until the policy clears them.
describe('turnController.startMessage — flash-and-yield notices clear on next prompt', () => {
beforeEach(() => {
resetUiState()
resetTurnState()
@ -22,6 +23,16 @@ describe('turnController.startMessage — usage-band notice clears on next promp
expect(getUiState().notice).toBeNull()
})
it('clears a standing credits.grant_spent notice when a new turn starts', () => {
// One-time "you've crossed onto top-up" heads-up — shouldn't camp the bar
// (e.g. "Grant spent · $990 top-up left" with plenty of top-up remaining).
patchUiState({
notice: { key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $990.00 top-up left' }
})
turnController.startMessage()
expect(getUiState().notice).toBeNull()
})
it('leaves a sticky credits.depleted notice across a new turn', () => {
patchUiState({
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /usage for balance' }

View file

@ -907,12 +907,16 @@ class TurnController {
this.toolTokenAcc = 0
this.interrupted = false
this.persistedToolLabels.clear()
// Usage-band notices (credits.usage) are "show until next prompt": a 50/75/90
// heads-up should flash and then yield, not camp the bar. Clear it as a new
// turn starts. Depletion (credits.depleted) and other notices stay — they're
// explicitly sticky until the policy clears them.
if (getUiState().notice?.key === 'credits.usage') {
this.clearNotice('credits.usage')
// "Flash and yield" notices clear when a new turn starts: a usage-band heads-up
// (credits.usage, 50/75/90%) and the one-time "grant spent" transition
// (credits.grant_spent) should show once, then get out of the way — not camp the
// bar (e.g. "Grant spent · $990 top-up left" sitting there with plenty of top-up
// left). Depletion (credits.depleted) and other notices stay — they're explicitly
// sticky until the policy clears them. The Python `active` latch retains the key,
// so a yielded notice won't re-fire on the next turn.
const yieldingNoticeKey = getUiState().notice?.key
if (yieldingNoticeKey === 'credits.usage' || yieldingNoticeKey === 'credits.grant_spent') {
this.clearNotice(yieldingNoticeKey)
}
patchUiState({ busy: true })
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })