fix(tui): widen usage comparator to key union + regression tests

Follow-up to the salvaged #41484 commit:
- usageChanged() iterates the union of Usage keys generically instead of a
  hardcoded field list — the original PR's list omitted active_subagents
  (consumed by the status rule's subagent segment and resume hint), which
  would have suppressed legitimate updates
- The memo(StatusRule) half of the original PR is intentionally dropped:
  main's StatusRule gained battery/subagent/resume-hint segments since,
  and the wrapper broke the direct-call test seam. The load-bearing fix is
  the stable usage reference: unchanged deltas no longer mint fresh objects,
  so $uiState subscribers stop re-rendering per streaming event
- Regression tests: unchanged-reference retention, active_subagents-only
  update, key-union asymmetry
This commit is contained in:
Teknium
2026-08-14 14:41:09 -07:00
parent 8606867ebc
commit 513f683aa7
2 changed files with 59 additions and 2 deletions

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { mergeUsageStable, usageChanged } from '../app/createGatewayEventHandler.js'
import type { Usage } from '../types.js'
const baseUsage: Usage = {
calls: 3,
input: 1200,
output: 400,
total: 1600,
context_max: 200000,
context_percent: 12,
context_used: 24000
}
describe('mergeUsageStable (#41480 status-bar flicker)', () => {
it('returns the PRIOR reference when a patch changes nothing', () => {
// The load-bearing behavior: an unchanged-value patch must NOT mint a new
// object, or every $uiState subscriber re-renders per streaming delta.
const patch = { calls: 3, total: 1600 }
expect(mergeUsageStable(baseUsage, patch)).toBe(baseUsage)
})
it('returns the prior reference for an undefined patch', () => {
expect(mergeUsageStable(baseUsage, undefined)).toBe(baseUsage)
})
it('returns a new merged object when a value actually changes', () => {
const merged = mergeUsageStable(baseUsage, { total: 1700 })
expect(merged).not.toBe(baseUsage)
expect(merged.total).toBe(1700)
expect(merged.calls).toBe(3)
})
it('detects an active_subagents-only update (field the original PR missed)', () => {
// usageChanged iterates the key union generically, so optional fields the
// status rule consumes (active_subagents drives the ⛓ segment and the
// resume hint) can never be silently dropped from the comparison.
const withSubagents = mergeUsageStable(baseUsage, { active_subagents: 2 })
expect(withSubagents).not.toBe(baseUsage)
expect(withSubagents.active_subagents).toBe(2)
// And clearing it back down is also a change.
const cleared = mergeUsageStable(withSubagents, { active_subagents: 0 })
expect(cleared).not.toBe(withSubagents)
expect(cleared.active_subagents).toBe(0)
})
it('treats a key present on only one side as a change', () => {
expect(usageChanged(baseUsage, { ...baseUsage, cost_usd: 0.01 })).toBe(true)
expect(usageChanged({ ...baseUsage, cost_usd: 0.01 }, baseUsage)).toBe(true)
})
it('reports no change for deep-equal usages', () => {
expect(usageChanged(baseUsage, { ...baseUsage })).toBe(false)
})
})

View File

@@ -48,7 +48,7 @@ const normalizeVoiceSubmitMode = (value: unknown): VoiceSubmitMode =>
// iterates the union of keys generically so a future Usage field (e.g.
// active_subagents, consumed by the status rule's subagent segment) can never
// be silently dropped from the comparison.
const usageChanged = (prev: Usage, next: Usage): boolean => {
export const usageChanged = (prev: Usage, next: Usage): boolean => {
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]) as Set<keyof Usage>
for (const key of keys) {
if (prev[key] !== next[key]) return true
@@ -56,7 +56,7 @@ const usageChanged = (prev: Usage, next: Usage): boolean => {
return false
}
const mergeUsageStable = (prev: Usage, patch: Partial<Usage> | undefined): Usage => {
export const mergeUsageStable = (prev: Usage, patch: Partial<Usage> | undefined): Usage => {
if (!patch) return prev
const merged: Usage = { ...prev, ...patch }
return usageChanged(prev, merged) ? merged : prev