Route text posts to channel root and coalesce tool calls in-thread#78
Merged
Conversation
…ad batches Previously every assistant block (text and tool_use alike) became its own Slack post, and everything after the received-prompt landed in the same thread. Two problems: 1. Threads were misleading. The agent is one continuous conversation, not parallel threads, so burying the agent's own text replies under the received-prompt thread made the channel read as if nothing was happening. 2. Slack's "high volume of activity" throttle was kicking in fast because each tool_use posted a separate message; a 5-tool turn was 5 API calls. New routing in bridge.ts, driven by a `kind` field on SlackPost: - text -> channel root, becomes the next thread anchor - tool -> in-thread under the most recent text - thinking -> in-thread (auxiliary trace, doesn't anchor) format.ts now emits one post per content block (text/tool_use/thinking are no longer concatenated into one), then coalesces consecutive tool posts into a single message so a burst of parallel/sequential Bash calls turns into ONE in-thread post instead of N. Same coalescing applied to codex exec_command_begin events. Tests cover: per-block kinds, coalescing run, text breaking the run, prose tool labels staying unfenced inside the coalesced block, and the codex out-of-credits warning routing as text (it has to be at the channel root, not buried in a thread, for operators to notice).
Light the Slack status pill ("agent is thinking") while a turn is in
flight and let it fall back to Slack's own dismissal mechanics on the
happy path. Per-agent state machine in the bridge:
- On a user prompt (codex user_message or the Claude announce path), set
"💭 thinking…" immediately so the channel shows the agent has started
before the first tool call lands in the transcript.
- On each tool / thinking block, refresh the pill with a short label that
tracks the current activity ("🛠️ Bash(view PR)…"). For coalesced tool
runs, the latest tool's label wins so the pill reflects what is happening
NOW, not the first call in the batch.
- On an assistant text reply, drop our refresh state. Slack auto-clears
the pill the moment a real message lands in the thread.
- A background interval re-sets the pill every 30s while a turn is open
to beat Slack's built-in 2-minute idle TTL. If the agent stalls or
crashes mid-turn the refresh stops, and Slack drops the pill within
two minutes on its own — no separate watchdog needed.
Adds SlackClient.setStatus(), a statusLabel field on SlackPost, and tests
for label population + truncation. The Claude announce path in announce.ts
also fires an immediate setStatus after writing the thread-root so the
pill goes up before the bridge poll loop catches the first transcript
block (UX latency).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
kindfield onSlackPost(text / tool / thinking); the bridge inspects it and decides root-vs-thread.Why
Before this PR the bridge treated every assistant content block as its own post and dropped them all into the same received-prompt thread. Two consequences:
chat.postMessage. The throttle banner showed up after just a handful of tool turns.What changed
cli/src/slack/format.ts—SlackPostnow haskind: "text" | "tool" | "thinking".formatTranscriptLinesemits one post per content block (text / tool_use / thinking), then acoalesceToolspass folds consecutive tool posts of the same role into one.formatCodexRolloutLinesfollows the same shape (user_message and agent_message → text; exec_command_begin → tool; out-of-credits warning → text so operators see it at the root).cli/src/slack/bridge.ts— single branch onpost.kind: text posts to channel root and update the shared thread-root file, everything else replies under the current root.Test plan
node --test src/slack-format.test.ts src/codex-runtime.test.tspasses locally (24/24).share-server.test.tsis a long-running HTTP test, unrelated.#agent-dependabot-scout, observe that the agent's "I'll check PR X" text lands at the channel root and its Bash calls fold into one in-thread post under it.