Skip to content

feat(slack): add [slack].streaming toggle (send-once mode)#1115

Open
dogzzdogzz wants to merge 1 commit into
openabdev:mainfrom
dogzzdogzz:feat/slack-streaming-toggle
Open

feat(slack): add [slack].streaming toggle (send-once mode)#1115
dogzzdogzz wants to merge 1 commit into
openabdev:mainfrom
dogzzdogzz:feat/slack-streaming-toggle

Conversation

@dogzzdogzz

@dogzzdogzz dogzzdogzz commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

What problem does this solve?

The Slack adapter always streams replies live — native streaming (chat.startStream + assistant.threads.setStatus) in assistant_mode (default), or a post+edit placeholder otherwise. Great UX for a single bot, but in multi-agent threads it drives a phantom-turn storm:

  1. user → bot A
  2. bot A → bot B (bot A's reply @-mentions bot B)
  3. bot B → bot A

At step 2 no other bot has posted in the thread yet, so bot A still treats it as a single-bot thread and streams its reply via post+edit. Each edit emits a message_changed event → bot B's app_mention re-fires once per edit, so bot B spawns a full agent turn for each intermediate/partial state instead of bot A's single settled message (observed: 2 real messages → 5 turns, one acting on a mid-stream fragment <@U…> -).

The existing other_bot_present gate only disables streaming after another bot has posted — it cannot help on the kickoff message that first mentions bot B. There is no operator switch to deterministically force send-once.

Closes #1114. Addresses the phantom-turn bug #1103 — chosen over the #1104 app_mention debounce approach: posting one final message removes the re-fire at the source rather than dedup'ing after.

Discord Discussion URL: https://discord.com/channels/1491295327620169908/1491969620754567270/1515891969401032715

At a Glance

            Before                                  After (streaming = false)
 ┌────────────────────────────────┐      ┌────────────────────────────────┐
 │ alone in thread → stream reply  │      │ streaming=false → send-once     │
 │   post+edit: N message_changed  │      │   one chat.postMessage per turn │
 │   each edit re-fires app_mention│      │   no native, no post+edit       │
 │   → bot B gets N phantom turns  │      │   → bot B gets ONE app_mention  │
 └────────────────────────────────┘      └────────────────────────────────┘
   default streaming = true → unchanged behaviour for everyone else

Proposed Solution

Add one optional boolean to [slack], default true (preserves current behaviour):

[slack]
streaming = true     # default — live streaming, as today
# streaming = false  # always post a single final message (send-once)

When streaming = false:

  • No native streaming (no chat.startStream / streamed assistant.threads.setStatus).
  • No post+edit placeholder.
  • The adapter posts exactly one final chat.postMessage per turn.
  • Independent of assistant_mode — the assistant status API (set-status / reaction) is unaffected; only the message streaming path is gated.

Gating is a single AND-in at the two decision points:

  • use_streaming()self.streaming && !other_bot_present
  • uses_native_streaming()self.streaming && self.assistant_mode && !other_bot_present

Validation

  • streaming = true (default): identical behaviour to today — native streaming in single-bot assistant_mode threads, post+edit otherwise, other_bot_present still disables streaming when another bot has posted.
  • streaming = false: use_streaming() and uses_native_streaming() both return false even when alone; uses_assistant_status() is unaffected (status API independent of the streaming switch).

Testing

  • cargo test --bin openab — passing, incl. extended assistant_mode_gates_status_and_native_streaming cases asserting streaming=false forces send-once (no post+edit, no native) while leaving assistant status intact.
  • cargo clippy --all-targets -- -D warnings — clean.
  • Helm: charts/openab/tests/configmap_test.yaml covers streaming omitted (default, key absent) and streaming: false (renders streaming = false).

Update (2026-06-18) — narration trimming via a dedicated narration_display flag

Follow-up commit 733abf5 added "send-once delivers only the final answer block" — dropping the inter-tool narration ("let me check… / now reading…") that otherwise leaks into the message. Per maintainer discussion this is being decoupled from streaming into its own flag rather than implicitly coupled to send-once:

  • New narration_display: bool, default false, on the adapter config.
  • Effective rule: in send-once delivery, narration is trimmed unless narration_display = true. (Streaming shows narration live regardless, so the flag only bites in send-once.)
  • default false preserves 733abf5's behaviour (trim by default); set true to keep the full inter-tool text.
  • Implemented in the shared adapter layer, so it applies to any send-once turn — Slack streaming=false, Slack/Discord multi-bot threads, and gateway platforms — not just Slack. This supersedes the per-backend narration filters in fix(agy-acp): filter narration based on OPENAB_TOOL_DISPLAY #1030 (agy-acp) and feat(acp): filter opencode planning narration from responses #1032 (opencode).

This also resolves the "is streaming overloaded?" review point — two independent switches: streaming = stream vs send-once; narration_display = include inter-tool narration or only the final block.

⚠️ Behavior change for existing send-once deployments

narration_display defaults false, which means all existing send-once paths deliver only the final answer block after upgrade — inter-tool narration is silently dropped. Affected paths:

  • Gateway platforms (Telegram, LINE, Google Chat) — GatewayConfig.streaming defaults false, so every gateway turn was already send-once.
  • Discord multi-bot threads — turns where other_bot_present=true were already send-once.
  • Slack streaming=false (new in this PR) — obviously affected, but this is opt-in so no existing deployment is surprised here.

To restore the old full-text behaviour, set narration_display = true in [reactions]:

[reactions]
narration_display = true  # keep full inter-tool text in send-once messages

The rationale for defaulting to false: send-once messages read like a composed artefact (clean, direct answer) rather than a stream-of-consciousness scratchpad. Most operators on gateway/multi-bot platforms will prefer this. Operators who want the full narration can opt back in.


Update (2026-06-21) — review fixes

Addressed remaining findings from the aggregated review:

  • gateway.streaming docs removedGatewayConfig.streaming is a pre-existing Rust field whose Helm template rendering was never wired (pre-existing gap). This PR was advertising it in values.yaml/config.toml.example without wiring the Helm template, creating a silent no-op. Docs removed; the Helm gap will be tracked separately.
  • split_delivery re-parse conditioned — the second parse_output_directives call now only runs when the delivered slice begins at byte 0 (answer_start==0 or keep_full). When answer_start>0 the slice is mid-buffer reply text; re-parsing it could wrongly strip a final answer that opens with [[...]].
  • split_delivery docstring qualified — directive-preservation guarantee now explicitly scoped to non-reset turns.
  • select_delivery_text stale-offset warningunwrap_or replaced with unwrap_or_else that emits a tracing::warn! so a future regression is observable rather than silently leaking narration.
  • Helm test patterns tightenednotMatchRegex: 'streaming''streaming = ' and notMatchRegex: 'narration_display''narration_display = ' to avoid matching comments or unrelated key names.

@antigenius0910 antigenius0910 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #1115 Code Review — feat(slack): add [slack].streaming toggle (send-once mode)

Related issue: #1114 (RFC)
Branch: dogzzdogzz:feat/slack-streaming-toggleopenabdev:main
Single commit: f249b60
Diff size: 7 files, +70 / -8


1. Overall (TL;DR)

A narrow, backward-compatible, cleanly implemented configuration-flag PR. It adds a streaming switch under [slack], defaulting to true (current behavior). When set to false, the Slack adapter posts a single final message per turn — no native streaming and no post+edit placeholder.

Recommendation: Mergeable once the two RFC open questions (naming, Discord symmetry) are resolved at the design level. Nothing in the implementation blocks merge.


2. Implementation review

2.1 Correct gating points

Both gates are placed at the right spots:

  • src/slack.rs:537use_streaming() prefixed with self.streaming &&, controls post+edit placeholder
  • src/slack.rs:549uses_native_streaming() likewise gated, controls chat.startStream

uses_assistant_status() (src/slack.rs:544) is intentionally unaffected, which is the right call — the test assistant_mode_gates_status_and_native_streaming at line 2114 asserts this explicitly. The user still gets the "Thinking…" status set via assistant.threads.setStatus; only the typewriter effect on the final message goes away. That UX trade-off is reasonable.

2.2 Dispatch flow integration

The send-once branch at src/adapter.rs:946 already exists — it's the path Discord falls through to when other_bot_present=true. streaming=false reuses it directly, without introducing a new code path. Risk surface is therefore tiny.

stream_begin (adapter.rs:698) is lazily triggered: only fires on first Text event when native=true. With streaming=false, native is always false, so stream_begin is never called → no placeholder leakage.

2.3 Config structure

src/config.rs:431-441 uses #[serde(default = "default_true")] so existing configs upgrade with zero changes. The doc comment explicitly explains:

Mirrors [gateway] streaming in concept, but the default deliberately differs: GatewayConfig.streaming defaults to false, whereas this defaults to true to preserve current Slack streaming.

Good doc comment — anyone confused by the asymmetric defaults between this and GatewayConfig.streaming (config.rs:467) gets the rationale on the spot.

2.4 Test coverage

src/slack.rs:2110-2114 adds a third adapter (streaming=false, assistant_mode=true) to the assistant_mode_gates_status_and_native_streaming test, with three assertions:

assert!(!adapter3.use_streaming(false), "streaming=false forces send-once (no post+edit)");
assert!(!adapter3.uses_native_streaming(false), "streaming=false disables native even with assistant_mode");
assert!(adapter3.uses_assistant_status(), "streaming switch does not affect assistant status API");

These cover all three gates' states cleanly — no obvious gap.

2.5 Helm chart

charts/openab/templates/configmap.yaml:141-144 mirrors the rendering pattern used for assistant_mode right above it: only emit to config.toml when explicitly set to false, otherwise defer to the Rust default. This avoids drift between chart-side and Rust-side defaults.

charts/openab/tests/configmap_test.yaml:184-204 adds two symmetric tests verifying "explicit false renders" and "explicit true does not render".

values.yaml:305-307 documents the use case (multi-agent threads avoiding app_mention re-fires), which is genuinely helpful for Helm users.


3. Discussion points

3.1 Naming (RFC open question #1)

The RFC listed three candidates: streaming / live_streaming / send_once. Author chose streaming.

I think streaming is acceptable but not optimal:

  • ✅ Symmetric with [gateway] streaming
  • ⚠️ Could be confused with assistant_mode, which is itself a flavor of streaming
  • ⚠️ The semantics aren't quite "enable streaming"; setting streaming = false is really "force send-once, overriding everything else"

live_streaming or even send_once (inverted boolean, default false) would be more obvious. But you pay a symmetry cost. A judgment call for the maintainer — not a blocker.

3.2 Discord symmetry (RFC open question #2)

The RFC notes "Discord lacks post+edit streaming", but the Discord adapter does have placeholder + edit (src/discord.rs:135's use_streaming). This PR only touches Slack, without adding a symmetric Discord switch.

I'd lean toward merging the Slack part first (it already addresses the #1103 phantom-turn bug) and tracking Discord symmetry as a follow-up issue, because:

  • Discord's MESSAGE_UPDATE doesn't deliver mention notifications (already noted in adapter.rs:921), so the phantom-turn risk is lower there
  • Slack-adapter diff is already non-trivial; bundling Discord would expand the review surface

3.3 Startup log

The current streaming value is not surfaced at startup. There is per-turn debug logging (src/slack.rs:550-555), but it's at debug level and per-turn only.

Suggest adding an info-level startup log near main.rs:236-243 after SlackAdapter::new:

tracing::info!(streaming = s.streaming, assistant_mode = s.assistant_mode, "slack adapter configured");

This makes diagnosing "I set streaming = false but still see streaming behavior" a one-line check. Optional polish — non-blocking.

3.4 Documentation consistency

config.toml.example:42 indentation looks slightly off relative to the assistant_mode comment block right above it. Inline format should align. Cosmetic.

3.5 What's missing (checklist)

  • ✅ No schema migration needed (additive field)
  • ✅ No changelog entry needed (release flow handles it)
  • clippy should be clean (no new lint risk)
  • ⚠️ No integration test that goes through config.toml → deserialize → SlackAdapter to verify use_streaming() returns the expected value. Current unit tests call SlackAdapter::new(..., false) directly, bypassing the deserialize path. A config.rs round-trip test asserting streaming = false lands as s.streaming == false would be belt-and-braces. Nice-to-have, not required.

4. E2E verification (actually executed — 2026-06-15 07:24–07:27 UTC)

Test environment: compose.issue1114.yaml + openab-claude:pr1115 image, bot1 with streaming = false, bot2 with default. Driven autonomously via agent-browser + xoxc-token in #mcpsupportbot-test (C0AV8B98NKV).

# Scenario Expected Observed Pass
T1 @bot1 standalone mention One final message, no edits bot1 log: streaming=false … native=false; reply edited=null, 354 chars in a single send
T2 @bot2 standalone mention (default streaming) Typewriter / edit loop visible bot2 log: streaming=true … native=true; reply edited=1781508333.000000, updated ~1.5s later
T3 @bot1 mentions @bot2 in a thread bot2's app_mention fires exactly once bot1 reply edited=null; bot2 receives 1 mentions_bot=true from bot1's reply; bot2's next turn has other_bot_present=trueuse_streaming=false, so no edit storm

Additional observations:

  • The slack assistant_mode decision debug log correctly surfaces the new streaming field — helps when debugging.
  • During T3, bot2's own streamed reply broadcasts subtype="message_changed" events to every socket (including its own and bot1's). These events carry mentions_bot=false so they don't re-trigger dispatch — the adapter handles this correctly.
  • With assistant_mode=true + streaming=false, the assistant status API still fires (since uses_assistant_status() is not gated by the new switch) — matches both the PR's intent and the unit test assertion.
  • No panics, no errors, no leftover placeholder ("…") messages throughout the E2E run.

5. Conclusion

Approve — all three E2E scenarios pass, implementation is correct with complete test coverage, fully backward compatible.

Remaining items are design-level (RFC open questions):

  1. Naming streaming vs live_streaming / send_once — I lean toward streaming being acceptable-but-ambiguous; maintainer's call.
  2. Discord symmetry — recommend tracking as a follow-up issue (Discord's phantom-turn risk is lower).
  3. Strong suggestion to fold into this PR: §3.3 startup info log (a single tracing::info!(streaming = s.streaming, …)). Saves a lot of debugging time.

The E2E run also incidentally verified that the existing other_bot_present=true → use_streaming=false rule from #534 still holds — this PR does not regress prior behavior.

@dogzzdogzz

Copy link
Copy Markdown
Contributor Author

Added a follow-up commit (733abf5) on top of the toggle: send-once now delivers only the final answer block — the text emitted after the last tool call.

Why: the turn buffer concatenates every agent_message_chunk, so an agent's inter-tool narration ("let me pull the diff", "now reading X") leaked into the final message. A tool-posted artefact (e.g. a GitHub PR comment) is clean because it's a single composed string; the send-once reply wasn't. This makes send-once read like that single composed artefact.

Scope: it's in the shared stream_prompt_blocks loop, gated on !use_streaming(), so it applies to any send-once turn — gateway platforms (streaming defaults false) and Discord multi-bot threads, not just Slack. Streaming paths are untouched.

Correctness: split_delivery() parses output directives from the full buffer before slicing, so a leading [[reply_to:...]] survives even when the narration carrying it is dropped. +7 unit tests covering 0/1/N tools, the no-tool case, UTF-8 boundaries, and directive preservation.

@antigenius0910

Copy link
Copy Markdown
Contributor

Follow-up review for 733abf5 ("send-once delivers only the final answer block").

What I like

The motivation is real — agents emit inter-tool narration ("let me pull the diff", "now reading X") that bleeds into send-once replies and makes them read like stream-of-consciousness, while a tool-posted artefact reads like a single composed string. This commit aligns send-once with the artefact form.

Implementation is contained:

  • One state variable (answer_start: usize) advanced on every ToolDone.
  • Two helpers (select_delivery_text, split_delivery) with single, well-named responsibilities.
  • One diff point in stream_prompt_blocks — streaming path untouched.

Test coverage is genuinely thorough: UTF-8 char-boundary fallback, leading directive preserved across tools, no-tool case re-stripping the directive header, streaming preserves full body + directive. The reset && !streaming && answer_start > 0 re-prepend of the session-reset notice is exactly the kind of edge case that often gets missed.

Discussion points

1. PR scope/title now extends beyond Slack

The follow-up applies to every send-once turn — not just [slack] streaming=false. That includes:

  • Gateway platforms (Telegram / LINE / Google Chat) where [gateway] streaming defaults to false
  • Discord multi-bot threads where use_streaming = !other_bot_present

Existing users on those platforms will see their messages get noticeably terser after upgrading. Most will probably like it (it's the artefact-form pitch), but it is a silent behavior change. Worth either:

  • Adjusting the PR title to surface the broader scope (e.g. feat(adapter): send-once strips inter-tool narration), or
  • Calling it out explicitly in the release note when this ships.

2. streaming field semantics are now more overloaded

Repeating a point from my previous review: streaming already gated two things (post+edit vs send-once, and native streaming via assistant.threads.setStatus). This commit adds a third — whether inter-tool narration is dropped from the final message. The three are conceptually independent:

  • Switch A: stream live or send once (placeholder + edit, or single post)
  • Switch B: include narration or only the final block

The PR bundles them under one flag. That is probably the right UX default (anyone who picks send-once likely wants the artefact form), but the field name streaming no longer captures the full surface. If you keep this coupling, a clarifying sentence in the doc comment along the lines of "streaming = false also implies 'final answer block only'" would help readers understand the implicit second behavior.

3. Implicit invariant: "agent emits the final answer after its last tool call"

answer_start = text_buf.len() is set on every ToolDone, so it always points just past the most recent tool. If the agent ever writes its answer before its final tool call (say: "Answer: 42" followed by a logging tool, then end-of-turn), answer_start advances past the answer and select_delivery_text returns empty — the user gets _(no response)_.

In practice agents don't write this way, and the ACP pattern is "answer follows the work", so this is mostly theoretical. But the assumption isn't stated anywhere. Suggest folding a one-liner into select_delivery_text's doc comment, e.g.:

Assumes the agent emits its final answer after its last tool call (the standard ACP pattern). A turn whose only post-tool emission is empty falls through to the _(no response)_ sentinel in stream_prompt_blocks.

4. Possible refactor (non-blocking)

use_streaming() is now an implicit double-gate: it controls placeholder behavior AND narration trimming. Reads cleanly today, but if any future change wants to vary the two independently (e.g. a platform that wants send-once with narration, or streaming with final-block-only), the entanglement will need to be unwound. Could be split into a separate trait method like delivers_final_block_only() at that point — not necessary now.

E2E verification (commit 733abf5)

Rebuilt the rig with the new image and ran two new scenarios on top of the previous T1–T3:

# Scenario Expected Observed Pass
T4 @bot1 (streaming=false) prompt that requires tool use ("read /etc/hostname then tell me the hostname in one short sentence") Only the post-last-tool answer block reaches Slack; no "let me check the file" narration bot1 log: streaming=false … native=false; reply 72 chars, edited=null: :white_check_mark: `Read /etc/hostname` The hostname is `c006f123b639` . — i.e. tool-line indicator + clean 17-char final answer, zero pre-tool narration
T5 @bot1 prompt that triggers no tools ("what is 2+2? answer in one short sentence without using any tools") Full reply preserved (regression guard: answer_start == 0 keeps the whole buffer) reply 10 chars, edited=null: 2 + 2 = 4. — pure answer, nothing dropped
T6 Send-once with leading [[reply_to:<msg_ts>]] directive emitted before a tool Directive parsed from full buffer and applied; body has header stripped Deferred — the 7 unit tests added in this commit cover the directive-preservation logic from every angle I could think of (leading-directive-survives-tools, no-tool case re-strips header, streaming preserves both, UTF-8 char-boundary fallback). I am willing to take the unit tests at face value here rather than engineer an agent prompt that emits [[reply_to:...]] reliably.

Additional notes from the E2E run:

  • The slack assistant_mode decision debug log still correctly surfaces streaming=false after the follow-up commit — per-turn decision logging is intact.
  • No empty replies, no _(no response)_ sentinel fallthrough, no leftover placeholder messages.
  • Subjective UX read: T4's reply ("The hostname is c006f123b639.") is meaningfully tighter than the equivalent on f249b60, which would have included a sentence or two of "let me read the file" / "I'll check the hostname" prefacing the answer. The artefact-form pitch in the commit message lands in practice.

Verdict

Direction supported. The behavior change is well-motivated, the implementation is clean, and the tests are good. The items above are clarification asks, not blockers — the only one I'd push for in this PR is either a PR title/release-note adjustment for the broader scope, or a one-liner in the select_delivery_text doc comment stating the "answer follows last tool" invariant.

@chaodu-agent

This comment has been minimized.

@howie

howie commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Final Aggregated Review — PR #1115

feat(slack): add [slack].streaming toggle (send-once mode) · +295/-13, 8 files

Mode

group-review (3/3 voices active: Claude + Codex + Gemini) · R1 independent + R2 cross-debate

TL;DR

The core Rust logic is correct (UTF-8 boundary handling, keep_full_text cross-platform wiring, all 6 SlackAdapter::new call sites, Rust↔Helm default parity for the wired keys). No merge-blocking logic bug in the new helpers. Two things genuinely need attention: a Helm knob that is documented but never rendered (3/3 consensus), and a silent send-once behavior change for existing deployments (disputed — design intent vs. backward-compat baseline). Plus three small consensus cleanups.


Consensus Important (should fix before merge)

1. gateway.streaming documented in Helm but never rendered → silent no-op ✅ 3/3 + empirically confirmed

  • Where: charts/openab/values.yaml:373-376 & config.toml.example:140-143 (docs added) vs charts/openab/templates/configmap.yaml [gateway] block (L206-240, no streaming render).
  • Why it matters: GatewayConfig.streaming is a pre-existing Rust field; the template never rendered it (pre-existing gap). But THIS PR newly advertises the knob in values.yaml/config.toml.example. A user who reads the new docs and sets agents.*.gateway.streaming: true gets TOML that omits it → Rust default false applies → setting silently does nothing. Asymmetric with slack.streaming, which the same PR DID wire (configmap.yaml:142-145).
  • Fix: Add a guarded streaming render to the [gateway] block + a Helm test mirroring the slack.streaming tests. (Lead verified: no streaming key exists in the gateway template block.)

2. split_delivery docstring overclaims directive survival (reset turn) ✅ 3/3

  • Where: src/adapter.rs:126-134 (mirror comment ~910-917); root cause at src/adapter.rs:657-659.
  • Why it matters: The new docstring states a leading [[reply_to:...]] always "survives the narration it was emitted alongside". In a reset turn the buffer is seeded with "⚠️ _Session expired…_\n\n" at byte 0 before agent output, so parse_output_directives breaks on the notice and the directive is dropped (no-tool case: leaked into body; with-tool case: silently dropped). The underlying parse behavior is PRE-EXISTING (out of scope to fix here), but the new docstring asserts a guarantee the code does not honor.
  • Fix (in-scope): Qualify the docstring to scope the guarantee to non-reset turns, or note the reset-turn gap. (Optional follow-up, out of scope: move reset-notice insertion to the final formatting stage so directives parse from a clean buffer — Gemini/Codex suggestion.)

3. AdapterRouter::stream_prompt_blocks integration untested ⚠️ 2 Important (Claude, Gemini) / 1 NIT (Codex)

  • Where: src/adapter.rs:619, 924-928.
  • Why it matters: The pure helpers are well unit-tested, but no test covers (a) the reset re-prepend conditional if reset && !keep_full_text && answer_start > 0 — the subtlest logic in the PR, (b) keep_full_text = streaming || self.reactions_config.narration_display reading real config, (c) the gateway send-once default path. AdapterRouter has no mock seam (dispatch.rs:1143).
  • Fix: Extract the reset re-prepend into a small pure helper (e.g. finalize_body(reset, keep_full, answer_start, body) -> String) and unit-test it. (Codex view: not itself a blocker; add focused regression tests alongside the fixes above.)

Disputed (maintainer decides)

Default send-once behavior change: narration trimmed by default

  • Fact (undisputed): Before this PR, every send-once path (gateway default, Slack/Discord multi-bot) delivered the FULL accumulated reply. After, with narration_display defaulting false, they deliver only the final answer block. Existing deployments get a visible change without opting in. (src/config.rs + src/adapter.rs:619.)
  • Codex: backward-compat REGRESSION — invokes "OpenAB's rule is backward-compatible defaults"; should ship as explicitly breaking or be made opt-in.
  • Gemini + Claude: this is the PR's intended product design (clean final artefact on non-streaming platforms beats raw scratchpad narration); document in migration/release notes rather than revert the default.
  • Lead recommendation: Keep the opt-out default (the feature's whole point), BUT (1) call the behavior change out prominently in the PR description + CHANGELOG so gateway/multi-bot operators aren't surprised, and (2) since Codex cites a project baseline ("backward-compatible defaults"), the opt-in-vs-opt-out call belongs to the maintainer — please rule explicitly. This is a product/baseline decision, not a reviewer call.

Actionable NIT (consensus — clean up before merge)

4. split_delivery redundant + unsafe directive re-parse ✅ 3/3

  • src/adapter.rs:140-142. Re-parsing the suffix slice is redundant (directives already parsed from full) and can wrongly strip a final answer block that legitimately begins with [[…]]. Fix: parse directives once from full; only strip the header from the body when the delivered slice is the original leading region (i.e. answer_start == 0).

5. select_delivery_text silent fallback leaks narration on stale offset ✅ 3/3

  • src/adapter.rs:122. full.get(answer_start..).unwrap_or(full) fails OPEN — on a stale/non-boundary offset it returns the whole buffer (incl. the narration the feature exists to drop) with no signal. Invariant holds today, so it's dead-code defense; add tracing::warn!(answer_start, full_len = full.len(), "stale answer_start; delivering full buffer") so a future regression is observable.

6. Helm negative test matcher too loose ✅ 3/3

  • charts/openab/tests/configmap_test.yaml (test "does not render slack streaming when enabled"): notMatchRegex: 'streaming' matches the bare substring (would catch unrelated keys/comments). Tighten to 'streaming = ' to match the rendered-TOML contract the positive test asserts.

Verified correct (no action)

  • UTF-8 char-boundary safety of full.get(answer_start..); stale offset falls back without panic (tested).
  • keep_full_text = streaming || narration_display coherent across Slack (streaming && !other_bot), Discord (!other_bot), gateway (self.streaming), and native streaming.
  • Rust default_true (slack.streaming) / default false (gateway.streaming, narration_display) all match docstrings + config.toml.example + values.yaml, mutually consistent across the 3 doc surfaces.
  • All 6 SlackAdapter::new call sites updated; adapter3 test asserts streaming=false forces send-once + orthogonality with assistant_status.
  • The 4 wired Helm tests (slack.streaming render-on/no-render, narration_display render-on/false/absent) assert full rendered values, not bare key presence.

Voices unavailable

  • None. (Codex R1 needed a manual re-run because the helper script hard-codes git fetch origin <base> and this is a cross-fork PR whose base lives on upstream; agy R1 needed one retry after entering agentic file-search mode. Both produced full R1+R2.)

Verdict

NEEDS_CHANGES — 1 consensus Helm fix (#1), 1 docstring fix (#2), 1 maintainer decision (send-once default), + 3 consensus NITs. No merge-blocking logic bug.

@github-actions github-actions Bot added the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Jun 20, 2026
@openab-app openab-app Bot removed the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Jun 21, 2026
@github-actions github-actions Bot added the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Jun 21, 2026
@chaodu-agent

This comment has been minimized.

@antigenius0910 antigenius0910 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving

Direction supported in my Jun 16 follow-up, and nothing in the subsequent rebases (#1136 / #1137 / #1138 / #1139 / #1145) changes that read — they're orthogonal to send-once. The behavior change is well-motivated, the implementation is clean, the tests are good.

Optional contribution — unit test for the reset re-prepend branch

In follow-up review item #3 I called out that the inline branch at the end of stream_prompt_blocks

let text_buf = if reset && !keep_full_text && answer_start > 0 {
    format!("⚠️ _Session expired, starting fresh..._\n\n{text_buf}")
} else {
    text_buf
};

— wasn't exercised by a unit test even though it's the exact edge case that would silently drop the session-reset notice on a tool-using turn. I built a 4-corner truth-table test for it locally, commit b07d295 on test/PR1115-add-finalize_body-test (in my worktree):

  • Extracts the inline if-else into a pure helper pub fn finalize_body(reset, keep_full_text, answer_start, body) -> String.
  • No behavior change — same truth table, same call site.
  • 4 tests covering every corner of (reset, keep_full_text, answer_start > 0):
    • reset + send-once + tool ran → notice re-prepended
    • reset + send-once + no tools → pass through (notice already in slice)
    • reset + keep_full_text → pass through
    • no reset (both flag combos) → pass through

cargo test passes on top of your 78fdee2. Embedding as a diff block below — git apply directly if you want it, ignore otherwise. I'm not pushing the branch (PAT scope mismatch with the workflow files pulled in by the rebases; not worth a token swap for one test commit).

git apply-able diff (124 lines, 1 file)
diff --git a/src/adapter.rs b/src/adapter.rs
index c95ad23..e0ca226 100644
--- a/src/adapter.rs
+++ b/src/adapter.rs
@@ -164,6 +164,33 @@ pub fn split_delivery(
     (directives, body)
 }
 
+/// Apply the session-reset re-prepend rule to a finalized turn body.
+///
+/// The session-reset notice (`"⚠️ _Session expired, starting fresh..._\n\n"`)
+/// is pushed at the head of the turn buffer so streaming consumers see it
+/// live. When the turn ends in send-once trimming mode (`!keep_full_text`) and
+/// a tool ran (`answer_start > 0`), the slice that `select_delivery_text`
+/// returns starts *after* the notice — so re-prepend it to keep the user
+/// aware their session was reset. In every other corner (no reset, or
+/// `keep_full_text`, or no tools ran) the notice is either absent or already
+/// included in `body`, and we must not duplicate it.
+///
+/// Pure helper: deliberately mirrors the inline branch at the end of
+/// `AdapterRouter::stream_prompt_blocks` so the four-corner truth table can be
+/// exercised in isolation without a live ACP session.
+pub fn finalize_body(
+    reset: bool,
+    keep_full_text: bool,
+    answer_start: usize,
+    body: String,
+) -> String {
+    if reset && !keep_full_text && answer_start > 0 {
+        format!("⚠️ _Session expired, starting fresh..._\n\n{body}")
+    } else {
+        body
+    }
+}
+
 // --- Platform-agnostic types ---
 
 /// Identifies a channel or thread across platforms.
@@ -1001,12 +1028,9 @@ impl AdapterRouter {
                     // The session-reset notice lives at the head of the buffer; a
                     // tool advancing answer_start past it would drop it from the
                     // slice, so re-prepend it to the (directive-stripped) body in
-                    // exactly that case (answer_start == 0 keeps it via the slice).
-                    let text_buf = if reset && !keep_full_text && answer_start > 0 {
-                        format!("⚠️ _Session expired, starting fresh..._\n\n{text_buf}")
-                    } else {
-                        text_buf
-                    };
+                    // exactly that case. `finalize_body` is the pure helper that
+                    // encodes the four-corner truth table so it can be unit-tested.
+                    let text_buf = finalize_body(reset, keep_full_text, answer_start, text_buf);
 
                     // Build final content
                     let final_content =
@@ -1605,6 +1629,69 @@ mod tests {
         assert_eq!(body, "narration then answer");
     }
 
+    // --- finalize_body: four-corner truth table for the reset re-prepend ---
+    //
+    // The send-once trimming logic in `stream_prompt_blocks` ends with an
+    // inline branch that decides whether to re-prepend the session-reset
+    // notice. Extracted into the pure helper `finalize_body` so each corner
+    // of (reset, keep_full_text, answer_start) can be exercised without a live
+    // ACP session. Mirrors the integration-level concern raised in PR #1115
+    // peer review (howie group-review, "Important #3").
+
+    #[test]
+    fn finalize_body_reset_send_once_with_tools_prepends_notice() {
+        // Reset turn, send-once trimming, a tool advanced answer_start past
+        // the notice → the slice no longer contains it → re-prepend.
+        let body = "the final answer".to_string();
+        let out = finalize_body(true, false, 42, body);
+        assert_eq!(
+            out, "⚠️ _Session expired, starting fresh..._\n\nthe final answer",
+            "send-once + reset + tool ran → notice must be re-prepended"
+        );
+    }
+
+    #[test]
+    fn finalize_body_reset_send_once_no_tools_passes_through() {
+        // answer_start == 0 means the slice still equals the full buffer,
+        // which already starts with the notice → re-prepending would
+        // duplicate it.
+        let body = "⚠️ _Session expired, starting fresh..._\n\nthe final answer".to_string();
+        let out = finalize_body(true, false, 0, body.clone());
+        assert_eq!(
+            out, body,
+            "send-once + reset + no tools → body already carries notice, pass through"
+        );
+    }
+
+    #[test]
+    fn finalize_body_reset_keep_full_passes_through() {
+        // keep_full_text means the slice is the whole buffer (incl. the
+        // notice) → must not duplicate, regardless of answer_start.
+        let body = "⚠️ _Session expired, starting fresh..._\n\nnarration then answer".to_string();
+        let out = finalize_body(true, true, 42, body.clone());
+        assert_eq!(
+            out, body,
+            "keep_full_text → body already carries notice, pass through even with tools"
+        );
+    }
+
+    #[test]
+    fn finalize_body_no_reset_passes_through() {
+        // Non-reset turn: there is no notice to manage, irrespective of the
+        // other two flags. Covers both halves of the cartesian product.
+        let body = "the final answer".to_string();
+        assert_eq!(
+            finalize_body(false, false, 42, body.clone()),
+            body,
+            "no reset → never prepend (send-once + tools)"
+        );
+        assert_eq!(
+            finalize_body(false, true, 0, body.clone()),
+            body,
+            "no reset → never prepend (keep_full + no tools)"
+        );
+    }
+
     /// Compile-time regression guard: use_streaming() is a required trait method

LGTM either way — approving on the PR itself, the diff above is just a take-it-or-leave-it contribution to close the test gap from my prior review item #3.

@antigenius0910 antigenius0910 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving

Direction supported in my Jun 16 follow-up, and nothing in the subsequent rebases (#1136 / #1137 / #1138 / #1139 / #1145) changes that read — they're orthogonal to send-once. The behavior change is well-motivated, the implementation is clean, the tests are good.


Optional follow-up: I opened dogzzdogzz#4 against feat/slack-streaming-toggle adding a unit test for the reset && !keep_full_text && answer_start > 0 re-prepend branch — that's the test gap I flagged as item #3 in my follow-up review. Merge it into your feature branch and it flows into this PR automatically, or skip it entirely — it's a nit, not a blocker for approval.

@thepagent thepagent added needs-rebase and removed closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. pending-maintainer-review labels Jun 21, 2026
@github-actions github-actions Bot added the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Jun 21, 2026
@openab-app openab-app Bot added pending-maintainer-review and removed closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. labels Jun 21, 2026
@dogzzdogzz

Copy link
Copy Markdown
Contributor Author

Resolution of aggregated review items

Thanks @howie for the thorough review. All items addressed — summary below.


#1 — `gateway.streaming` documented but never rendered → removed from docs

Chose Option A: removed the newly-added docs rather than wiring the Helm template. The Rust field `GatewayConfig.streaming` is pre-existing and functional — the gap (template never renders it) predates this PR. Adding docs for a knob that silently does nothing in Helm was the bug; removing them restores the pre-PR state. The Rust field itself is untouched.

Commits: `e1e4a243` (values.yaml, config.toml.example)


#2 — `split_delivery` docstring overclaims directive survival → qualified to non-reset turns

Docstring updated to explicitly scope the guarantee: directives survive in normal (non-reset) turns; reset-turn behaviour noted as a pre-existing gap where the session-notice prefix prevents clean directive parsing.

Commit: `efbc799`


#3 — `stream_prompt_blocks` reset re-prepend untested → `finalize_body` helper + 4 unit tests

Extracted the inline 4-line reset re-prepend branch into `pub(crate) fn finalize_body(reset, keep_full_text, answer_start, body)` and added four tests covering every corner of the `(reset, keep_full_text, answer_start > 0)` truth table (reviewed by the agent panel; no logic bugs found).

Commit: `67ea868` (merge of PR #4 authored by @antigenius0910)


Disputed — send-once default behavior change → documented; maintainer decision requested

Kept `narration_display` defaulting `false` (the feature's intent: clean final-answer artefact, not stream-of-consciousness scratchpad). Added a prominent `⚠️ Behavior change for existing send-once deployments` block to the PR description. Operators who want the old full-narration output can set `narration_display = true` in `[reactions]`.

Awaiting maintainer sign-off on the opt-out default.


#4 — `split_delivery` redundant + unsafe re-parse → fixed

`parse_output_directives` is now only called on the delivered slice when `answer_start == 0` (delivered slice starts at the buffer head, where directives live). For later slices (answer-start > 0) the body is used as-is, preventing a final-answer block that opens with `[[…]]` from having its content silently stripped.

Commit: `efbc799`


#5 — `select_delivery_text` silent fallback → warn log added

`unwrap_or(full)` → `unwrap_or_else(|| { tracing::warn!(answer_start, full_len, "stale answer_start offset; delivering full buffer"); full })`. The invariant still holds today; the warn makes a future regression observable.

Commit: `efbc799`


#6 — Helm negative test matchers too loose → tightened

`notMatchRegex: 'streaming'` → `'streaming = '`
`notMatchRegex: 'narration_display'` (×2) → `'narration_display = '`

Commit: `efbc799`


Current branch tip: `67ea868`

Rebased 8 feature commits cleanly onto upstream/main (15 new commits including
Cargo workspace restructure openabdev#1146, slack allow-list refactor openabdev#1152, adapter fix openabdev#1153).
Auto-merged into crates/openab-core/ new layout. All tests pass, clippy clean.
@dogzzdogzz dogzzdogzz force-pushed the feat/slack-streaming-toggle branch from 67ea868 to c516310 Compare June 22, 2026 01:19
@chaodu-agent

Copy link
Copy Markdown
Collaborator

LGTM ✅ — All aggregated review findings addressed; code is correct and well-tested.

What This PR Does

Adds a [slack] streaming master toggle (default true) that lets operators force send-once mode — posting exactly one final chat.postMessage per turn — to eliminate the phantom-turn storm caused by message_changed events during post+edit streaming in multi-agent threads. Companion [reactions] narration_display flag (default false) controls whether inter-tool narration survives in send-once messages across all platforms.

How It Works

  • use_streaming() and uses_native_streaming() are now gated by self.streaming && — when false, send-once is forced regardless of assistant_mode or thread composition.
  • select_delivery_text / split_delivery / finalize_body — pure helpers in the shared adapter layer that select the final-answer slice, preserve output directives across narration trimming, and handle the session-reset re-prepend corner.
  • keep_full_text = streaming || narration_display — platform-agnostic flag computed once per turn.

Findings

# Severity Finding Location
1 🟢 All 6 howie-review items resolved cleanly
2 🟢 finalize_body extraction + 5 unit tests cover the 4-corner truth table adapter.rs:148-170
3 🟢 Helm tests properly tightened to match rendered TOML, not bare substrings configmap_test.yaml
4 🟢 Clean separation of concerns: streaming (transport) vs narration_display (content) config.rs, adapter.rs
5 🟢 UTF-8 boundary safety with observable fallback (tracing::warn!) adapter.rs:119-125
What's Good (🟢)
  • Thorough unit test coverage — pure helpers extracted specifically to enable isolated testing of subtler logic paths (reset re-prepend, directive preservation across trimming, stale-offset fallback).
  • Excellent PR description with behavior-change callout for existing send-once deployments and explicit opt-back-in path (narration_display = true).
  • Review iteration well-managed — each finding mapped to a specific commit with clear rationale.
  • Orthogonality between streaming (transport mode switch) and narration_display (content selection) cleanly addresses the "overloaded flag" concern from the aggregated review.
Baseline Check
  • PR opened: 2026-06-15
  • Main has: use_streaming() gated only by other_bot_present; no streaming config field on SlackConfig; no narration trimming in send-once path; no narration_display config.
  • Net-new value: operator-controllable send-once mode eliminating phantom-turn storms at the source; platform-agnostic narration trimming for clean send-once messages.
Addressing External Reviewer Feedback

@howie (Aggregated Review — Round 2)

#1: gateway.streaming documented but never rendered

Addressed in e1e4a24: Docs removed from values.yaml/config.toml.example. Pre-existing Rust field untouched; Helm template gap tracked separately.

#2: split_delivery docstring overclaims directive survival

Addressed in efbc799: Docstring now scopes the guarantee to non-reset turns with explicit note about the reset-turn gap.

#3: Reset re-prepend untested

Addressed in 67ea868: Extracted finalize_body helper + 5 unit tests covering every corner of (reset, keep_full_text, answer_start > 0).

#4: split_delivery redundant + unsafe re-parse

Addressed in efbc799: Re-parse conditioned on answer_start == 0 || keep_full to prevent stripping a final answer that legitimately opens with [[…]].

#5: select_delivery_text silent fallback

Addressed in efbc799: unwrap_or_else with tracing::warn! for observable failure.

#6: Helm test matchers too loose

Addressed in efbc799: Patterns tightened to 'streaming = ' / 'narration_display = '.

@antigenius0910 (Round 1 + Approval)

Approved: Direction supported; no concerns on subsequent rebases.


⚠️ Note: PR currently has merge conflicts (needs-rebase label). Rebase onto latest main required before merge. The narration_display default-false behavior change (disputed item from aggregated review) awaits explicit maintainer sign-off — documented prominently in PR description.

@github-actions github-actions Bot added the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Jun 22, 2026
@howie

howie commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Question on AGENTS.md §1 compliance for the narration_display=false default

Surfaced by a cross-model group review (Claude + Codex). Scoping this comment only to the backward-compatible-defaults guideline — not the UX merits of trimming, which the description argues well.

The rule (AGENTS.md §1, "Backward-Compatible Defaults"):

New config fields MUST default to the previous behavior. Never change what existing deployments experience without an explicit opt-in.

The fact (verified against pre-PR adapter.rs): before this PR, send-once turns delivered the full buffer (inter-tool narration included). After this PR, with narration_display defaulting to false, keep_full_text = streaming || narration_display is false for existing send-once paths, so split_delivery/select_delivery_text trim everything before the last completed tool. This changes output for existing deployments with no opt-in to trigger it:

  • Gateway (Telegram / LINE / Google Chat) — GatewayConfig.streaming defaults false, so every gateway turn was already send-once.
  • Discord multi-bot threads — other_bot_present=true turns were already send-once.
  • Slack multi-bot threads — likewise send-once once another bot is present.

The description already documents this break, gives a clear rationale, offers opt-back-in (narration_display = true), and cites "maintainer discussion." The only gap we want to close is the §1 process point: a documented break + an opt-in to restore the old behavior is not the same as defaulting to the previous behavior, which §1 phrases as a MUST.

The question: was the §1 deviation explicitly accepted by maintainers in that discussion?

  • If yes — could it be recorded on the PR via the breaking-change path (a ! in the commit/title, a short migration note, and a release-note callout) so the deviation is explicit rather than implied? That would satisfy §1's "explicit opt-in/acknowledgement" intent.
  • If the intent is to stay §1-compliant — the alternative is to default the absent-config path to keep-full (i.e. make trimming the opt-in), leaving existing send-once deployments unchanged after upgrade.

Either path is fine from our side; we just want the §1 status to be unambiguous before merge. Thanks!

@antigenius0910

Copy link
Copy Markdown
Contributor

Hi @thepagent — gentle ping for CODEOWNER review when you have a moment.

State of the PR:

  • ✅ All CI green (3/3)
  • ✅ Two MEMBER approvals (myself + @howie at c516310)
  • ✅ Rebase clean against upstream/main
  • ✅ Author addressed all items from the aggregated review

One outstanding discussion item worth your eye before merge: @howie's §1 backward-compat question (above, 06:41 UTC) about the narration_display = false default changing output for existing send-once deployments without an opt-in. Not blocking implementation — just needs a §1 process call on whether to record as a breaking-change (title ! + migration note) or invert the default.

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. pending-maintainer-review slack

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFC: [slack].streaming toggle to disable live streaming (send-once mode)

5 participants