mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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.
This commit is contained in:
@@ -245,6 +245,99 @@ describe('AcpMessageHandler', () => {
|
||||
expect(calls[1].name).toBe('hapi_change_title');
|
||||
});
|
||||
|
||||
it('intercepts rate_limit_event chunk before it enters the text buffer', () => {
|
||||
const messages: AgentMessage[] = [];
|
||||
const handler = new AcpMessageHandler((message) => messages.push(message));
|
||||
|
||||
// Normal text chunk first
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
|
||||
content: { type: 'text', text: 'thinking...' }
|
||||
});
|
||||
|
||||
// rate_limit_event arrives as a separate chunk in the same turn
|
||||
const rateLimitJson = JSON.stringify({
|
||||
type: 'rate_limit_event',
|
||||
rate_limit_info: {
|
||||
status: 'allowed_warning',
|
||||
resetsAt: 1774278000,
|
||||
rateLimitType: 'five_hour',
|
||||
utilization: 0.9,
|
||||
},
|
||||
});
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
|
||||
content: { type: 'text', text: rateLimitJson }
|
||||
});
|
||||
|
||||
handler.flushText();
|
||||
|
||||
// The normal text should be preserved
|
||||
const textMessages = messages.filter(m => m.type === 'text');
|
||||
expect(textMessages).toHaveLength(2);
|
||||
// First: the normal text
|
||||
expect(textMessages[0]).toEqual({ type: 'text', text: 'thinking...' });
|
||||
// Second: the converted rate limit warning (not raw JSON)
|
||||
expect((textMessages[1] as { text: string }).text).toMatch(/^Claude AI usage limit warning\|/);
|
||||
});
|
||||
|
||||
it('suppresses allowed rate_limit_event chunk without affecting text buffer', () => {
|
||||
const messages: AgentMessage[] = [];
|
||||
const handler = new AcpMessageHandler((message) => messages.push(message));
|
||||
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
|
||||
content: { type: 'text', text: 'hello' }
|
||||
});
|
||||
|
||||
const allowedJson = JSON.stringify({
|
||||
type: 'rate_limit_event',
|
||||
rate_limit_info: {
|
||||
status: 'allowed',
|
||||
resetsAt: 1774278000,
|
||||
},
|
||||
});
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
|
||||
content: { type: 'text', text: allowedJson }
|
||||
});
|
||||
|
||||
handler.flushText();
|
||||
|
||||
// Only the normal text, no rate limit noise
|
||||
expect(messages).toEqual([{ type: 'text', text: 'hello' }]);
|
||||
});
|
||||
|
||||
it('does not split text buffer when suppressing allowed event mid-stream', () => {
|
||||
const messages: AgentMessage[] = [];
|
||||
const handler = new AcpMessageHandler((message) => messages.push(message));
|
||||
|
||||
// text → allowed → text → flush should produce ONE merged text message
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
|
||||
content: { type: 'text', text: 'part one ' }
|
||||
});
|
||||
|
||||
const allowedJson = JSON.stringify({
|
||||
type: 'rate_limit_event',
|
||||
rate_limit_info: { status: 'allowed', resetsAt: 1774278000 },
|
||||
});
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
|
||||
content: { type: 'text', text: allowedJson }
|
||||
});
|
||||
|
||||
handler.handleUpdate({
|
||||
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
|
||||
content: { type: 'text', text: 'part two' }
|
||||
});
|
||||
|
||||
handler.flushText();
|
||||
|
||||
// Must be a single text message, not split into two
|
||||
expect(messages).toEqual([{ type: 'text', text: 'part one part two' }]);
|
||||
});
|
||||
|
||||
it('allows kind fallback to replace placeholder tool name', () => {
|
||||
const messages: AgentMessage[] = [];
|
||||
const handler = new AcpMessageHandler((message) => messages.push(message));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentMessage, PlanItem } from '@/agent/types';
|
||||
import { asString, isObject } from '@hapi/protocol';
|
||||
import { deriveToolNameWithSource, isPlaceholderToolName } from '@/agent/utils';
|
||||
import { parseRateLimitText } from '@/agent/rateLimitParser';
|
||||
import { ACP_SESSION_UPDATE_TYPES } from './constants';
|
||||
|
||||
function normalizeStatus(status: unknown): 'pending' | 'in_progress' | 'completed' | 'failed' {
|
||||
@@ -108,8 +109,9 @@ export class AcpMessageHandler {
|
||||
if (!this.bufferedText) {
|
||||
return;
|
||||
}
|
||||
this.onMessage({ type: 'text', text: this.bufferedText });
|
||||
const text = this.bufferedText;
|
||||
this.bufferedText = '';
|
||||
this.onMessage({ type: 'text', text });
|
||||
}
|
||||
|
||||
private appendTextChunk(text: string): void {
|
||||
@@ -156,6 +158,15 @@ export class AcpMessageHandler {
|
||||
const content = update.content;
|
||||
const text = extractTextContent(content);
|
||||
if (text) {
|
||||
const rateLimit = parseRateLimitText(text);
|
||||
if (rateLimit) {
|
||||
if (rateLimit.suppress) {
|
||||
return;
|
||||
}
|
||||
this.flushText();
|
||||
this.onMessage(rateLimit.message);
|
||||
return;
|
||||
}
|
||||
this.appendTextChunk(text);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseRateLimitText } from './rateLimitParser';
|
||||
|
||||
describe('parseRateLimitText', () => {
|
||||
it('returns null for non-JSON text', () => {
|
||||
expect(parseRateLimitText('Hello world')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for JSON that is not a rate_limit_event', () => {
|
||||
expect(parseRateLimitText('{"type":"other"}')).toBeNull();
|
||||
});
|
||||
|
||||
it('converts allowed_warning to pipe-delimited warning text', () => {
|
||||
const result = parseRateLimitText(JSON.stringify({
|
||||
type: 'rate_limit_event',
|
||||
rate_limit_info: {
|
||||
status: 'allowed_warning',
|
||||
resetsAt: 1774278000,
|
||||
rateLimitType: 'five_hour',
|
||||
utilization: 0.9,
|
||||
isUsingOverage: false,
|
||||
surpassedThreshold: 0.9,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result).toEqual({
|
||||
suppress: false,
|
||||
message: {
|
||||
type: 'text',
|
||||
text: 'Claude AI usage limit warning|1774278000|90|five_hour',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('includes seven_day rateLimitType', () => {
|
||||
const result = parseRateLimitText(JSON.stringify({
|
||||
type: 'rate_limit_event',
|
||||
rate_limit_info: {
|
||||
status: 'allowed_warning',
|
||||
resetsAt: 1774850400,
|
||||
rateLimitType: 'seven_day',
|
||||
utilization: 0.85,
|
||||
surpassedThreshold: 0.75,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result).toEqual({
|
||||
suppress: false,
|
||||
message: {
|
||||
type: 'text',
|
||||
text: 'Claude AI usage limit warning|1774850400|85|seven_day',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing rateLimitType gracefully', () => {
|
||||
const result = parseRateLimitText(JSON.stringify({
|
||||
type: 'rate_limit_event',
|
||||
rate_limit_info: {
|
||||
status: 'allowed_warning',
|
||||
resetsAt: 1774278000,
|
||||
utilization: 0.9,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result).toEqual({
|
||||
suppress: false,
|
||||
message: {
|
||||
type: 'text',
|
||||
text: 'Claude AI usage limit warning|1774278000|90|',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('converts rejected to existing pipe-delimited reached text', () => {
|
||||
const result = parseRateLimitText(JSON.stringify({
|
||||
type: 'rate_limit_event',
|
||||
rate_limit_info: {
|
||||
status: 'rejected',
|
||||
resetsAt: 1774278000,
|
||||
rateLimitType: 'five_hour',
|
||||
overageStatus: 'rejected',
|
||||
isUsingOverage: false,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result).toEqual({
|
||||
suppress: false,
|
||||
message: {
|
||||
type: 'text',
|
||||
text: 'Claude AI usage limit reached|1774278000|five_hour',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('suppresses allowed status', () => {
|
||||
const result = parseRateLimitText(JSON.stringify({
|
||||
type: 'rate_limit_event',
|
||||
rate_limit_info: {
|
||||
status: 'allowed',
|
||||
resetsAt: 1774278000,
|
||||
utilization: 0.3,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result).toEqual({ suppress: true });
|
||||
});
|
||||
|
||||
it('passes through unknown statuses (returns null)', () => {
|
||||
const result = parseRateLimitText(JSON.stringify({
|
||||
type: 'rate_limit_event',
|
||||
rate_limit_info: {
|
||||
status: 'some_future_status',
|
||||
resetsAt: 1774278000,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('handles wrapped { type: "output", data: { ... } } format', () => {
|
||||
const result = parseRateLimitText(JSON.stringify({
|
||||
type: 'output',
|
||||
data: {
|
||||
type: 'rate_limit_event',
|
||||
rate_limit_info: {
|
||||
status: 'allowed_warning',
|
||||
resetsAt: 1774278000,
|
||||
utilization: 1,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result).toEqual({
|
||||
suppress: false,
|
||||
message: {
|
||||
type: 'text',
|
||||
text: 'Claude AI usage limit warning|1774278000|100|',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when resetsAt is missing', () => {
|
||||
const result = parseRateLimitText(JSON.stringify({
|
||||
type: 'rate_limit_event',
|
||||
rate_limit_info: {
|
||||
status: 'rejected',
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { AgentMessage } from './types';
|
||||
|
||||
/**
|
||||
* Detect rate_limit_event JSON in agent text output and convert to
|
||||
* a standardized AgentMessage, so the web layer doesn't need to
|
||||
* parse undocumented Claude-internal JSON formats.
|
||||
*
|
||||
* Converted text format (pipe-delimited, parsed by web's reducerEvents.ts):
|
||||
* - "Claude AI usage limit warning|{unixSeconds}|{percentInt}|{rateLimitType}"
|
||||
* - "Claude AI usage limit reached|{unixSeconds}|{rateLimitType}"
|
||||
*
|
||||
* Returns null if the text is not a rate_limit_event (pass through as-is).
|
||||
* Returns { suppress: true } for known-noisy statuses (e.g. 'allowed').
|
||||
* Returns { suppress: false, message } for statuses worth displaying.
|
||||
*/
|
||||
export type RateLimitResult =
|
||||
| null
|
||||
| { suppress: true }
|
||||
| { suppress: false; message: AgentMessage };
|
||||
|
||||
export function parseRateLimitText(text: string): RateLimitResult {
|
||||
if (text[0] !== '{') return null;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
|
||||
// Unwrap { type: "output", data: { ... } } wrapper
|
||||
const record = parsed as Record<string, unknown>;
|
||||
let inner = record;
|
||||
if (record.type === 'output' && typeof record.data === 'object' && record.data !== null) {
|
||||
inner = record.data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (inner.type !== 'rate_limit_event') return null;
|
||||
|
||||
const info = inner.rate_limit_info;
|
||||
if (typeof info !== 'object' || info === null) return null;
|
||||
|
||||
const { status, resetsAt, utilization, rateLimitType } = info as Record<string, unknown>;
|
||||
if (typeof resetsAt !== 'number') return null;
|
||||
|
||||
if (status === 'allowed_warning') {
|
||||
const pct = typeof utilization === 'number' ? Math.round(utilization * 100) : 0;
|
||||
const limitType = typeof rateLimitType === 'string' ? rateLimitType : '';
|
||||
return {
|
||||
suppress: false,
|
||||
message: {
|
||||
type: 'text',
|
||||
text: `Claude AI usage limit warning|${resetsAt}|${pct}|${limitType}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'rejected') {
|
||||
const limitType = typeof rateLimitType === 'string' ? rateLimitType : '';
|
||||
return {
|
||||
suppress: false,
|
||||
message: {
|
||||
type: 'text',
|
||||
text: `Claude AI usage limit reached|${resetsAt}|${limitType}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'allowed') {
|
||||
return { suppress: true };
|
||||
}
|
||||
|
||||
// Unknown status — return null so the original text passes through.
|
||||
// Suppressing unknown statuses risks hiding important new events.
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getEventPresentation, formatResetTime } from './presentation'
|
||||
|
||||
describe('getEventPresentation — limit-warning', () => {
|
||||
it('formats five_hour warning', () => {
|
||||
const result = getEventPresentation({
|
||||
type: 'limit-warning',
|
||||
utilization: 0.9,
|
||||
endsAt: 1774278000,
|
||||
limitType: 'five_hour',
|
||||
})
|
||||
|
||||
expect(result.icon).toBe('⚠️')
|
||||
expect(result.text).toMatch(/Usage limit 90% \(5-hour\)/)
|
||||
expect(result.text).toMatch(/resets/)
|
||||
})
|
||||
|
||||
it('formats seven_day warning', () => {
|
||||
const result = getEventPresentation({
|
||||
type: 'limit-warning',
|
||||
utilization: 0.85,
|
||||
endsAt: 1774850400,
|
||||
limitType: 'seven_day',
|
||||
})
|
||||
|
||||
expect(result.text).toMatch(/Usage limit 85% \(7-day\)/)
|
||||
})
|
||||
|
||||
it('omits type label when limitType is empty', () => {
|
||||
const result = getEventPresentation({
|
||||
type: 'limit-warning',
|
||||
utilization: 1,
|
||||
endsAt: 1774278000,
|
||||
limitType: '',
|
||||
})
|
||||
|
||||
expect(result.text).toMatch(/^Usage limit 100% · resets/)
|
||||
expect(result.text).not.toMatch(/\(/)
|
||||
})
|
||||
|
||||
it('formats unknown limitType with underscore replacement', () => {
|
||||
const result = getEventPresentation({
|
||||
type: 'limit-warning',
|
||||
utilization: 0.5,
|
||||
endsAt: 1774278000,
|
||||
limitType: 'thirty_day',
|
||||
})
|
||||
|
||||
expect(result.text).toMatch(/\(thirty day\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEventPresentation — limit-reached', () => {
|
||||
it('shows limitType when present', () => {
|
||||
const result = getEventPresentation({
|
||||
type: 'limit-reached',
|
||||
endsAt: 1774278000,
|
||||
limitType: 'five_hour',
|
||||
})
|
||||
|
||||
expect(result.icon).toBe('⏳')
|
||||
expect(result.text).toMatch(/^Usage limit reached \(5-hour\) until/)
|
||||
})
|
||||
|
||||
it('omits limitType when empty', () => {
|
||||
const result = getEventPresentation({
|
||||
type: 'limit-reached',
|
||||
endsAt: 1774278000,
|
||||
limitType: '',
|
||||
})
|
||||
|
||||
expect(result.icon).toBe('⏳')
|
||||
expect(result.text).toMatch(/^Usage limit reached until/)
|
||||
expect(result.text).not.toMatch(/\(/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatResetTime', () => {
|
||||
it('formats a unix timestamp to a non-empty string', () => {
|
||||
const result = formatResetTime(1774278000)
|
||||
expect(result).toBeTruthy()
|
||||
expect(typeof result).toBe('string')
|
||||
})
|
||||
|
||||
it('handles millisecond timestamps', () => {
|
||||
const result = formatResetTime(1774278000000)
|
||||
expect(result).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns raw value for invalid timestamps', () => {
|
||||
const result = formatResetTime(NaN)
|
||||
expect(result).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,40 @@
|
||||
import type { AgentEvent } from '@/chat/types'
|
||||
|
||||
export function formatUnixTimestamp(value: number): string {
|
||||
function normalizeTimestamp(value: number): Date {
|
||||
const ms = value < 1_000_000_000_000 ? value * 1000 : value
|
||||
const date = new Date(ms)
|
||||
return new Date(ms)
|
||||
}
|
||||
|
||||
export function formatUnixTimestamp(value: number): string {
|
||||
const date = normalizeTimestamp(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
export function formatResetTime(value: number): string {
|
||||
const date = normalizeTimestamp(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value)
|
||||
|
||||
const now = new Date()
|
||||
const isToday = date.getFullYear() === now.getFullYear()
|
||||
&& date.getMonth() === now.getMonth()
|
||||
&& date.getDate() === now.getDate()
|
||||
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// Known types: five_hour → "5-hour", seven_day → "7-day".
|
||||
// Unknown types use underscore-to-space fallback (e.g. thirty_day → "thirty day").
|
||||
function formatLimitType(limitType: string | undefined): string {
|
||||
if (!limitType) return ''
|
||||
if (limitType === 'five_hour') return '5-hour'
|
||||
if (limitType === 'seven_day') return '7-day'
|
||||
return limitType.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = ms / 1000
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`
|
||||
@@ -47,9 +75,20 @@ export function getEventPresentation(event: AgentEvent): EventPresentation {
|
||||
const mode = typeof modeValue === 'string' ? modeValue : 'default'
|
||||
return { icon: '🔐', text: `Permission mode: ${mode}` }
|
||||
}
|
||||
if (event.type === 'limit-warning') {
|
||||
const ev = event as { utilization?: number; endsAt?: number; limitType?: string }
|
||||
const pct = Math.round((ev.utilization ?? 0) * 100)
|
||||
const endsAt = typeof ev.endsAt === 'number' ? ev.endsAt : null
|
||||
const typeLabel = formatLimitType(ev.limitType)
|
||||
const suffix = typeLabel ? ` (${typeLabel})` : ''
|
||||
return { icon: '⚠️', text: endsAt ? `Usage limit ${pct}%${suffix} · resets ${formatResetTime(endsAt)}` : `Usage limit ${pct}%${suffix}` }
|
||||
}
|
||||
if (event.type === 'limit-reached') {
|
||||
const endsAt = typeof event.endsAt === 'number' ? event.endsAt : null
|
||||
return { icon: '⏳', text: endsAt ? `Usage limit reached until ${formatUnixTimestamp(endsAt)}` : 'Usage limit reached' }
|
||||
const ev = event as { endsAt?: number; limitType?: string }
|
||||
const endsAt = typeof ev.endsAt === 'number' ? ev.endsAt : null
|
||||
const typeLabel = formatLimitType(ev.limitType)
|
||||
const suffix = typeLabel ? ` (${typeLabel})` : ''
|
||||
return { icon: '⏳', text: endsAt ? `Usage limit reached${suffix} until ${formatUnixTimestamp(endsAt)}` : `Usage limit reached${suffix}` }
|
||||
}
|
||||
if (event.type === 'message') {
|
||||
return { icon: null, text: typeof event.message === 'string' ? event.message : 'Message' }
|
||||
|
||||
@@ -85,7 +85,9 @@ function getEventKey(event: AgentEvent): string {
|
||||
case 'title-changed':
|
||||
return `title:${event.title}`
|
||||
case 'limit-reached':
|
||||
return `limit:${event.endsAt}`
|
||||
return `limit:${event.endsAt}:${(event as Record<string, unknown>).limitType}`
|
||||
case 'limit-warning':
|
||||
return `limit-warning:${event.endsAt}:${(event as Record<string, unknown>).utilization}:${(event as Record<string, unknown>).limitType}`
|
||||
case 'ready':
|
||||
return 'ready'
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseMessageAsEvent } from './reducerEvents'
|
||||
import type { NormalizedMessage } from './types'
|
||||
|
||||
function makeAgentTextMessage(text: string): NormalizedMessage {
|
||||
return {
|
||||
role: 'agent',
|
||||
content: [{ type: 'text', text, uuid: 'u1', parentUUID: null }],
|
||||
id: 'msg-1',
|
||||
localId: null,
|
||||
createdAt: Date.now(),
|
||||
isSidechain: false,
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseMessageAsEvent — usage limit formats', () => {
|
||||
it('parses reached with limitType', () => {
|
||||
const msg = makeAgentTextMessage('Claude AI usage limit reached|1774278000|five_hour')
|
||||
expect(parseMessageAsEvent(msg)).toEqual({
|
||||
type: 'limit-reached',
|
||||
endsAt: 1774278000,
|
||||
limitType: 'five_hour',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses reached without limitType (backward compat)', () => {
|
||||
const msg = makeAgentTextMessage('Claude AI usage limit reached|1774278000')
|
||||
expect(parseMessageAsEvent(msg)).toEqual({
|
||||
type: 'limit-reached',
|
||||
endsAt: 1774278000,
|
||||
limitType: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses warning with five_hour type', () => {
|
||||
const msg = makeAgentTextMessage('Claude AI usage limit warning|1774278000|90|five_hour')
|
||||
expect(parseMessageAsEvent(msg)).toEqual({
|
||||
type: 'limit-warning',
|
||||
utilization: 0.9,
|
||||
endsAt: 1774278000,
|
||||
limitType: 'five_hour',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses warning with seven_day type', () => {
|
||||
const msg = makeAgentTextMessage('Claude AI usage limit warning|1774850400|85|seven_day')
|
||||
expect(parseMessageAsEvent(msg)).toEqual({
|
||||
type: 'limit-warning',
|
||||
utilization: 0.85,
|
||||
endsAt: 1774850400,
|
||||
limitType: 'seven_day',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles missing limitType', () => {
|
||||
const msg = makeAgentTextMessage('Claude AI usage limit warning|1774278000|100|')
|
||||
expect(parseMessageAsEvent(msg)).toEqual({
|
||||
type: 'limit-warning',
|
||||
utilization: 1,
|
||||
endsAt: 1774278000,
|
||||
limitType: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for non-limit text', () => {
|
||||
const msg = makeAgentTextMessage('Hello world')
|
||||
expect(parseMessageAsEvent(msg)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for sidechain messages', () => {
|
||||
const msg = makeAgentTextMessage('Claude AI usage limit reached|1774278000')
|
||||
msg.isSidechain = true
|
||||
expect(parseMessageAsEvent(msg)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,25 @@
|
||||
import type { AgentEvent, AgentEventBlock, ChatBlock, NormalizedMessage } from '@/chat/types'
|
||||
|
||||
function parseClaudeUsageLimit(text: string): number | null {
|
||||
const match = text.match(/^Claude AI usage limit reached\|(\d+)$/)
|
||||
if (!match) return null
|
||||
const timestamp = Number.parseInt(match[1], 10)
|
||||
if (!Number.isFinite(timestamp)) return null
|
||||
return timestamp
|
||||
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 {
|
||||
@@ -14,9 +28,9 @@ export function parseMessageAsEvent(msg: NormalizedMessage): AgentEvent | null {
|
||||
|
||||
for (const content of msg.content) {
|
||||
if (content.type === 'text') {
|
||||
const limitReached = parseClaudeUsageLimit(content.text)
|
||||
if (limitReached !== null) {
|
||||
return { type: 'limit-reached', endsAt: limitReached }
|
||||
const limitEvent = parseClaudeUsageLimit(content.text)
|
||||
if (limitEvent !== null) {
|
||||
return limitEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ export type AgentEvent =
|
||||
| { type: 'switch'; mode: 'local' | 'remote' }
|
||||
| { type: 'message'; message: string }
|
||||
| { type: 'title-changed'; title: string }
|
||||
| { type: 'limit-reached'; endsAt: number }
|
||||
| { type: 'limit-reached'; endsAt: number; limitType: string }
|
||||
| { type: 'limit-warning'; /** 0–1 ratio (e.g. 0.9 = 90%), integer-precision via CLI pipe format */ utilization: number; endsAt: number; limitType: string }
|
||||
| { type: 'ready' }
|
||||
| { type: 'api-error'; retryAttempt: number; maxRetries: number; error: unknown }
|
||||
| { type: 'turn-duration'; durationMs: number }
|
||||
|
||||
Reference in New Issue
Block a user