diff --git a/packages/happy-app/sources/components/MessageView.tsx b/packages/happy-app/sources/components/MessageView.tsx index 2fab4cfba..51f6652b3 100644 --- a/packages/happy-app/sources/components/MessageView.tsx +++ b/packages/happy-app/sources/components/MessageView.tsx @@ -134,10 +134,14 @@ function AgentEventBlock(props: { } }; + const displayText = props.event.endsAt + ? t('message.usageLimitUntil', { time: formatTime(props.event.endsAt) }) + : props.event.message || t('message.usageLimitReached'); + return ( - - - {t('message.usageLimitUntil', { time: formatTime(props.event.endsAt) })} + + + {displayText} ); @@ -212,6 +216,20 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.agentEventText, fontSize: 14, }, + limitReachedContainer: { + marginHorizontal: 16, + marginVertical: 8, + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: theme.colors.box.warning.background, + borderRadius: 12, + alignItems: 'center', + }, + limitReachedText: { + color: theme.colors.box.warning.text, + fontSize: 14, + textAlign: 'center', + }, toolContainer: { marginHorizontal: 8, }, diff --git a/packages/happy-app/sources/sync/reducer/messageToEvent.test.ts b/packages/happy-app/sources/sync/reducer/messageToEvent.test.ts new file mode 100644 index 000000000..8ef6629f4 --- /dev/null +++ b/packages/happy-app/sources/sync/reducer/messageToEvent.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { parseMessageAsEvent, isRateLimitMessage } from './messageToEvent'; +import { NormalizedMessage } from '../typesRaw'; + +function createAgentTextMessage(text: string): NormalizedMessage { + return { + role: 'agent', + content: [{ type: 'text', text }], + id: 'test-id', + localId: null, + createdAt: Date.now(), + isSidechain: false, + } as NormalizedMessage; +} + +describe('messageToEvent', () => { + describe('parseMessageAsEvent', () => { + it('parses Claude AI usage limit with timestamp', () => { + const msg = createAgentTextMessage('Claude AI usage limit reached|1700000000'); + const event = parseMessageAsEvent(msg); + expect(event).toEqual({ + type: 'limit-reached', + endsAt: 1700000000, + }); + }); + + it('converts Gemini quota exceeded messages to limit-reached events', () => { + const msg = createAgentTextMessage('Gemini quota exceeded. Quota resets in 3h20m. Try using a different model (gemini-2.5-flash-lite) or wait for quota reset.'); + const event = parseMessageAsEvent(msg); + expect(event).not.toBeNull(); + expect(event!.type).toBe('limit-reached'); + if (event!.type === 'limit-reached') { + expect(event!.message).toContain('Gemini quota exceeded'); + } + }); + + it('converts rate limit exceeded messages to limit-reached events', () => { + const msg = createAgentTextMessage('Gemini API rate limit exceeded. Please wait a moment and try again.'); + const event = parseMessageAsEvent(msg); + expect(event).not.toBeNull(); + expect(event!.type).toBe('limit-reached'); + }); + + it('does not convert regular agent messages', () => { + const msg = createAgentTextMessage('Hello, how can I help you?'); + const event = parseMessageAsEvent(msg); + expect(event).toBeNull(); + }); + + it('skips sidechain messages', () => { + const msg = { + ...createAgentTextMessage('Claude AI usage limit reached|1700000000'), + isSidechain: true, + } as NormalizedMessage; + const event = parseMessageAsEvent(msg); + expect(event).toBeNull(); + }); + }); + + describe('isRateLimitMessage', () => { + it('detects "rate limit exceeded"', () => { + expect(isRateLimitMessage('API rate limit exceeded')).toBe(true); + }); + + it('detects "quota exceeded"', () => { + expect(isRateLimitMessage('Gemini quota exceeded.')).toBe(true); + }); + + it('detects "quota exhausted"', () => { + expect(isRateLimitMessage('Your quota has been exhausted')).toBe(true); + }); + + it('detects "usage limit reached"', () => { + expect(isRateLimitMessage('Usage limit reached for this account')).toBe(true); + }); + + it('detects "resource exhausted"', () => { + expect(isRateLimitMessage('RESOURCE_EXHAUSTED: resource has been exhausted')).toBe(true); + }); + + it('detects "rate_limit_error"', () => { + expect(isRateLimitMessage('Error: rate_limit_error on request')).toBe(true); + }); + + it('detects "rateLimitExceeded"', () => { + expect(isRateLimitMessage('rateLimitExceeded for model')).toBe(true); + }); + + it('is case insensitive', () => { + expect(isRateLimitMessage('RATE LIMIT EXCEEDED')).toBe(true); + expect(isRateLimitMessage('Quota Exceeded')).toBe(true); + }); + + it('does not match regular messages', () => { + expect(isRateLimitMessage('Hello world')).toBe(false); + expect(isRateLimitMessage('The rate of change is high')).toBe(false); + expect(isRateLimitMessage('This is a limited feature')).toBe(false); + }); + }); +}); diff --git a/packages/happy-app/sources/sync/reducer/messageToEvent.ts b/packages/happy-app/sources/sync/reducer/messageToEvent.ts index cf19990bf..e2d9b9c91 100644 --- a/packages/happy-app/sources/sync/reducer/messageToEvent.ts +++ b/packages/happy-app/sources/sync/reducer/messageToEvent.ts @@ -29,19 +29,27 @@ export function parseMessageAsEvent(msg: NormalizedMessage): AgentEvent | null { // Check for agent messages that should become events if (msg.role === 'agent') { for (const content of msg.content) { - // Check for Claude AI usage limit messages if (content.type === 'text') { + // Check for Claude AI usage limit messages (format: "Claude AI usage limit reached|{timestamp}") const limitMatch = content.text.match(/^Claude AI usage limit reached\|(\d+)$/); if (limitMatch) { const timestamp = parseInt(limitMatch[1], 10); if (!isNaN(timestamp)) { return { type: 'limit-reached', - endsAt: timestamp + endsAt: timestamp, } as AgentEvent; } } - + + // Check for generic rate limit / quota messages from any backend + const text = content.text; + if (isRateLimitMessage(text)) { + return { + type: 'limit-reached', + message: text, + } as AgentEvent; + } } // Check for mcp__happy__change_title tool calls @@ -74,4 +82,21 @@ export function parseMessageAsEvent(msg: NormalizedMessage): AgentEvent | null { export function shouldSkipNormalProcessing(msg: NormalizedMessage): boolean { // If a message converts to an event, it should skip normal processing return parseMessageAsEvent(msg) !== null; +} + +/** + * Detects rate limit / quota exhaustion messages from any backend. + * These messages should be surfaced as limit-reached events for visual emphasis. + */ +export function isRateLimitMessage(text: string): boolean { + const lower = text.toLowerCase(); + return ( + (lower.includes('rate limit') && lower.includes('exceeded')) || + (lower.includes('quota') && lower.includes('exceeded')) || + (lower.includes('quota') && lower.includes('exhausted')) || + (lower.includes('usage limit') && lower.includes('reached')) || + (lower.includes('resource') && lower.includes('exhausted')) || + lower.includes('rate_limit_error') || + lower.includes('ratelimitexceeded') + ); } \ No newline at end of file diff --git a/packages/happy-app/sources/sync/typesRaw.ts b/packages/happy-app/sources/sync/typesRaw.ts index 5ac33bb0f..b39c0ac2d 100644 --- a/packages/happy-app/sources/sync/typesRaw.ts +++ b/packages/happy-app/sources/sync/typesRaw.ts @@ -34,7 +34,8 @@ const agentEventSchema = z.discriminatedUnion('type', [z.object({ message: z.string(), }), z.object({ type: z.literal('limit-reached'), - endsAt: z.number(), + endsAt: z.number().optional(), + message: z.string().optional(), }), z.object({ type: z.literal('ready'), })]); diff --git a/packages/happy-app/sources/text/_default.ts b/packages/happy-app/sources/text/_default.ts index c3a0d673a..e92d4478a 100644 --- a/packages/happy-app/sources/text/_default.ts +++ b/packages/happy-app/sources/text/_default.ts @@ -746,6 +746,7 @@ export const en = { switchedToMode: ({ mode }: { mode: string }) => `Switched to ${mode} mode`, unknownEvent: 'Unknown event', usageLimitUntil: ({ time }: { time: string }) => `Usage limit reached until ${time}`, + usageLimitReached: 'Usage limit reached. Please wait and try again.', unknownTime: 'unknown time', }, diff --git a/packages/happy-app/sources/text/translations/ca.ts b/packages/happy-app/sources/text/translations/ca.ts index 76d928500..a28bbff72 100644 --- a/packages/happy-app/sources/text/translations/ca.ts +++ b/packages/happy-app/sources/text/translations/ca.ts @@ -747,6 +747,7 @@ export const ca: TranslationStructure = { switchedToMode: ({ mode }: { mode: string }) => `S'ha canviat al mode ${mode}`, unknownEvent: 'Esdeveniment desconegut', usageLimitUntil: ({ time }: { time: string }) => `Límit d'ús assolit fins a ${time}`, + usageLimitReached: 'Límit d\'ús assolit. Espereu i torneu-ho a provar.', unknownTime: 'temps desconegut', }, diff --git a/packages/happy-app/sources/text/translations/en.ts b/packages/happy-app/sources/text/translations/en.ts index b86aa4af6..f136c27ba 100644 --- a/packages/happy-app/sources/text/translations/en.ts +++ b/packages/happy-app/sources/text/translations/en.ts @@ -762,6 +762,7 @@ export const en: TranslationStructure = { switchedToMode: ({ mode }: { mode: string }) => `Switched to ${mode} mode`, unknownEvent: 'Unknown event', usageLimitUntil: ({ time }: { time: string }) => `Usage limit reached until ${time}`, + usageLimitReached: 'Usage limit reached. Please wait and try again.', unknownTime: 'unknown time', }, diff --git a/packages/happy-app/sources/text/translations/es.ts b/packages/happy-app/sources/text/translations/es.ts index 41c17664e..d1991cafd 100644 --- a/packages/happy-app/sources/text/translations/es.ts +++ b/packages/happy-app/sources/text/translations/es.ts @@ -747,6 +747,7 @@ export const es: TranslationStructure = { switchedToMode: ({ mode }: { mode: string }) => `Cambiado al modo ${mode}`, unknownEvent: 'Evento desconocido', usageLimitUntil: ({ time }: { time: string }) => `Límite de uso alcanzado hasta ${time}`, + usageLimitReached: 'Límite de uso alcanzado. Espera e inténtalo de nuevo.', unknownTime: 'tiempo desconocido', }, diff --git a/packages/happy-app/sources/text/translations/it.ts b/packages/happy-app/sources/text/translations/it.ts index 6dbfb52ac..e5765d8cb 100644 --- a/packages/happy-app/sources/text/translations/it.ts +++ b/packages/happy-app/sources/text/translations/it.ts @@ -776,6 +776,7 @@ export const it: TranslationStructure = { switchedToMode: ({ mode }: { mode: string }) => `Passato alla modalità ${mode}`, unknownEvent: 'Evento sconosciuto', usageLimitUntil: ({ time }: { time: string }) => `Limite di utilizzo raggiunto fino a ${time}`, + usageLimitReached: 'Limite di utilizzo raggiunto. Attendi e riprova.', unknownTime: 'ora sconosciuta', }, diff --git a/packages/happy-app/sources/text/translations/ja.ts b/packages/happy-app/sources/text/translations/ja.ts index 090480d7a..0679beea6 100644 --- a/packages/happy-app/sources/text/translations/ja.ts +++ b/packages/happy-app/sources/text/translations/ja.ts @@ -779,6 +779,7 @@ export const ja: TranslationStructure = { switchedToMode: ({ mode }: { mode: string }) => `${mode}モードに切り替えました`, unknownEvent: '不明なイベント', usageLimitUntil: ({ time }: { time: string }) => `${time}まで使用制限中`, + usageLimitReached: '使用制限に達しました。しばらく待ってから再試行してください。', unknownTime: '不明な時間', }, diff --git a/packages/happy-app/sources/text/translations/pl.ts b/packages/happy-app/sources/text/translations/pl.ts index 55401af6a..f8f8dc77a 100644 --- a/packages/happy-app/sources/text/translations/pl.ts +++ b/packages/happy-app/sources/text/translations/pl.ts @@ -757,6 +757,7 @@ export const pl: TranslationStructure = { switchedToMode: ({ mode }: { mode: string }) => `Przełączono na tryb ${mode}`, unknownEvent: 'Nieznane zdarzenie', usageLimitUntil: ({ time }: { time: string }) => `Osiągnięto limit użycia do ${time}`, + usageLimitReached: 'Osiągnięto limit użycia. Poczekaj i spróbuj ponownie.', unknownTime: 'nieznany czas', }, diff --git a/packages/happy-app/sources/text/translations/pt.ts b/packages/happy-app/sources/text/translations/pt.ts index 75d9afed2..697f23d36 100644 --- a/packages/happy-app/sources/text/translations/pt.ts +++ b/packages/happy-app/sources/text/translations/pt.ts @@ -747,6 +747,7 @@ export const pt: TranslationStructure = { switchedToMode: ({ mode }: { mode: string }) => `Mudou para o modo ${mode}`, unknownEvent: 'Evento desconhecido', usageLimitUntil: ({ time }: { time: string }) => `Limite de uso atingido até ${time}`, + usageLimitReached: 'Limite de uso atingido. Aguarde e tente novamente.', unknownTime: 'horário desconhecido', }, diff --git a/packages/happy-app/sources/text/translations/ru.ts b/packages/happy-app/sources/text/translations/ru.ts index 930474bb7..12a54c3d5 100644 --- a/packages/happy-app/sources/text/translations/ru.ts +++ b/packages/happy-app/sources/text/translations/ru.ts @@ -745,6 +745,7 @@ export const ru: TranslationStructure = { switchedToMode: ({ mode }: { mode: string }) => `Переключено в режим ${mode}`, unknownEvent: 'Неизвестное событие', usageLimitUntil: ({ time }: { time: string }) => `Лимит использования достигнут до ${time}`, + usageLimitReached: 'Лимит использования достигнут. Подождите и попробуйте снова.', unknownTime: 'неизвестное время', }, diff --git a/packages/happy-app/sources/text/translations/zh-Hans.ts b/packages/happy-app/sources/text/translations/zh-Hans.ts index a9db3ead3..bf4585399 100644 --- a/packages/happy-app/sources/text/translations/zh-Hans.ts +++ b/packages/happy-app/sources/text/translations/zh-Hans.ts @@ -749,6 +749,7 @@ export const zhHans: TranslationStructure = { switchedToMode: ({ mode }: { mode: string }) => `已切换到 ${mode} 模式`, unknownEvent: '未知事件', usageLimitUntil: ({ time }: { time: string }) => `使用限制到 ${time}`, + usageLimitReached: '已达到使用限制。请稍候再试。', unknownTime: '未知时间', }, diff --git a/packages/happy-app/sources/text/translations/zh-Hant.ts b/packages/happy-app/sources/text/translations/zh-Hant.ts index e09ca6f3c..734412d13 100644 --- a/packages/happy-app/sources/text/translations/zh-Hant.ts +++ b/packages/happy-app/sources/text/translations/zh-Hant.ts @@ -748,6 +748,7 @@ export const zhHant: TranslationStructure = { switchedToMode: ({ mode }: { mode: string }) => `已切換到 ${mode} 模式`, unknownEvent: '未知事件', usageLimitUntil: ({ time }: { time: string }) => `使用限制到 ${time}`, + usageLimitReached: '已達到使用限制。請稍候再試。', unknownTime: '未知時間', },