1pub(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
35pub(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
81fn 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
99fn 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 SlackSubscriptionTarget::Mentions => false,
113 })
114 .map(|sub| sub.id)
115 .collect()
116}
117
118fn 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 SlackSubscriptionTarget::Mentions => false,
127 })
128}
129
130fn 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
154fn 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
176fn 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
207const 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
290fn 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
301fn 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#[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
543fn 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
954const 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
1048const DEFAULT_HISTORY_LIMIT: u32 = 50;
1050
1051const MAX_HISTORY_LIMIT: u32 = 999;
1053
1054const DEFAULT_REPLIES_LIMIT: u32 = 200;
1056
1057const MAX_REPLIES_LIMIT: u32 = 1000;
1059
1060fn 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 #[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 #[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 #[tokio::test]
1716 async fn slack_subscribe_keeps_every_kinds_rows() {
1717 let mock = Arc::new(MockSlackFacade::new());
1718 *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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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(¬_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(¬_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 #[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 #[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 #[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 #[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 #[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 #[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}