Skip to content

fix(slack): add streaming keepalive to prevent session timeout#240

Open
gakonst wants to merge 1 commit intovercel:mainfrom
gakonst:fix/slack-streaming-keepalive
Open

fix(slack): add streaming keepalive to prevent session timeout#240
gakonst wants to merge 1 commit intovercel:mainfrom
gakonst:fix/slack-streaming-keepalive

Conversation

@gakonst
Copy link
Contributor

@gakonst gakonst commented Mar 14, 2026

Problem

Slack's streaming API expires the session after ~5 minutes of inactivity. When the textStream iterable pauses for extended periods — which is common during long-running agent tool calls, multi-step reasoning, or external API waits — the session expires silently. All subsequent streamer.append() or streamer.stop() calls then fail with:

Error: An API error occurred: message_not_in_streaming_state

This is fatal: the SDK's sendStructuredChunk catch handler disables structured chunks for the rest of the stream, and if text streaming also fails, the entire response is lost. The user sees only an error message.

Currently the SDK has no keepalive or heartbeat mechanism — the for await loop in stream() simply blocks waiting for the next chunk with no timeout awareness.

Fix

Replace the for await loop with a Promise.race pattern that races each iter.next() against a 2-minute keepalive timer (well under Slack's ~5-minute TTL). If no chunk arrives within 2 minutes, a zero-width space (\u200B) is appended via the existing flushMarkdownDelta helper to keep the session alive.

The same pending iterator promise is re-raced after each keepalive, so no chunks are ever dropped or duplicated.

Before

for await (const chunk of textStream) { ... }

After

while (true) {
  if (!pending) pending = iter.next();
  const raced = await Promise.race([
    pending.then(r => ({ kind: 'value', result: r })),
    new Promise(r => setTimeout(() => r({ kind: 'keepalive' }), 120_000)),
  ]);
  if (raced.kind === 'keepalive') {
    await flushMarkdownDelta('\u200B');  // invisible keepalive
    continue;
  }
  pending = null;
  if (raced.result.done) break;
  // ... process chunk as before
}

Testing

  • pnpm --filter @chat-adapter/slack build
  • pnpm --filter @chat-adapter/slack typecheck
  • pnpm --filter @chat-adapter/slack test — 296/297 pass (1 pre-existing network-dependent failure unrelated to this change)

Slack's streaming API expires after ~5 min of inactivity. When the
textStream iterable pauses during long-running agent work (tool calls,
reasoning, etc.), the session expires and subsequent append/stop calls
fail with message_not_in_streaming_state.

Race each chunk against a 2-minute keepalive timer. If no chunk arrives
in time, append a zero-width space to keep the session alive. The same
pending iterator promise is re-raced after each keepalive, so no chunks
are ever dropped.
@vercel
Copy link
Contributor

vercel bot commented Mar 14, 2026

Someone is attempting to deploy a commit to the Vercel Team on Vercel.

A member of the Team first needs to authorize it.

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