Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions packages/happy-app/sources/components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<View style={styles.agentEventContainer}>
<Text style={styles.agentEventText}>
{t('message.usageLimitUntil', { time: formatTime(props.event.endsAt) })}
<View style={styles.limitReachedContainer}>
<Text style={styles.limitReachedText}>
{displayText}
</Text>
</View>
);
Expand Down Expand Up @@ -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,
},
Expand Down
100 changes: 100 additions & 0 deletions packages/happy-app/sources/sync/reducer/messageToEvent.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
31 changes: 28 additions & 3 deletions packages/happy-app/sources/sync/reducer/messageToEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
);
}
3 changes: 2 additions & 1 deletion packages/happy-app/sources/sync/typesRaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
})]);
Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/_default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},

Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/ca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},

Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},

Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},

Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},

Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,7 @@ export const ja: TranslationStructure = {
switchedToMode: ({ mode }: { mode: string }) => `${mode}モードに切り替えました`,
unknownEvent: '不明なイベント',
usageLimitUntil: ({ time }: { time: string }) => `${time}まで使用制限中`,
usageLimitReached: '使用制限に達しました。しばらく待ってから再試行してください。',
unknownTime: '不明な時間',
},

Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},

Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},

Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,7 @@ export const ru: TranslationStructure = {
switchedToMode: ({ mode }: { mode: string }) => `Переключено в режим ${mode}`,
unknownEvent: 'Неизвестное событие',
usageLimitUntil: ({ time }: { time: string }) => `Лимит использования достигнут до ${time}`,
usageLimitReached: 'Лимит использования достигнут. Подождите и попробуйте снова.',
unknownTime: 'неизвестное время',
},

Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/zh-Hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,7 @@ export const zhHans: TranslationStructure = {
switchedToMode: ({ mode }: { mode: string }) => `已切换到 ${mode} 模式`,
unknownEvent: '未知事件',
usageLimitUntil: ({ time }: { time: string }) => `使用限制到 ${time}`,
usageLimitReached: '已达到使用限制。请稍候再试。',
unknownTime: '未知时间',
},

Expand Down
1 change: 1 addition & 0 deletions packages/happy-app/sources/text/translations/zh-Hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@ export const zhHant: TranslationStructure = {
switchedToMode: ({ mode }: { mode: string }) => `已切換到 ${mode} 模式`,
unknownEvent: '未知事件',
usageLimitUntil: ({ time }: { time: string }) => `使用限制到 ${time}`,
usageLimitReached: '已達到使用限制。請稍候再試。',
unknownTime: '未知時間',
},

Expand Down