Skip to content

Blank message shown for ~1min before streaming text appears #97

@sentry-junior

Description

@sentry-junior

Summary

When a streaming response begins, there is a visible delay (sometimes up to ~1 minute) where a blank/empty message appears in Slack before any text renders. The first content push is blocked by a combination of two accumulation layers: the pendingStreamText redundancy-detection gate in reply-executor.ts and the createNormalizingStream newline-flush gate — causing the streaming post to be created but then stall with no visible content.

Root cause

Two buffering layers compound each other before any text reaches Slack's streaming API:

Layer 1 — pendingStreamText / redundancy gate (reply-executor.ts)

pendingStreamText += deltaText;
if (isPotentialRedundantReactionAckText(pendingStreamText)) {
  return; // hold until text is "safe"
}
flushPendingStreamText(); // only then call startStreamingReply()

isPotentialRedundantReactionAckText holds all incoming deltas until the accumulated text is clearly not a short reaction-only response (e.g. "ok", "got it", a partial emoji :thinking). For responses that begin with a brief acknowledgment phrase (e.g. "on it", "sure", "let me check"), every delta passes through this gate until the text grows long enough to no longer match any prefix of the redundant-ack list. This can be several tokens.

Layer 2 — createNormalizingStream newline gate (streaming.ts)

Once text is flushed to the textStream bridge, it is wrapped in createNormalizingStream(textStream.iterable, ensureBlockSpacing) before being passed to thread.post(). That stream only yields normalized output once a \n is seen (it accumulates everything into stable = accumulated.slice(0, lastNewline + 1) before normalizing). If the first batch of streamed text has no newline (very common for inline sentence-first responses), zero bytes are pushed to the Slack streamer.append() call.

Result: thread.post() is called with an async iterable (the normalizing stream) that yields nothing for an arbitrary period. In @chat-adapter/slack's stream() implementation, streamer.append() is called only once a delta is non-empty. So Slack sees no append calls while the message post has already been created — rendering a blank bubble in the UI.

The blank message is the Slack assistant thread shell created by chatStream() at the beginning of the stream call, before any content arrives.

Reproduction

  1. Send any message to junior that produces a response beginning with inline sentence text (no leading newline), especially if the first few tokens could match a potential redundant-ack prefix (e.g. response starts with "sure", "let me", "on it").
  2. Observe a blank Slack message bubble for 15–60+ seconds before text appears.
  3. Longer responses with tool calls are worse because the LLM may not emit any onTextDelta for a long time (tool execution), and then when it does the normalizing stream holds the first chunk until a newline arrives.

Expected behavior

  • The streaming Slack message should show visible content within the first few tokens of text.
  • At minimum, the createNormalizingStream should eagerly yield content when no newline has been seen yet (the no-newline branch already does yield delta — but this path is only hit after pendingStreamText is flushed, which itself waits for the redundancy gate).
  • Alternatively, the normalizing stream should not be applied to the initial post; normalization could be applied only to the content that reaches Slack's editMessage finalizer.

Potential fixes

  1. Remove createNormalizingStream from the streaming path. The ensureBlockSpacing normalization is only needed for the final editMessage / non-streaming post. The Slack stream() adapter's getCommittableText() already handles incremental rendering correctly and doesn't need pre-normalization.

  2. Flush pendingStreamText eagerly after a timeout. If text has been accumulating for >N ms and streamedReplyPromise is still unset, force-flush regardless of the redundancy check result.

  3. Pass createNormalizingStream only as the final text normalizer, not as the iterable fed to thread.post(). The streaming bridge (textStream) should carry raw deltas; normalization should happen at finalize time.

Files

  • packages/junior/src/chat/runtime/reply-executor.tspendingStreamText gate, createNormalizingStream wrapping
  • packages/junior/src/chat/runtime/streaming.tscreateNormalizingStream (newline-holdback behavior)
  • vercel/chat (packages/adapter-slack/src/index.ts) — stream() impl, flushMarkdownDelta only fires on non-empty delta

Action taken on behalf of David Cramer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions