Skip to content

fix: preserve markdown formatting when responses split across Discord messages - #12

Open
wolframs wants to merge 3 commits into
antra-tess:mainfrom
wolframs:fix/markdown-across-message-splits
Open

fix: preserve markdown formatting when responses split across Discord messages#12
wolframs wants to merge 3 commits into
antra-tess:mainfrom
wolframs:fix/markdown-across-message-splits

Conversation

@wolframs

Copy link
Copy Markdown

Problem

Discord parses each message independently. When the bot's response is long enough to be divided across multiple messages, a markdown construct that straddles the boundary breaks. For code fences this cascades: an unclosed ```bash at the end of one message leaves the next message's content rendering as code, and the matching closing fence then becomes an opener — inverting every block for the rest of the response.

This bites at two layers, both previously markdown-unaware:

  1. Within one message — the >1800-char splitter
  2. Across messages — a fence opened before a tool call and continued after it spans two independent sends (streaming pre-tool flush, then final send)

Fix

A pure, dependency-free balancer (src/utils/discord-markdown.ts) that closes any construct open at a chunk boundary and reopens it in the next message, threading carry state across separate sends. Covers code fences (```/~~~), inline code, and emphasis (*, **, ***, _, __, ~~, ||).

  • connector.sendSegmentChunks resolves mentions/emojis, then markdown-splits once, returning per-message bridge metadata — so resolution can never push a chunk over the limit and trigger an unrecorded second split.
  • sendSegments threads markdownCarry across the native + inline tool paths. Only exclusive constructs (fence/inline) cross a send boundary; dangling emphasis is left literal (re-opening a span the model may never close would render worse).
  • Synthetic close/reopen strings are recorded on MessageContext (bridgeOpen/bridgeClose) and stripped during context reconstruction, so the model reads back its original unbroken markdown rather than the bridging delimiters.
  • Also persists messageContexts in the generated-image response path (was dropping prefix/suffix + bridge metadata there).

Tests

src/utils/discord-markdown.test.ts — exhaustive unit suite (fences of varying run lengths, info-string rules, inline code, nested emphasis, flanking/escaping, startCarry/endCarry, budget/boundary edges, cross-send round-trip). Plus connector-level tests for resolve-then-split (incl. emoji expansion past the limit) and context-reconstruction stripping. Full suite green.

Notes for reviewers

The emphasis parser is pragmatic, not full CommonMark — well-formed/nested cases are handled; pathological interleaving (**a*b**) degrades to literal (no worse than today). Fences and inline code are the rock-solid path.

🤖 Generated with Claude Code

… messages

Discord parses each message independently, so when a long LLM response is
divided across multiple messages, a markdown construct straddling the boundary
breaks — an open code fence corrupts every block after it (the matching closer
becomes an opener, cascading through the rest of the response).

Add a pure markdown balancer (src/utils/discord-markdown.ts) that closes any
construct open at a chunk boundary and reopens it in the next message, with
carry state threaded across separate sends. Covers code fences, inline code,
and emphasis spans (*, **, ***, _, __, ~~, ||).

- connector.sendSegmentChunks resolves mentions/emojis then markdown-splits
  once (post-resolution), returning per-message bridge metadata so resolution
  can never trigger an unrecorded second split
- sendSegments threads markdownCarry across the native + inline tool paths;
  only exclusive constructs (fence/inline) cross a send boundary
- synthetic close/reopen strings recorded on MessageContext and stripped during
  context reconstruction, so the model sees its original unbroken markdown
- persist messageContexts in the generated-image response path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@wolframs
wolframs marked this pull request as ready for review June 20, 2026 17:48
@wolframs

Copy link
Copy Markdown
Author

@antra-tess Greptile Review has started over twice for this review already, no idea what's going on here, just thought I'd let you know.

@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a pure, dependency-free markdown balancer (discord-markdown.ts) that closes open constructs (code fences, inline code, emphasis) at Discord message boundaries and reopens them in the next message, threading carry state across separate sends. It fixes visible corruption where an unclosed ``` at the end of one message caused all subsequent messages to render as code.

  • splitPreservingMarkdown splits a segment markdown-safely, injecting synthetic close/reopen bridge strings at each cut and returning per-chunk metadata so callers can strip them during context reconstruction.
  • sendSegmentChunks (new connector method) resolves mentions/emojis first, splits once, then sends each chunk with its own retry — preventing earlier successful chunks from being re-sent on a later-chunk transient failure.
  • sendSegments (loop) threads markdownCarry across pre-tool flushes and final sends within an activation; also fixes a dropped messageContexts bug in the generated-image response path.

Confidence Score: 4/5

The core markdown-balancing logic is correct and well-tested; carry threading is properly wired across all send paths in both the native and inline tool execution branches.

The stripBridgeOpen fallback silently matches by fence-marker type when Discord normalizes info-string content — correct for the known cases but gives no visibility when something unexpected triggers the fallback. The stale-channel reference in sendSegmentChunks inner retries is a theoretical failure mode that would only surface under prolonged rate-limiting. Both are quality/robustness concerns rather than present defects.

src/utils/discord-markdown.ts — the guard-loop escape path where a chunk may exceed maxLength; src/context/stages/injections.ts — the broad fallback in stripBridgeOpen.

Important Files Changed

Filename Overview
src/utils/discord-markdown.ts New 640-line parser + splitter: parses fences, inline code, and emphasis into constructs, then splits text so open constructs are bridged at chunk boundaries. Logic is thorough; guard-loop escape path and non-exclusive inherited carry are minor edge cases that do not cause bugs in the current calling convention.
src/discord/connector.ts Adds sendSegmentChunks (resolve → split → send per-chunk with individual retries) and extracts sendChunk helper; splitMessage now delegates to splitPreservingMarkdown. Retry separation correctly prevents re-sending earlier chunks.
src/agent/loop.ts markdownCarry threaded through native pre-tool flush, streaming pre-tool flush, inline pre-tool send, and finalizeInlineExecution; all call sites correctly update carry after each send. Also fixes a pre-existing dropped messageContexts in the image response path.
src/context/stages/injections.ts Adds stripBridgeOpen/stripBridgeClose to remove synthetic delimiters during context reconstruction; fallback in stripBridgeOpen matches by fence marker type to handle Discord normalization of info-string content.
src/activation/types.ts Adds optional bridgeOpen/bridgeClose fields to MessageContext with clear documentation.
src/utils/discord-markdown.test.ts New 1200-line test suite covering fences, inline code, emphasis, nesting, startCarry continuation, and edge cases; includes reconstruct invariant checker.
src/discord/connector.send-segments.test.ts Tests for sendSegmentChunks: single-message, inherited carry, split-after-resolution, reply targeting, and per-chunk retry without re-sending earlier chunks.
src/context/builder.test.ts Three new tests verify stripBridgeOpen/Close in injectActivationCompletions: exact match, emoji normalization, and mention normalization in info strings.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant AL as AgentLoop
    participant SS as sendSegments
    participant SC as sendSegmentChunks
    participant SPM as splitPreservingMarkdown
    participant DC as Discord API

    Note over AL: markdownCarry = []
    AL->>SS: "segments, markdownCarry=[]"
    SS->>SC: "segment.visible, carry=[]"
    SC->>SC: resolveMentions + resolveEmojis
    SC->>SPM: "resolvedContent, 1800, startCarry=[]"
    SPM-->>SC: "chunks[{text,bridgeClose},...], endCarry=[fence]"
    SC->>DC: send chunk[0] (reply)
    DC-->>SC: "id=msg-1"
    SC->>DC: send chunk[1] (with bridgeOpen)
    DC-->>SC: "id=msg-2"
    SC-->>SS: "[{id,bridgeClose},{id,bridgeOpen}], endCarry=[fence]"
    SS->>SS: "carry = exclusiveOnly(endCarry) = [fence]"
    SS-->>AL: "endCarry=[fence], messageContexts"
    Note over AL: markdownCarry=[fence]

    AL->>SS: "next segments, markdownCarry=[fence]"
    SS->>SC: "segment.visible, carry=[fence]"
    SC->>SPM: "resolvedContent, 1800, startCarry=[fence]"
    SPM-->>SC: "chunks (bridgeOpen prepended), endCarry=[]"
    SC->>DC: send chunk (reopened fence)
    DC-->>SC: "id=msg-3"
    SC-->>SS: "[{id,bridgeOpen}], endCarry=[]"
    SS-->>AL: "endCarry=[]"

    Note over AL,DC: Context reconstruction strips bridgeOpen/bridgeClose
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant AL as AgentLoop
    participant SS as sendSegments
    participant SC as sendSegmentChunks
    participant SPM as splitPreservingMarkdown
    participant DC as Discord API

    Note over AL: markdownCarry = []
    AL->>SS: "segments, markdownCarry=[]"
    SS->>SC: "segment.visible, carry=[]"
    SC->>SC: resolveMentions + resolveEmojis
    SC->>SPM: "resolvedContent, 1800, startCarry=[]"
    SPM-->>SC: "chunks[{text,bridgeClose},...], endCarry=[fence]"
    SC->>DC: send chunk[0] (reply)
    DC-->>SC: "id=msg-1"
    SC->>DC: send chunk[1] (with bridgeOpen)
    DC-->>SC: "id=msg-2"
    SC-->>SS: "[{id,bridgeClose},{id,bridgeOpen}], endCarry=[fence]"
    SS->>SS: "carry = exclusiveOnly(endCarry) = [fence]"
    SS-->>AL: "endCarry=[fence], messageContexts"
    Note over AL: markdownCarry=[fence]

    AL->>SS: "next segments, markdownCarry=[fence]"
    SS->>SC: "segment.visible, carry=[fence]"
    SC->>SPM: "resolvedContent, 1800, startCarry=[fence]"
    SPM-->>SC: "chunks (bridgeOpen prepended), endCarry=[]"
    SC->>DC: send chunk (reopened fence)
    DC-->>SC: "id=msg-3"
    SC-->>SS: "[{id,bridgeOpen}], endCarry=[]"
    SS-->>AL: "endCarry=[]"

    Note over AL,DC: Context reconstruction strips bridgeOpen/bridgeClose
Loading
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/context/stages/injections.ts:21-31
The fallback in `stripBridgeOpen` matches any fence-opening first line whose marker type (` ``` ` vs `~~~`) is the same as the one recorded in `bridgeOpen`, regardless of the info string. This is intentional for handling mention/emoji normalisation in the info string, but it also silently succeeds when the fetched message text happens to start with a *different* fence (e.g. `bridgeOpen` was `` ```python
 `` but Discord returned `` ```java
... ``). In practice the bot always prepends `bridgeOpen` verbatim before sending, so the fetched text should start with a normalised form of it; still, adding a log at the fallback site makes unexpected mismatch observable without changing any behaviour.

```suggestion
  const marker = /^(`{3,}|~{3,}).*\n$/.exec(bridgeOpen)?.[1]
  if (!marker) return text

  const lineEnd = text.indexOf('\n')
  if (lineEnd < 0) return text
  const firstLine = text.slice(0, lineEnd)
  const actualMarker = /^(`{3,}|~{3,})/.exec(firstLine)?.[1]
  if (actualMarker === marker) {
    // Fallback: info-string was normalised by Discord (mention/emoji rewrite).
    // We matched by marker type only; log so unexpected mismatches are visible.
    if (!text.startsWith(bridgeOpen)) {
      logger.debug({ bridgeOpen, firstLine }, 'stripBridgeOpen: info-string normalised, stripping by marker type')
    }
    return text.slice(lineEnd + 1)
  }
  return text
```

### Issue 2 of 3
src/utils/discord-markdown.ts:569-611
**Guard loop cap of 64 may still emit an over-length chunk**

The `guard` loop reduces `end` by one character per iteration until either `reopenLen + bodyLen + closeLen <= maxLength` or `cut <= chunkStart + 1`. On the escape path (`cut <= chunkStart + 1`) the chunk is pushed even if it exceeds `maxLength` — the comment calls this "the pathological case where the closers alone exceed maxLength". Callers rely on chunks being ≤ `maxLength` for Discord's 2000-char hard limit. If `startCarry` contains a fence with a very long info string (the opener is echoed verbatim as `bridgeOpen`), the reopener alone could push the first chunk over the limit. Worth a `logger.warn` when the escape fires so it surfaces in production logs.

### Issue 3 of 3
src/discord/connector.ts:1133-1141
`sendSegmentChunks` correctly separates resolution/split (outer retry) from individual sends (inner retries), preventing re-sends of earlier chunks. However, the inner `retryDiscord` calls hold a reference to `channel` captured from the outer retry block. If the channel reference goes stale between the two stages (rare but possible under prolonged rate-limiting), all inner retries fail against the same stale object. Fetching the channel lazily inside `sendChunk` would give each retry a fresh reference, at the cost of an extra round-trip per chunk.

```suggestion
    const sent: SentMessageChunk[] = []
    for (let i = 0; i < chunks.length; i++) {
      const piece = chunks[i]!
      // `channel` is captured from the outer retry block. On a long rate-limit
      // delay the reference could go stale; re-fetching inside the retry would
      // give a fresh reference but adds a round-trip per chunk.
      const id = await retryDiscord(
        () => this.sendChunk(channel, piece.text, i === 0 ? replyToMessageId : undefined),
        this.options.maxBackoffMs,
      )
      sent.push({ id, bridgeOpen: piece.bridgeOpen, bridgeClose: piece.bridgeClose })
    }
```

Reviews (1): Last reviewed commit: "fix: preserve markdown formatting when r..." | Re-trigger Greptile

Comment on lines +569 to +611
while (chunkStart < text.length) {
const reopenLen = reopenMarks(inherited).length

// Pick a cut whose real size (reopener + body + the closers for constructs
// open AT THE CUT) fits maxLength. Shrink from the largest feasible end,
// re-deriving the cut and its close-stack each pass so closers added by a
// snapped cut are counted. `cut <= chunkStart + 1` escapes the pathological
// case where the closers alone exceed maxLength.
let end = Math.min(text.length, chunkStart + Math.max(1, maxLength - reopenLen))
let cut = -1
let closeStack: OpenMark[] = []
for (let guard = 0; guard < 64; guard++) {
if (end >= text.length) {
cut = text.length
// Final chunk: close only exclusive constructs (emphasis stays literal).
closeStack = exclusiveOnly(stackAt(text.length))
} else {
// Prefer a newline within (chunkStart, end]; keep it at this chunk's end
// so reconstruction is exact. Otherwise hard-split without bisecting a
// delimiter run.
const body = text.slice(chunkStart, end)
const lastNl = body.lastIndexOf('\n')
if (lastNl >= 0) {
cut = chunkStart + lastNl + 1
} else {
cut = avoidDelimiterBisect(end, bridgeable, chunkStart)
if (cut <= chunkStart) cut = end
}
closeStack = stackAt(cut)
}

const bodyLen = cut - chunkStart
const closeLen = closeMarksForBody(text.slice(chunkStart, cut), closeStack).length
if (reopenLen + bodyLen + closeLen <= maxLength || cut <= chunkStart + 1) break
end = cut - 1
if (end <= chunkStart) end = chunkStart + 1
}

if (cut < 0) cut = chunkStart + 1
const isLast = cut >= text.length
pushChunk(cut, closeStack)
if (isLast) break
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Guard loop cap of 64 may still emit an over-length chunk

The guard loop reduces end by one character per iteration until either reopenLen + bodyLen + closeLen <= maxLength or cut <= chunkStart + 1. On the escape path (cut <= chunkStart + 1) the chunk is pushed even if it exceeds maxLength — the comment calls this "the pathological case where the closers alone exceed maxLength". Callers rely on chunks being ≤ maxLength for Discord's 2000-char hard limit. If startCarry contains a fence with a very long info string (the opener is echoed verbatim as bridgeOpen), the reopener alone could push the first chunk over the limit. Worth a logger.warn when the escape fires so it surfaces in production logs.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/utils/discord-markdown.ts
Line: 569-611

Comment:
**Guard loop cap of 64 may still emit an over-length chunk**

The `guard` loop reduces `end` by one character per iteration until either `reopenLen + bodyLen + closeLen <= maxLength` or `cut <= chunkStart + 1`. On the escape path (`cut <= chunkStart + 1`) the chunk is pushed even if it exceeds `maxLength` — the comment calls this "the pathological case where the closers alone exceed maxLength". Callers rely on chunks being ≤ `maxLength` for Discord's 2000-char hard limit. If `startCarry` contains a fence with a very long info string (the opener is echoed verbatim as `bridgeOpen`), the reopener alone could push the first chunk over the limit. Worth a `logger.warn` when the escape fires so it surfaces in production logs.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/discord/connector.ts
Comment on lines +1133 to +1141
const sent: SentMessageChunk[] = []
for (let i = 0; i < chunks.length; i++) {
const piece = chunks[i]!
const id = await retryDiscord(
() => this.sendChunk(channel, piece.text, i === 0 ? replyToMessageId : undefined),
this.options.maxBackoffMs,
)
sent.push({ id, bridgeOpen: piece.bridgeOpen, bridgeClose: piece.bridgeClose })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 sendSegmentChunks correctly separates resolution/split (outer retry) from individual sends (inner retries), preventing re-sends of earlier chunks. However, the inner retryDiscord calls hold a reference to channel captured from the outer retry block. If the channel reference goes stale between the two stages (rare but possible under prolonged rate-limiting), all inner retries fail against the same stale object. Fetching the channel lazily inside sendChunk would give each retry a fresh reference, at the cost of an extra round-trip per chunk.

Suggested change
const sent: SentMessageChunk[] = []
for (let i = 0; i < chunks.length; i++) {
const piece = chunks[i]!
const id = await retryDiscord(
() => this.sendChunk(channel, piece.text, i === 0 ? replyToMessageId : undefined),
this.options.maxBackoffMs,
)
sent.push({ id, bridgeOpen: piece.bridgeOpen, bridgeClose: piece.bridgeClose })
}
const sent: SentMessageChunk[] = []
for (let i = 0; i < chunks.length; i++) {
const piece = chunks[i]!
// `channel` is captured from the outer retry block. On a long rate-limit
// delay the reference could go stale; re-fetching inside the retry would
// give a fresh reference but adds a round-trip per chunk.
const id = await retryDiscord(
() => this.sendChunk(channel, piece.text, i === 0 ? replyToMessageId : undefined),
this.options.maxBackoffMs,
)
sent.push({ id, bridgeOpen: piece.bridgeOpen, bridgeClose: piece.bridgeClose })
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/discord/connector.ts
Line: 1133-1141

Comment:
`sendSegmentChunks` correctly separates resolution/split (outer retry) from individual sends (inner retries), preventing re-sends of earlier chunks. However, the inner `retryDiscord` calls hold a reference to `channel` captured from the outer retry block. If the channel reference goes stale between the two stages (rare but possible under prolonged rate-limiting), all inner retries fail against the same stale object. Fetching the channel lazily inside `sendChunk` would give each retry a fresh reference, at the cost of an extra round-trip per chunk.

```suggestion
    const sent: SentMessageChunk[] = []
    for (let i = 0; i < chunks.length; i++) {
      const piece = chunks[i]!
      // `channel` is captured from the outer retry block. On a long rate-limit
      // delay the reference could go stale; re-fetching inside the retry would
      // give a fresh reference but adds a round-trip per chunk.
      const id = await retryDiscord(
        () => this.sendChunk(channel, piece.text, i === 0 ? replyToMessageId : undefined),
        this.options.maxBackoffMs,
      )
      sent.push({ id, bridgeOpen: piece.bridgeOpen, bridgeClose: piece.bridgeClose })
    }
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

The markdown splitter targets 1800 chars but has a pathological escape
(closers alone exceeding the budget) that can emit an over-length chunk.
Surface it at the send boundary instead of letting Discord 400 silently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@wolframs

Copy link
Copy Markdown
Author

Thanks for the thorough pass, @greptile-apps — the sequence diagram is a nice touch. Went through all three:

Issue 2 (guard-loop escape may emit an over-length chunk) — addressed in 99143fa, though at the send boundary rather than inside the splitter. discord-markdown.ts is intentionally pure/dependency-free (it states so in its header), so I didn't want to pull logger into it. Instead sendChunk — the single funnel both send paths share right before channel.send — now warns when a chunk exceeds Discord's 2000-char hard limit. That's cause-agnostic and fires exactly where a silent 400 would otherwise happen.

Issue 1 (stripBridgeOpen fallback log) — leaving as-is. The fallback only matches by fence marker (``` vs ~~~); the info string can only differ via Discord's mention/emoji normalization, since we prepend the exact bridgeOpen bytes ourselves. A different-language fence can't occur here, so a debug log would only ever fire on a case the calling convention rules out.

Issue 3 (stale channel reference in inner retries) — leaving as-is. discord.js channel objects are live references into the client cache rather than point-in-time snapshots, so they don't go stale across backoff; and the suggested edit is a comment-only change (it notes lazy re-fetch would add a round-trip per chunk without recommending it). Not worth the extra fetch for a path that doesn't actually fail.

Appreciate the review!

@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown

All three responses are solid — thanks for the detailed follow-through.

The sendChunk placement for the over-length warning makes more sense than instrumenting inside the pure utility; cause-agnostic and positioned exactly where the failure would surface. The calling-convention argument for the stripBridgeOpen fallback is sound — if the only source of the bridgeOpen string is the bridge logic itself, a marker mismatch genuinely can't arise in practice. And the live-reference clarification on discord.js channels resolves that concern cleanly.

Nothing blocking here. Looks good to merge.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

@wolframs

wolframs commented Jul 4, 2026

Copy link
Copy Markdown
Author

✅ Acceptance-tested (2026-07-04)

Verified live on a Discord server (GPT-5.5 bot, mode: chat), two scenarios:

1. Code fence across the boundary — a ~2500-char fenced block split into two messages; both chunks render as code (fence closed at the end of chunk 1, reopened at the start of chunk 2), no content lost:

code fence split acceptance

2. Inline span across the boundary, hostile case — a single ~2500-char bold paragraph with no line breaks anywhere, so the splitter had no safe break point and had to cut mid-span — in fact mid-word (…independent c / lauses…). Bold rendering survives on both sides of the seam:

bold span split acceptance

The oversize-chunk warning (99143fa) stayed quiet throughout — all emitted chunks were within Discord's 2000-char limit.

🤖 Generated with Claude Code

…splitter

Property-based fuzzing of splitPreservingMarkdown surfaced two defect classes.

Defect 1 (invariant A — chunk.text.length <= maxLength): a fence whose opening
line carries a long info string was reopened by replaying that entire verbatim
line, so a chunk that reopened it could exceed maxLength (fuzz saw 1018 > 300).
Fences now reopen as the marker run plus at most the first whitespace-delimited
word of the info string (fenceReopener); in the splitter that word is further
capped so the reopener always leaves room for the fence's own closer and one
body char, making invariant A a hard guarantee for maxLength >= 60. The
reopener is a synthetic bridge (stripped on reconstruction), so losslessness is
unaffected.

Defect 2 (seam run-merge): the cut avoided bisecting a delimiter run but not a
cut where an inserted bridge string abutted an identical delimiter char in the
adjacent body, fusing runs and changing parsing (e.g. reopener "`" + body "`x"
-> "``x"; body "...a~~" + closer "~~" -> "~~~~"). The cut selection now (a)
does not prefer a newline that lands inside a non-fence construct (which would
fragment a small span and force a fusing bridge), and (b) pulls a cut left off
the offending delimiter until neither the reopener fuses with the next body's
first char nor the closer fuses with this body's last char, for the run-merging
chars ` ~ * _ | (fences are newline-anchored and never fuse). On the final
chunk, an unclosed inline-code span whose original content ends in backticks is
left open rather than emitting a closer that would fuse — Discord already
renders it literally and endCarry still reports it for continuation.

Fuzz (50k cases, maxLength in {60..2000}): zero A/B/D and zero seam-adjacency
failures. Losslessness and no-crash preserved. The scanMarkdown "C" oracle
still flags pre-existing, unrelated line-position backtick reparse (3+ backtick
inline code becoming a fence at a chunk's line start / mixed ```-vs-~~~ nesting
in isolation); these dropped ~55% vs baseline and never lose text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wolframs

wolframs commented Jul 4, 2026

Copy link
Copy Markdown
Author

🔬 Property-based fuzz hardening (2026-07-04, follow-up commit 4c1da0f)

Before merge we fuzzed splitPreservingMarkdown with 50k random markdown-soup cases (maxLength 60–2000) against three invariants: (A) every chunk ≤ maxLength, (B) exact lossless reconstruction after bridge stripping, (D) termination/no-crash. B and D held from the start; the fuzz surfaced three defects, all fixed in 4c1da0f:

  1. maxLength overflow via fence reopener — a fence's verbatim opening line (incl. arbitrarily long info string) was replayed as the next chunk's reopener; fuzz saw a 1018-char chunk at limit 300. Fences now reopen as marker + first info word (the language tag — all Discord uses), budget-capped, making invariant A a hard guarantee for maxLength ≥ 60.
  2. Seam run-merges — a bridge string abutting an identical delimiter char in the body fused runs and changed parsing (e.g. reopener ` + body `x… x… ``; a cut right after a ~~ opener yielded literal `~~~~`). Cut selection now avoids newlines inside non-fence constructs and pulls the cut off fusing delimiters.
  3. Infinite loop — a cut landing where the remaining body began with \n made the cut position stop advancing; the split hung the caller (a synchronous DoS on the send path, reachable by adversarial message content).

Post-fix: 50k cases re-run with an added seam-adjacency oracle — zero A/B/D/seam failures, 7 new regression tests from the minimized repros (140 markdown tests total), full suite green.

🤖 Generated with Claude Code

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.

1 participant