diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index 6a8e8b57e..b64ca2e6c 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -138,6 +138,10 @@ data: {{- if and (hasKey ($cfg.slack | default dict) "assistantMode") (not ($cfg.slack).assistantMode) }} assistant_mode = false {{- end }} + {{- /* streaming: master switch; false → send-once (no native, no post+edit) */ -}} + {{- if and (hasKey ($cfg.slack | default dict) "streaming") (not ($cfg.slack).streaming) }} + streaming = false + {{- end }} {{- end }} [agent] @@ -176,6 +180,10 @@ data: {{- end }} tool_display = {{ ($cfg.reactions).toolDisplay | toJson }} {{- end }} + {{- /* narrationDisplay: true → keep inter-tool narration in send-once (default false → final answer block only) */ -}} + {{- if ($cfg.reactions).narrationDisplay }} + narration_display = true + {{- end }} {{- if ($cfg.stt).enabled }} {{- if not ($cfg.stt).apiKey }} {{ fail (printf "agents.%s.stt.apiKey is required when stt.enabled=true" $name) }} diff --git a/charts/openab/tests/configmap_test.yaml b/charts/openab/tests/configmap_test.yaml index b2be1d90e..d1de58daa 100644 --- a/charts/openab/tests/configmap_test.yaml +++ b/charts/openab/tests/configmap_test.yaml @@ -180,3 +180,49 @@ tests: - notMatchRegex: path: data["config.toml"] pattern: 'assistant_mode' + + - it: renders slack streaming = false when explicitly disabled + set: + agents.kiro.slack.enabled: true + agents.kiro.slack.botToken: xoxb-x + agents.kiro.slack.appToken: xapp-y + agents.kiro.slack.streaming: false + asserts: + - matchRegex: + path: data["config.toml"] + pattern: 'streaming = false' + + - it: does not render slack streaming when enabled (Rust default true) + set: + agents.kiro.slack.enabled: true + agents.kiro.slack.botToken: xoxb-x + agents.kiro.slack.appToken: xapp-y + agents.kiro.slack.streaming: true + asserts: + - notMatchRegex: + path: data["config.toml"] + pattern: 'streaming' + + - it: renders reactions narration_display = true when explicitly enabled + set: + agents.kiro.reactions.narrationDisplay: true + asserts: + - matchRegex: + path: data["config.toml"] + pattern: 'narration_display = true' + + - it: does not render reactions narration_display when off (Rust default false) + set: + agents.kiro.reactions.narrationDisplay: false + asserts: + - notMatchRegex: + path: data["config.toml"] + pattern: 'narration_display' + + - it: does not render reactions narration_display when key absent (Rust default false) + set: + agents.kiro.reactions.enabled: true + asserts: + - notMatchRegex: + path: data["config.toml"] + pattern: 'narration_display' diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 27f5effe4..3038ecc0a 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -302,6 +302,12 @@ agents: # AI app with the `assistant:write` scope (plus chat:write). Default: true — set to # false for non-AI Slack apps (without assistant:write) to keep emoji-reaction status. assistantMode: true + # streaming: master switch for live reply streaming. Default true. Set + # false to always post a single final message (send-once) — no native + # streaming, no post+edit placeholder — regardless of assistantMode. + # Useful in multi-agent threads to avoid streamed-message edit states + # re-firing app_mention. Mirrors gateway.streaming. + streaming: true # allowAllChannels/allowAllUsers: same auto-infer logic as discord allowedChannels: [] # empty + no allowAllChannels → allow all (auto-inferred) allowedUsers: [] # empty + no allowAllUsers → allow all (auto-inferred) @@ -346,6 +352,11 @@ agents: # compact: show count summary (e.g. ✅ 3 · 🔧 1 tool(s)) # full: show complete tool titles (for debugging) # none: hide tool lines entirely + # narrationDisplay: default false. In send-once mode, deliver ONLY the final + # answer block (text after the last tool call); inter-tool narration is + # dropped. Set true to keep the full text. Platform-agnostic (Slack + # streaming=false, multi-bot threads, gateway). No effect while streaming. + # narrationDisplay: false stt: enabled: false apiKey: "" @@ -359,6 +370,11 @@ agents: deploy: true # set to false to skip Gateway Deployment/Service (config-only mode) url: "" # e.g. ws://openab-gateway:8080/ws platform: "telegram" # default platform when gateway is enabled + # streaming: default false → gateway platforms are send-once, which (like + # all send-once) delivers only the final answer block — inter-tool + # narration is dropped. Set true to stream live + keep full text (needs a + # platform that supports message editing). + # streaming: false token: "" # optional shared secret (injected via GATEWAY_WS_TOKEN env var) botUsername: "" # optional, for @mention gating # messageProcessingMode: "per-message" (default) | "per-thread" | "per-lane" diff --git a/config.toml.example b/config.toml.example index 950d188e2..b4ddb44da 100644 --- a/config.toml.example +++ b/config.toml.example @@ -39,10 +39,20 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # # post+edit + emoji reactions. Requires the Slack app to be # # an AI app with the `assistant:write` scope (plus chat:write). # # Set false for non-AI Slack apps to keep emoji reactions. +# streaming = true # default true: stream the reply live (native or post+edit). +# # Set false to always post a single final message (send-once), +# # regardless of assistant_mode — useful in multi-agent threads +# # to avoid streamed-edit states re-firing app_mention. Mirrors +# # [gateway] streaming (which defaults false). What a send-once +# # message *contains* is controlled by [reactions] narration_display. # [gateway] # url = "ws://openab-gateway:8080/ws" # WebSocket URL of the custom gateway # platform = "line" # "telegram" (default) | "line" | "googlechat" +# streaming = false # default false → send-once: gateway platforms post a single +# # final message, and (like all send-once) deliver only the +# # final answer block — inter-tool narration is dropped. Set +# # true to stream live + keep full text (needs msg-editing support). # token = "${GATEWAY_TOKEN}" # shared token for WebSocket auth (optional but recommended) # bot_username = "my_bot" # for @mention gating in groups # allow_all_channels = true # true = allow all channels; false = only allowed_channels @@ -172,6 +182,12 @@ remove_after_reply = false # # Controls how tool calls appear in the final message. # # Applies to both assistant_mode and post+edit mode. # # Set "none" to hide the tool summary entirely. +# narration_display = false # default false: in send-once mode, deliver ONLY the final +# # answer block (text after the last tool call); inter-tool +# # narration ("let me check… / now reading X") is dropped. +# # Set true to keep the full text. Platform-agnostic (Slack +# # streaming=false, multi-bot threads, gateway). No effect +# # while streaming — streamed text is shown live as produced. [reactions.emojis] queued = "👀" diff --git a/src/adapter.rs b/src/adapter.rs index af0f0d6c8..0e9b16ee4 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -96,6 +96,53 @@ pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) { (directives, remaining) } +/// Select the answer text to deliver from the turn's accumulated agent-message +/// buffer. +/// +/// `full` is every `agent_message_chunk` concatenated across the turn, which +/// includes the inter-tool narration the agent emits between tool calls ("let +/// me pull the diff", "now reading the validator", ...). `answer_start` is the +/// byte offset where the final answer block begins — set to the buffer length +/// each time a tool call completes, so it ends up pointing just past the last +/// tool. +/// +/// When `keep_full` is false we deliver only that final block, dropping the +/// narration so the message reads like the single composed artefact a +/// tool-posted comment is. `keep_full` is true when the reply was streamed +/// (the text was already shown live) or when `[reactions] narration_display` is +/// set; in that case the whole buffer is returned unchanged. +/// +/// `answer_start` is always a previous `full.len()`, hence a valid char +/// boundary; the `get(..)` fallback to `full` only guards against a future +/// caller passing a stale offset. +pub fn select_delivery_text(full: &str, answer_start: usize, keep_full: bool) -> &str { + if keep_full { + full + } else { + full.get(answer_start..).unwrap_or(full) + } +} + +/// Resolve the directives and body to deliver for a finished turn. +/// +/// Output directives (e.g. `[[reply_to:...]]`) sit at the very start of the +/// turn's output per `docs/output-directives.md`. When `keep_full` is false +/// (send-once trimming) that start can be inter-tool narration that +/// [`select_delivery_text`] discards — so we parse directives from the **full** +/// buffer (preserving them) and then take the body from the delivered slice. +/// The slice is re-parsed only to strip a directive header in the no-tool case, +/// where the slice still equals `full` and therefore still carries the header. +pub fn split_delivery( + full: &str, + answer_start: usize, + keep_full: bool, +) -> (OutputDirectives, String) { + let (directives, _) = parse_output_directives(full); + let delivered = select_delivery_text(full, answer_start, keep_full); + let (_, body) = parse_output_directives(delivered); + (directives, body) +} + // --- Platform-agnostic types --- /// Identifies a channel or thread across platforms. @@ -563,6 +610,13 @@ impl AdapterRouter { let thread_channel = thread_channel.clone(); let message_limit = adapter.message_limit(); let streaming = adapter.use_streaming(other_bot_present); + // Keep the full turn text (incl. inter-tool narration) when streaming + // (it was already shown live) OR when `[reactions] narration_display` is + // set. Otherwise a send-once turn delivers only the final answer block. + // Platform-agnostic — read from the shared reactions config, alongside + // `tool_display`. `streaming` still drives the placeholder / native-stream + // paths below; only the final-text selection uses `keep_full_text`. + let keep_full_text = streaming || self.reactions_config.narration_display; let native = adapter.uses_native_streaming(other_bot_present); let assistant_status = adapter.uses_assistant_status(); // Platforms that render Markdown tables natively (e.g. Slack Block Kit @@ -593,6 +647,12 @@ impl AdapterRouter { let mut text_buf = String::new(); let mut tool_lines: Vec = Vec::new(); + // Byte offset into `text_buf` where the final answer block + // begins — advanced to the buffer end on every tool + // completion so it tracks "just past the last tool". Used by + // send-once mode to drop inter-tool narration (see + // `select_delivery_text`). + let mut answer_start = 0usize; if reset { text_buf.push_str("⚠️ _Session expired, starting fresh..._\n\n"); @@ -791,6 +851,12 @@ impl AdapterRouter { } } AcpEvent::ToolDone { id, title, status } => { + // The final answer block is whatever text the agent + // emits AFTER its last tool. Advancing this on every + // completion leaves it pointing just past the last + // tool; send-once delivery slices from here so the + // preceding inter-tool narration is dropped. + answer_start = text_buf.len(); // Live indicator: assistant status line vs emoji reaction. if assistant_status { let _ = adapter @@ -841,11 +907,25 @@ impl AdapterRouter { // Stop the edit loop drop(buf_tx); - // Parse output directives from raw text_buf BEFORE compose_display. - // Directives are agent meta-layer, not content — must be stripped - // before tool lines are composed into the display output. - let (directives, stripped_text) = parse_output_directives(&text_buf); - let text_buf = stripped_text; + // In send-once mode, deliver only the final answer block — + // the text after the last tool call — so inter-tool narration + // ("let me pull the diff", "now reading X") never reaches the + // message. Streaming modes already showed that text live, so + // they keep the whole buffer. Directives are parsed from the + // FULL buffer (they sit at output start, which the slice may + // drop) so a leading [[reply_to:...]] survives the narration + // it was emitted alongside. + let (directives, text_buf) = + split_delivery(&text_buf, answer_start, keep_full_text); + // 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 + }; // Build final content let final_content = @@ -1165,6 +1245,75 @@ fn compose_display( mod tests { use super::*; + #[test] + fn select_delivery_text_send_once_keeps_only_final_block() { + // Simulates: narration "n1" → tool (answer_start→2) → narration "n2" + // → tool (answer_start→14) → final answer. In send-once mode only the + // text after the last tool survives. + let full = "n1[tool]n2[tool]the final answer"; + let answer_start = "n1[tool]n2[tool]".len(); + assert_eq!( + select_delivery_text(full, answer_start, false), + "the final answer" + ); + } + + #[test] + fn select_delivery_text_streaming_keeps_full_buffer() { + // Streaming already showed the text live, so the whole buffer is kept + // regardless of answer_start. + let full = "narration then answer"; + assert_eq!(select_delivery_text(full, 10, true), full); + } + + #[test] + fn select_delivery_text_send_once_no_tools_keeps_everything() { + // No tool ever completed → answer_start stays 0 → the whole (tool-free) + // reply is delivered, including a leading session-reset notice. + let full = "⚠️ _Session expired, starting fresh..._\n\njust the answer"; + assert_eq!(select_delivery_text(full, 0, false), full); + } + + #[test] + fn select_delivery_text_stale_offset_falls_back_to_full() { + // A byte offset past the end (or a non-char-boundary) must not panic — + // get(..) returns None and we fall back to the full buffer. + let full = "abc"; + assert_eq!(select_delivery_text(full, 999, false), full); + // 1 is a non-boundary inside the multi-byte '✓' (3 bytes); fallback. + assert_eq!(select_delivery_text("✓x", 1, false), "✓x"); + } + + #[test] + fn split_delivery_send_once_preserves_leading_directive_across_tools() { + // Regression: a [[reply_to:...]] emitted at output start, followed by + // narration + a tool, must survive even though the narration is dropped. + let full = "[[reply_to:101]]\nlet me check...[tool]the final answer"; + let answer_start = "[[reply_to:101]]\nlet me check...[tool]".len(); + let (directives, body) = split_delivery(full, answer_start, false); + assert_eq!(directives.reply_to.as_deref(), Some("101")); + assert_eq!(body, "the final answer"); + } + + #[test] + fn split_delivery_send_once_no_tools_strips_directive_from_body() { + // No tool ran (answer_start == 0): the slice still carries the header, + // so the body must have it stripped while directives are still parsed. + let full = "[[reply_to:55]]\njust the answer"; + let (directives, body) = split_delivery(full, 0, false); + assert_eq!(directives.reply_to.as_deref(), Some("55")); + assert_eq!(body, "just the answer"); + } + + #[test] + fn split_delivery_streaming_keeps_full_body_and_directive() { + // Streaming keeps the full buffer; directive parsed and stripped once. + let full = "[[reply_to:7]]\nnarration then answer"; + let (directives, body) = split_delivery(full, 5, true); + assert_eq!(directives.reply_to.as_deref(), Some("7")); + assert_eq!(body, "narration then answer"); + } + /// Compile-time regression guard: use_streaming() is a required trait method /// (no default). Any adapter that forgets to implement it will fail to compile. /// This test documents the contract — see PR #503 / issue #502 for context. diff --git a/src/config.rs b/src/config.rs index 991071164..8408f7396 100644 --- a/src/config.rs +++ b/src/config.rs @@ -430,6 +430,15 @@ pub struct SlackConfig { /// that are not AI apps (no `assistant:write`) to keep emoji-reaction status. #[serde(default = "default_true")] pub assistant_mode: bool, + /// Master streaming switch. When `false`, the Slack adapter always posts a + /// single final message (send-once) — no native streaming, no post+edit + /// placeholder — regardless of `assistant_mode`. Default `true`. Useful for + /// multi-agent threads to avoid streamed-message edit states re-firing + /// `app_mention`. 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. + #[serde(default = "default_true")] + pub streaming: bool, } #[derive(Debug, Deserialize)] @@ -454,6 +463,13 @@ pub struct GatewayConfig { #[serde(default)] pub allowed_users: Vec, /// Enable streaming (typewriter) mode — requires gateway platform to support message editing. + /// Defaults to `false`, so gateway platforms (Telegram / LINE / Google Chat) are **send-once + /// by default**. By default send-once delivers **only the final answer block** — the text after + /// the last tool call — dropping inter-tool narration (the shared default send-once trimming in + /// `AdapterRouter::stream_prompt_blocks`, controlled by the platform-agnostic + /// `[reactions] narration_display`). Discord is likewise send-once in multi-bot threads + /// (`use_streaming` = `!other_bot_present`) and gets the same default trimming. Set `true` to + /// stream live and keep the full inter-tool text. #[serde(default)] pub streaming: bool, /// Show "…" placeholder at streaming start. Default: true. Set false for platforms using drafts. @@ -655,6 +671,20 @@ pub struct ReactionsConfig { pub remove_after_reply: bool, #[serde(default)] pub tool_display: ToolDisplay, + /// Whether to include the agent's inter-tool narration ("let me pull the + /// diff", "now reading X") in send-once replies. Default `false` — a + /// send-once turn delivers **only the final answer block** (the text after + /// the last tool call), so the message reads like the single composed + /// artefact a tool-posted comment is. Set `true` to keep the full text. + /// + /// Platform-agnostic (sits beside `tool_display`): the trimming lives in the + /// shared adapter layer and applies to every send-once turn — Slack + /// `streaming=false`, Slack/Discord multi-bot threads, and gateway. Only + /// affects send-once; live streaming always shows the text as produced. + /// Orthogonal to `streaming`, which is the per-platform stream-vs-send-once + /// switch. + #[serde(default)] + pub narration_display: bool, #[serde(default)] pub emojis: ReactionEmojis, #[serde(default)] @@ -786,6 +816,7 @@ impl Default for ReactionsConfig { enabled: true, remove_after_reply: false, tool_display: ToolDisplay::default(), + narration_display: false, emojis: ReactionEmojis::default(), timing: ReactionTiming::default(), } diff --git a/src/main.rs b/src/main.rs index 600028368..e9de402ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -250,6 +250,7 @@ async fn main() -> anyhow::Result<()> { s.allow_bot_messages, s.assistant_mode, multibot_cache.clone(), + s.streaming, )) }); diff --git a/src/slack.rs b/src/slack.rs index 94fdcf0a1..cc5c77abf 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -81,6 +81,12 @@ pub struct SlackAdapter { session_ttl: std::time::Duration, /// Assistant mode: stream via chat.startStream + assistant.threads.setStatus. assistant_mode: bool, + /// Master streaming switch. When false, the adapter always posts a single + /// final message (send-once): no native streaming, no post+edit placeholder. + /// Default true. Set false to avoid streamed-message edit states (e.g. a + /// reply that @-mentions another bot re-firing app_mention in multi-agent + /// threads). Mirrors `[gateway] streaming`. + streaming: bool, /// streaming message ts → state. active=false = degraded (post+edit fallback). /// Lifecycle: stream_begin inserts, stream_finish removes; insert_stream /// bounds the map (STREAM_CACHE_MAX) as a safety net against aborted turns. @@ -94,6 +100,7 @@ impl SlackAdapter { _allow_bot_messages: AllowBots, assistant_mode: bool, multibot_cache: crate::multibot_cache::MultibotCache, + streaming: bool, ) -> Self { Self { // Bound every Slack Web API call; an unbounded inline gating call in the @@ -111,6 +118,7 @@ impl SlackAdapter { multibot_cache, session_ttl, assistant_mode, + streaming, streams: tokio::sync::Mutex::new(HashMap::new()), } } @@ -533,7 +541,7 @@ impl ChatAdapter for SlackAdapter { } fn use_streaming(&self, other_bot_present: bool) -> bool { - !other_bot_present + self.streaming && !other_bot_present } fn renders_native_tables(&self) -> bool { @@ -545,8 +553,9 @@ impl ChatAdapter for SlackAdapter { } fn uses_native_streaming(&self, other_bot_present: bool) -> bool { - let native = self.assistant_mode && !other_bot_present; + let native = self.streaming && self.assistant_mode && !other_bot_present; debug!( + streaming = self.streaming, assistant_mode = self.assistant_mode, other_bot_present, native, @@ -1840,7 +1849,7 @@ mod tests { #[tokio::test] async fn degraded_stream_append_accumulates() { - let adapter = SlackAdapter::new("xoxb-test".into(), std::time::Duration::from_secs(60), AllowBots::Off, true, crate::multibot_cache::MultibotCache::load("/dev/null".into())); + let adapter = SlackAdapter::new("xoxb-test".into(), std::time::Duration::from_secs(60), AllowBots::Off, true, crate::multibot_cache::MultibotCache::load("/dev/null".into()), true); adapter.streams.lock().await.insert( "TS".into(), StreamEntry { active: false, degraded_buf: String::new() }, @@ -2149,7 +2158,7 @@ mod tests { #[test] fn streaming_per_thread() { let ttl = std::time::Duration::from_secs(300); - let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Mentions, false, crate::multibot_cache::MultibotCache::load("/dev/null".into())); + let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Mentions, false, crate::multibot_cache::MultibotCache::load("/dev/null".into()), true); assert!( adapter.use_streaming(false), @@ -2166,16 +2175,22 @@ mod tests { let ttl = std::time::Duration::from_secs(60); // assistant_mode=true → status API on; native streaming on (no other bot), // off when another bot is present; post+edit streaming on regardless. - let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Off, true, crate::multibot_cache::MultibotCache::load("/dev/null".into())); + let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Off, true, crate::multibot_cache::MultibotCache::load("/dev/null".into()), true); assert!(adapter.uses_assistant_status(), "assistant_mode enables status API"); assert!(adapter.use_streaming(false), "post+edit streaming on when no other bot"); assert!(adapter.uses_native_streaming(false), "native streaming on when no other bot"); assert!(!adapter.uses_native_streaming(true), "other bot present disables native"); // assistant_mode=false → no status API, no native streaming; post+edit still streams. - let adapter2 = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Off, false, crate::multibot_cache::MultibotCache::load("/dev/null".into())); + let adapter2 = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Off, false, crate::multibot_cache::MultibotCache::load("/dev/null".into()), true); assert!(!adapter2.uses_assistant_status()); assert!(adapter2.use_streaming(false), "post+edit streaming independent of assistant_mode"); assert!(!adapter2.uses_native_streaming(false), "native streaming requires assistant_mode"); + + // streaming=false → send-once: neither post+edit nor native, even alone. + let adapter3 = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Off, true, crate::multibot_cache::MultibotCache::load("/dev/null".into()), false); + 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"); } /// chat.postMessage body carries Block Kit `markdown` blocks with the raw @@ -2229,7 +2244,7 @@ mod tests { #[test] fn typical_long_table_stays_in_one_chunk() { let ttl = std::time::Duration::from_secs(300); - let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Mentions, true, crate::multibot_cache::MultibotCache::load("/dev/null".into())); + let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Mentions, true, crate::multibot_cache::MultibotCache::load("/dev/null".into()), true); let limit = adapter.message_limit(); assert_eq!(limit, MARKDOWN_BLOCK_LIMIT); let mut table = String::from("| col a | col b | col c |\n|---|---|---|\n"); @@ -2291,7 +2306,7 @@ mod tests { #[test] fn slack_renders_native_tables() { let ttl = std::time::Duration::from_secs(300); - let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Mentions, true, crate::multibot_cache::MultibotCache::load("/dev/null".into())); + let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Mentions, true, crate::multibot_cache::MultibotCache::load("/dev/null".into()), true); assert!(adapter.renders_native_tables()); } }