Files
hapi/web/src/chat/reducerEvents.ts
T
Junmo KimandGitHub 00ba610ab0 feat: display rate limit warnings instead of raw JSON (#388)
* refactor(web): extract normalizeTimestamp helper in presentation

Extract the shared seconds-vs-milliseconds normalization logic into
a private `normalizeTimestamp()` helper. No behavior change —
`formatUnixTimestamp()` produces identical output.

* refactor(web): return AgentEvent from parseClaudeUsageLimit

Change return type from `number | null` to `AgentEvent | null` so
the caller doesn't need to construct the event object. No behavior
change — the same `limit-reached` event is produced.

* feat(cli): convert rate_limit_event to standardized text format

Parse undocumented Claude `rate_limit_event` JSON in the CLI adapter
layer (AcpMessageHandler) before it reaches the web.

Converted text format (pipe-delimited):
  - "Claude AI usage limit warning|{ts}|{pct}|{rateLimitType}"
  - "Claude AI usage limit reached|{ts}|{rateLimitType}"

Status handling:
  - `allowed_warning` → warning text with utilization and limit type
  - `rejected` → reached text with limit type
  - `allowed` → silently suppressed (noise)
  - unknown statuses → passed through as-is (forward-compatible)

* feat(web): display rate limit warnings with limit type

Parse standardized pipe-delimited text from the CLI adapter into
`limit-warning` and `limit-reached` events, displaying the rate
limit type (5-hour, 7-day) when available.

- `limit-warning`: "⚠️ Usage limit 90% (5-hour) · resets 2:00 PM"
- `limit-reached`: " Usage limit reached (5-hour) until 4/2/2026"
- Backward compatible: `limit-reached` without limitType still works

The `reached` regex uses `(?:\|([^|]*))?$` to optionally match the
limitType field, maintaining compatibility with the existing format.

* refactor(cli): move rate limit parsing out of flushText

Remove rate limit detection from flushText() back to plain buffer
flush. The next commit will re-add parsing at the chunk level
(handleUpdate) where it can intercept before buffer merging.

Includes failing tests that demonstrate the mixed-chunk bug:
when a rate_limit_event chunk arrives in the same turn as normal
text, the JSON leaks into the merged buffer.

* fix(cli): intercept rate_limit_event at chunk level, not flush

Move rate limit detection from flushText() to the agentMessageChunk
handler so it fires before the chunk enters the shared text buffer.

Previously, a rate_limit_event chunk arriving in the same turn as
normal text would merge into bufferedText and leak as raw JSON.
Now the chunk is intercepted individually, the existing buffer is
flushed first (preserving prior text), and the converted message
is emitted separately.

* fix(cli): skip flush when suppressing allowed rate_limit_event

Only flush the text buffer when the parsed event will actually be
displayed. Suppressed events (e.g. status: 'allowed') now return
immediately without flushing, preventing a text → allowed → text
sequence from splitting one answer into two agent-text blocks.

* fix(web): include limitType in limit-reached reconcile key

Without this, reprocessing a message from the old format (no
limitType) to the new typed format reuses the stale block and
the (5-hour)/(7-day) suffix never appears.
2026-04-03 08:25:22 +08:00

129 lines
3.8 KiB
TypeScript

import type { AgentEvent, AgentEventBlock, ChatBlock, NormalizedMessage } from '@/chat/types'
function parseClaudeUsageLimit(text: string): AgentEvent | null {
const reachedMatch = text.match(/^Claude AI usage limit reached\|(\d+)(?:\|([^|]*))?$/)
if (reachedMatch) {
const timestamp = Number.parseInt(reachedMatch[1], 10)
if (Number.isFinite(timestamp)) {
return { type: 'limit-reached', endsAt: timestamp, limitType: reachedMatch[2] || '' }
}
}
const warningMatch = text.match(/^Claude AI usage limit warning\|(\d+)\|(\d+)\|([^|]*)$/)
if (warningMatch) {
const timestamp = Number.parseInt(warningMatch[1], 10)
const utilization = Number.parseInt(warningMatch[2], 10) / 100
const limitType = warningMatch[3] || ''
if (Number.isFinite(timestamp) && Number.isFinite(utilization)) {
return { type: 'limit-warning', utilization, endsAt: timestamp, limitType }
}
}
return null
}
export function parseMessageAsEvent(msg: NormalizedMessage): AgentEvent | null {
if (msg.isSidechain) return null
if (msg.role !== 'agent') return null
for (const content of msg.content) {
if (content.type === 'text') {
const limitEvent = parseClaudeUsageLimit(content.text)
if (limitEvent !== null) {
return limitEvent
}
}
}
return null
}
export function dedupeAgentEvents(blocks: ChatBlock[]): ChatBlock[] {
const result: ChatBlock[] = []
let prevEventKey: string | null = null
let prevTitleChangedTo: string | null = null
for (const block of blocks) {
if (block.kind !== 'agent-event') {
result.push(block)
prevEventKey = null
prevTitleChangedTo = null
continue
}
const event = block.event as { type: string; [key: string]: unknown }
if (event.type === 'title-changed' && typeof event.title === 'string') {
const title = event.title.trim()
const key = `title-changed:${title}`
if (key === prevEventKey) {
continue
}
result.push(block)
prevEventKey = key
prevTitleChangedTo = title
continue
}
if (event.type === 'message' && typeof event.message === 'string') {
const message = event.message.trim()
const key = `message:${message}`
if (key === prevEventKey) {
continue
}
if (prevTitleChangedTo && message === prevTitleChangedTo) {
continue
}
result.push(block)
prevEventKey = key
prevTitleChangedTo = null
continue
}
let key: string
try {
key = `event:${JSON.stringify(event)}`
} catch {
key = `event:${String(event.type)}`
}
if (key === prevEventKey) {
continue
}
result.push(block)
prevEventKey = key
prevTitleChangedTo = null
}
return result
}
/**
* Fold consecutive api-error events, keeping only the latest state.
*/
export function foldApiErrorEvents(blocks: ChatBlock[]): ChatBlock[] {
const result: ChatBlock[] = []
for (const block of blocks) {
if (block.kind !== 'agent-event') {
result.push(block)
continue
}
const event = block.event as { type: string }
if (event.type !== 'api-error') {
result.push(block)
continue
}
const prev = result[result.length - 1] as AgentEventBlock | undefined
if (prev?.kind === 'agent-event' && (prev.event as { type: string }).type === 'api-error') {
result[result.length - 1] = block
} else {
result.push(block)
}
}
return result
}