Skip to main content

forge_workspace/mcp/
slack.rs

1//! Slack MCP - read the conversations the caller's workspace offers
2//! (`mcp__forge__slack__*`).
3//!
4//! No tool takes a `workspace` argument: the calling session's slot names
5//! its project, and `forge.toml`'s org or project binding names that
6//! project's workspace. `slack__list` lists every conversation in it and
7//! marks which ones the caller has subscribed; `slack__subscribe` and
8//! `slack__unsubscribe` manage those records. A subscription is live: the
9//! per-workspace poll pump sweeps it and delivers matches into the
10//! owning session.
11//!
12//! - [`facade`] - the `SlackFacade` seam (prod over `Weak<Workspace>` +
13//!   a mock for tool tests).
14
15pub(crate) mod facade;
16
17use std::sync::Arc;
18
19use forge_connectors::slack::SlackHistoryMessage;
20use forge_primitives::slack::{
21    SlackConversation, SlackSubscription, SlackSubscriptionTarget, SlackWatchMode,
22};
23use forge_sdk::mcp::server::McpServerBuilder;
24use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput};
25use uuid::Uuid;
26
27use crate::SessionSlot;
28use crate::mcp::slack::facade::{
29    SlackAttachmentError, SlackChannelWatch, SlackEditError, SlackEditRequest, SlackFacade,
30    SlackFetchRequest, SlackPostError, SlackPostRequest, SlackReactError, SlackReactRequest,
31    SlackReadError, SlackSubscribeError, SlackSubscribeRequest, SlackUnsubscribeError,
32    SlackUploadRequest, format_workspace_error,
33};
34
35/// Attach the Slack tools to an existing [`McpServerBuilder`]. Called for
36/// BOTH lead and worker sessions (any-caller), so `build_forge_server`
37/// invokes this unconditionally. `subscribe` / `unsubscribe` are scoped
38/// to the caller's own records, so they need the caller slot.
39pub(crate) fn add_tools(
40    builder: McpServerBuilder,
41    facade: Arc<dyn SlackFacade>,
42    slot: SessionSlot,
43) -> McpServerBuilder {
44    let list = List { facade: facade.clone(), slot: slot.clone() };
45    let subscribe = Subscribe { facade: facade.clone(), slot: slot.clone() };
46    let unsubscribe = Unsubscribe { facade: facade.clone(), slot: slot.clone() };
47    let post = Post { facade: facade.clone(), slot: slot.clone() };
48    let edit = Edit { facade: facade.clone(), slot: slot.clone() };
49    let react = React { facade: facade.clone(), slot: slot.clone() };
50    let attachment = Attachment { facade: facade.clone(), slot: slot.clone() };
51    let search = Search { facade: facade.clone(), slot: slot.clone() };
52    let history = History { facade: facade.clone(), slot: slot.clone() };
53    let replies = Replies { facade: facade.clone(), slot: slot.clone() };
54    let user = User { facade: facade.clone(), slot: slot.clone() };
55    let users = Users { facade: facade.clone(), slot: slot.clone() };
56    let me = Me { facade: facade.clone(), slot: slot.clone() };
57    let pins = Pins { facade: facade.clone(), slot: slot.clone() };
58    let bookmarks = Bookmarks { facade, slot };
59    builder
60        .tool(list)
61        .tool(subscribe)
62        .tool(unsubscribe)
63        .tool(post)
64        .tool(edit)
65        .tool(react)
66        .tool(attachment)
67        .tool(search)
68        .tool(history)
69        .tool(replies)
70        .tool(user)
71        .tool(users)
72        .tool(me)
73        .tool(pins)
74        .tool(bookmarks)
75}
76
77fn tool_error(text: String) -> ToolOutput {
78    ToolOutput::error(text)
79}
80
81/// The conversation's name, or the DM partner's user id when Slack omits
82/// one - a DM's display name is otherwise empty.
83fn conversation_name(conversation: &SlackConversation) -> String {
84    conversation.name.clone().or_else(|| conversation.user.clone()).unwrap_or_default()
85}
86
87fn conversation_kind(conversation: &SlackConversation) -> &'static str {
88    if conversation.is_im {
89        "im"
90    } else if conversation.is_mpim {
91        "mpim"
92    } else if conversation.is_private {
93        "private"
94    } else {
95        "public"
96    }
97}
98
99/// The caller's subscription ids that cover `conversation` - after a
100/// restart these are the only handle a session has to unsubscribe with.
101fn covering_subscription_ids(
102    conversation: &SlackConversation,
103    subscribed: &[SlackSubscription],
104) -> Vec<Uuid> {
105    subscribed
106        .iter()
107        .filter(|sub| match &sub.target {
108            SlackSubscriptionTarget::DirectMessages => conversation.is_im || conversation.is_mpim,
109            SlackSubscriptionTarget::Conversation { id, .. } => *id == conversation.id,
110            // A mention target is not a conversation subscription, so it
111            // marks no row.
112            SlackSubscriptionTarget::Mentions => false,
113        })
114        .map(|sub| sub.id)
115        .collect()
116}
117
118/// A conversation is watched when a target names its id, or - for a DM
119/// or group DM - when the caller watches the whole DM class.
120fn is_subscribed(conversation: &SlackConversation, subscribed: &[SlackSubscriptionTarget]) -> bool {
121    subscribed.iter().any(|target| match target {
122        SlackSubscriptionTarget::DirectMessages => conversation.is_im || conversation.is_mpim,
123        SlackSubscriptionTarget::Conversation { id, .. } => id == &conversation.id,
124        // A mention target is not a conversation subscription, so it
125        // marks no row.
126        SlackSubscriptionTarget::Mentions => false,
127    })
128}
129
130/// One row per conversation, marking the ones in `subscribed` and
131/// listing the caller's own subscription ids that cover it.
132fn list_rows(
133    conversations: &[SlackConversation],
134    subscribed: &[SlackSubscriptionTarget],
135    caller_subscriptions: &[SlackSubscription],
136) -> Vec<serde_json::Value> {
137    conversations
138        .iter()
139        .map(|conversation| {
140            serde_json::json!({
141                "id": conversation.id,
142                "name": conversation_name(conversation),
143                "kind": conversation_kind(conversation),
144                "subscribed": is_subscribed(conversation, subscribed),
145                "subscription_ids": covering_subscription_ids(conversation, caller_subscriptions)
146                    .iter()
147                    .map(std::string::ToString::to_string)
148                    .collect::<Vec<_>>(),
149            })
150        })
151        .collect()
152}
153
154/// The caller's own class subscriptions - the DM class and the mention
155/// target. Neither covers one conversation, so no row can carry them, and
156/// their ids are the only handle `slack__unsubscribe` accepts. The target
157/// names match the ones the Inspector's SLACK section shows.
158fn class_subscriptions(subscribed: &[SlackSubscription]) -> Vec<serde_json::Value> {
159    subscribed
160        .iter()
161        .filter_map(|sub| {
162            let target = match &sub.target {
163                SlackSubscriptionTarget::DirectMessages => "direct messages",
164                SlackSubscriptionTarget::Mentions => "mentions anywhere",
165                SlackSubscriptionTarget::Conversation { .. } => return None,
166            };
167            Some(serde_json::json!({
168                "id": sub.id.to_string(),
169                "workspace": sub.workspace,
170                "target": target,
171            }))
172        })
173        .collect()
174}
175
176/// Whether one conversation passes `slack__list`'s optional filters: a
177/// case-insensitive substring over the display name, purpose and topic,
178/// and an exact kind match.
179fn passes_filter(conversation: &SlackConversation, name: Option<&str>, kind: Option<&str>) -> bool {
180    if let Some(kind) = kind
181        && conversation_kind(conversation) != kind
182    {
183        return false;
184    }
185    let Some(needle) = name else { return true };
186    let needle = needle.to_lowercase();
187    [conversation_name(conversation).to_lowercase()]
188        .into_iter()
189        .chain(conversation.purpose.iter().map(|p| p.value.to_lowercase()))
190        .chain(conversation.topic.iter().map(|t| t.value.to_lowercase()))
191        .any(|haystack| haystack.contains(&needle))
192}
193
194struct List {
195    facade: Arc<dyn SlackFacade>,
196    slot: SessionSlot,
197}
198
199#[derive(serde::Deserialize)]
200struct ListArgs {
201    #[serde(default)]
202    name: Option<String>,
203    #[serde(default)]
204    kind: Option<String>,
205}
206
207/// The kinds `kind` accepts, matching what each row reports.
208const LIST_KINDS: &str = "public, private, im, mpim";
209
210#[async_trait::async_trait]
211impl Tool for List {
212    fn name(&self) -> &'static str {
213        "slack__list"
214    }
215
216    fn description(&self) -> &'static str {
217        "List every conversation in the Slack workspace bound to your project - public and \
218         private channels, DMs and group DMs the token's user is a member of - each row marked \
219         with whether YOU are subscribed, counting only your own subscriptions rather than \
220         another session's. Pass `name` for only conversations whose name, purpose or topic \
221         contains that text (case-insensitive), or `kind` for only one conversation type. \
222         Returns a JSON object of three keys. `workspace` is the resolved `[[slack]]` label; \
223         `conversations` is an array of {id, name, kind, subscribed, subscription_ids}; \
224         `subscription_ids` lists your own ids covering that conversation, which is what \
225         slack__unsubscribe takes. `subscriptions` lists your class subscriptions - the DM \
226         class, and the workspace mention target with its id - since neither covers one \
227         conversation, so no row can carry them. Any session in the project may call this."
228    }
229
230    fn input_schema(&self) -> serde_json::Value {
231        serde_json::json!({
232            "type": "object",
233            "properties": {
234                "name": {
235                    "type": "string",
236                    "description": "Only conversations whose name, purpose or topic contains \
237                                    this text (case-insensitive).",
238                },
239                "kind": {
240                    "type": "string",
241                    "enum": ["public", "private", "im", "mpim"],
242                    "description": "Only conversations of this kind, matching the `kind` field \
243                                    in each row.",
244                },
245            },
246            "additionalProperties": false,
247        })
248    }
249
250    async fn call(&self, input: ToolInput) -> ToolOutput {
251        let args: ListArgs = match serde_json::from_value(input.value) {
252            Ok(args) => args,
253            Err(err) => return tool_error(format!("invalid arguments: {err}")),
254        };
255        if let Some(kind) = args.kind.as_deref()
256            && !matches!(kind, "public" | "private" | "im" | "mpim")
257        {
258            return tool_error(format!("unknown kind '{kind}'; the kinds are {LIST_KINDS}"));
259        }
260        match self.facade.conversations(&self.slot).await {
261            Ok((workspace, conversations)) => {
262                let own = self.facade.subscribed_targets(&self.slot, &workspace);
263                let subscribed: Vec<SlackSubscriptionTarget> =
264                    own.iter().map(|sub| sub.target.clone()).collect();
265                let kept: Vec<SlackConversation> = conversations
266                    .iter()
267                    .filter(|conversation| {
268                        passes_filter(conversation, args.name.as_deref(), args.kind.as_deref())
269                    })
270                    .cloned()
271                    .collect();
272                let rows = list_rows(&kept, &subscribed, &own);
273                let listed = serde_json::json!({
274                    "workspace": workspace,
275                    "conversations": rows,
276                    "subscriptions": class_subscriptions(&own),
277                });
278                match serde_json::to_string_pretty(&listed) {
279                    Ok(json) => ToolOutput::text(json),
280                    Err(err) => {
281                        tool_error(format!("conversation-list serialization failed: {err}"))
282                    }
283                }
284            }
285            Err(err) => tool_error(format_read_error(&err)),
286        }
287    }
288}
289
290/// One subscription as a tool-result row: its id (the only handle
291/// `slack__unsubscribe` accepts), the workspace it watches, and its target
292/// in a shape a reader can switch on.
293fn sub_to_row(sub: &SlackSubscription) -> serde_json::Value {
294    serde_json::json!({
295        "id": sub.id.to_string(),
296        "workspace": sub.workspace,
297        "target": target_to_json(&sub.target),
298    })
299}
300
301/// The subscription target's tool-output spelling. The durable enum's own
302/// serde form is externally tagged and unreadable (`"DirectMessages"`, or
303/// `{"Conversation": {..}}`), so results carry this instead; `name` is
304/// absent until the sweep's directory walk has seen the conversation.
305fn target_to_json(target: &SlackSubscriptionTarget) -> serde_json::Value {
306    match target {
307        SlackSubscriptionTarget::DirectMessages => serde_json::json!({ "kind": "dm" }),
308        SlackSubscriptionTarget::Mentions => serde_json::json!({ "kind": "mentions" }),
309        SlackSubscriptionTarget::Conversation { id, name, mode } => {
310            let mut map = serde_json::Map::new();
311            map.insert("kind".to_owned(), serde_json::json!("conversation"));
312            map.insert("id".to_owned(), serde_json::json!(id));
313            if let Some(name) = name {
314                map.insert("name".to_owned(), serde_json::json!(name));
315            }
316            map.insert(
317                "mode".to_owned(),
318                serde_json::json!(match mode {
319                    SlackWatchMode::All => "all",
320                    SlackWatchMode::MentionsOnly => "mentions",
321                }),
322            );
323            serde_json::Value::Object(map)
324        }
325    }
326}
327
328/// The mode a caller writes in a `slack__subscribe` argument.
329#[derive(serde::Deserialize, Default, Clone, Copy)]
330#[serde(rename_all = "snake_case")]
331enum WatchModeArg {
332    #[default]
333    All,
334    Mentions,
335}
336
337impl From<WatchModeArg> for SlackWatchMode {
338    fn from(arg: WatchModeArg) -> Self {
339        match arg {
340            WatchModeArg::All => Self::All,
341            WatchModeArg::Mentions => Self::MentionsOnly,
342        }
343    }
344}
345
346#[derive(serde::Deserialize)]
347struct ChannelWatchArg {
348    id: String,
349    #[serde(default)]
350    mode: WatchModeArg,
351}
352
353fn format_subscribe_error(err: &SlackSubscribeError) -> String {
354    match err {
355        SlackSubscribeError::Workspace(err) => format_workspace_error(err),
356        SlackSubscribeError::UnknownCallerProject => {
357            "couldn't resolve your project; is this session attached to a forge.toml project?"
358                .to_owned()
359        }
360    }
361}
362
363struct Subscribe {
364    facade: Arc<dyn SlackFacade>,
365    slot: SessionSlot,
366}
367
368#[derive(serde::Deserialize)]
369struct SubscribeArgs {
370    #[serde(default)]
371    direct_messages: Option<bool>,
372    #[serde(default)]
373    mentions: Option<bool>,
374    #[serde(default)]
375    conversations: Option<Vec<ChannelWatchArg>>,
376}
377
378#[async_trait::async_trait]
379impl Tool for Subscribe {
380    fn name(&self) -> &'static str {
381        "slack__subscribe"
382    }
383
384    fn description(&self) -> &'static str {
385        "Subscribe YOUR session to a Slack workspace: the whole DM class, being mentioned \
386         anywhere in the workspace, or named conversations with a mode. A mention subscription \
387         sees mentions in public channels you are not in as well as in every conversation you \
388         are, but not in private channels you are not in - those you could not read anyway, and \
389         search lag means a mention is not instantaneous. A conversation in `mentions` mode \
390         delivers only messages that mention the user; `all` delivers every message. Returns \
391         one row per subscription as {id, workspace, target}, where target is \
392         {kind: \"conversation\", \
393         id, name, mode} | {kind: \"dm\"} | {kind: \"mentions\"}; the id is the only handle \
394         slack__unsubscribe accepts. A conversation already watched hands back that record \
395         rather than creating a second one. Any session in the project may call this."
396    }
397
398    fn input_schema(&self) -> serde_json::Value {
399        serde_json::json!({
400            "type": "object",
401            "properties": {
402                "direct_messages": {
403                    "type": "boolean",
404                    "description": "Watch every DM in the workspace, group DMs included.",
405                },
406                "mentions": {
407                    "type": "boolean",
408                    "description": "Watch for being mentioned anywhere in the workspace, including \
409                                    public channels you are not in. Not private channels you are \
410                                    not in.",
411                },
412                "conversations": {
413                    "type": "array",
414                    "description": "Conversations to watch, each with its own mode.",
415                    "items": {
416                        "type": "object",
417                        "properties": {
418                            "id": {
419                                "type": "string",
420                                "description": "The conversation id from slack__list.",
421                            },
422                            "mode": {
423                                "type": "string",
424                                "enum": ["all", "mentions"],
425                                "description": "`all` delivers every message; `mentions` only \
426                                                those that mention the user. Defaults to `all`.",
427                            },
428                        },
429                        "required": ["id"],
430                        "additionalProperties": false,
431                    },
432                },
433            },
434            "additionalProperties": false,
435        })
436    }
437
438    async fn call(&self, input: ToolInput) -> ToolOutput {
439        let args: SubscribeArgs = match serde_json::from_value(input.value) {
440            Ok(args) => args,
441            Err(err) => return tool_error(format!("invalid arguments: {err}")),
442        };
443        let mut requests = Vec::new();
444        if args.direct_messages.unwrap_or(false) {
445            requests.push(SlackSubscribeRequest::DirectMessages);
446        }
447        if args.mentions.unwrap_or(false) {
448            requests.push(SlackSubscribeRequest::Mentions);
449        }
450        if let Some(watches) = args.conversations.filter(|watches| !watches.is_empty()) {
451            requests.push(SlackSubscribeRequest::Conversations(
452                watches
453                    .into_iter()
454                    .map(|watch| SlackChannelWatch { id: watch.id, mode: watch.mode.into() })
455                    .collect(),
456            ));
457        }
458        if requests.is_empty() {
459            return tool_error(
460                "pass `direct_messages: true`, `mentions: true`, or a non-empty `conversations` \
461                 array"
462                    .to_owned(),
463            );
464        }
465        let mut rows: Vec<serde_json::Value> = Vec::new();
466        for request in requests {
467            match self.facade.subscribe(&self.slot, request) {
468                Ok(created) => rows.extend(created.iter().map(sub_to_row)),
469                Err(err) => {
470                    return tool_error(format_subscribe_error(&err));
471                }
472            }
473        }
474        match serde_json::to_string_pretty(&serde_json::Value::Array(rows)) {
475            Ok(json) => ToolOutput::text(json),
476            Err(err) => tool_error(format!("response serialization failed: {err}")),
477        }
478    }
479}
480
481struct Unsubscribe {
482    facade: Arc<dyn SlackFacade>,
483    slot: SessionSlot,
484}
485
486#[derive(serde::Deserialize)]
487struct UnsubscribeArgs {
488    id: String,
489}
490
491#[async_trait::async_trait]
492impl Tool for Unsubscribe {
493    fn name(&self) -> &'static str {
494        "slack__unsubscribe"
495    }
496
497    fn description(&self) -> &'static str {
498        "Remove one of YOUR OWN Slack subscriptions by id (from slack__subscribe / \
499         slack__list), scoped to what you subscribed - a caller manages only what it created. \
500         Returns the removed subscription row. Any session in the project may call this."
501    }
502
503    fn input_schema(&self) -> serde_json::Value {
504        serde_json::json!({
505            "type": "object",
506            "properties": {
507                "id": { "type": "string", "description": "The subscription id to remove." },
508            },
509            "required": ["id"],
510            "additionalProperties": false,
511        })
512    }
513
514    async fn call(&self, input: ToolInput) -> ToolOutput {
515        let args: UnsubscribeArgs = match serde_json::from_value(input.value) {
516            Ok(args) => args,
517            Err(err) => return tool_error(format!("invalid arguments: {err}")),
518        };
519        let Ok(id) = Uuid::parse_str(&args.id) else {
520            return tool_error(format!("not a valid subscription id: {}", args.id));
521        };
522        match self.facade.unsubscribe(&self.slot, id) {
523            Ok(removed) => {
524                let envelope = crate::mcp::deleted::removed_record(&sub_to_row(&removed));
525                match serde_json::to_string_pretty(&envelope) {
526                    Ok(json) => ToolOutput::text(json),
527                    Err(err) => tool_error(format!("response serialization failed: {err}")),
528                }
529            }
530            Err(SlackUnsubscribeError::UnknownCallerProject) => {
531                tool_error("couldn't resolve your project".to_owned())
532            }
533            Err(SlackUnsubscribeError::NoSuchSubscription) => {
534                tool_error(format!("no subscription with id {id} in this project"))
535            }
536            Err(SlackUnsubscribeError::NotOwnedByCaller) => tool_error(format!(
537                "subscription {id} exists in this project but was not subscribed by you"
538            )),
539        }
540    }
541}
542
543/// What a blocked call reads when the reader sends its draft back. The note
544/// is the reader's own words and stays whole; the fixed halves say what did
545/// not happen and what the agent is expected to do next.
546fn sent_back_refusal(outcome: &str, tool: &str, note: &str) -> String {
547    format!(
548        "{outcome}: the reader sent it back with this feedback and expects a revision.\n\n\
549         {note}\n\nRevise it and call {tool} again."
550    )
551}
552
553fn format_post_error(err: &SlackPostError) -> String {
554    match err {
555        SlackPostError::Rejected => "the post was not approved, so nothing was sent".to_owned(),
556        SlackPostError::SentBack { note } => {
557            sent_back_refusal("the post was not sent", "slack__post", note)
558        }
559        SlackPostError::Workspace(err) => format_workspace_error(err),
560        SlackPostError::Partial { posted, total, source } => {
561            format!("posted part {posted} of {total}; the rest were NOT sent ({source})")
562        }
563        SlackPostError::Fetch(message) => format!("Slack request failed: {message}"),
564    }
565}
566
567fn format_edit_error(err: &SlackEditError) -> String {
568    match err {
569        SlackEditError::NotOwnMessage => {
570            "that message was not posted by you, so Slack will not let you change it".to_owned()
571        }
572        SlackEditError::Rejected => {
573            "the replacement was not approved, so the message is untouched".to_owned()
574        }
575        SlackEditError::SentBack { note } => {
576            sent_back_refusal("the change was not made", "slack__edit", note)
577        }
578        SlackEditError::Workspace(err) => format_workspace_error(err),
579        SlackEditError::Fetch(message) => format!("Slack request failed: {message}"),
580    }
581}
582
583fn format_react_error(err: &SlackReactError) -> String {
584    match err {
585        SlackReactError::Rejected => {
586            "the reaction was not approved, so nothing was added or removed".to_owned()
587        }
588        SlackReactError::SentBack { note } => {
589            sent_back_refusal("the reaction was not applied", "slack__react", note)
590        }
591        SlackReactError::Workspace(err) => format_workspace_error(err),
592        SlackReactError::Fetch(message) => format!("Slack request failed: {message}"),
593    }
594}
595
596struct Post {
597    facade: Arc<dyn SlackFacade>,
598    slot: SessionSlot,
599}
600
601#[derive(serde::Deserialize)]
602struct PostArgs {
603    conversation: String,
604    #[serde(default)]
605    thread_ts: Option<String>,
606    text: String,
607}
608
609#[async_trait::async_trait]
610impl Tool for Post {
611    fn name(&self) -> &'static str {
612        "slack__post"
613    }
614
615    fn description(&self) -> &'static str {
616        "Post a message to Slack as the user - a root message, or a reply into an existing \
617         thread when `thread_ts` is passed. This is HELD FOR APPROVAL: the call does not return \
618         until the user decides in the dock prompt, with no timeout of its own, and a rejected \
619         draft posts nothing. The user may instead send the draft back with feedback: the call \
620         fails with their words and nothing is posted, so revise the draft and call again. Text \
621         past 4000 characters is split into numbered parts automatically, each a \
622         separate message. Returns {workspace, ts, conversation_name, parts}: `workspace` is \
623         the resolved `[[slack]]` label it posted into, and `ts` names every message it posted, \
624         in the order they went out; slack__edit, slack__react and a reply each take one. Any \
625         session in the project may call this. If this call fails for any other reason, report \
626         the exact error and stop - never fall back to Slack's web API with the configured token."
627    }
628
629    fn input_schema(&self) -> serde_json::Value {
630        serde_json::json!({
631            "type": "object",
632            "properties": {
633                "conversation": {
634                    "type": "string",
635                    "description": "The conversation id from slack__list, or a user id to open \
636                                    a DM with.",
637                },
638                "thread_ts": {
639                    "type": "string",
640                    "description": "The parent message's ts to reply into that thread. Omit to \
641                                    post a root message.",
642                },
643                "text": { "type": "string", "description": "The message body." },
644            },
645            "required": ["conversation", "text"],
646            "additionalProperties": false,
647        })
648    }
649
650    async fn call(&self, input: ToolInput) -> ToolOutput {
651        let args: PostArgs = match serde_json::from_value(input.value) {
652            Ok(args) => args,
653            Err(err) => return tool_error(format!("invalid arguments: {err}")),
654        };
655        let request = SlackPostRequest {
656            conversation: args.conversation,
657            thread_ts: args.thread_ts,
658            text: args.text,
659        };
660        match self.facade.post(&self.slot, request).await {
661            Ok(outcome) => match serde_json::to_string_pretty(&serde_json::json!({
662                "workspace": outcome.workspace,
663                "ts": outcome.ts,
664                "conversation_name": outcome.conversation_name,
665                "parts": outcome.parts,
666            })) {
667                Ok(json) => ToolOutput::text(json),
668                Err(err) => tool_error(format!("response serialization failed: {err}")),
669            },
670            Err(err) => tool_error(format_post_error(&err)),
671        }
672    }
673}
674
675struct Edit {
676    facade: Arc<dyn SlackFacade>,
677    slot: SessionSlot,
678}
679
680#[derive(serde::Deserialize)]
681struct EditArgs {
682    conversation: String,
683    ts: String,
684    #[serde(default)]
685    delete: Option<bool>,
686    #[serde(default)]
687    text: Option<String>,
688}
689
690#[async_trait::async_trait]
691impl Tool for Edit {
692    fn name(&self) -> &'static str {
693        "slack__edit"
694    }
695
696    fn description(&self) -> &'static str {
697        "Update or delete one of YOUR OWN Slack messages: pass `text` to replace its body, or \
698         `delete: true` to remove it. BOTH ARE HELD FOR APPROVAL like slack__post - a \
699         replacement puts new words in front of people as you, and a deletion changes what they \
700         see on a message attributed to you and cannot be undone by editing again. The call does \
701         not return until the user decides, with no timeout of its own, and a rejected draft \
702         leaves the message untouched. The user may instead send the draft back with feedback: \
703         the call fails with their words and the message is untouched, so revise the change and \
704         call again. Slack refuses a message you did not post - including an edit of \
705         your own thread reply, which a channel-history lookup cannot see. Any session in \
706         the project may call this."
707    }
708
709    fn input_schema(&self) -> serde_json::Value {
710        serde_json::json!({
711            "type": "object",
712            "properties": {
713                "conversation": { "type": "string", "description": "The conversation id." },
714                "ts": { "type": "string", "description": "The message's ts." },
715                "text": {
716                    "type": "string",
717                    "description": "The replacement body. Omit when deleting.",
718                },
719                "delete": {
720                    "type": "boolean",
721                    "description": "Set true to delete the message instead of updating it.",
722                },
723            },
724            "required": ["conversation", "ts"],
725            "additionalProperties": false,
726        })
727    }
728
729    async fn call(&self, input: ToolInput) -> ToolOutput {
730        let args: EditArgs = match serde_json::from_value(input.value) {
731            Ok(args) => args,
732            Err(err) => return tool_error(format!("invalid arguments: {err}")),
733        };
734        let delete = args.delete.unwrap_or(false);
735        if !delete && args.text.is_none() {
736            return tool_error("pass `text` to update the message, or `delete: true`".to_owned());
737        }
738        let request = SlackEditRequest {
739            conversation: args.conversation,
740            ts: args.ts,
741            text: if delete { None } else { args.text },
742        };
743        match self.facade.edit(&self.slot, request).await {
744            Ok(()) => {
745                ToolOutput::text(if delete { "deleted".to_owned() } else { "updated".to_owned() })
746            }
747            Err(err) => tool_error(format_edit_error(&err)),
748        }
749    }
750}
751
752struct React {
753    facade: Arc<dyn SlackFacade>,
754    slot: SessionSlot,
755}
756
757#[derive(serde::Deserialize)]
758struct ReactArgs {
759    conversation: String,
760    ts: String,
761    name: String,
762    #[serde(default)]
763    remove: Option<bool>,
764}
765
766#[async_trait::async_trait]
767impl Tool for React {
768    fn name(&self) -> &'static str {
769        "slack__react"
770    }
771
772    fn description(&self) -> &'static str {
773        "Add a reaction to a Slack message, or remove one with `remove: true`. `name` is Slack's \
774         shortcode without colons, e.g. `white_check_mark`. Removing requires being the original \
775         reaction's author. HELD FOR APPROVAL like slack__post: a reaction is authored content \
776         in the user's name. The user may instead send the draft back with feedback: the call \
777         fails with their words and nothing is added or removed, so revise the reaction and \
778         call again. Any session in the project may call this."
779    }
780
781    fn input_schema(&self) -> serde_json::Value {
782        serde_json::json!({
783            "type": "object",
784            "properties": {
785                "conversation": { "type": "string", "description": "The conversation id." },
786                "ts": { "type": "string", "description": "The message's ts." },
787                "name": {
788                    "type": "string",
789                    "description": "Slack reaction shortcode without colons, e.g. \
790                                    `white_check_mark`.",
791                },
792                "remove": {
793                    "type": "boolean",
794                    "description": "Set true to remove the reaction instead of adding it.",
795                },
796            },
797            "required": ["conversation", "ts", "name"],
798            "additionalProperties": false,
799        })
800    }
801
802    async fn call(&self, input: ToolInput) -> ToolOutput {
803        let args: ReactArgs = match serde_json::from_value(input.value) {
804            Ok(args) => args,
805            Err(err) => return tool_error(format!("invalid arguments: {err}")),
806        };
807        let request = SlackReactRequest {
808            conversation: args.conversation,
809            ts: args.ts,
810            name: args.name,
811            add: !args.remove.unwrap_or(false),
812        };
813        match self.facade.react(&self.slot, request).await {
814            Ok(()) => ToolOutput::text("reaction applied".to_owned()),
815            Err(err) => tool_error(format_react_error(&err)),
816        }
817    }
818}
819
820fn format_attachment_error(err: &SlackAttachmentError) -> String {
821    match err {
822        SlackAttachmentError::Io(message) => format!("file error: {message}"),
823        SlackAttachmentError::Rejected => {
824            "the upload was not approved, so nothing was sent".to_owned()
825        }
826        SlackAttachmentError::SentBack { note } => {
827            sent_back_refusal("the upload was not sent", "slack__attachment", note)
828        }
829        SlackAttachmentError::Workspace(err) => format_workspace_error(err),
830        SlackAttachmentError::Fetch(message) => format!("Slack request failed: {message}"),
831        SlackAttachmentError::NotADirectory(path) => {
832            format!("`dir` is a file, not a directory: {}", path.display())
833        }
834    }
835}
836
837struct Attachment {
838    facade: Arc<dyn SlackFacade>,
839    slot: SessionSlot,
840}
841
842#[derive(serde::Deserialize)]
843struct AttachmentArgs {
844    #[serde(default)]
845    file_id: Option<String>,
846    #[serde(default)]
847    dir: Option<String>,
848    #[serde(default)]
849    path: Option<String>,
850    #[serde(default)]
851    conversation: Option<String>,
852    #[serde(default)]
853    thread_ts: Option<String>,
854}
855
856#[async_trait::async_trait]
857impl Tool for Attachment {
858    fn name(&self) -> &'static str {
859        "slack__attachment"
860    }
861
862    fn description(&self) -> &'static str {
863        "Move a file between Slack and the local machine. Pass `file_id` with `dir` to fetch a \
864         file, and it lands in that directory under the uploader's file name (sanitised, so it \
865         cannot escape the directory); the returned path is where it landed. Pass `path` with \
866         `conversation` to upload a local file into that conversation, optionally into a thread \
867         with `thread_ts`. An upload is HELD FOR APPROVAL like slack__post, because it posts new \
868         content; fetching is not. The user may instead send the draft back with feedback: the \
869         call fails with their words and nothing is uploaded, so revise the file or the message \
870         and call again. Any session in the project may call this."
871    }
872
873    fn input_schema(&self) -> serde_json::Value {
874        serde_json::json!({
875            "type": "object",
876            "properties": {
877                "file_id": {
878                    "type": "string",
879                    "description": "The Slack file id to fetch. Pair it with `dir`.",
880                },
881                "dir": {
882                    "type": "string",
883                    "description": "The local directory a fetched file lands in.",
884                },
885                "path": {
886                    "type": "string",
887                    "description": "The local file to upload. Pair it with `conversation`.",
888                },
889                "conversation": {
890                    "type": "string",
891                    "description": "The conversation id an uploaded file goes into.",
892                },
893                "thread_ts": {
894                    "type": "string",
895                    "description": "The parent ts to upload into that thread. Omit to post a \
896                                    root message.",
897                },
898            },
899            "additionalProperties": false,
900        })
901    }
902
903    async fn call(&self, input: ToolInput) -> ToolOutput {
904        let args: AttachmentArgs = match serde_json::from_value(input.value) {
905            Ok(args) => args,
906            Err(err) => return tool_error(format!("invalid arguments: {err}")),
907        };
908        match (args.file_id, args.path) {
909            (Some(file_id), _) => {
910                let Some(dir) = args.dir else {
911                    return tool_error(
912                        "pass `dir`, the directory the fetched file should land in".to_owned(),
913                    );
914                };
915                let request = SlackFetchRequest { file_id, dir: std::path::PathBuf::from(dir) };
916                match self.facade.fetch_attachment(&self.slot, request).await {
917                    Ok(path) => ToolOutput::text(format!("fetched to {}", path.display())),
918                    Err(err) => tool_error(format_attachment_error(&err)),
919                }
920            }
921            (None, Some(path)) => {
922                let Some(conversation) = args.conversation else {
923                    return tool_error(
924                        "pass `conversation`, the conversation to upload into".to_owned(),
925                    );
926                };
927                let request = SlackUploadRequest {
928                    conversation,
929                    thread_ts: args.thread_ts,
930                    path: std::path::PathBuf::from(path),
931                    title: None,
932                };
933                match self.facade.post_attachment(&self.slot, request).await {
934                    Ok(()) => ToolOutput::text("uploaded".to_owned()),
935                    Err(err) => tool_error(format_attachment_error(&err)),
936                }
937            }
938            (None, None) => tool_error(
939                "pass `file_id` and `dir` to fetch a file, or `path` and `conversation` to upload \
940                 one"
941                .to_owned(),
942            ),
943        }
944    }
945}
946
947fn format_read_error(err: &SlackReadError) -> String {
948    match err {
949        SlackReadError::Workspace(err) => format_workspace_error(err),
950        SlackReadError::Fetch(message) => format!("Slack request failed: {message}"),
951    }
952}
953
954/// Results per `slack__search` when the caller does not cap them.
955const DEFAULT_SEARCH_COUNT: u32 = 20;
956
957struct Search {
958    facade: Arc<dyn SlackFacade>,
959    slot: SessionSlot,
960}
961
962#[derive(serde::Deserialize)]
963struct SearchArgs {
964    query: String,
965    #[serde(default)]
966    count: Option<u32>,
967    #[serde(default)]
968    conversation: Option<String>,
969}
970
971#[async_trait::async_trait]
972impl Tool for Search {
973    fn name(&self) -> &'static str {
974        "slack__search"
975    }
976
977    fn description(&self) -> &'static str {
978        "Search the Slack workspace bound to your project by text, newest first. This is a \
979         LOOKUP, not a mention detector: it matches the words you pass and cannot find every \
980         message that mentions a user - subscribe to the mention target for that. Pass \
981         `conversation` with an id from slack__list to keep only that conversation's hits. \
982         Returns a JSON object: `workspace` is the resolved `[[slack]]` label, and `matches` is \
983         an array of {ts, text, conversation, conversation_name, username}; `conversation_name` \
984         is null for a DM, which has none. Any session in the project may call this."
985    }
986
987    fn input_schema(&self) -> serde_json::Value {
988        serde_json::json!({
989            "type": "object",
990            "properties": {
991                "query": {
992                    "type": "string",
993                    "description": "Slack search syntax. Matches text; it is not a mention \
994                                    filter.",
995                },
996                "count": {
997                    "type": "integer",
998                    "minimum": 1,
999                    "description": "Maximum results (default 20).",
1000                },
1001                "conversation": {
1002                    "type": "string",
1003                    "description": "Only hits from this conversation id, as slack__list \
1004                                    reports it.",
1005                },
1006            },
1007            "required": ["query"],
1008            "additionalProperties": false,
1009        })
1010    }
1011
1012    async fn call(&self, input: ToolInput) -> ToolOutput {
1013        let args: SearchArgs = match serde_json::from_value(input.value) {
1014            Ok(args) => args,
1015            Err(err) => return tool_error(format!("invalid arguments: {err}")),
1016        };
1017        let count = args.count.unwrap_or(DEFAULT_SEARCH_COUNT);
1018        match self.facade.search(&self.slot, &args.query, count, args.conversation.as_deref()).await
1019        {
1020            Ok((workspace, found)) => {
1021                let rows: Vec<serde_json::Value> = found
1022                    .iter()
1023                    .map(|hit| {
1024                        serde_json::json!({
1025                            "ts": hit.ts,
1026                            "text": hit.text,
1027                            "conversation": hit.conversation_id,
1028                            "conversation_name": hit.conversation_name,
1029                            "username": hit.username,
1030                            "files": hit.files.iter().map(|file| serde_json::json!({
1031                                "id": file.id,
1032                                "name": file.name,
1033                            })).collect::<Vec<_>>(),
1034                        })
1035                    })
1036                    .collect();
1037                let found = serde_json::json!({ "workspace": workspace, "matches": rows });
1038                match serde_json::to_string_pretty(&found) {
1039                    Ok(json) => ToolOutput::text(json),
1040                    Err(err) => tool_error(format!("search serialization failed: {err}")),
1041                }
1042            }
1043            Err(err) => tool_error(format_read_error(&err)),
1044        }
1045    }
1046}
1047
1048/// Messages per read when the caller does not cap them.
1049const DEFAULT_HISTORY_LIMIT: u32 = 50;
1050
1051/// Slack's own ceiling for one `conversations.history` page.
1052const MAX_HISTORY_LIMIT: u32 = 999;
1053
1054/// Replies per thread read when the caller does not cap them.
1055const DEFAULT_REPLIES_LIMIT: u32 = 200;
1056
1057/// Slack's own ceiling for one `conversations.replies` page.
1058const MAX_REPLIES_LIMIT: u32 = 1000;
1059
1060/// One message as both read tools report it. `files` keeps the ids an
1061/// attachment fetch takes; the file's bearer URL never leaves the connector.
1062fn message_row(message: &SlackHistoryMessage) -> serde_json::Value {
1063    serde_json::json!({
1064        "ts": message.ts,
1065        "user": message.user,
1066        "bot_name": message.bot_name,
1067        "text": message.text,
1068        "thread_ts": message.thread_ts,
1069        "reply_count": message.reply_count,
1070        "edited": message.edited,
1071        "files": message.files.iter().map(|file| serde_json::json!({
1072            "id": file.id,
1073            "name": file.name,
1074        })).collect::<Vec<_>>(),
1075    })
1076}
1077
1078fn read_rows(messages: &[SlackHistoryMessage]) -> serde_json::Value {
1079    serde_json::Value::Array(messages.iter().map(message_row).collect())
1080}
1081
1082struct History {
1083    facade: Arc<dyn SlackFacade>,
1084    slot: SessionSlot,
1085}
1086
1087#[derive(serde::Deserialize)]
1088struct HistoryArgs {
1089    conversation: String,
1090    #[serde(default)]
1091    limit: Option<u32>,
1092    #[serde(default)]
1093    oldest: Option<String>,
1094    #[serde(default)]
1095    newest: Option<String>,
1096}
1097
1098#[async_trait::async_trait]
1099impl Tool for History {
1100    fn name(&self) -> &'static str {
1101        "slack__history"
1102    }
1103
1104    fn description(&self) -> &'static str {
1105        "Read one Slack conversation's messages, newest first. `oldest` and `newest` are Slack \
1106         ts bounds and both are exclusive; to read further back, pass the earliest `ts` of the \
1107         page you just read as `newest`. Returns a JSON object: `workspace` is the resolved \
1108         `[[slack]]` label, and `messages` is an array of {ts, user, bot_name, text, thread_ts, \
1109         reply_count, edited, files}; `user` is the author's id, which slack__user resolves to a \
1110         name, and `reply_count` above zero means slack__replies has more. Read-only and not \
1111         held for approval. Any session in the project may call this."
1112    }
1113
1114    fn input_schema(&self) -> serde_json::Value {
1115        serde_json::json!({
1116            "type": "object",
1117            "properties": {
1118                "conversation": {
1119                    "type": "string",
1120                    "description": "The conversation id from slack__list.",
1121                },
1122                "limit": {
1123                    "type": "integer",
1124                    "minimum": 1,
1125                    "maximum": MAX_HISTORY_LIMIT,
1126                    "description": "Maximum messages to read (default 50).",
1127                },
1128                "oldest": {
1129                    "type": "string",
1130                    "description": "Only messages newer than this Slack ts.",
1131                },
1132                "newest": {
1133                    "type": "string",
1134                    "description": "Only messages older than this Slack ts.",
1135                },
1136            },
1137            "required": ["conversation"],
1138            "additionalProperties": false,
1139        })
1140    }
1141
1142    async fn call(&self, input: ToolInput) -> ToolOutput {
1143        let args: HistoryArgs = match serde_json::from_value(input.value) {
1144            Ok(args) => args,
1145            Err(err) => return tool_error(format!("invalid arguments: {err}")),
1146        };
1147        let limit = args.limit.unwrap_or(DEFAULT_HISTORY_LIMIT);
1148        if limit == 0 || limit > MAX_HISTORY_LIMIT {
1149            return tool_error(format!(
1150                "`limit` must be between 1 and {MAX_HISTORY_LIMIT}, Slack's page ceiling"
1151            ));
1152        }
1153        match self
1154            .facade
1155            .history(
1156                &self.slot,
1157                &args.conversation,
1158                limit,
1159                args.oldest.as_deref(),
1160                args.newest.as_deref(),
1161            )
1162            .await
1163        {
1164            Ok((workspace, messages)) => {
1165                let page = serde_json::json!({
1166                    "workspace": workspace,
1167                    "messages": read_rows(&messages),
1168                });
1169                match serde_json::to_string_pretty(&page) {
1170                    Ok(json) => ToolOutput::text(json),
1171                    Err(err) => tool_error(format!("history serialization failed: {err}")),
1172                }
1173            }
1174            Err(err) => tool_error(format_read_error(&err)),
1175        }
1176    }
1177}
1178
1179struct Replies {
1180    facade: Arc<dyn SlackFacade>,
1181    slot: SessionSlot,
1182}
1183
1184#[derive(serde::Deserialize)]
1185struct RepliesArgs {
1186    conversation: String,
1187    ts: String,
1188    #[serde(default)]
1189    limit: Option<u32>,
1190}
1191
1192#[async_trait::async_trait]
1193impl Tool for Replies {
1194    fn name(&self) -> &'static str {
1195        "slack__replies"
1196    }
1197
1198    fn description(&self) -> &'static str {
1199        "Read one Slack thread: the parent message first, then replies oldest first, up to \
1200         `limit` rows (default 200, max 1000). `conversation` is the id from slack__list and \
1201         `ts` the parent message's ts, which is the `ts` slack__history reports for it. Returns \
1202         the same shape as slack__history - `workspace` and `messages` - with the parent exactly \
1203         once. Read-only and not held for approval. Any session in the project may call this."
1204    }
1205
1206    fn input_schema(&self) -> serde_json::Value {
1207        serde_json::json!({
1208            "type": "object",
1209            "properties": {
1210                "conversation": {
1211                    "type": "string",
1212                    "description": "The conversation id from slack__list.",
1213                },
1214                "ts": {
1215                    "type": "string",
1216                    "description": "The parent message's ts.",
1217                },
1218                "limit": {
1219                    "type": "integer",
1220                    "minimum": 1,
1221                    "maximum": MAX_REPLIES_LIMIT,
1222                    "description": "Maximum messages to read (default 200).",
1223                },
1224            },
1225            "required": ["conversation", "ts"],
1226            "additionalProperties": false,
1227        })
1228    }
1229
1230    async fn call(&self, input: ToolInput) -> ToolOutput {
1231        let args: RepliesArgs = match serde_json::from_value(input.value) {
1232            Ok(args) => args,
1233            Err(err) => return tool_error(format!("invalid arguments: {err}")),
1234        };
1235        let limit = args.limit.unwrap_or(DEFAULT_REPLIES_LIMIT);
1236        if limit == 0 || limit > MAX_REPLIES_LIMIT {
1237            return tool_error(format!(
1238                "`limit` must be between 1 and {MAX_REPLIES_LIMIT}, Slack's page ceiling"
1239            ));
1240        }
1241        match self.facade.replies(&self.slot, &args.conversation, &args.ts, limit).await {
1242            Ok((workspace, messages)) => {
1243                let thread = serde_json::json!({
1244                    "workspace": workspace,
1245                    "messages": read_rows(&messages),
1246                });
1247                match serde_json::to_string_pretty(&thread) {
1248                    Ok(json) => ToolOutput::text(json),
1249                    Err(err) => tool_error(format!("replies serialization failed: {err}")),
1250                }
1251            }
1252            Err(err) => tool_error(format_read_error(&err)),
1253        }
1254    }
1255}
1256
1257struct User {
1258    facade: Arc<dyn SlackFacade>,
1259    slot: SessionSlot,
1260}
1261
1262#[derive(serde::Deserialize)]
1263struct UserArgs {
1264    user: String,
1265}
1266
1267#[async_trait::async_trait]
1268impl Tool for User {
1269    fn name(&self) -> &'static str {
1270        "slack__user"
1271    }
1272
1273    fn description(&self) -> &'static str {
1274        "Look up one Slack user by id: their handle, real name and timezone. Returns \
1275         {id, name, real_name, tz} plus `workspace`, the resolved `[[slack]]` label; each of \
1276         real_name and tz is null when the workspace does not report it. Use slack__users to \
1277         find an id from a name. Any session in the project may call this."
1278    }
1279
1280    fn input_schema(&self) -> serde_json::Value {
1281        serde_json::json!({
1282            "type": "object",
1283            "properties": {
1284                "user": { "type": "string", "description": "The user id, e.g. `U06MVPF6HU4`." },
1285            },
1286            "required": ["user"],
1287            "additionalProperties": false,
1288        })
1289    }
1290
1291    async fn call(&self, input: ToolInput) -> ToolOutput {
1292        let args: UserArgs = match serde_json::from_value(input.value) {
1293            Ok(args) => args,
1294            Err(err) => return tool_error(format!("invalid arguments: {err}")),
1295        };
1296        match self.facade.user(&self.slot, &args.user).await {
1297            Ok((workspace, user)) => {
1298                let body = serde_json::json!({
1299                    "workspace": workspace,
1300                    "id": user.id,
1301                    "name": user.name,
1302                    "real_name": user.real_name,
1303                    "tz": user.tz,
1304                });
1305                match serde_json::to_string_pretty(&body) {
1306                    Ok(json) => ToolOutput::text(json),
1307                    Err(err) => tool_error(format!("user serialization failed: {err}")),
1308                }
1309            }
1310            Err(err) => tool_error(format_read_error(&err)),
1311        }
1312    }
1313}
1314
1315struct Me {
1316    facade: Arc<dyn SlackFacade>,
1317    slot: SessionSlot,
1318}
1319
1320#[derive(serde::Deserialize)]
1321struct MeArgs {}
1322
1323#[async_trait::async_trait]
1324impl Tool for Me {
1325    fn name(&self) -> &'static str {
1326        "slack__me"
1327    }
1328
1329    fn description(&self) -> &'static str {
1330        "Report which Slack identity the project's workspace token acts as, over `auth.test`: \
1331         the team and the user behind it. `user_id` is what a message's `user` field carries \
1332         and what a \
1333         mention is written with, so this is how a caller knows which id is its own. Returns \
1334         {workspace, team, team_id, user, user_id, url}; `workspace` is the `[[slack]]` label, \
1335         which can differ from the team's name. Read-only and not held for approval. Any \
1336         session in the project may call this."
1337    }
1338
1339    fn input_schema(&self) -> serde_json::Value {
1340        serde_json::json!({
1341            "type": "object",
1342            "properties": {},
1343            "additionalProperties": false,
1344        })
1345    }
1346
1347    async fn call(&self, input: ToolInput) -> ToolOutput {
1348        let MeArgs {} = match serde_json::from_value(input.value) {
1349            Ok(args) => args,
1350            Err(err) => return tool_error(format!("invalid arguments: {err}")),
1351        };
1352        match self.facade.me(&self.slot).await {
1353            Ok((workspace, auth)) => {
1354                let body = serde_json::json!({
1355                    "workspace": workspace,
1356                    "team": auth.team,
1357                    "team_id": auth.team_id,
1358                    "user": auth.user,
1359                    "user_id": auth.user_id,
1360                    "url": auth.url,
1361                });
1362                match serde_json::to_string_pretty(&body) {
1363                    Ok(json) => ToolOutput::text(json),
1364                    Err(err) => tool_error(format!("identity serialization failed: {err}")),
1365                }
1366            }
1367            Err(err) => tool_error(format_read_error(&err)),
1368        }
1369    }
1370}
1371
1372struct Users {
1373    facade: Arc<dyn SlackFacade>,
1374    slot: SessionSlot,
1375}
1376
1377#[derive(serde::Deserialize)]
1378struct UsersArgs {
1379    query: String,
1380}
1381
1382#[async_trait::async_trait]
1383impl Tool for Users {
1384    fn name(&self) -> &'static str {
1385        "slack__users"
1386    }
1387
1388    fn description(&self) -> &'static str {
1389        "Find Slack users by handle, real name or display name, so a mention can name the right \
1390         id. Matching is a case-insensitive substring over all three, and EVERY match is \
1391         returned - silently choosing one of several is how a mention reaches the wrong person, \
1392         so read the rows and pick the `id` yourself. Write a mention as `<@ID>`. Deactivated \
1393         members are excluded. Returns a JSON object: `workspace` is the resolved `[[slack]]` \
1394         label, and `users` is an array of {id, name, real_name, display_name}. Read-only and \
1395         not held for approval. Any session in the project may call this."
1396    }
1397
1398    fn input_schema(&self) -> serde_json::Value {
1399        serde_json::json!({
1400            "type": "object",
1401            "properties": {
1402                "query": {
1403                    "type": "string",
1404                    "description": "A handle, real name or display name, or part of one.",
1405                },
1406            },
1407            "required": ["query"],
1408            "additionalProperties": false,
1409        })
1410    }
1411
1412    async fn call(&self, input: ToolInput) -> ToolOutput {
1413        let args: UsersArgs = match serde_json::from_value(input.value) {
1414            Ok(args) => args,
1415            Err(err) => return tool_error(format!("invalid arguments: {err}")),
1416        };
1417        if args.query.trim().is_empty() {
1418            return tool_error(
1419                "pass a handle, real name or display name to search for; an empty query would \
1420                 return the whole workspace"
1421                    .to_owned(),
1422            );
1423        }
1424        match self.facade.users(&self.slot, &args.query).await {
1425            Ok((workspace, found)) => {
1426                let rows: Vec<serde_json::Value> = found
1427                    .iter()
1428                    .map(|user| {
1429                        serde_json::json!({
1430                            "id": user.id,
1431                            "name": user.name,
1432                            "real_name": user.real_name,
1433                            "display_name": user
1434                                .profile
1435                                .as_ref()
1436                                .and_then(|profile| profile.display_name.as_deref()),
1437                        })
1438                    })
1439                    .collect();
1440                let found = serde_json::json!({ "workspace": workspace, "users": rows });
1441                match serde_json::to_string_pretty(&found) {
1442                    Ok(json) => ToolOutput::text(json),
1443                    Err(err) => tool_error(format!("user-list serialization failed: {err}")),
1444                }
1445            }
1446            Err(err) => tool_error(format_read_error(&err)),
1447        }
1448    }
1449}
1450
1451struct Pins {
1452    facade: Arc<dyn SlackFacade>,
1453    slot: SessionSlot,
1454}
1455
1456#[derive(serde::Deserialize)]
1457struct PinsArgs {
1458    conversation: String,
1459}
1460
1461#[async_trait::async_trait]
1462impl Tool for Pins {
1463    fn name(&self) -> &'static str {
1464        "slack__pins"
1465    }
1466
1467    fn description(&self) -> &'static str {
1468        "List a Slack conversation's pinned messages - what the channel has kept as standing \
1469         context. Read-only and not held for approval. Returns a JSON object: `workspace` is \
1470         the resolved `[[slack]]` label, and `pins` is an array of {ts, user, text}. Any \
1471         session in the project may call this."
1472    }
1473
1474    fn input_schema(&self) -> serde_json::Value {
1475        serde_json::json!({
1476            "type": "object",
1477            "properties": {
1478                "conversation": {
1479                    "type": "string",
1480                    "description": "The conversation id from slack__list.",
1481                },
1482            },
1483            "required": ["conversation"],
1484            "additionalProperties": false,
1485        })
1486    }
1487
1488    async fn call(&self, input: ToolInput) -> ToolOutput {
1489        let args: PinsArgs = match serde_json::from_value(input.value) {
1490            Ok(args) => args,
1491            Err(err) => return tool_error(format!("invalid arguments: {err}")),
1492        };
1493        match self.facade.pins(&self.slot, &args.conversation).await {
1494            Ok((workspace, pins)) => {
1495                let rows: Vec<serde_json::Value> = pins
1496                    .iter()
1497                    .filter_map(|pin| {
1498                        let message = pin.message.as_ref()?;
1499                        Some(serde_json::json!({
1500                            "ts": message.ts,
1501                            "user": message.user,
1502                            "text": message.text,
1503                        }))
1504                    })
1505                    .collect();
1506                let listed = serde_json::json!({ "workspace": workspace, "pins": rows });
1507                match serde_json::to_string_pretty(&listed) {
1508                    Ok(json) => ToolOutput::text(json),
1509                    Err(err) => tool_error(format!("pins serialization failed: {err}")),
1510                }
1511            }
1512            Err(err) => tool_error(format_read_error(&err)),
1513        }
1514    }
1515}
1516
1517struct Bookmarks {
1518    facade: Arc<dyn SlackFacade>,
1519    slot: SessionSlot,
1520}
1521
1522#[derive(serde::Deserialize)]
1523struct BookmarksArgs {
1524    conversation: String,
1525}
1526
1527#[async_trait::async_trait]
1528impl Tool for Bookmarks {
1529    fn name(&self) -> &'static str {
1530        "slack__bookmarks"
1531    }
1532
1533    fn description(&self) -> &'static str {
1534        "List a Slack conversation's bookmarks - the links saved on the channel. Read-only and \
1535         not held for approval. Returns a JSON object: `workspace` is the resolved `[[slack]]` \
1536         label, and `bookmarks` is an array of {id, title, link}, each of the last two null when \
1537         the workspace does not report it. Any session in the project may call this."
1538    }
1539
1540    fn input_schema(&self) -> serde_json::Value {
1541        serde_json::json!({
1542            "type": "object",
1543            "properties": {
1544                "conversation": {
1545                    "type": "string",
1546                    "description": "The conversation id from slack__list.",
1547                },
1548            },
1549            "required": ["conversation"],
1550            "additionalProperties": false,
1551        })
1552    }
1553
1554    async fn call(&self, input: ToolInput) -> ToolOutput {
1555        let args: BookmarksArgs = match serde_json::from_value(input.value) {
1556            Ok(args) => args,
1557            Err(err) => return tool_error(format!("invalid arguments: {err}")),
1558        };
1559        match self.facade.bookmarks(&self.slot, &args.conversation).await {
1560            Ok((workspace, bookmarks)) => {
1561                let rows: Vec<serde_json::Value> = bookmarks
1562                    .iter()
1563                    .map(|bookmark| {
1564                        serde_json::json!({
1565                            "id": bookmark.id,
1566                            "title": bookmark.title,
1567                            "link": bookmark.link,
1568                        })
1569                    })
1570                    .collect();
1571                let listed = serde_json::json!({ "workspace": workspace, "bookmarks": rows });
1572                match serde_json::to_string_pretty(&listed) {
1573                    Ok(json) => ToolOutput::text(json),
1574                    Err(err) => tool_error(format!("bookmarks serialization failed: {err}")),
1575                }
1576            }
1577            Err(err) => tool_error(format_read_error(&err)),
1578        }
1579    }
1580}
1581
1582#[cfg(test)]
1583mod tests {
1584    use super::*;
1585    use crate::mcp::slack::facade::{MockSlackFacade, SlackPostOutcome, SlackWorkspaceError};
1586    use crate::mcp::test_support::text_of;
1587    use forge_connectors::slack::AuthTest;
1588    use forge_primitives::slack::{
1589        SlackBookmark, SlackConversation, SlackConversationText, SlackPin, SlackPinMessage,
1590        SlackUser, SlackUserProfile,
1591    };
1592    use std::sync::Arc;
1593
1594    fn channel(id: &str, name: &str) -> SlackConversation {
1595        SlackConversation {
1596            id: id.to_owned(),
1597            name: Some(name.to_owned()),
1598            is_channel: true,
1599            is_private: false,
1600            is_im: false,
1601            is_mpim: false,
1602            is_archived: false,
1603            user: None,
1604            purpose: None,
1605            topic: None,
1606        }
1607    }
1608
1609    fn dm(id: &str, user: &str) -> SlackConversation {
1610        SlackConversation {
1611            id: id.to_owned(),
1612            name: None,
1613            is_channel: false,
1614            is_private: false,
1615            is_im: true,
1616            is_mpim: false,
1617            is_archived: false,
1618            user: Some(user.to_owned()),
1619            purpose: None,
1620            topic: None,
1621        }
1622    }
1623
1624    fn input(value: serde_json::Value) -> ToolInput {
1625        ToolInput { value }
1626    }
1627
1628    fn watching(id: &str) -> SlackSubscriptionTarget {
1629        SlackSubscriptionTarget::Conversation {
1630            id: id.to_owned(),
1631            name: None,
1632            mode: SlackWatchMode::All,
1633        }
1634    }
1635
1636    fn owned_sub(
1637        id: Uuid,
1638        target: SlackSubscriptionTarget,
1639    ) -> forge_primitives::slack::SlackSubscription {
1640        forge_primitives::slack::SlackSubscription {
1641            id,
1642            workspace: "acme".to_owned(),
1643            project: "forge".to_owned(),
1644            team_role: None,
1645            target,
1646            created_at: std::time::SystemTime::UNIX_EPOCH,
1647        }
1648    }
1649
1650    /// A mention target covers no conversation, so it appears on no row. It
1651    /// is still live and swept, and without its id nothing can remove it.
1652    #[tokio::test]
1653    async fn slack_list_reports_class_subscriptions_with_their_ids() {
1654        let mock = Arc::new(MockSlackFacade::new());
1655        *mock.conversations_result.lock() = Some(Ok(vec![channel("C1", "general")]));
1656        let mentions = owned_sub(Uuid::from_u128(0x77), SlackSubscriptionTarget::Mentions);
1657        *mock.subscribed_targets.lock() = vec![mentions.clone()];
1658        let tool = List { facade: mock, slot: caller_slot() };
1659
1660        let out = tool.call(input(serde_json::json!({}))).await;
1661
1662        assert!(!out.is_error, "the list succeeds: {}", text_of(&out));
1663        assert!(
1664            text_of(&out).contains(&mentions.id.to_string()),
1665            "the class target's id is reachable: {}",
1666            text_of(&out),
1667        );
1668        assert!(
1669            text_of(&out).contains("mentions anywhere"),
1670            "and it is named as the target it is: {}",
1671            text_of(&out),
1672        );
1673    }
1674
1675    /// One structured row per subscription, compared whole against the
1676    /// row formatter so no key can be dropped or renamed: the ids are the
1677    /// only handle `slack__unsubscribe` accepts, and the target is what
1678    /// says a row is a channel, the DM class or a workspace-wide mention.
1679    #[tokio::test]
1680    async fn slack_subscribe_returns_one_row_per_created_subscription() {
1681        let mock = Arc::new(MockSlackFacade::new());
1682        let dm = owned_sub(Uuid::from_u128(0xabc), SlackSubscriptionTarget::DirectMessages);
1683        let channel = owned_sub(
1684            Uuid::from_u128(0xdef),
1685            SlackSubscriptionTarget::Conversation {
1686                id: "C1".to_owned(),
1687                name: Some("general".to_owned()),
1688                mode: SlackWatchMode::MentionsOnly,
1689            },
1690        );
1691        let mentions = owned_sub(Uuid::from_u128(0x1a), SlackSubscriptionTarget::Mentions);
1692        *mock.subscribe_result.lock() =
1693            Some(Ok(vec![dm.clone(), channel.clone(), mentions.clone()]));
1694        let tool = Subscribe { facade: mock, slot: caller_slot() };
1695
1696        let out = tool.call(input(serde_json::json!({ "direct_messages": true }))).await;
1697
1698        assert!(!out.is_error, "a valid subscribe succeeds: {}", text_of(&out));
1699        assert_eq!(out.blocks.len(), 1, "the rows stay one text block: {out:?}");
1700        let rows: serde_json::Value =
1701            serde_json::from_str(text_of(&out)).expect("the result is one row per record");
1702        assert_eq!(rows[0], sub_to_row(&dm), "the DM class's row, whole");
1703        assert_eq!(rows[1], sub_to_row(&channel), "the conversation's row, name and mode included");
1704        assert_eq!(
1705            rows[2],
1706            sub_to_row(&mentions),
1707            "and the mention target's own spelling, which the description promises",
1708        );
1709        assert_eq!(rows[0]["id"], dm.id.to_string(), "the id is the unsubscribe handle");
1710    }
1711
1712    /// One call naming two kinds keeps both kinds' rows: collecting the
1713    /// last request's rows instead of extending would drop the DM class's,
1714    /// and with them its only unsubscribe handle.
1715    #[tokio::test]
1716    async fn slack_subscribe_keeps_every_kinds_rows() {
1717        let mock = Arc::new(MockSlackFacade::new());
1718        // The mock answers every request with this row, so the count is
1719        // what discriminates: two requests, both answers kept.
1720        *mock.subscribe_result.lock() = Some(Ok(vec![owned_sub(
1721            Uuid::from_u128(0xabc),
1722            SlackSubscriptionTarget::DirectMessages,
1723        )]));
1724        let tool = Subscribe { facade: mock.clone(), slot: caller_slot() };
1725
1726        let out = tool
1727            .call(input(serde_json::json!({
1728                "direct_messages": true,
1729                "mentions": true,
1730            })))
1731            .await;
1732
1733        assert!(!out.is_error, "a two-kind subscribe succeeds: {}", text_of(&out));
1734        assert_eq!(mock.subscribe_calls.lock().len(), 2, "both kinds reach the facade");
1735        let rows: serde_json::Value =
1736            serde_json::from_str(text_of(&out)).expect("the result is one row per record");
1737        assert_eq!(rows.as_array().expect("a row list").len(), 2, "and both answers are kept");
1738    }
1739
1740    /// "Nothing new to watch" is an empty array, not a sentence: a reader
1741    /// parses one shape, and prose turns a no-op answer into an unreadable
1742    /// one.
1743    #[tokio::test]
1744    async fn slack_subscribe_with_nothing_new_still_answers_a_list() {
1745        let mock = Arc::new(MockSlackFacade::new());
1746        *mock.subscribe_result.lock() = Some(Ok(Vec::new()));
1747        let tool = Subscribe { facade: mock, slot: caller_slot() };
1748
1749        let out = tool.call(input(serde_json::json!({ "mentions": true }))).await;
1750
1751        assert!(!out.is_error, "nothing new to watch is not an error: {}", text_of(&out));
1752        let rows: serde_json::Value =
1753            serde_json::from_str(text_of(&out)).expect("the answer is a row list");
1754        assert_eq!(rows, serde_json::json!([]), "an empty list, not prose");
1755    }
1756
1757    /// Editing, deleting or reacting all take the ts, and Slack has
1758    /// already sent it: a count is not a handle. The conversation's name
1759    /// and the part count come along so a reader can name the channel
1760    /// without a second lookup and see that the text was split.
1761    #[tokio::test]
1762    async fn slack_post_reports_the_ts_the_channel_and_the_part_count() {
1763        let mock = Arc::new(MockSlackFacade::new());
1764        *mock.post_result.lock() = Some(Ok(SlackPostOutcome {
1765            workspace: "acme".to_owned(),
1766            ts: vec!["1790186552.442169".to_owned(), "1790186552.442170".to_owned()],
1767            conversation_name: "granite-staging".to_owned(),
1768            parts: 2,
1769        }));
1770        let tool = Post { facade: mock, slot: caller_slot() };
1771
1772        let out =
1773            tool.call(input(serde_json::json!({ "conversation": "C1", "text": "hello" }))).await;
1774
1775        assert!(!out.is_error, "the post succeeds: {}", text_of(&out));
1776        assert_eq!(out.blocks.len(), 1, "the record stays one text block: {out:?}");
1777        let json: serde_json::Value =
1778            serde_json::from_str(text_of(&out)).expect("the result is the posted record");
1779        assert_eq!(
1780            json["ts"],
1781            serde_json::json!(["1790186552.442169", "1790186552.442170"]),
1782            "the ts of every message, in the order they went out",
1783        );
1784        assert_eq!(json["conversation_name"], "granite-staging");
1785        assert_eq!(json["parts"], 2, "so a reader sees the text went out in two messages");
1786        assert_eq!(json["workspace"], "acme", "the resolved label rides the result");
1787    }
1788
1789    /// A failed `slack__post` is the moment the configured token looks
1790    /// like a way round it, so the description's closing sentence
1791    /// forbids that and is pinned here: a trim that drops it fails
1792    /// instead of shipping.
1793    #[test]
1794    fn slack_post_description_names_the_no_fallback_rule() {
1795        let tool = Post { facade: Arc::new(MockSlackFacade::new()), slot: caller_slot() };
1796        assert!(
1797            tool.description()
1798                .contains("never fall back to Slack's web API with the configured token"),
1799            "the no-fallback sentence is pinned: {}",
1800            tool.description()
1801        );
1802    }
1803
1804    #[test]
1805    fn list_marks_a_conversation_as_subscribed_only_when_it_is() {
1806        let conversations = vec![channel("C1", "general")];
1807        let rows = list_rows(&conversations, &[watching("C1")], &[]);
1808        assert_eq!(rows.len(), 1, "one row per conversation");
1809        assert_eq!(rows[0]["id"], "C1");
1810        assert_eq!(rows[0]["name"], "general");
1811        assert_eq!(rows[0]["subscribed"], true);
1812
1813        let rows = list_rows(&conversations, &[], &[]);
1814        assert_eq!(rows[0]["subscribed"], false, "an empty subscribed set marks nothing");
1815    }
1816
1817    #[test]
1818    fn a_watched_conversation_is_marked_and_an_unwatched_one_is_not() {
1819        let conversations = vec![channel("C1", "general"), channel("C2", "random")];
1820        let rows = list_rows(&conversations, &[watching("C1")], &[]);
1821        let by_id =
1822            |id: &str| rows.iter().find(|row| row["id"] == id).expect("row for the id").clone();
1823        assert_eq!(by_id("C1")["subscribed"], true, "the watched channel is marked");
1824        assert_eq!(by_id("C2")["subscribed"], false, "its unwatched sibling is not");
1825    }
1826
1827    #[test]
1828    fn the_dm_class_marks_every_dm_and_group_dm() {
1829        let conversations = vec![dm("D1", "U1"), dm("D2", "U2"), channel("C1", "general")];
1830        let rows = list_rows(&conversations, &[SlackSubscriptionTarget::DirectMessages], &[]);
1831        let by_id =
1832            |id: &str| rows.iter().find(|row| row["id"] == id).expect("row for the id").clone();
1833        assert_eq!(by_id("D1")["subscribed"], true, "every DM is covered by the class");
1834        assert_eq!(by_id("D2")["subscribed"], true, "including the second one");
1835        assert_eq!(
1836            by_id("C1")["subscribed"],
1837            false,
1838            "a channel is not a DM, so the class does not mark it",
1839        );
1840    }
1841
1842    #[test]
1843    fn a_dm_with_no_name_is_named_after_its_partner() {
1844        let rows = list_rows(&[dm("D1", "U9")], &[], &[]);
1845        assert_eq!(rows[0]["name"], "U9", "a DM's display name falls back to the partner");
1846    }
1847
1848    #[test]
1849    fn each_conversation_reports_its_kind() {
1850        let mut private = channel("G1", "secret");
1851        private.is_channel = false;
1852        private.is_private = true;
1853        let mut group_dm = channel("G2", "huddle");
1854        group_dm.is_channel = false;
1855        group_dm.is_mpim = true;
1856
1857        let rows =
1858            list_rows(&[channel("C1", "general"), dm("D1", "U9"), private, group_dm], &[], &[]);
1859        let kinds: Vec<&str> =
1860            rows.iter().map(|row| row["kind"].as_str().unwrap_or_default()).collect();
1861        assert_eq!(kinds, vec!["public", "im", "private", "mpim"]);
1862    }
1863
1864    #[test]
1865    fn the_name_filter_matches_the_name_purpose_and_topic_case_insensitively() {
1866        let mut ch = channel("C1", "random");
1867        ch.purpose = Some(SlackConversationText { value: "Deploy chatter".to_owned() });
1868        ch.topic = Some(SlackConversationText { value: "release coordination".to_owned() });
1869
1870        assert!(passes_filter(&ch, Some("rand"), None), "the name matches");
1871        assert!(passes_filter(&ch, Some("deploy"), None), "the purpose matches");
1872        assert!(passes_filter(&ch, Some("RELEASE"), None), "the topic matches, case-insensitively");
1873        assert!(!passes_filter(&ch, Some("general"), None), "an unrelated needle matches nothing");
1874        assert!(
1875            passes_filter(&dm("D1", "U9"), Some("u9"), None),
1876            "a DM's display name is its partner id",
1877        );
1878    }
1879
1880    #[test]
1881    fn the_kind_filter_keeps_only_that_kind() {
1882        let conversations = [channel("C1", "general"), dm("D1", "U9")];
1883        let kept: Vec<&SlackConversation> =
1884            conversations.iter().filter(|c| passes_filter(c, None, Some("im"))).collect();
1885        assert_eq!(kept.len(), 1, "one conversation survives the kind filter");
1886        assert_eq!(kept[0].id, "D1", "and it is the one of that kind");
1887    }
1888
1889    #[test]
1890    fn each_workspace_refusal_names_its_cause() {
1891        let not_configured = format_workspace_error(&SlackWorkspaceError::NotConfigured);
1892        assert!(not_configured.contains("[[slack]]"), "got: {not_configured}");
1893
1894        let unbound = format_workspace_error(&SlackWorkspaceError::Unbound {
1895            org: "Granite".to_owned(),
1896            project: "core-v1".to_owned(),
1897        });
1898        assert!(unbound.contains("Granite/core-v1"), "names the scope: {unbound}");
1899        assert!(unbound.contains("[orgs.slack]"), "names the org key: {unbound}");
1900        assert!(unbound.contains("[orgs.projects.slack]"), "names the project key: {unbound}");
1901
1902        let not_loaded = format_workspace_error(&SlackWorkspaceError::BoundButNotLive {
1903            label: "acme".to_owned(),
1904        });
1905        assert!(not_loaded.contains("'acme'"), "names the label: {not_loaded}");
1906        assert!(not_loaded.contains("restart"), "names the way out: {not_loaded}");
1907
1908        let fetch = format_read_error(&SlackReadError::Fetch("boom".to_owned()));
1909        assert!(fetch.contains("boom"), "carries the cause: {fetch}");
1910    }
1911
1912    /// A project with no binding has one answer whichever tool was called:
1913    /// a tool that reworded it would send the caller looking for a config key
1914    /// that is already set.
1915    #[test]
1916    fn every_tool_words_a_workspace_refusal_the_same_way() {
1917        let unbound = SlackWorkspaceError::Unbound {
1918            org: "Granite".to_owned(),
1919            project: "core-v1".to_owned(),
1920        };
1921        let expected = format_workspace_error(&unbound);
1922        let answers = [
1923            format_read_error(&SlackReadError::Workspace(unbound.clone())),
1924            format_post_error(&SlackPostError::Workspace(unbound.clone())),
1925            format_edit_error(&SlackEditError::Workspace(unbound.clone())),
1926            format_react_error(&SlackReactError::Workspace(unbound.clone())),
1927            format_attachment_error(&SlackAttachmentError::Workspace(unbound.clone())),
1928            format_subscribe_error(&SlackSubscribeError::Workspace(unbound)),
1929        ];
1930        for answer in answers {
1931            assert_eq!(answer, expected, "every tool shares the one wording");
1932        }
1933    }
1934
1935    /// The reader's words are what the agent revises from, so the refusal
1936    /// has to carry them whole and say what is expected next: a bare "not
1937    /// approved" leaves it guessing at the change, which is the loop this
1938    /// answer exists to close.
1939    #[test]
1940    fn a_sent_back_refusal_carries_the_note_and_the_next_step() {
1941        let note = "say staging, not prod";
1942        let answers = [
1943            (format_post_error(&SlackPostError::SentBack { note: note.to_owned() }), "slack__post"),
1944            (format_edit_error(&SlackEditError::SentBack { note: note.to_owned() }), "slack__edit"),
1945            (
1946                format_react_error(&SlackReactError::SentBack { note: note.to_owned() }),
1947                "slack__react",
1948            ),
1949            (
1950                format_attachment_error(&SlackAttachmentError::SentBack { note: note.to_owned() }),
1951                "slack__attachment",
1952            ),
1953        ];
1954        for (answer, tool) in answers {
1955            assert!(answer.contains(note), "the note stays verbatim in {tool}'s refusal: {answer}");
1956            assert!(answer.contains("expects a revision"), "and says what is expected: {answer}");
1957            assert!(answer.contains(tool), "and names the call to repeat: {answer}");
1958        }
1959    }
1960
1961    /// An agent reads the description before it acts. A send-back is part of
1962    /// what every gated call can return, and the agent's correct response to
1963    /// it is to revise and call again - so all four gated tools say so up
1964    /// front.
1965    #[test]
1966    fn the_gated_slack_descriptions_state_that_a_send_back_expects_a_revision() {
1967        let facade: Arc<dyn SlackFacade> = Arc::new(MockSlackFacade::new());
1968        let post = Post { facade: facade.clone(), slot: caller_slot() }.description();
1969        let edit = Edit { facade: facade.clone(), slot: caller_slot() }.description();
1970        let react = React { facade: facade.clone(), slot: caller_slot() }.description();
1971        let attachment = Attachment { facade, slot: caller_slot() }.description();
1972        for (name, description) in [
1973            ("slack__post", post),
1974            ("slack__edit", edit),
1975            ("slack__react", react),
1976            ("slack__attachment", attachment),
1977        ] {
1978            assert!(
1979                description.contains("send the draft back with feedback"),
1980                "{name} must say the user can send it back: {description}",
1981            );
1982            assert!(
1983                description.contains("revise"),
1984                "{name} must say what the agent should do with the feedback: {description}",
1985            );
1986        }
1987        // The stop-and-report tail is the other failures' rule. Left
1988        // unqualified it contradicts the sentence above it: a send-back IS a
1989        // failure of the call, and the agent must call again.
1990        assert!(
1991            post.contains("any other reason"),
1992            "the post's stop-and-report tail is qualified for a send-back: {post}",
1993        );
1994    }
1995
1996    /// The headline of the cut: no Slack tool advertises a `workspace`
1997    /// argument, whatever its verb. One re-advertised property is a model
1998    /// that keeps passing an argument the tool no longer reads.
1999    #[test]
2000    fn no_slack_tool_advertises_a_workspace_argument() {
2001        let facade: Arc<dyn SlackFacade> = Arc::new(MockSlackFacade::new());
2002        let server =
2003            add_tools(McpServerBuilder::new("forge", "test"), facade, caller_slot()).build();
2004        let schemas = server.tool_schemas();
2005        assert_eq!(schemas.len(), 15, "every Slack tool is registered: {schemas:?}");
2006        for (name, schema) in &schemas {
2007            assert!(
2008                schema["properties"].get("workspace").is_none(),
2009                "{name} still advertises a workspace argument: {schema}",
2010            );
2011        }
2012    }
2013
2014    #[tokio::test]
2015    async fn slack_list_returns_rows_and_asks_for_the_callers_slot() {
2016        let mock = Arc::new(MockSlackFacade::new());
2017        *mock.conversations_result.lock() = Some(Ok(vec![channel("C1", "general")]));
2018        let tool = List { facade: mock.clone(), slot: caller_slot() };
2019
2020        let out = tool.call(input(serde_json::json!({}))).await;
2021        assert!(!out.is_error, "a configured workspace succeeds: {}", text_of(&out));
2022        assert!(text_of(&out).contains("C1"), "the row reaches the output: {}", text_of(&out));
2023        let json: serde_json::Value =
2024            serde_json::from_str(text_of(&out)).expect("the result is the list object");
2025        assert_eq!(json["workspace"], "acme", "the resolved label rides the result");
2026        assert_eq!(
2027            mock.conversations_calls.lock().as_slice(),
2028            [caller_slot()],
2029            "the tool hands the facade the calling session's slot, never a workspace argument",
2030        );
2031        assert_eq!(
2032            mock.subscribed_targets_calls.lock().as_slice(),
2033            [(caller_slot(), "acme".to_owned())],
2034            "the marks use the read's own resolved label, not a second resolution",
2035        );
2036    }
2037
2038    /// A caller that still sends `workspace` is ignored, not refused: the
2039    /// argument is gone from the schema, and a stale transcript replaying it
2040    /// must not fail the call.
2041    #[tokio::test]
2042    async fn a_stale_workspace_argument_is_ignored() {
2043        let mock = Arc::new(MockSlackFacade::new());
2044        *mock.conversations_result.lock() = Some(Ok(vec![channel("C1", "general")]));
2045        let tool = List { facade: mock.clone(), slot: caller_slot() };
2046
2047        let out = tool.call(input(serde_json::json!({ "workspace": "elsewhere" }))).await;
2048        assert!(!out.is_error, "the stale argument does not refuse the call: {}", text_of(&out));
2049        assert_eq!(mock.conversations_calls.lock().as_slice(), [caller_slot()]);
2050    }
2051
2052    fn caller_slot() -> SessionSlot {
2053        SessionSlot::from_str_for_test("caller")
2054    }
2055
2056    #[tokio::test]
2057    async fn slack_subscribe_passes_the_targets_through_to_the_facade() {
2058        let mock = Arc::new(MockSlackFacade::new());
2059        *mock.subscribe_result.lock() =
2060            Some(Ok(vec![owned_sub(Uuid::from_u128(0x42), SlackSubscriptionTarget::Mentions)]));
2061        let tool = Subscribe { facade: mock.clone(), slot: caller_slot() };
2062
2063        let out = tool
2064            .call(input(serde_json::json!({
2065                "conversations": [{ "id": "C1", "mode": "mentions" }],
2066            })))
2067            .await;
2068        assert!(!out.is_error, "a valid subscribe succeeds: {}", text_of(&out));
2069
2070        let calls = mock.subscribe_calls.lock();
2071        assert_eq!(calls.len(), 1);
2072        assert_eq!(calls[0].0, caller_slot(), "the caller's slot is what the facade resolves on");
2073        assert_eq!(
2074            calls[0].1,
2075            SlackSubscribeRequest::Conversations(vec![SlackChannelWatch {
2076                id: "C1".to_owned(),
2077                mode: SlackWatchMode::MentionsOnly,
2078            }]),
2079            "the channel and its mode survive the argument decode",
2080        );
2081    }
2082
2083    #[tokio::test]
2084    async fn slack_subscribe_without_a_target_is_refused() {
2085        let mock = Arc::new(MockSlackFacade::new());
2086        let tool = Subscribe { facade: mock.clone(), slot: caller_slot() };
2087
2088        let out = tool.call(input(serde_json::json!({}))).await;
2089        assert!(out.is_error, "a call naming nothing to watch must not create records");
2090        assert!(
2091            text_of(&out).contains("direct_messages"),
2092            "the error says what to pass: {}",
2093            text_of(&out),
2094        );
2095        assert!(mock.subscribe_calls.lock().is_empty(), "nothing reaches the facade");
2096    }
2097
2098    #[tokio::test]
2099    async fn slack_subscribe_names_the_unbound_scope() {
2100        let mock = Arc::new(MockSlackFacade::new());
2101        *mock.subscribe_result.lock() =
2102            Some(Err(SlackSubscribeError::Workspace(SlackWorkspaceError::Unbound {
2103                org: "Granite".to_owned(),
2104                project: "core-v1".to_owned(),
2105            })));
2106        let tool = Subscribe { facade: mock, slot: caller_slot() };
2107
2108        let out = tool.call(input(serde_json::json!({ "direct_messages": true }))).await;
2109        assert!(out.is_error);
2110        assert!(
2111            text_of(&out).contains("Granite/core-v1"),
2112            "the error names the scope that has no binding: {}",
2113            text_of(&out),
2114        );
2115    }
2116
2117    /// A refusal has to say which case it is: an id that exists in the
2118    /// project but is not the caller's own reads as *not yours*, an id
2119    /// the project never had reads as *not there*. One string for both
2120    /// lets a caller take its own refused unsubscribe as proof the
2121    /// subscription is gone.
2122    #[tokio::test]
2123    async fn slack_unsubscribe_refusals_name_the_case() {
2124        let id = Uuid::from_u128(0xc1);
2125        let mock = Arc::new(MockSlackFacade::new());
2126        let tool = Unsubscribe { facade: mock.clone(), slot: caller_slot() };
2127
2128        *mock.unsubscribe_result.lock() = Some(Err(SlackUnsubscribeError::NotOwnedByCaller));
2129        let not_yours = tool.call(input(serde_json::json!({ "id": id.to_string() }))).await;
2130        assert!(not_yours.is_error, "a refused unsubscribe is an error");
2131        assert_eq!(
2132            text_of(&not_yours),
2133            format!("subscription {id} exists in this project but was not subscribed by you"),
2134            "a subscription that exists but is not the caller's own must say so",
2135        );
2136
2137        *mock.unsubscribe_result.lock() = Some(Err(SlackUnsubscribeError::NoSuchSubscription));
2138        let not_there = tool.call(input(serde_json::json!({ "id": id.to_string() }))).await;
2139        assert!(not_there.is_error, "an unknown id is an error");
2140        assert_eq!(
2141            text_of(&not_there),
2142            format!("no subscription with id {id} in this project"),
2143            "an id the project never had must not read as another session's subscription",
2144        );
2145    }
2146
2147    /// An unsubscribe echoes the row it removed, whole, so a reader can
2148    /// name the target that stopped; a bare uuid names nothing a human can
2149    /// place. The target here carries no name, which is also the case that
2150    /// pins the key as absent rather than null.
2151    #[tokio::test]
2152    async fn slack_unsubscribe_echoes_the_removed_row() {
2153        let mock = Arc::new(MockSlackFacade::new());
2154        let removed = owned_sub(
2155            Uuid::from_u128(0x9),
2156            SlackSubscriptionTarget::Conversation {
2157                id: "C1".to_owned(),
2158                name: None,
2159                mode: SlackWatchMode::All,
2160            },
2161        );
2162        *mock.unsubscribe_result.lock() = Some(Ok(removed.clone()));
2163        let tool = Unsubscribe { facade: mock.clone(), slot: caller_slot() };
2164
2165        let out = tool.call(input(serde_json::json!({ "id": removed.id.to_string() }))).await;
2166        assert!(!out.is_error, "unsubscribe succeeds: {out:?}");
2167        assert_eq!(out.blocks.len(), 1, "the envelope stays one text block: {out:?}");
2168        let json: serde_json::Value =
2169            serde_json::from_str(text_of(&out)).expect("the result is the adopted envelope");
2170        assert_eq!(json["status"], "deleted");
2171        assert_eq!(
2172            json["removed"],
2173            sub_to_row(&removed),
2174            "the echo is the whole row, and an unknown name stays absent: {json}",
2175        );
2176    }
2177
2178    #[tokio::test]
2179    async fn slack_unsubscribe_rejects_a_bad_uuid_without_touching_the_facade() {
2180        let mock = Arc::new(MockSlackFacade::new());
2181        let tool = Unsubscribe { facade: mock.clone(), slot: caller_slot() };
2182
2183        let out = tool.call(input(serde_json::json!({ "id": "not-a-uuid" }))).await;
2184        assert!(out.is_error);
2185        assert!(mock.unsubscribe_calls.lock().is_empty(), "a bad id never reaches the facade");
2186    }
2187
2188    #[tokio::test]
2189    async fn slack_list_marks_what_the_caller_watches() {
2190        let mock = Arc::new(MockSlackFacade::new());
2191        *mock.conversations_result.lock() =
2192            Some(Ok(vec![channel("C1", "general"), channel("C2", "random")]));
2193        *mock.subscribed_targets.lock() = vec![owned_sub(Uuid::from_u128(0x21), watching("C1"))];
2194        let tool = List { facade: mock.clone(), slot: caller_slot() };
2195
2196        let out = tool.call(input(serde_json::json!({}))).await;
2197        assert!(!out.is_error, "list succeeds: {}", text_of(&out));
2198        let listed: serde_json::Value =
2199            serde_json::from_str(text_of(&out)).expect("the output is JSON");
2200        let by_id = |id: &str| {
2201            listed["conversations"]
2202                .as_array()
2203                .expect("an array of rows")
2204                .iter()
2205                .find(|row| row["id"] == id)
2206                .expect("row for the id")
2207                .clone()
2208        };
2209        assert_eq!(by_id("C1")["subscribed"], true, "the caller's target reaches its row");
2210        assert_eq!(by_id("C2")["subscribed"], false, "an unwatched row stays unmarked");
2211        assert_eq!(
2212            by_id("C1")["subscription_ids"].as_array().expect("the ids ride the watched row").len(),
2213            1,
2214            "the caller's own subscription id reaches the row",
2215        );
2216        assert!(
2217            by_id("C2")["subscription_ids"].as_array().expect("ids ride every row").is_empty(),
2218            "an unwatched row carries no subscription ids",
2219        );
2220    }
2221
2222    #[tokio::test]
2223    async fn slack_list_filters_apply_before_the_rows_are_built() {
2224        let mock = Arc::new(MockSlackFacade::new());
2225        *mock.conversations_result.lock() =
2226            Some(Ok(vec![channel("C1", "general"), dm("D1", "U9")]));
2227        let tool = List { facade: mock.clone(), slot: caller_slot() };
2228
2229        let out = tool.call(input(serde_json::json!({ "kind": "im" }))).await;
2230        assert!(!out.is_error, "a known kind succeeds: {}", text_of(&out));
2231        let listed: serde_json::Value =
2232            serde_json::from_str(text_of(&out)).expect("the output is JSON");
2233        let ids: Vec<&str> = listed["conversations"]
2234            .as_array()
2235            .expect("an array of rows")
2236            .iter()
2237            .map(|row| row["id"].as_str().expect("an id"))
2238            .collect();
2239        assert_eq!(ids, vec!["D1"], "the kind filter keeps only that kind");
2240        assert_eq!(
2241            mock.conversations_calls.lock().len(),
2242            1,
2243            "filtering is local; the workspace is queried once",
2244        );
2245    }
2246
2247    #[tokio::test]
2248    async fn slack_list_refuses_an_unknown_kind() {
2249        let mock = Arc::new(MockSlackFacade::new());
2250        let tool = List { facade: mock.clone(), slot: caller_slot() };
2251
2252        let out = tool.call(input(serde_json::json!({ "kind": "chanels" }))).await;
2253        assert!(out.is_error, "a typo must not read as an empty workspace");
2254        assert!(
2255            text_of(&out).contains("chanels"),
2256            "the error names what was rejected: {}",
2257            text_of(&out),
2258        );
2259        assert!(
2260            mock.conversations_calls.lock().is_empty(),
2261            "an invalid filter never reaches Slack",
2262        );
2263    }
2264
2265    #[tokio::test]
2266    async fn slack_search_passes_the_query_and_count_through() {
2267        let mock = Arc::new(MockSlackFacade::new());
2268        *mock.search_result.lock() = Some(Ok(vec![forge_primitives::slack::SlackSearchMatch {
2269            ts: "100.1".to_owned(),
2270            text: "hello".to_owned(),
2271            conversation_id: "C1".to_owned(),
2272            conversation_name: Some("general".to_owned()),
2273            username: Some("ved".to_owned()),
2274            user: Some("U9".to_owned()),
2275            thread_ts: None,
2276            files: Vec::new(),
2277        }]));
2278        let tool = Search { facade: mock.clone(), slot: caller_slot() };
2279
2280        let out = tool
2281            .call(input(serde_json::json!({
2282                "query": "hello",
2283                "count": 5,
2284                "conversation": "C1",
2285            })))
2286            .await;
2287        assert!(!out.is_error, "a search succeeds: {}", text_of(&out));
2288        assert!(text_of(&out).contains("general"), "got: {}", text_of(&out));
2289        let json: serde_json::Value =
2290            serde_json::from_str(text_of(&out)).expect("the result is the search object");
2291        assert_eq!(json["workspace"], "acme", "the resolved label rides the result");
2292        assert_eq!(json["matches"][0]["text"], "hello", "the hits ride under their own key");
2293        assert_eq!(
2294            mock.search_calls.lock().as_slice(),
2295            [(caller_slot(), "hello".to_owned(), 5, Some("C1".to_owned()))],
2296            "the caller's slot, the query, the cap and the conversation filter reach the facade",
2297        );
2298    }
2299
2300    fn history_message(ts: &str, text: &str) -> SlackHistoryMessage {
2301        SlackHistoryMessage {
2302            ts: ts.to_owned(),
2303            user: Some("U1".to_owned()),
2304            text: text.to_owned(),
2305            thread_ts: None,
2306            reply_count: 0,
2307            bot_name: None,
2308            parent_user_id: None,
2309            latest_reply: None,
2310            edited: None,
2311            files: Vec::new(),
2312        }
2313    }
2314
2315    /// The bounds are what make the read a window rather than the channel's
2316    /// newest page forever: both must reach Slack, and the author's id must
2317    /// reach the caller so a name can be resolved.
2318    #[tokio::test]
2319    async fn slack_history_passes_the_bounds_and_reports_the_rows() {
2320        let mock = Arc::new(MockSlackFacade::new());
2321        *mock.history_result.lock() = Some(Ok(vec![history_message("100.5", "hello")]));
2322        let tool = History { facade: mock.clone(), slot: caller_slot() };
2323
2324        let out = tool
2325            .call(input(serde_json::json!({
2326                "conversation": "C1",
2327                "limit": 5,
2328                "oldest": "100.0",
2329                "newest": "200.0",
2330            })))
2331            .await;
2332
2333        assert!(!out.is_error, "a read succeeds: {}", text_of(&out));
2334        let json: serde_json::Value =
2335            serde_json::from_str(text_of(&out)).expect("the result is the history object");
2336        assert_eq!(json["workspace"], "acme", "the resolved label rides the result");
2337        let rows = &json["messages"];
2338        assert_eq!(rows[0]["ts"], "100.5", "the ts the caller pages with");
2339        assert_eq!(rows[0]["user"], "U1", "the author id a name resolves from");
2340        assert_eq!(rows[0]["text"], "hello");
2341        assert_eq!(
2342            mock.history_calls.lock().as_slice(),
2343            [(
2344                caller_slot(),
2345                "C1".to_owned(),
2346                5,
2347                Some("100.0".to_owned()),
2348                Some("200.0".to_owned())
2349            )],
2350            "both bounds, the limit and the conversation reach the facade",
2351        );
2352    }
2353
2354    #[tokio::test]
2355    async fn slack_history_refuses_a_limit_past_slacks_ceiling() {
2356        let mock = Arc::new(MockSlackFacade::new());
2357        let tool = History { facade: mock.clone(), slot: caller_slot() };
2358
2359        let out =
2360            tool.call(input(serde_json::json!({ "conversation": "C1", "limit": 1000 }))).await;
2361        assert!(out.is_error, "a page past Slack's ceiling cannot be served");
2362        assert!(text_of(&out).contains("999"), "the error names the ceiling: {}", text_of(&out));
2363        assert!(mock.history_calls.lock().is_empty(), "nothing reaches the facade");
2364    }
2365
2366    /// A thread read is the whole thread: the parent first and then the
2367    /// replies, which is what the read path was missing.
2368    #[tokio::test]
2369    async fn slack_replies_passes_the_parent_and_reports_the_thread() {
2370        let mock = Arc::new(MockSlackFacade::new());
2371        *mock.replies_result.lock() =
2372            Some(Ok(vec![history_message("100.0", "parent"), history_message("100.5", "reply")]));
2373        let tool = Replies { facade: mock.clone(), slot: caller_slot() };
2374
2375        let out =
2376            tool.call(input(serde_json::json!({ "conversation": "C1", "ts": "100.0" }))).await;
2377
2378        assert!(!out.is_error, "a thread read succeeds: {}", text_of(&out));
2379        let json: serde_json::Value =
2380            serde_json::from_str(text_of(&out)).expect("the result is the replies object");
2381        assert_eq!(json["workspace"], "acme", "the resolved label rides the result");
2382        let rows = &json["messages"];
2383        assert_eq!(rows[0]["text"], "parent", "the parent comes first");
2384        assert_eq!(rows[1]["text"], "reply");
2385        assert_eq!(
2386            mock.replies_calls.lock().as_slice(),
2387            [(caller_slot(), "C1".to_owned(), "100.0".to_owned(), DEFAULT_REPLIES_LIMIT)],
2388            "the conversation, parent ts and default cap reach the facade",
2389        );
2390    }
2391
2392    #[tokio::test]
2393    async fn slack_replies_refuses_a_limit_past_slacks_ceiling() {
2394        let mock = Arc::new(MockSlackFacade::new());
2395        let tool = Replies { facade: mock.clone(), slot: caller_slot() };
2396
2397        let out = tool
2398            .call(input(serde_json::json!({
2399                "conversation": "C1",
2400                "ts": "100.0",
2401                "limit": 1001,
2402            })))
2403            .await;
2404
2405        assert!(out.is_error, "a read past Slack's ceiling cannot be served");
2406        assert!(text_of(&out).contains("1000"), "the error names the ceiling: {}", text_of(&out));
2407        assert!(mock.replies_calls.lock().is_empty(), "nothing reaches the facade");
2408    }
2409
2410    /// A limit the caller passes must be the one the facade is given: a tool
2411    /// that always used the default would otherwise pass every other test.
2412    #[tokio::test]
2413    async fn slack_replies_forwards_the_callers_limit() {
2414        let mock = Arc::new(MockSlackFacade::new());
2415        let tool = Replies { facade: mock.clone(), slot: caller_slot() };
2416
2417        let out = tool
2418            .call(input(serde_json::json!({
2419                "conversation": "C1",
2420                "ts": "100.0",
2421                "limit": 50,
2422            })))
2423            .await;
2424
2425        assert!(!out.is_error, "a read succeeds: {}", text_of(&out));
2426        assert_eq!(
2427            mock.replies_calls.lock().as_slice(),
2428            [(caller_slot(), "C1".to_owned(), "100.0".to_owned(), 50)],
2429            "the caller's limit reaches the facade",
2430        );
2431    }
2432
2433    #[tokio::test]
2434    async fn slack_user_passes_the_id_through() {
2435        let mock = Arc::new(MockSlackFacade::new());
2436        *mock.user_result.lock() = Some(Ok(forge_primitives::slack::SlackUser {
2437            id: "U1".to_owned(),
2438            name: "ved".to_owned(),
2439            real_name: Some("Vedhavyas S".to_owned()),
2440            tz: Some("Asia/Kolkata".to_owned()),
2441            deleted: false,
2442            profile: None,
2443        }));
2444        let tool = User { facade: mock.clone(), slot: caller_slot() };
2445
2446        let out = tool.call(input(serde_json::json!({ "user": "U1" }))).await;
2447        assert!(!out.is_error, "a lookup succeeds: {}", text_of(&out));
2448        assert!(text_of(&out).contains("Vedhavyas S"), "got: {}", text_of(&out));
2449        let json: serde_json::Value =
2450            serde_json::from_str(text_of(&out)).expect("the result is the user record");
2451        assert_eq!(json["workspace"], "acme", "the resolved label rides the result");
2452        assert_eq!(mock.user_calls.lock().as_slice(), [(caller_slot(), "U1".to_owned())]);
2453    }
2454
2455    fn resolved_user(id: &str, name: &str, display_name: Option<&str>) -> SlackUser {
2456        SlackUser {
2457            id: id.to_owned(),
2458            name: name.to_owned(),
2459            real_name: None,
2460            tz: None,
2461            deleted: false,
2462            profile: display_name.map(|display_name| SlackUserProfile {
2463                display_name: Some(display_name.to_owned()),
2464            }),
2465        }
2466    }
2467
2468    /// The resolver hands back every match so the caller picks the id, and
2469    /// the display name rides the row: it is often what the caller typed.
2470    #[tokio::test]
2471    async fn slack_users_returns_every_match_with_its_id() {
2472        let mock = Arc::new(MockSlackFacade::new());
2473        *mock.users_result.lock() = Some(Ok(vec![
2474            resolved_user("U1", "sam", Some("Sam")),
2475            resolved_user("U2", "samuel", None),
2476        ]));
2477        let tool = Users { facade: mock.clone(), slot: caller_slot() };
2478
2479        let out = tool.call(input(serde_json::json!({ "query": "sam" }))).await;
2480
2481        assert!(!out.is_error, "a lookup succeeds: {}", text_of(&out));
2482        let json: serde_json::Value =
2483            serde_json::from_str(text_of(&out)).expect("the result is the users object");
2484        assert_eq!(json["workspace"], "acme", "the resolved label rides the result");
2485        let rows = &json["users"];
2486        assert_eq!(rows[0]["id"], "U1");
2487        assert_eq!(rows[1]["id"], "U2", "both matches are returned, none silently chosen");
2488        assert_eq!(rows[0]["display_name"], "Sam");
2489        assert_eq!(
2490            mock.users_calls.lock().as_slice(),
2491            [(caller_slot(), "sam".to_owned())],
2492            "the query reaches the facade",
2493        );
2494    }
2495
2496    #[tokio::test]
2497    async fn slack_users_refuses_an_empty_query() {
2498        let mock = Arc::new(MockSlackFacade::new());
2499        let tool = Users { facade: mock.clone(), slot: caller_slot() };
2500
2501        let out = tool.call(input(serde_json::json!({ "query": "   " }))).await;
2502
2503        assert!(out.is_error, "an empty query would return the whole workspace");
2504        assert!(mock.users_calls.lock().is_empty(), "nothing reaches the facade");
2505    }
2506
2507    /// The `[[slack]]` label and the team's own name are different things
2508    /// (`Subspace` is team `Autonomys`), so both have to reach the caller.
2509    #[tokio::test]
2510    async fn slack_me_reports_the_label_and_the_token_identity() {
2511        let mock = Arc::new(MockSlackFacade::new());
2512        *mock.me_result.lock() = Some(Ok((
2513            "Subspace".to_owned(),
2514            AuthTest {
2515                team: "Autonomys".to_owned(),
2516                user: "ved".to_owned(),
2517                team_id: "T2".to_owned(),
2518                user_id: "U2".to_owned(),
2519                url: "https://autonomys.slack.com/".to_owned(),
2520            },
2521        )));
2522        let tool = Me { facade: mock.clone(), slot: caller_slot() };
2523
2524        let out = tool.call(input(serde_json::json!({}))).await;
2525
2526        assert!(!out.is_error, "an identity lookup succeeds: {}", text_of(&out));
2527        let json: serde_json::Value =
2528            serde_json::from_str(text_of(&out)).expect("the result is the identity record");
2529        assert_eq!(json["workspace"], "Subspace", "the resolved label is named in the result");
2530        assert_eq!(json["team"], "Autonomys", "the team the token belongs to");
2531        assert_eq!(json["user"], "ved");
2532        assert_eq!(json["user_id"], "U2", "the id a mention and a message's user carry");
2533        assert_eq!(json["url"], "https://autonomys.slack.com/");
2534        assert_eq!(
2535            mock.me_calls.lock().as_slice(),
2536            [caller_slot()],
2537            "the caller's slot is what the facade resolves on",
2538        );
2539    }
2540
2541    fn pinned_row(ts: &str, text: &str) -> SlackPin {
2542        SlackPin {
2543            created: 1_700_000_000,
2544            created_by: Some("U1".to_owned()),
2545            message: Some(SlackPinMessage {
2546                ts: ts.to_owned(),
2547                user: Some("U2".to_owned()),
2548                text: text.to_owned(),
2549            }),
2550        }
2551    }
2552
2553    #[tokio::test]
2554    async fn slack_pins_passes_the_conversation_through_and_lists_the_rows() {
2555        let mock = Arc::new(MockSlackFacade::new());
2556        *mock.pins_result.lock() =
2557            Some(Ok(vec![pinned_row("1700000000.000100", "the pinned text")]));
2558        let tool = Pins { facade: mock.clone(), slot: caller_slot() };
2559
2560        let out = tool.call(input(serde_json::json!({ "conversation": "C1" }))).await;
2561        assert!(!out.is_error, "a read succeeds: {}", text_of(&out));
2562        assert!(text_of(&out).contains("the pinned text"), "got: {}", text_of(&out));
2563        assert!(
2564            text_of(&out).contains("1700000000.000100"),
2565            "the ts reaches the caller verbatim: {}",
2566            text_of(&out),
2567        );
2568        let json: serde_json::Value =
2569            serde_json::from_str(text_of(&out)).expect("the result is the pins object");
2570        assert_eq!(json["workspace"], "acme", "the resolved label rides the result");
2571        assert_eq!(
2572            mock.pins_calls.lock().as_slice(),
2573            [(caller_slot(), "C1".to_owned())],
2574            "the conversation reaches the facade",
2575        );
2576    }
2577
2578    #[tokio::test]
2579    async fn slack_pins_skips_a_row_without_a_message() {
2580        let mock = Arc::new(MockSlackFacade::new());
2581        *mock.pins_result.lock() = Some(Ok(vec![
2582            pinned_row("1700000000.000100", "readable"),
2583            SlackPin { created: 1, created_by: None, message: None },
2584        ]));
2585        let tool = Pins { facade: mock, slot: caller_slot() };
2586
2587        let out = tool.call(input(serde_json::json!({ "conversation": "C1" }))).await;
2588        assert!(!out.is_error);
2589        assert!(text_of(&out).contains("readable"), "got: {}", text_of(&out));
2590        assert_eq!(
2591            text_of(&out).matches("ts").count(),
2592            1,
2593            "a message-less row contributes nothing: {}",
2594            text_of(&out),
2595        );
2596    }
2597
2598    #[tokio::test]
2599    async fn slack_pins_surfaces_a_facade_error() {
2600        let mock = Arc::new(MockSlackFacade::new());
2601        *mock.pins_result.lock() = Some(Err(SlackReadError::Fetch("boom".to_owned())));
2602        let tool = Pins { facade: mock, slot: caller_slot() };
2603
2604        let out = tool.call(input(serde_json::json!({ "conversation": "C1" }))).await;
2605        assert!(out.is_error, "a failed read is an error, not an empty list");
2606        assert!(text_of(&out).contains("boom"), "the cause reaches the LLM: {}", text_of(&out));
2607    }
2608
2609    #[tokio::test]
2610    async fn slack_bookmarks_lists_the_rows_and_passes_the_conversation_through() {
2611        let mock = Arc::new(MockSlackFacade::new());
2612        *mock.bookmarks_result.lock() = Some(Ok(vec![SlackBookmark {
2613            id: "Bk1".to_owned(),
2614            title: Some("Runbook".to_owned()),
2615            link: Some("https://example.com".to_owned()),
2616        }]));
2617        let tool = Bookmarks { facade: mock.clone(), slot: caller_slot() };
2618
2619        let out = tool.call(input(serde_json::json!({ "conversation": "C1" }))).await;
2620        assert!(!out.is_error, "a read succeeds: {}", text_of(&out));
2621        assert!(text_of(&out).contains("Runbook"), "got: {}", text_of(&out));
2622        let json: serde_json::Value =
2623            serde_json::from_str(text_of(&out)).expect("the result is the bookmarks object");
2624        assert_eq!(json["workspace"], "acme", "the resolved label rides the result");
2625        assert_eq!(
2626            mock.bookmarks_calls.lock().as_slice(),
2627            [(caller_slot(), "C1".to_owned())],
2628            "the conversation reaches the facade",
2629        );
2630    }
2631
2632    #[tokio::test]
2633    async fn slack_attachment_without_a_file_or_a_path_is_refused() {
2634        let mock = Arc::new(MockSlackFacade::new());
2635        let tool = Attachment { facade: mock.clone(), slot: caller_slot() };
2636
2637        let out = tool.call(input(serde_json::json!({}))).await;
2638        assert!(out.is_error, "a call naming neither a file nor a path must not reach Slack");
2639        assert!(
2640            text_of(&out).contains("file_id") && text_of(&out).contains("path"),
2641            "the error says what to pass: {}",
2642            text_of(&out),
2643        );
2644        assert!(mock.fetch_calls.lock().is_empty(), "nothing reaches the facade");
2645        assert!(mock.upload_calls.lock().is_empty());
2646    }
2647
2648    #[tokio::test]
2649    async fn slack_attachment_names_the_directory_when_it_is_a_file() {
2650        let mock = Arc::new(MockSlackFacade::new());
2651        let offender = std::env::temp_dir().join("slack-attachment-not-a-dir");
2652        *mock.fetch_result.lock() =
2653            Some(Err(SlackAttachmentError::NotADirectory(offender.clone())));
2654        let tool = Attachment { facade: mock.clone(), slot: caller_slot() };
2655
2656        let out = tool
2657            .call(input(serde_json::json!({
2658                "file_id": "F1",
2659                "dir": offender.display().to_string(),
2660            })))
2661            .await;
2662        assert!(out.is_error, "a file in the dir slot is an error: {}", text_of(&out));
2663        assert!(
2664            text_of(&out).contains(&offender.display().to_string()),
2665            "the rendered error names the path the caller chose: {}",
2666            text_of(&out),
2667        );
2668    }
2669
2670    #[tokio::test]
2671    async fn slack_list_surfaces_a_facade_error() {
2672        let mock = Arc::new(MockSlackFacade::new());
2673        *mock.conversations_result.lock() = Some(Err(SlackReadError::Fetch("boom".to_owned())));
2674        let tool = List { facade: mock, slot: caller_slot() };
2675
2676        let out = tool.call(input(serde_json::json!({}))).await;
2677        assert!(out.is_error, "a failed fetch is an error, not an empty list");
2678        assert!(text_of(&out).contains("boom"), "the cause reaches the LLM: {}", text_of(&out));
2679    }
2680}