Files
hapi/cli/src/agent/rateLimitParser.test.ts
T
9eb0dacf75 fix(cli): filter raw SSE event JSON from leaking into chat messages (#405)
* fix(cli): filter raw SSE event JSON from leaking into chat messages

Two types of internal JSON were appearing as visible text in Telegram
Mini App and web chat:

1. `rate_limit_event` — the rate limit parser returned `null` for
   unknown statuses, causing raw JSON to pass through as assistant text.
   Changed to `{ suppress: true }` so all rate_limit_event variants are
   handled; new statuses that need display can be added explicitly.

2. `{ type: "output", data: { ... } }` — internal session metadata
   envelopes leaked through the ACP text chunk pipeline. Added an
   `isInternalEventJson` filter that catches JSON objects with known
   internal envelope types (output, event, queue-operation) before they
   enter the text buffer.

Closes #386

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(cli): narrow internal event filter to match only leaked metadata shape

Address review feedback: the broad type-based filter could suppress
legitimate assistant JSON with type "event" or "queue-operation".

Narrow the check to only match the specific leaked metadata envelope:
{ type: "output", data: { parentUuid, sessionId, userType } }

Add negative tests confirming other JSON types pass through.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(cli): suppress malformed rate_limit_event without resetsAt

Address review: rate_limit_event payloads missing resetsAt still leaked
as raw JSON because parseRateLimitText returned null before reaching the
unknown-status suppress. Move the allowed check before the resetsAt
guard and suppress malformed payloads instead of passing them through.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(cli): handle parentUuid: null in internal event filter

Root/first-message metadata envelopes have parentUuid: null rather than
a string, so the filter missed them. Accept both string and null.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test(cli): add e2e regression tests for metadata envelope filtering

Add AcpMessageHandler tests that verify leaked { type: "output", data }
metadata envelopes (both parentUuid string and null) are dropped before
reaching the text buffer.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(cli): clear buffered prefix when cumulative metadata chunk arrives

When a leaked metadata envelope arrives as cumulative streaming chunks
(first an incomplete JSON prefix, then the full blob), the filter
dropped the full chunk but left the prefix in bufferedText. Clear the
buffer when the detected internal JSON starts with the buffered prefix.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(cli): round resetsAt to integer for pipe-delimited format

The web-side regex uses \d+ to parse the timestamp, so a float value
would silently fail to match. Apply Math.round to ensure integer output.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(cli): clear buffered prefix for cumulative rate_limit_event chunks

The prefix-clearing logic only applied to the isInternalEventJson
branch but not to the parseRateLimitText branch, so cumulative
rate_limit_event chunks could leave a raw JSON prefix in the buffer.

Hoist the prefix check before both filters and apply uniformly.
Add regression tests for suppressed and displayable cumulative
rate_limit_event scenarios.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-06 20:42:28 +08:00

154 lines
4.6 KiB
TypeScript

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('suppresses unknown statuses to prevent raw JSON leaking', () => {
const result = parseRateLimitText(JSON.stringify({
type: 'rate_limit_event',
rate_limit_info: {
status: 'some_future_status',
resetsAt: 1774278000,
},
}));
expect(result).toEqual({ suppress: true });
});
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('suppresses when resetsAt is missing to prevent raw JSON leak', () => {
const result = parseRateLimitText(JSON.stringify({
type: 'rate_limit_event',
rate_limit_info: {
status: 'rejected',
},
}));
expect(result).toEqual({ suppress: true });
});
});