From 4000a3c93d9156ac140ab85dcd719edf56728500 Mon Sep 17 00:00:00 2001 From: Juan Lagrange Date: Wed, 3 Jun 2026 22:58:38 +0000 Subject: [PATCH] fix(slackbot): keep streamed answer when a plan block is finalized Slack streaming works as startStream -> appendStream (chunks) -> stopStream (final blocks). The stopStream `blocks` REPLACE the streamed message body rather than appending to it. In closeTextStream() the answer markdown was only included in the final blocks when it was NOT streamed live (`!streamedTextLive`), on the assumption the accumulated stream chunks would stand. That holds only when no blocks are sent. When a plan block is emitted in the final layout (plan-emitting harnesses like Codex always render a "Thinking" plan), stopStream is called with those blocks, which replace the streamed body and drop the live-streamed answer. The user is left with a Thinking widget and no answer. Claude Code is unaffected because it does not emit a plan block, so stopStream is called with no blocks and the streamed answer survives. Fix: when we emit any composed blocks (e.g. a plan block), also re-include the answer markdown, since those blocks replace the streamed chunks. Only omit the answer blocks when sending no blocks at all and the answer was already streamed live. Verified end-to-end on a live deployment: a Codex Slack reply that previously rendered blocks [rich_text, plan] (no answer) now renders [rich_text, plan, rich_text, table] with the answer present. All existing agent-session and codex-session tests pass. --- services/slackbot/src/slack/agent-session.ts | 23 ++++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/services/slackbot/src/slack/agent-session.ts b/services/slackbot/src/slack/agent-session.ts index 68c9604a0..a50d0ccd2 100644 --- a/services/slackbot/src/slack/agent-session.ts +++ b/services/slackbot/src/slack/agent-session.ts @@ -300,14 +300,23 @@ export class AgentSessionRenderer { }) const streamedTextLive = Boolean(segment.streamedText.trim()) && segment.streamedText.length < MAX_LIVE_TEXT_CHARS - // Slack accumulates appendStream chunks; stopStream blocks are the composed final layout. - // Only add blocks for content that was not streamed live; live task_update chunks carry - // fenced details/output, and the header has already been streamed as the first chunk. - const blocks = sanitizeFinalMessagePayload([ - ...(tasks.length && !segment.planStarted + // Slack accumulates appendStream chunks, but stopStream `blocks` REPLACE + // the streamed message body. So whenever we emit a composed block layout + // (e.g. a plan block), any answer that was streamed live as chunks must be + // re-included in the blocks or it is dropped from the final message. This + // is what makes plan-emitting harnesses (Codex) lose short answers that + // were already streamed: the plan block alone replaces them. Only omit the + // answer blocks when we send no blocks at all and the answer was already + // streamed live (the accumulated chunks then stand on their own). + const planBlocks = + tasks.length && !segment.planStarted ? [planBlock(planTitle(state.title, originalTasks), tasks, EXECUTION_PLAN_ID)] - : []), - ...(!streamedTextLive && answerMarkdown ? renderMarkdownBlocks(answerMarkdown) : []) + : [] + const includeAnswerBlocks = + Boolean(answerMarkdown) && (planBlocks.length > 0 || !streamedTextLive) + const blocks = sanitizeFinalMessagePayload([ + ...planBlocks, + ...(includeAnswerBlocks ? renderMarkdownBlocks(answerMarkdown) : []) ] as AnyBlock[]) const fallbackText = buildFinalFallbackText({ title: state.title,