From ae8f8c056fd9ff238dabb9563cb97511cbb3f54d Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Thu, 9 Apr 2026 03:37:43 +0000 Subject: [PATCH 1/4] fix(shared): filter rate_limit_event in isClaudeChatVisibleMessage Claude SDK emits rate_limit_event messages that are not recognized by the web normalizer, causing them to fall through to the safeStringify fallback and render as raw JSON in the chat UI. Add an early return in isClaudeChatVisibleMessage to mark rate_limit_event as non-visible, preventing it from reaching the web layer via the local session scanner path. --- cli/src/claude/utils/chatVisibility.test.ts | 4 ++++ shared/src/messages.ts | 4 ++++ 2 files changed, 8 insertions(+) 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/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 } From 643085042ccd831a229798d000b2a3790bdc9b18 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Thu, 9 Apr 2026 03:37:43 +0000 Subject: [PATCH 2/4] fix(cli): drop rate_limit_event before state mutation in sdkToLogConverter Guard against rate_limit_event at the top of convert(), before UUID generation and sidechain bookkeeping. This prevents phantom UUIDs from corrupting the parent chain when a rate_limit_event carries a parent_tool_use_id, and stops the event from reaching the web UI via the remote SDK message path. Includes parent-chain-integrity test to verify dropped events do not break UUID continuity. --- .../claude/utils/sdkToLogConverter.test.ts | 35 +++++++++++++++++++ cli/src/claude/utils/sdkToLogConverter.ts | 2 ++ 2 files changed, 37 insertions(+) diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index c265fa9d6..33e6996d9 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -269,6 +269,41 @@ describe('SDKToLogConverter', () => { }) }) + describe('Internal event filtering', () => { + it('should drop rate_limit_event messages', () => { + const sdkMessage = { + type: 'rate_limit_event', + rate_limit_info: { + status: 'allowed', + resetsAt: 1775559600, + rateLimitType: 'five_hour' + } + } as unknown as SDKMessage + + const logMessage = converter.convert(sdkMessage) + + expect(logMessage).toBeNull() + }) + + it('should not break parent chain when rate_limit_event is dropped', () => { + const user = converter.convert({ + type: 'user', + message: { role: 'user', content: 'hi' } + } as SDKUserMessage) + + converter.convert({ + type: 'rate_limit_event', + } 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) + }) + }) + 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..847950b42 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -90,6 +90,8 @@ export class SDKToLogConverter { * Convert SDK message to log format */ convert(sdkMessage: SDKMessage): RawJSONLines | null { + if (sdkMessage.type === 'rate_limit_event') return null + const uuid = randomUUID() const timestamp = new Date().toISOString() let parentUuid = this.lastUuid; From d5a8a6ea6ca4723932846bfc3ca4d50460c47758 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Thu, 9 Apr 2026 03:49:44 +0000 Subject: [PATCH 3/4] fix(cli): convert displayable rate_limit_event instead of dropping all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit suppressed all rate_limit_event messages, but allowed_warning and rejected statuses should be converted to the pipe-delimited text format the web layer already understands. - allowed: suppress (no display needed) - allowed_warning → "Claude AI usage limit warning|{resetsAt}|{pct}|{type}" - rejected → "Claude AI usage limit reached|{resetsAt}|{type}" Addresses review feedback on PR #423. --- .../claude/utils/sdkToLogConverter.test.ts | 44 +++++++++++++-- cli/src/claude/utils/sdkToLogConverter.ts | 54 ++++++++++++++++++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index 33e6996d9..45daa54d0 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -270,7 +270,7 @@ describe('SDKToLogConverter', () => { }) describe('Internal event filtering', () => { - it('should drop rate_limit_event messages', () => { + it('should suppress rate_limit_event with allowed status', () => { const sdkMessage = { type: 'rate_limit_event', rate_limit_info: { @@ -280,12 +280,49 @@ describe('SDKToLogConverter', () => { } } 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).toBeNull() + 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 dropped', () => { + it('should not break parent chain when rate_limit_event is suppressed', () => { const user = converter.convert({ type: 'user', message: { role: 'user', content: 'hi' } @@ -293,6 +330,7 @@ describe('SDKToLogConverter', () => { converter.convert({ type: 'rate_limit_event', + rate_limit_info: { status: 'allowed' } } as unknown as SDKMessage) const assistant = converter.convert({ diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index 847950b42..0516084c3 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -86,11 +86,63 @@ 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 uuid = randomUUID() + const timestamp = new Date().toISOString() + this.lastUuid = uuid + + return { + parentUuid: this.lastUuid, + 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 null + if (sdkMessage.type === 'rate_limit_event') { + return this.convertRateLimitEvent(sdkMessage) + } const uuid = randomUUID() const timestamp = new Date().toISOString() From e12c4645f922802c62a0599f60299fe128beea92 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Thu, 9 Apr 2026 03:57:41 +0000 Subject: [PATCH 4/4] fix(cli): correct parentUuid ordering in convertRateLimitEvent The previous commit set this.lastUuid before reading it for parentUuid, causing every converted rate_limit_event to be a self-referencing node (parentUuid === uuid). Capture parentUuid before assigning the new uuid, matching the ordering used by the main convert() method. Add test for parent chain continuity through converted (non-suppressed) rate_limit_event messages. --- .../claude/utils/sdkToLogConverter.test.ts | 25 +++++++++++++++++++ cli/src/claude/utils/sdkToLogConverter.ts | 3 ++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/cli/src/claude/utils/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index 45daa54d0..2110c709e 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -340,6 +340,31 @@ describe('SDKToLogConverter', () => { 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', () => { diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index 0516084c3..4b551958a 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -114,12 +114,13 @@ export class SDKToLogConverter { return null } + const parentUuid = this.lastUuid const uuid = randomUUID() const timestamp = new Date().toISOString() this.lastUuid = uuid return { - parentUuid: this.lastUuid, + parentUuid, isSidechain: false, userType: 'external' as const, cwd: this.context.cwd,