Skip to content

fix(delivery): drop empty chunks so Telegram doesn't reject "text must be non-empty"#38

Open
hah23255 wants to merge 1 commit intoop7418:mainfrom
hah23255:fix/empty-chunk-text-must-be-non-empty
Open

fix(delivery): drop empty chunks so Telegram doesn't reject "text must be non-empty"#38
hah23255 wants to merge 1 commit intoop7418:mainfrom
hah23255:fix/empty-chunk-text-must-be-non-empty

Conversation

@hah23255
Copy link
Copy Markdown

@hah23255 hah23255 commented May 1, 2026

Summary

chunkText strips only one leading \n between slices. When the splitter lands splitIdx at the boundary of a blank-line gap (very common — markdown blocks separated by \n\n or more), the next chunk's first character is still whitespace. After markdown → IR → HTML rendering, that leading whitespace can collapse to an empty payload — which Telegram's Bot API rejects:

400 Bad Request: text must be non-empty

The retry layer classifies HTTP 400 as client_error and (correctly) does not retry, so the chunk is silently dropped and the user sees a [X/N part(s) failed to send — response may be incomplete] notice even though the rest of the response was fine.

Reproduction

Any Claude reply long enough to be split into ≥ 2 chunks where the split-point is preceded by ≥ 2 newlines. In real-world traffic this surfaces as a deterministic "Chunk 2/N failed" in the bridge log, on roughly every other long response.

Sample log lines from a production install over ~6 weeks (chat-id redacted):

[2026-03-14] Chunk 2/7  failed: Bad Request: text must be non-empty
[2026-03-14] Chunk 5/7  failed: Bad Request: text must be non-empty
[2026-03-26] Chunk 18/34, 23/34, 26/34, 29/34 failed: text must be non-empty
[2026-04-16] Chunk 2/6  failed: text must be non-empty
[2026-04-17] Chunk 2/4  failed: text must be non-empty
[2026-04-25] Chunk 2/9  failed: text must be non-empty
[2026-04-25] Chunk 2/4  failed: text must be non-empty
[2026-04-26] Chunk 2/7  failed: text must be non-empty
[2026-04-30] Chunk 2/4  failed: text must be non-empty
[2026-04-30] Chunk 2/3  failed: text must be non-empty
[2026-04-30] Chunk 2/6  failed: text must be non-empty

11 of 14 failures are at chunk index #2 — which is exactly where the leading-whitespace artefact lands when the first chunk consumed everything up to the first newline of a multi-blank-line gap.

Fix

Two-layer fix:

  1. replace(/^\n/, '')replace(/^\s+/, '') so all leading whitespace (newlines, tabs, spaces) is consumed regardless of how many blank lines preceded the split.
  2. Defensive chunks.filter(c => c.trim().length > 0) at the end so downstream code never receives a whitespace-only chunk — even if a future chunker tuning re-introduces the class of bug.

Both chunkText copies (src/lib/bridge/delivery-layer.ts and src/lib/bridge/markdown/ir.ts) are patched with matching commentary so the fix doesn't drift.

Risk

Very low. The change only affects the input boundary handling between chunks; non-empty content payloads are unchanged. No existing tests assert whitespace-only outputs, so behaviour is strictly an improvement.

Test

Existing unit tests pass unchanged. I did not add a new test in this PR to keep it minimal — happy to add one focused on the multi-blank-line split case if the maintainers prefer.


🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

…t be non-empty"

`chunkText` strips only one leading `\n` between slices. When the
splitter lands `splitIdx` at the boundary of a blank-line gap (very
common — markdown blocks separated by `\n\n` or more), the next
chunk's first character is still whitespace. After
`markdown → IR → HTML` rendering, that leading whitespace can
collapse to an empty payload — which Telegram's Bot API rejects:

    400 Bad Request: text must be non-empty

The retry layer classifies HTTP 400 as `client_error` and (correctly)
does not retry, so the chunk is silently dropped and the user sees a
"[X/N part(s) failed to send — response may be incomplete]" notice
even though the rest of the response was fine.

Reproduction: any Claude reply long enough to be split into ≥ 2
chunks where the split-point is preceded by ≥ 2 newlines. Locally
this surfaces as "Chunk 2/N failed" on roughly every other long
response.

Fix is two-layer:

1. `replace(/^\n/, '')` → `replace(/^\s+/, '')` so all leading
   whitespace (newlines, tabs, spaces) is consumed regardless of
   how many blank lines preceded the split.
2. Defensive `chunks.filter(c => c.trim().length > 0)` at the end
   so downstream code never receives a whitespace-only chunk —
   even if a future chunker tuning re-introduces the class of bug.

Both `chunkText` copies (delivery-layer.ts and markdown/ir.ts) are
patched with matching commentary so the fix doesn't drift.

No test changes — existing IR/chunk tests pass with the new
behaviour because they only assert non-empty inputs.
Copilot AI review requested due to automatic review settings May 1, 2026 09:45
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes outbound message chunking so whitespace-only chunks aren’t produced/sent (which can trigger Telegram Bot API 400 Bad Request: text must be non-empty), improving reliability for long, multi-chunk responses.

Changes:

  • Update chunk split boundary handling to strip leading whitespace from subsequent slices.
  • Add a defensive filter to drop all-whitespace chunks in both chunker implementations.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
src/lib/bridge/markdown/ir.ts Adjusts IR text chunking to avoid leading-whitespace/empty chunks and filters whitespace-only chunks.
src/lib/bridge/delivery-layer.ts Adjusts generic delivery chunking to avoid leading-whitespace/empty chunks and filters whitespace-only chunks before sending.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +55 to +61
// Strip ALL leading whitespace from the next slice — not only one '\n'.
// The previous `replace(/^\n/, '')` only removed a single newline, so when
// `splitIdx` landed before a run of blank lines the next chunk would start
// with leading whitespace. After markdown → IR → HTML rendering that
// whitespace can collapse to an empty payload, which Telegram rejects with
// `Bad Request: text must be non-empty`.
remaining = remaining.slice(splitIdx).replace(/^\s+/, '');
Comment on lines +64 to +67
// Drop any chunks that ended up containing only whitespace (defensive — keeps
// downstream code from sending empty payloads regardless of how the splitter
// is tuned).
return chunks.filter((c) => c.trim().length > 0);
Comment on lines 54 to +67
chunks.push(remaining.slice(0, splitIdx));
remaining = remaining.slice(splitIdx).replace(/^\n/, '');
// Strip ALL leading whitespace from the next slice — not only one '\n'.
// The previous `replace(/^\n/, '')` only removed a single newline, so when
// `splitIdx` landed before a run of blank lines the next chunk would start
// with leading whitespace. After markdown → IR → HTML rendering that
// whitespace can collapse to an empty payload, which Telegram rejects with
// `Bad Request: text must be non-empty`.
remaining = remaining.slice(splitIdx).replace(/^\s+/, '');
}

return chunks;
// Drop any chunks that ended up containing only whitespace (defensive — keeps
// downstream code from sending empty payloads regardless of how the splitter
// is tuned).
return chunks.filter((c) => c.trim().length > 0);
Comment on lines +750 to +753
// Strip ALL leading whitespace from the next slice — not only one '\n' —
// so chunks downstream don't render to empty payloads at platform send.
// See accompanying note in lib/bridge/delivery-layer.ts.
remaining = remaining.slice(splitIdx).replace(/^\s+/, '');
Comment on lines +755 to +756
// Drop any all-whitespace chunks defensively.
return chunks.filter((c) => c.trim().length > 0);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants