Skip to content

Route text posts to channel root and coalesce tool calls in-thread#78

Merged
rogeriochaves merged 2 commits into
mainfrom
slack-text-root-tools-thread
May 30, 2026
Merged

Route text posts to channel root and coalesce tool calls in-thread#78
rogeriochaves merged 2 commits into
mainfrom
slack-text-root-tools-thread

Conversation

@rogeriochaves
Copy link
Copy Markdown
Contributor

Summary

  • Slack channel root now carries the actual conversation (received user messages and the agent's own text replies), while tool_use / thinking blocks fold into a single in-thread post under the latest text.
  • Routing is driven by a new kind field on SlackPost (text / tool / thinking); the bridge inspects it and decides root-vs-thread.
  • Consecutive tool posts within a poll batch coalesce into ONE Slack message — a 5-Bash turn used to be 5 API calls, now it's 1, which fixes the "high volume of activity, not displaying some messages" throttle.

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:

  1. The channel made it look like nothing was happening because the agent's own text replies were buried in the thread under "Received user message". The agent is one continuous conversation, not parallel threads — the channel should read that way.
  2. Slack throttled the bot fast because every parallel Bash call became its own chat.postMessage. The throttle banner showed up after just a handful of tool turns.

What changed

  • cli/src/slack/format.tsSlackPost now has kind: "text" | "tool" | "thinking". formatTranscriptLines emits one post per content block (text / tool_use / thinking), then a coalesceTools pass folds consecutive tool posts of the same role into one. formatCodexRolloutLines follows 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 on post.kind: text posts to channel root and update the shared thread-root file, everything else replies under the current root.
  • Tests updated to assert per-block emission, coalescing, the text-breaks-the-run case, and the codex routing.

Test plan

  • node --test src/slack-format.test.ts src/codex-runtime.test.ts passes locally (24/24).
  • Full suite (13 unrelated test files) passes too; share-server.test.ts is a long-running HTTP test, unrelated.
  • Once merged + redeployed: send a message in #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.
  • Verify Slack "high volume of activity" warning no longer fires under burst loads.

…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).
@rogeriochaves rogeriochaves merged commit 2b2a1b8 into main May 30, 2026
1 check passed
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