diff --git a/cli/src/claude/utils/chatVisibility.test.ts b/cli/src/claude/utils/chatVisibility.test.ts index 65b8cfde8..f85c4deb6 100644 --- a/cli/src/claude/utils/chatVisibility.test.ts +++ b/cli/src/claude/utils/chatVisibility.test.ts @@ -20,4 +20,8 @@ describe('isClaudeChatVisibleMessage', () => { expect(isClaudeChatVisibleMessage({ type: 'assistant' })).toBe(true) expect(isClaudeChatVisibleMessage({ type: 'summary' })).toBe(true) }) + + it('hides rate_limit_event messages from chat delivery', () => { + expect(isClaudeChatVisibleMessage({ type: 'rate_limit_event' } as any)).toBe(false) + }) }) diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index c265fa9d6..2110c709e 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -269,6 +269,104 @@ describe('SDKToLogConverter', () => { }) }) + describe('Internal event filtering', () => { + it('should suppress rate_limit_event with allowed status', () => { + const sdkMessage = { + type: 'rate_limit_event', + rate_limit_info: { + status: 'allowed', + resetsAt: 1775559600, + rateLimitType: 'five_hour' + } + } as unknown as SDKMessage + + expect(converter.convert(sdkMessage)).toBeNull() + }) + + it('should convert allowed_warning to pipe-delimited text', () => { + const sdkMessage = { + type: 'rate_limit_event', + rate_limit_info: { + status: 'allowed_warning', + resetsAt: 1775559600, + utilization: 0.85, + rateLimitType: 'five_hour' + } + } as unknown as SDKMessage + + const logMessage = converter.convert(sdkMessage) + + expect(logMessage).not.toBeNull() + expect(logMessage!.type).toBe('assistant') + expect((logMessage as any).message.content[0].text).toBe( + 'Claude AI usage limit warning|1775559600|85|five_hour' + ) + }) + + it('should convert rejected to pipe-delimited text', () => { + const sdkMessage = { + type: 'rate_limit_event', + rate_limit_info: { + status: 'rejected', + resetsAt: 1775559600, + rateLimitType: 'five_hour' + } + } as unknown as SDKMessage + + const logMessage = converter.convert(sdkMessage) + + expect(logMessage).not.toBeNull() + expect(logMessage!.type).toBe('assistant') + expect((logMessage as any).message.content[0].text).toBe( + 'Claude AI usage limit reached|1775559600|five_hour' + ) + }) + + it('should not break parent chain when rate_limit_event is suppressed', () => { + const user = converter.convert({ + type: 'user', + message: { role: 'user', content: 'hi' } + } as SDKUserMessage) + + converter.convert({ + type: 'rate_limit_event', + rate_limit_info: { status: 'allowed' } + } as unknown as SDKMessage) + + const assistant = converter.convert({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] } + } as SDKAssistantMessage) + + expect(assistant!.parentUuid).toBe(user!.uuid) + }) + + it('should chain parent correctly when rate_limit_event is converted', () => { + const user = converter.convert({ + type: 'user', + message: { role: 'user', content: 'hi' } + } as SDKUserMessage) + + const warning = converter.convert({ + type: 'rate_limit_event', + rate_limit_info: { + status: 'allowed_warning', + resetsAt: 1775559600, + utilization: 0.8, + rateLimitType: 'five_hour' + } + } as unknown as SDKMessage) + + const assistant = converter.convert({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] } + } as SDKAssistantMessage) + + expect(warning!.parentUuid).toBe(user!.uuid) + expect(assistant!.parentUuid).toBe(warning!.uuid) + }) + }) + describe('Convenience function', () => { it('should convert single message without state', () => { const sdkMessage: SDKUserMessage = { diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index fac178cf4..4b551958a 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -86,10 +86,65 @@ export class SDKToLogConverter { this.context.parentUuid = null } + /** + * Convert rate_limit_event to pipe-delimited text matching the ACP path format, + * or suppress if the status does not need display (e.g. 'allowed'). + * Must not mutate converter state (UUID chain) so dropped events are invisible. + */ + private convertRateLimitEvent(sdkMessage: SDKMessage): RawJSONLines | null { + const info = (sdkMessage as any).rate_limit_info + if (typeof info !== 'object' || info === null) return null + + const { status, resetsAt, utilization, rateLimitType } = info + + if (status === 'allowed') return null + if (typeof resetsAt !== 'number') return null + + const resetsAtInt = Math.round(resetsAt) + let text: string + + if (status === 'allowed_warning') { + const pct = typeof utilization === 'number' ? Math.round(utilization * 100) : 0 + const limitType = typeof rateLimitType === 'string' ? rateLimitType : '' + text = `Claude AI usage limit warning|${resetsAtInt}|${pct}|${limitType}` + } else if (status === 'rejected') { + const limitType = typeof rateLimitType === 'string' ? rateLimitType : '' + text = `Claude AI usage limit reached|${resetsAtInt}|${limitType}` + } else { + return null + } + + const parentUuid = this.lastUuid + const uuid = randomUUID() + const timestamp = new Date().toISOString() + this.lastUuid = uuid + + return { + parentUuid, + isSidechain: false, + userType: 'external' as const, + cwd: this.context.cwd, + sessionId: this.context.sessionId, + version: this.context.version, + gitBranch: this.context.gitBranch, + uuid, + timestamp, + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text }] + } + } as RawJSONLines + } + /** * Convert SDK message to log format */ convert(sdkMessage: SDKMessage): RawJSONLines | null { + if (sdkMessage.type === 'rate_limit_event') { + return this.convertRateLimitEvent(sdkMessage) + } + const uuid = randomUUID() const timestamp = new Date().toISOString() let parentUuid = this.lastUuid; diff --git a/shared/src/messages.ts b/shared/src/messages.ts index c97fb59b5..9deca35f9 100644 --- a/shared/src/messages.ts +++ b/shared/src/messages.ts @@ -39,6 +39,10 @@ export function isClaudeChatVisibleSystemSubtype(subtype: unknown): subtype is s } export function isClaudeChatVisibleMessage(message: { type: unknown; subtype?: unknown }): boolean { + if (message.type === 'rate_limit_event') { + return false + } + if (message.type !== 'system') { return true }