feat(slack): live status via chat.startStream/appendStream/stopStream#210
feat(slack): live status via chat.startStream/appendStream/stopStream#210LIU9293 wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d52a7ae01a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Behind ODE_SLACK_STATUS_STREAMING=1, replace the per-tick chat.update edits
on the status message with Slack's text-streaming API (Feb/Apr 2026). Tool
lifecycle events become task_update chunks inside a plan block — spinner
on running tools, checkmark on complete, error pill on failure — instead
of a 4000-char text blob being repeatedly edited.
Why:
- chat.appendStream is Tier 4 (100+/min) vs chat.update Tier 3 (50+/min),
so we get ~2x headroom against the same per-channel posting cap.
- Native Slack rendering (no flicker, animated spinner, sources chips)
matches what Slack's own AI agents render.
- Eliminates the streaming_state_conflict / edit_window_closed failure
modes that chat.update is starting to hit on long runs.
What:
- packages/utils/status-stream.ts: SessionMessageState -> minimal
task_update/plan_update chunks with fingerprint-based dedupe so we
only ship deltas (saves the Tier-4 budget).
- packages/ims/slack/api.ts: startSlackStream / appendSlackStream /
stopSlackStream helpers. Encodes the empirical quirks (mode-locking,
recipient_team_id required for channel streams, 256-char chunk caps).
- packages/core/types.ts: optional startStatusStream / appendStatusStream
/ stopStatusStream on IMAdapter. Discord/Lark unaffected — when the
methods are missing the kernel auto-falls-back to chat.update.
- packages/ims/slack/client.ts: wire the three methods, resolve
recipient_team_id from the channel's registered WorkspaceAuth.
- packages/core/kernel/request-run.ts: env-gated branch. Failure path
sends a final plan_update with the error then stop the stream first
(avoids streaming_state_conflict on the cleanup chat.update).
- scripts/demo-stream-status.ts: standalone smoke test that drives the
raw Slack API end-to-end against a thread.
Empirical quirks the docs miss (encoded in helpers):
- Channel (non-DM) streams require both recipient_user_id and
recipient_team_id; omission yields missing_recipient_team_id.
- The stream is mode-locked to text-mode or chunks-mode at start; mixing
markdown_text and chunks on appendStream returns
cannot_provide_both_markdown_text_and_chunks; switching modes returns
streaming_mode_mismatch.
- chat.stopStream cannot carry markdown_text on a chunks-mode stream,
so the final summary line is sent as a plan_update chunk before stop.
Tests: existing 84 pass; typecheck clean.
Demo: bun run scripts/demo-stream-status.ts <channelId> <threadTs> with
SLACK_BOT_TOKEN / SLACK_RECIPIENT_USER_ID / SLACK_RECIPIENT_TEAM_ID set.
d52a7ae to
927045b
Compare
…led deltas Address two P1 issues from Codex review on PR #210: 1. Fall back to chat.postMessage when startStatusStream returns undefined (e.g. resolveWorkspaceAuth can't produce a team_id) or throws. Previously the run aborted with 'Failed to send status message' instead of degrading to the existing chat.update path. Now we track a runtime 'useStreaming' flag that flips false on fallback, and the tick + final-text + error paths all key off it so a fallback-posted TS gets chat.update, not chat.appendStream. 2. Only advance the differ's fingerprint cache after chat.appendStream confirms success. createStatusStreamDiffer now returns { chunks, commit() }; the kernel calls commit() inside the try block. On a transient appendStream failure (rate limit, network blip) we skip commit() so the next tick re-emits the same delta — Slack's task_update / plan_update payloads are idempotent on (task_id, status) so re-sends are safe. Added 6 unit tests in packages/utils/test/status-stream.test.ts covering: - initial plan_update emission - no-op diff after commit - regression test: retry-on-skipped-commit (the actual fix from #2) - task_update emission and dedupe - task transition from running → completed - plan title change Tests: 411 pass (+6); typecheck clean.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7a742d9a5d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ting Address two more P1 issues from Codex review on PR #210: 3. Disable stream mode when status message TS is rotated. The kernel rotates request.statusMessageTs to a fresh chat.postMessage TS after a question.replied/question.rejected turn (see line ~419 of this file). Previously the tick kept calling appendStatusStream against the old stream TS, which would fail with message_not_in_streaming_state and leave progress silently stalled for the rest of the run. Track streamingStatusTs alongside useStreaming. On each tick compare request.statusMessageTs vs streamingStatusTs; if they differ, log the rotation, flip useStreaming = false, and fall through to the existing chat.update path. We deliberately don't try to re-start a fresh stream in the rotated TS — that would post a second 'plan' card in the thread for the same turn, which is more confusing than a continuation. 4. Use the same bot token for append/stop as startStream. Previously appendSlackStream / stopSlackStream called getSlackBotToken(channelId) which can resolve a different workspace's token than startStream used (multi-workspace installs, processor-scoped tokens). Slack mid-stream would then either silently reject or attribute updates to the wrong bot identity. - api.ts: make a required parameter on start/append/stop so callers must thread it explicitly. No more implicit lookup inside. - client.ts: at startStatusStream resolve the token once (processor first, then channel-scoped), register it against the streamed TS via slackAuthRegistry.setMessageBotToken — same mechanism we already use for tracking which token edited a given message. appendStatus- Stream/stopStatusStream read the token back via getMessageBotToken so the whole stream lifecycle uses one consistent identity. Tests: 411 pass (no new ones; the rotation path is exercised end-to-end by existing question-reply tests, the token-consistency by the existing multi-workspace status-update tests). Typecheck clean.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e0a84b9a1e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const currentState = liveParsedState.get(getStatusMessageKey(request)); | ||
| if (currentState && deps.im.appendStatusStream) { |
There was a problem hiding this comment.
Emit stream summary before clearing parsed state
This callback reads liveParsedState to build the terminal plan_update, but runTrackedRequest deletes liveParsedState before it invokes publishFinalText on the success path. As a result currentState is always missing here, so streamDiffer.finalize(...) is skipped and streamed runs never emit the intended final summary line (elapsed/tokens/cost) before stopStatusStream.
Useful? React with 👍 / 👎.
Summary
Behind
ODE_SLACK_STATUS_STREAMING=1, render the live agent status message via Slack's text-streaming API (chat.startStream/chat.appendStream/chat.stopStream, Feb/Apr 2026) instead of the repeatedchat.updateedits we do today.Tool lifecycle events become
task_updatechunks inside aplanblock — spinner on running tools, checkmark on complete, error pill on failure — matching the UI Slack's own AI agents use.A live demo posting against the source thread is here: https://roombasehq.slack.com/archives/C0ATJJHRV5Y/p1779701209263979 (the "Drafting response" plan card with three completed task rows is the actual streaming-API output).
Why
chat.appendStreamis Tier 4 (100+/min) vschat.updateTier 3 (50+/min) — ~2× headroom against the same per-channel "1 msg/sec/channel" posting cap.streaming_state_conflict/edit_window_closedfailure modeschat.updateis starting to hit on long-running sessions.task_card/planblocks (new in Feb 2026) are now first-class — sources chips, rich-text output, etc.What changed
packages/utils/status-stream.ts(new):SessionMessageState→ minimaltask_update/plan_updatechunks. Per-tool fingerprint cache so only deltas are sent (preserves the Tier-4 budget).packages/ims/slack/api.ts:startSlackStream/appendSlackStream/stopSlackStreamhelpers. Encodes the empirical quirks (mode-locking,recipient_team_idrequired, 256-char chunk caps).packages/core/types.ts: optionalstartStatusStream/appendStatusStream/stopStatusStreamonIMAdapter. Discord/Lark unaffected — missing methods → kernel auto-fallback tochat.update.packages/ims/slack/client.ts: wire the three methods; resolverecipient_team_idfrom the channel's registeredWorkspaceAuth.packages/core/kernel/request-run.ts: env-gated branch. Failure path sends a finalplan_updatewith the error first, then stops the stream — avoidsstreaming_state_conflicton the cleanupchat.update.scripts/demo-stream-status.ts(new): standalone smoke test that drives the raw Slack API end-to-end against a thread.Empirical quirks the docs miss (encoded in helpers)
recipient_user_idandrecipient_team_id; omission →missing_recipient_team_id.startStream. Mixingmarkdown_textandchunksonappendStreamreturnscannot_provide_both_markdown_text_and_chunks; switching modes returnsstreaming_mode_mismatch.chat.stopStreamcannot carrymarkdown_texton a chunks-mode stream, so the final summary line is sent as aplan_updatechunk before stop.Rollout
chat.updatepath remains the default and is untouched.ODE_SLACK_STATUS_STREAMING=1 bun run dev.startStatusStreamis missing on the adapter (Discord/Lark), or if any stream call throws, the kernel logs a warning and the tick continues against the existing status TS viachat.update.Verification
bun run typecheckclean.bun test— all 84 tests pass (no behavior change on default code path; streaming path is unit-coverable separately as a follow-up).#odein Roombase (see permalink above and screenshot in the source thread).Follow-ups (not in this PR)
createStatusStreamDiffer(fingerprint dedupe, edge cases around tool id reuse).