From c203faedb0a640cf35c18e7354c80c1ac9d46950 Mon Sep 17 00:00:00 2001 From: Paradigm AI Date: Sat, 14 Mar 2026 16:39:24 +0000 Subject: [PATCH] fix(slack): add streaming keepalive to prevent session timeout 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. --- .changeset/slack-streaming-keepalive.md | 9 ++++++++ packages/adapter-slack/src/index.ts | 29 ++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .changeset/slack-streaming-keepalive.md diff --git a/.changeset/slack-streaming-keepalive.md b/.changeset/slack-streaming-keepalive.md new file mode 100644 index 00000000..c6966eef --- /dev/null +++ b/.changeset/slack-streaming-keepalive.md @@ -0,0 +1,9 @@ +--- +"@chat-adapter/slack": patch +--- + +Add streaming keepalive to prevent `message_not_in_streaming_state` errors + +Slack's streaming API expires the session after ~5 minutes of inactivity. When the upstream `textStream` iterable pauses for extended periods (e.g. during long-running agent tool calls), the session expires and all subsequent `append` or `stop` calls fail with `message_not_in_streaming_state`. + +The fix races each chunk from the text stream against a 2-minute keepalive timer. If no chunk arrives within 2 minutes, a zero-width-space is appended to keep the Slack session alive. No chunks are ever dropped — the same pending promise is re-raced after each keepalive. diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index ebc419d4..2ea434e6 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -3019,7 +3019,34 @@ export class SlackAdapter implements Adapter { lastAppended = committable; }; - for await (const chunk of textStream) { + // Race each chunk against a keepalive timer. Slack expires the streaming + // session after ~5 min of inactivity; we send a zero-width-space append + // every 2 min to keep it alive during long-running agent turns. + const KEEPALIVE_MS = 120_000; + const iter = textStream[Symbol.asyncIterator](); + let pending: Promise> | null = null; + + // eslint-disable-next-line no-constant-condition + while (true) { + if (!pending) pending = iter.next(); + + const raced = await Promise.race([ + pending.then((r) => ({ kind: "value" as const, result: r })), + new Promise<{ kind: "keepalive" }>((r) => + setTimeout(() => r({ kind: "keepalive" }), KEEPALIVE_MS) + ), + ]); + + if (raced.kind === "keepalive") { + // Send an invisible append to keep the Slack streaming session alive + await flushMarkdownDelta("\u200B"); + continue; + } + + pending = null; + if (raced.result.done) break; + + const chunk = raced.result.value; if (typeof chunk === "string") { await pushTextAndFlush(chunk); } else if (chunk.type === "markdown_text") {