Skip to main content

forge_workspace/mcp/slack/
facade.rs

1//! `SlackFacade` - the seam between the Slack MCP tools and workspace
2//! state. The production impl ([`ProdSlackFacade`]) resolves the
3//! caller's workspace from its project's config binding and drives that
4//! workspace's client; the mock returns preloaded results so the tool
5//! tests can assert argument handling and error surfacing without a real
6//! workspace.
7
8use std::collections::HashMap;
9use std::sync::{Arc, Weak};
10use std::time::SystemTime;
11
12use forge_connectors::slack::{
13    AuthTest, MENTION_CURSOR, SlackApi, SlackError, SlackHistoryMessage, flatten_mrkdwn,
14    mention_ids, one_line_excerpt, read_thread, shared_messages, thread_parent, user_display_name,
15};
16use forge_primitives::slack::{
17    SlackBookmark, SlackConversation, SlackDraft, SlackPerson, SlackPin, SlackSearchMatch,
18    SlackSubscription, SlackSubscriptionTarget, SlackThreadName, SlackUser, SlackWatchMode,
19};
20use uuid::Uuid;
21
22use crate::SessionSlot;
23use crate::mcp::caller_context::caller_context;
24use crate::mcp::gotify::facade::resolve_identity;
25use crate::workspace::Workspace;
26
27/// Why resolving a tool's workspace failed. One shape for every Slack
28/// tool, so the wording is the same whichever tool was called. The scope
29/// is the caller's slot, so the cases are a file with no `[[slack]]`
30/// section at all, a project with nothing bound to it, and a binding to a
31/// workspace whose entry was added or renamed since boot.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub(crate) enum SlackWorkspaceError {
34    /// No `[[slack]]` entry in forge.toml.
35    NotConfigured,
36    /// Neither the project nor its org binds a `[[slack]]` label.
37    Unbound { org: String, project: String },
38    /// The project binds a label whose client is not loaded - a `[[slack]]`
39    /// entry added or renamed since boot, whose token is captured only at
40    /// start.
41    BoundButNotLive { label: String },
42}
43
44const BINDING_KEYS: &str = "[orgs.slack] workspace = \"<label>\" to the org, or \
45                            [orgs.projects.slack] workspace = \"<label>\" to the project";
46
47/// The one wording for a refused workspace, so every Slack tool answers
48/// this case the way `slack__list` does.
49pub(crate) fn format_workspace_error(err: &SlackWorkspaceError) -> String {
50    match err {
51        SlackWorkspaceError::NotConfigured => {
52            "no Slack workspace configured in forge.toml [[slack]]".to_owned()
53        }
54        SlackWorkspaceError::Unbound { org, project } => format!(
55            "no Slack workspace is bound to {org}/{project}; add {BINDING_KEYS} in forge.toml"
56        ),
57        SlackWorkspaceError::BoundButNotLive { label } => format!(
58            "the [[slack]] workspace '{label}' is bound but not live; a [[slack]] entry added \
59             or renamed since boot needs a forge restart"
60        ),
61    }
62}
63
64/// Why `slack__subscribe` failed.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub(crate) enum SlackSubscribeError {
67    /// The caller's project resolved no live workspace.
68    Workspace(SlackWorkspaceError),
69    /// The caller couldn't be mapped to a project.
70    UnknownCallerProject,
71}
72
73/// Why `slack__unsubscribe` failed. The last two are the two cases a
74/// refusal keeps apart: an id the project holds under another session
75/// reads differently from one the project never had.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub(crate) enum SlackUnsubscribeError {
78    /// The caller couldn't be mapped to a project.
79    UnknownCallerProject,
80    /// No subscription with that id in the caller's project.
81    NoSuchSubscription,
82    /// The id is in the caller's project but belongs to another owner.
83    NotOwnedByCaller,
84}
85
86/// What a caller asked to post. Composed here, not in the connector, so
87/// the draft can be held for the user's decision before anything is sent.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub(crate) struct SlackPostRequest {
90    pub conversation: String,
91    /// `None` posts a root message; `Some(ts)` replies into that thread.
92    pub thread_ts: Option<String>,
93    pub text: String,
94}
95
96/// Why a post did not happen.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub(crate) enum SlackPostError {
99    /// The user rejected the draft. Nothing was posted.
100    Rejected,
101    /// The user sent it back with words to revise from. Nothing was posted.
102    SentBack { note: String },
103    /// Some parts of a split draft are live and the rest is not. Carries
104    /// how many landed so a retry resumes instead of duplicating.
105    Partial { posted: usize, total: usize, source: String },
106    /// The caller's project resolved no live workspace.
107    Workspace(SlackWorkspaceError),
108    /// The Web API call failed. Carries the formatted error for the LLM.
109    Fetch(String),
110}
111
112/// What a post did: the `ts` of each message it sent, in the order they
113/// went out, the conversation it landed in (the name, or the id when the
114/// lookup failed), and how many parts the text was split into. A draft past
115/// the truncation point is split and posted in sequence, so a long one is
116/// several messages a reply can attach to.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub(crate) struct SlackPostOutcome {
119    /// The `[[slack]]` label the post went into, so the result names the
120    /// workspace even though the call takes no argument for it.
121    pub workspace: String,
122    pub ts: Vec<String>,
123    pub conversation_name: String,
124    pub parts: usize,
125}
126
127/// One message to edit or delete. Both actions are gated like a post.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub(crate) struct SlackEditRequest {
130    pub conversation: String,
131    pub ts: String,
132    /// `None` deletes the message; `Some(text)` replaces its body.
133    pub text: Option<String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub(crate) enum SlackEditError {
138    /// Slack refused the change because the message was not posted by the
139    /// authenticated user. The wire's verdict, surfaced as its reason.
140    NotOwnMessage,
141    /// The user rejected the replacement. The message is untouched.
142    Rejected,
143    /// The user sent it back with words to revise from. The message is
144    /// untouched.
145    SentBack {
146        note: String,
147    },
148    Workspace(SlackWorkspaceError),
149    Fetch(String),
150}
151
152/// One reaction to add or remove.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub(crate) struct SlackReactRequest {
155    pub conversation: String,
156    pub ts: String,
157    /// Slack's shortcode, without colons, e.g. `white_check_mark`.
158    pub name: String,
159    pub add: bool,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub(crate) enum SlackReactError {
164    /// The user rejected the reaction. Nothing was added or removed.
165    Rejected,
166    /// The user sent it back with words to revise from. Nothing was added or
167    /// removed.
168    SentBack {
169        note: String,
170    },
171    Workspace(SlackWorkspaceError),
172    Fetch(String),
173}
174
175/// Why a read-only lookup failed. Shared by every read tool because their
176/// failure sets are identical; the per-operation error convention exists
177/// where the sets differ (post, edit and react each fail differently), and
178/// two names for one set would be duplication.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub(crate) enum SlackReadError {
181    Workspace(SlackWorkspaceError),
182    Fetch(String),
183}
184
185/// What a caller asked to fetch.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub(crate) struct SlackFetchRequest {
188    pub file_id: String,
189    /// The directory the file lands in. The name inside it comes from
190    /// Slack and is sanitised before it is used.
191    pub dir: std::path::PathBuf,
192}
193
194/// What a caller asked to upload.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub(crate) struct SlackUploadRequest {
197    pub conversation: String,
198    /// `None` posts a root message; `Some(ts)` replies into that thread.
199    pub thread_ts: Option<String>,
200    pub path: std::path::PathBuf,
201    pub title: Option<String>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub(crate) enum SlackAttachmentError {
206    /// The file could not be read from disk, or written to it.
207    Io(String),
208    /// The user rejected the upload. Nothing was sent.
209    Rejected,
210    /// The user sent it back with words to revise from. Nothing was sent.
211    SentBack {
212        note: String,
213    },
214    Workspace(SlackWorkspaceError),
215    /// The Web API or the transfer failed.
216    Fetch(String),
217    /// The `dir` a fetch should land in exists but is not a directory.
218    NotADirectory(std::path::PathBuf),
219}
220
221/// A file name that came from Slack is untrusted input: it is whatever the
222/// uploader chose to call the file. Take only the final path component,
223/// refuse `.` and `..`, and fall back to the file id when nothing usable
224/// is left - so the result can only ever be one name inside the directory
225/// the caller asked for.
226fn safe_file_name(name: &str, fallback: &str) -> String {
227    let base = name.rsplit(['/', '\\']).next().unwrap_or("").trim();
228    if base.is_empty() || base == "." || base == ".." {
229        return fallback.to_owned();
230    }
231    base.to_owned()
232}
233
234/// How many search pages a conversation filter scans. A named conversation
235/// is narrowed server-side and usually answers on the first page; an unnamed
236/// DM is filtered locally, and five pages is where a sparse hit stops being
237/// worth another call against the token's allowance.
238const MAX_SEARCH_FILTER_PAGES: usize = 5;
239
240/// What a caller asked to watch, before it becomes records.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub(crate) enum SlackSubscribeRequest {
243    /// The whole DM class in this workspace.
244    DirectMessages,
245    /// Named conversations, each with its own mode.
246    Conversations(Vec<SlackChannelWatch>),
247    /// Being mentioned anywhere in the workspace.
248    Mentions,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub(crate) struct SlackChannelWatch {
253    pub id: String,
254    pub mode: SlackWatchMode,
255}
256
257/// The current instant as a Slack `ts`, so a new subscription can start
258/// from now rather than from the channel's history.
259fn slack_ts_now() -> String {
260    let now = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
261    format!("{}.{:06}", now.as_secs(), now.subsec_micros())
262}
263
264/// Whether `user` answers to `needle`, which is already lowercase: a
265/// case-insensitive substring over the handle, the real name and the display
266/// name, which is the one people actually type.
267fn user_matches(user: &SlackUser, needle: &str) -> bool {
268    let display_name = user.profile.as_ref().and_then(|profile| profile.display_name.as_deref());
269    [Some(user.name.as_str()), user.real_name.as_deref(), display_name]
270        .into_iter()
271        .flatten()
272        .any(|field| !field.is_empty() && field.to_lowercase().contains(needle))
273}
274
275/// The workspace `caller`'s project acts in, and the client for it: the
276/// project's binding, else its org's, both resolved at load.
277fn resolve_in(
278    ws: &Workspace,
279    caller: &SessionSlot,
280) -> Result<(String, Arc<dyn SlackApi>), SlackWorkspaceError> {
281    if ws.slack.is_empty() {
282        return Err(SlackWorkspaceError::NotConfigured);
283    }
284    let unbound = || SlackWorkspaceError::Unbound {
285        org: caller.org().to_owned(),
286        project: caller.project().to_owned(),
287    };
288    let Some(label) = ws.slack_workspace_for_slot(caller) else {
289        return Err(unbound());
290    };
291    let Some(api) = ws.slack.client(&label) else {
292        return Err(SlackWorkspaceError::BoundButNotLive { label });
293    };
294    Ok((label, api))
295}
296
297/// The Slack tools' view of the workspace.
298#[async_trait::async_trait]
299pub(crate) trait SlackFacade: Send + Sync {
300    /// Every conversation the caller's workspace token is in, with the
301    /// label it came from - the one resolution the tool's result names, so
302    /// a reload between calls cannot report a different workspace than the
303    /// data was read from.
304    async fn conversations(
305        &self,
306        caller: &SessionSlot,
307    ) -> Result<(String, Vec<SlackConversation>), SlackReadError>;
308
309    /// The caller's OWN subscriptions in `label` - a lead's the lead's, a
310    /// worker's its own, never another owner's. `slack__list` passes the
311    /// label its read resolved, so the mark and the rows cannot disagree
312    /// about which workspace they are in. After a restart these ids are
313    /// the only handle a session has to unsubscribe with.
314    fn subscribed_targets(&self, caller: &SessionSlot, label: &str) -> Vec<SlackSubscription>;
315
316    /// Post a message, held for the user's decision first. Returns only
317    /// once that decision is in, and nothing is sent unless it was an
318    /// approval - a rejected or unanswered draft is `Rejected`.
319    async fn post(
320        &self,
321        caller: &SessionSlot,
322        request: SlackPostRequest,
323    ) -> Result<SlackPostOutcome, SlackPostError>;
324
325    /// Replace the body of one of the user's own messages, or delete it
326    /// when `text` is `None`.
327    ///
328    /// Both arms are gated exactly as `slack__post` is. A replacement puts
329    /// new words in front of people as the user, which is the same act a
330    /// post is; a deletion changes what they see on a message attributed
331    /// to him and, unlike a bad edit, cannot be fixed by editing again.
332    /// Either way the call waits for the decision and fails closed on a
333    /// rejected or unanswered draft.
334    async fn edit(
335        &self,
336        caller: &SessionSlot,
337        request: SlackEditRequest,
338    ) -> Result<(), SlackEditError>;
339
340    /// Add or remove one reaction, held for the user's decision first -
341    /// a reaction is authored content in the user's name.
342    async fn react(
343        &self,
344        caller: &SessionSlot,
345        request: SlackReactRequest,
346    ) -> Result<(), SlackReactError>;
347
348    /// Fetch one Slack file to `request.dir` and return where it landed.
349    /// Not gated: reading a file is not posting one, and the name is
350    /// sanitised so it cannot escape the directory.
351    async fn fetch_attachment(
352        &self,
353        caller: &SessionSlot,
354        request: SlackFetchRequest,
355    ) -> Result<std::path::PathBuf, SlackAttachmentError>;
356
357    /// Upload a local file into a conversation, held for the user's
358    /// decision first - an upload posts new content, so it is a first
359    /// send and belongs behind the same gate as `slack__post`.
360    async fn post_attachment(
361        &self,
362        caller: &SessionSlot,
363        request: SlackUploadRequest,
364    ) -> Result<(), SlackAttachmentError>;
365
366    /// `search.messages` in a workspace, always timestamp-ordered. It
367    /// matches text; it cannot find every message that mentions a user.
368    /// `conversation` keeps only hits from that conversation id.
369    async fn search(
370        &self,
371        caller: &SessionSlot,
372        query: &str,
373        count: u32,
374        conversation: Option<&str>,
375    ) -> Result<(String, Vec<SlackSearchMatch>), SlackReadError>;
376
377    /// One page of a conversation's messages. `oldest` and `newest` are
378    /// exclusive bounds, so a caller pages backwards by feeding the earliest
379    /// `ts` it saw back as `newest`.
380    async fn history(
381        &self,
382        caller: &SessionSlot,
383        conversation: &str,
384        limit: u32,
385        oldest: Option<&str>,
386        newest: Option<&str>,
387    ) -> Result<(String, Vec<SlackHistoryMessage>), SlackReadError>;
388
389    /// Every reply in one thread, the parent first, up to `limit` rows.
390    async fn replies(
391        &self,
392        caller: &SessionSlot,
393        conversation: &str,
394        ts: &str,
395        limit: u32,
396    ) -> Result<(String, Vec<SlackHistoryMessage>), SlackReadError>;
397
398    /// `users.info` for one user id.
399    async fn user(
400        &self,
401        caller: &SessionSlot,
402        user: &str,
403    ) -> Result<(String, SlackUser), SlackReadError>;
404
405    /// Every member whose handle, real name or display name contains
406    /// `query`, case-insensitively. Every match is returned: silently
407    /// picking one of several is how a mention reaches the wrong person.
408    /// Deactivated members are excluded.
409    async fn users(
410        &self,
411        caller: &SessionSlot,
412        query: &str,
413    ) -> Result<(String, Vec<SlackUser>), SlackReadError>;
414
415    /// `auth.test`: the resolved `[[slack]]` label and the identity the
416    /// workspace's token acts as. The label is returned alongside because it
417    /// can differ from the team's name (`Subspace` is team `Autonomys`).
418    async fn me(&self, caller: &SessionSlot) -> Result<(String, AuthTest), SlackReadError>;
419
420    /// `pins.list` for one conversation.
421    async fn pins(
422        &self,
423        caller: &SessionSlot,
424        conversation: &str,
425    ) -> Result<(String, Vec<SlackPin>), SlackReadError>;
426
427    /// `bookmarks.list` for one conversation.
428    async fn bookmarks(
429        &self,
430        caller: &SessionSlot,
431        conversation: &str,
432    ) -> Result<(String, Vec<SlackBookmark>), SlackReadError>;
433
434    /// Record what the caller wants to watch in its project's workspace,
435    /// one record per target. Returns the records as they now stand, so a
436    /// caller can name what it created.
437    fn subscribe(
438        &self,
439        caller: &SessionSlot,
440        request: SlackSubscribeRequest,
441    ) -> Result<Vec<SlackSubscription>, SlackSubscribeError>;
442
443    /// Remove one of the caller's OWN subscriptions by id within its
444    /// project, returning the row as it stood. A refusal says which case
445    /// it is: [`SlackUnsubscribeError::NoSuchSubscription`] when the
446    /// project has no such id, [`SlackUnsubscribeError::NotOwnedByCaller`]
447    /// when it has one under another owner.
448    fn unsubscribe(
449        &self,
450        caller: &SessionSlot,
451        id: Uuid,
452    ) -> Result<SlackSubscription, SlackUnsubscribeError>;
453}
454
455/// Production facade over `Weak<Workspace>` (weak to avoid a cycle with
456/// the MCP server the workspace owns).
457pub(crate) struct ProdSlackFacade {
458    workspace: Weak<Workspace>,
459}
460
461/// How a held draft ended: the reader's answer, or nothing at all when the
462/// asking session went away mid-wait.
463#[derive(Debug, Clone, PartialEq, Eq)]
464pub(crate) enum GateDecision {
465    Approved,
466    Rejected,
467    /// The reader did not send it, and wrote words for the agent to revise
468    /// from.
469    SentBack {
470        note: String,
471    },
472}
473
474/// Resolves the draft if the awaiting future is dropped before the reader
475/// answers - the calling session died mid-wait. Without this the registry
476/// entry and its oneshot sender outlive everyone who could answer them.
477struct ResolveOnDrop {
478    workspace: Arc<Workspace>,
479    id: Uuid,
480    caller: SessionSlot,
481}
482
483impl Drop for ResolveOnDrop {
484    fn drop(&mut self) {
485        // The waiter is gone rather than answering: the drop path is a
486        // session that died mid-wait, never a decision.
487        self.workspace.resolve_slack_draft(
488            self.id,
489            &self.caller,
490            crate::slack::DraftResolution::Abandoned,
491        );
492    }
493}
494
495impl ProdSlackFacade {
496    pub(crate) fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn SlackFacade> {
497        Arc::new(Self { workspace: Arc::downgrade(workspace) })
498    }
499
500    /// The workspace a tool call acts on, with the refusal shape every Slack
501    /// tool shares.
502    fn resolve(
503        &self,
504        caller: &SessionSlot,
505    ) -> Result<(String, Arc<dyn SlackApi>), SlackWorkspaceError> {
506        let ws = self.workspace.upgrade().ok_or(SlackWorkspaceError::NotConfigured)?;
507        resolve_in(&ws, caller)
508    }
509
510    /// The conversation a request addresses, as `(id, display label)`.
511    /// `conversation` may be an id, a channel name or a DM's partner, and
512    /// the id is what the ledger and the in-flight mark key on - a post or
513    /// an edit addressed one way lands in the same conversation the sweep
514    /// reads. The label is what the approval prompt shows: a channel's name
515    /// carries the `#` the card draws, a DM is named after its partner - the
516    /// partner's id when Slack names none. A failed lookup is not a reason to
517    /// refuse the action - but it IS why the prompt would show an id, so it
518    /// says so.
519    async fn resolve_conversation(
520        api: &Arc<dyn SlackApi>,
521        workspace: &str,
522        conversation: &str,
523    ) -> (String, String) {
524        // A caller may echo the card's own `#channel` label back at a tool.
525        // Slack's directory keys on the bare name, so the `#` is not part of
526        // the match - a channel has one spelling across every tool.
527        let wanted = conversation.strip_prefix('#').unwrap_or(conversation);
528        let conversations = match api.list_conversations().await {
529            Ok(conversations) => conversations,
530            Err(error) => {
531                tracing::warn!(
532                    target: "forge_workspace::slack",
533                    event_name = "slack_conversation_list_failed",
534                    workspace,
535                    conversation,
536                    %error,
537                    "listing conversations failed; the approval prompt names the id instead",
538                );
539                return (wanted.to_owned(), conversation.to_owned());
540            }
541        };
542        match conversations.iter().find(|known| {
543            known.id == wanted
544                || known.name.as_deref() == Some(wanted)
545                || known.user.as_deref() == Some(wanted)
546        }) {
547            Some(known) => {
548                let label = match known.name.as_deref().filter(|name| !name.is_empty()) {
549                    // A group DM's generated name is Slack's own label, not a
550                    // channel's, so only a channel carries the `#`.
551                    Some(name) if !known.is_im && !known.is_mpim => format!("#{name}"),
552                    Some(name) => name.to_owned(),
553                    None => known.user.clone().unwrap_or_else(|| known.id.clone()),
554                };
555                (known.id.clone(), label)
556            }
557            None => (wanted.to_owned(), conversation.to_owned()),
558        }
559    }
560
561    /// How much of a thread's parent the card's line quotes.
562    const THREAD_EXCERPT_CHARS: usize = 80;
563
564    /// Name the draft's thread and the people its text tags, at the moment
565    /// it is parked. Never fails the park: whatever resolves rides the draft,
566    /// and what does not stays unset.
567    async fn name_draft(
568        ws: &Arc<Workspace>,
569        api: &Arc<dyn SlackApi>,
570        label: &str,
571        conversation: &str,
572        draft: &mut SlackDraft,
573    ) {
574        let host = Arc::new(crate::slack::SlackSubsystemHost::new(ws));
575        // A tag lookup is a Slack call, so they run beside the thread read
576        // rather than one after another in front of the park. The parent's
577        // own mentions join the same set once it lands, so one lookup names
578        // a person whether the draft tags them or the quoted parent does.
579        let mut lookups = Vec::new();
580        let mut looked_up: Vec<String> = Vec::new();
581        for id in mention_ids(&draft.text) {
582            lookups.push((id.clone(), Self::look_up_user(&host, api, label, id.clone())));
583            looked_up.push(id);
584        }
585        let mut parent = None;
586        if let Some(ts) = draft.thread_ts.clone() {
587            parent = match thread_parent(api.as_ref(), conversation, &ts).await {
588                // A read anchored at a message inside a thread answers the
589                // thread's parent instead. Naming it would label an edit or
590                // a delete with the wrong message, so only an exact ts is
591                // the message the card is about; anything else leaves the
592                // name unset.
593                Ok(Some(parent)) if parent.ts == ts => Some(parent),
594                Ok(_) => None,
595                Err(error) => {
596                    // A missing thread name is the only other symptom, so a
597                    // persistent failure must not live at debug, where no
598                    // shipped directive reaches it.
599                    let first = ws.slack_thread_failures.lock().insert((
600                        label.to_owned(),
601                        conversation.to_owned(),
602                        ts.clone(),
603                    ));
604                    if first {
605                        tracing::warn!(
606                            target: "forge_workspace::slack",
607                            event_name = "slack_draft_thread_unreadable",
608                            workspace = label,
609                            conversation,
610                            parent = %ts,
611                            %error,
612                            "reading a held draft's thread parent failed",
613                        );
614                    } else {
615                        tracing::debug!(
616                            target: "forge_workspace::slack",
617                            event_name = "slack_draft_thread_unreadable",
618                            workspace = label,
619                            conversation,
620                            parent = %ts,
621                            %error,
622                            "reading a held draft's thread parent failed again",
623                        );
624                    }
625                    None
626                }
627            };
628        }
629        if let Some(parent) = &parent {
630            for id in mention_ids(&parent.text) {
631                if !looked_up.contains(&id) {
632                    lookups.push((id.clone(), Self::look_up_user(&host, api, label, id.clone())));
633                    looked_up.push(id);
634                }
635            }
636            // The author's name joins the same batch, so naming the thread
637            // does not cost a Slack call of its own. A bot already names
638            // itself in the payload, so only a user author is looked up.
639            let bot_named = parent.bot_name.as_deref().is_some_and(|name| !name.is_empty());
640            if !bot_named
641                && let Some(user) = parent.user.as_deref().filter(|user| !user.is_empty())
642                && !looked_up.iter().any(|seen| seen.as_str() == user)
643            {
644                lookups.push((
645                    user.to_owned(),
646                    Self::look_up_user(&host, api, label, user.to_owned()),
647                ));
648                looked_up.push(user.to_owned());
649            }
650        }
651        let mut names = HashMap::new();
652        for (id, lookup) in lookups {
653            if let Some(name) = lookup.await.unwrap_or(None) {
654                names.insert(id, name);
655            }
656        }
657        draft.tagged = mention_ids(&draft.text)
658            .into_iter()
659            .map(|id| SlackPerson { name: names.get(&id).cloned(), id })
660            .collect();
661        draft.thread = parent.as_ref().and_then(|parent| Self::thread_name(parent, &names));
662    }
663
664    /// One user-name lookup on its own task, so a batch of them overlaps the
665    /// thread read in front of the park.
666    fn look_up_user(
667        host: &Arc<crate::slack::SlackSubsystemHost>,
668        api: &Arc<dyn SlackApi>,
669        label: &str,
670        user: String,
671    ) -> tokio::task::JoinHandle<Option<String>> {
672        let host = Arc::clone(host);
673        let api = Arc::clone(api);
674        let workspace = label.to_owned();
675        tokio::spawn(async move {
676            user_display_name(host.as_ref(), api.as_ref(), &workspace, &user).await
677        })
678    }
679
680    /// The thread name drawn for `parent`: its author and one line of its
681    /// text flattened to prose. `None` when the parent names no author at all.
682    fn thread_name(
683        parent: &SlackHistoryMessage,
684        names: &HashMap<String, String>,
685    ) -> Option<SlackThreadName> {
686        let author = if let Some(name) = parent.bot_name.as_deref().filter(|name| !name.is_empty())
687        {
688            // A bot names itself in the payload; that is its display name.
689            name.to_owned()
690        } else {
691            let user = parent.user.as_deref().filter(|user| !user.is_empty())?;
692            names.get(user).cloned().unwrap_or_else(|| user.to_owned())
693        };
694        Some(SlackThreadName {
695            author,
696            excerpt: one_line_excerpt(
697                &flatten_mrkdwn(&parent.text, names, Self::THREAD_EXCERPT_CHARS),
698                Self::THREAD_EXCERPT_CHARS,
699            ),
700        })
701    }
702
703    /// Register a draft for the user's decision and wait for it. The one
704    /// place "did the user approve this" is decided, so the fail-closed
705    /// path exists once rather than at each gated operation - posting,
706    /// replacing a body and deleting one all come through here.
707    ///
708    /// **No timeout rides the wait.** A held post is an ask like a question,
709    /// a permission or a hand-off: it parks until the reader answers or the
710    /// asking session goes away, and the dock's own "don't send" is the way
711    /// out. A clock here would refuse a draft the user was about to approve.
712    ///
713    /// The views hear `SlackDraftResolved` from the answering dispatch: the
714    /// draft leaving the registry is the whole of what a view does with it,
715    /// and every path that removes it emits that one signal.
716    async fn await_approval(
717        workspace: &Arc<Workspace>,
718        caller: &SessionSlot,
719        draft: SlackDraft,
720    ) -> GateDecision {
721        let (id, decision) = workspace.register_slack_draft(caller, draft);
722        let guard = ResolveOnDrop { workspace: Arc::clone(workspace), id, caller: caller.clone() };
723        let resolved = match decision.await {
724            Ok(forge_primitives::slack::SlackDraftAnswer::Approve) => GateDecision::Approved,
725            Ok(forge_primitives::slack::SlackDraftAnswer::SendBack { note }) => {
726                GateDecision::SentBack { note }
727            }
728            // A dead waiter is the abandoned path, and it fails closed on
729            // the same side a decline does.
730            Ok(forge_primitives::slack::SlackDraftAnswer::Decline) | Err(_) => {
731                GateDecision::Rejected
732            }
733        };
734        drop(guard);
735        resolved
736    }
737}
738
739#[async_trait::async_trait]
740impl SlackFacade for ProdSlackFacade {
741    async fn conversations(
742        &self,
743        caller: &SessionSlot,
744    ) -> Result<(String, Vec<SlackConversation>), SlackReadError> {
745        let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
746        let conversations =
747            api.list_conversations().await.map_err(|err| SlackReadError::Fetch(err.to_string()))?;
748        Ok((label, conversations))
749    }
750
751    async fn post(
752        &self,
753        caller: &SessionSlot,
754        request: SlackPostRequest,
755    ) -> Result<SlackPostOutcome, SlackPostError> {
756        let ws = self
757            .workspace
758            .upgrade()
759            .ok_or(SlackPostError::Workspace(SlackWorkspaceError::NotConfigured))?;
760        let (label, api) = resolve_in(&ws, caller).map_err(SlackPostError::Workspace)?;
761        let (conversation, conversation_label) =
762            Self::resolve_conversation(&api, &label, &request.conversation).await;
763        let mut draft = SlackDraft {
764            id: Uuid::new_v4(),
765            workspace: label,
766            conversation: request.conversation,
767            conversation_label,
768            thread_ts: request.thread_ts,
769            text: request.text,
770            operation: forge_primitives::slack::SlackDraftOperation::Post,
771            thread: None,
772            tagged: Vec::new(),
773        };
774        let workspace = draft.workspace.clone();
775        Self::name_draft(&ws, &api, &workspace, &conversation, &mut draft).await;
776        match Self::await_approval(&ws, caller, draft.clone()).await {
777            GateDecision::Approved => {}
778            GateDecision::Rejected => return Err(SlackPostError::Rejected),
779            GateDecision::SentBack { note } => return Err(SlackPostError::SentBack { note }),
780        }
781        // The agent-facing result names the channel the bare way every other
782        // tool does; the `#` is the card's spelling, not the tool's.
783        let conversation_name = draft
784            .conversation_label
785            .strip_prefix('#')
786            .unwrap_or(&draft.conversation_label)
787            .to_owned();
788        let parts = forge_connectors::slack::split_for_post(&draft.text);
789        let mut ts: Vec<String> = Vec::new();
790        for part in &parts {
791            // One guard per call, covering the call and its ledger write:
792            // each part has its own request budget, so a stamp taken once
793            // for the whole operation would expire between parts on a slow
794            // but healthy Slack.
795            let _in_flight = ws.begin_slack_post(&draft.workspace, &conversation);
796            match api.post_message(&draft.conversation, part, draft.thread_ts.as_deref()).await {
797                Ok(posted) => {
798                    // The channel Slack answers with, not the request
799                    // string: a DM addressed by user id lands in `D...`,
800                    // and the sweep looks it up by that id. Recorded per
801                    // part as it lands, so a post that fails halfway still
802                    // refuses the parts Slack took.
803                    let landed = if let Some(channel) = posted.channel.as_deref() {
804                        channel
805                    } else {
806                        tracing::debug!(
807                            target: "forge_workspace::slack",
808                            event_name = "slack_post_channel_omitted",
809                            workspace = %draft.workspace,
810                            conversation = %draft.conversation,
811                            "chat.postMessage answered no channel; the ledger keys on the resolved conversation",
812                        );
813                        &conversation
814                    };
815                    ws.record_slack_own_post(&draft.workspace, landed, &posted.ts);
816                    ts.push(posted.ts);
817                }
818                Err(error) if ts.is_empty() => {
819                    // Nothing landed, so the failure is an ordinary one.
820                    return Err(SlackPostError::Fetch(error.to_string()));
821                }
822                Err(error) => {
823                    // Say exactly what landed: a truncated multi-part post
824                    // is live and readable, so a retry must resume rather
825                    // than duplicate the parts already sent.
826                    return Err(SlackPostError::Partial {
827                        posted: ts.len(),
828                        total: parts.len(),
829                        source: error.to_string(),
830                    });
831                }
832            }
833        }
834        Ok(SlackPostOutcome {
835            workspace: draft.workspace.clone(),
836            ts,
837            conversation_name,
838            parts: parts.len(),
839        })
840    }
841
842    async fn edit(
843        &self,
844        caller: &SessionSlot,
845        request: SlackEditRequest,
846    ) -> Result<(), SlackEditError> {
847        let ws = self
848            .workspace
849            .upgrade()
850            .ok_or(SlackEditError::Workspace(SlackWorkspaceError::NotConfigured))?;
851        let (label, api) = resolve_in(&ws, caller).map_err(SlackEditError::Workspace)?;
852
853        // A replacement puts new words in front of people as the user, and
854        // a deletion changes what they see on a message attributed to him -
855        // and unlike a bad edit it cannot be fixed by editing again. Both
856        // go through the same gate a post does.
857        let (conversation, conversation_label) =
858            Self::resolve_conversation(&api, &label, &request.conversation).await;
859        let mut draft = SlackDraft {
860            id: Uuid::new_v4(),
861            workspace: label.clone(),
862            conversation: request.conversation.clone(),
863            conversation_label,
864            thread_ts: Some(request.ts.clone()),
865            text: match &request.text {
866                Some(text) => text.clone(),
867                None => format!("[delete] message {}", request.ts),
868            },
869            operation: match &request.text {
870                Some(_) => forge_primitives::slack::SlackDraftOperation::Edit,
871                None => forge_primitives::slack::SlackDraftOperation::Delete,
872            },
873            thread: None,
874            tagged: Vec::new(),
875        };
876        let workspace = draft.workspace.clone();
877        Self::name_draft(&ws, &api, &workspace, &conversation, &mut draft).await;
878        match Self::await_approval(&ws, caller, draft).await {
879            GateDecision::Approved => {}
880            GateDecision::Rejected => return Err(SlackEditError::Rejected),
881            GateDecision::SentBack { note } => return Err(SlackEditError::SentBack { note }),
882        }
883
884        // Ownership is the wire's verdict, never a local lookup's:
885        // conversations.history cannot see thread replies, so a pre-check
886        // would wrongly refuse an edit of the user's own reply.
887        let result = match &request.text {
888            Some(text) => api.update_message(&request.conversation, &request.ts, text).await,
889            None => api.delete_message(&request.conversation, &request.ts).await,
890        };
891        match result {
892            Ok(()) => {
893                // An edit or a delete keeps the message's ts, so this is
894                // the identity the sweep sees - and the agent authored the
895                // change, whoever posted the message first. Keyed by the
896                // resolved id: the request may have named the DM's partner.
897                ws.record_slack_own_post(&label, &conversation, &request.ts);
898                Ok(())
899            }
900            Err(SlackError::Api { error, .. })
901                if error == "cant_update_message" || error == "cant_delete_message" =>
902            {
903                Err(SlackEditError::NotOwnMessage)
904            }
905            Err(err) => Err(SlackEditError::Fetch(err.to_string())),
906        }
907    }
908
909    async fn react(
910        &self,
911        caller: &SessionSlot,
912        request: SlackReactRequest,
913    ) -> Result<(), SlackReactError> {
914        let ws = self
915            .workspace
916            .upgrade()
917            .ok_or(SlackReactError::Workspace(SlackWorkspaceError::NotConfigured))?;
918        let (label, api) = resolve_in(&ws, caller).map_err(SlackReactError::Workspace)?;
919
920        // A reaction is authored content in the user's name, so it goes
921        // through the same gate a post does.
922        let verb = if request.add { "react" } else { "unreact" };
923        let (conversation, conversation_label) =
924            Self::resolve_conversation(&api, &label, &request.conversation).await;
925        let mut draft = SlackDraft {
926            id: Uuid::new_v4(),
927            workspace: label,
928            conversation: request.conversation.clone(),
929            conversation_label,
930            thread_ts: Some(request.ts.clone()),
931            text: format!("[{verb}: {}]", request.name),
932            operation: if request.add {
933                forge_primitives::slack::SlackDraftOperation::React
934            } else {
935                forge_primitives::slack::SlackDraftOperation::Unreact
936            },
937            thread: None,
938            tagged: Vec::new(),
939        };
940        let workspace = draft.workspace.clone();
941        Self::name_draft(&ws, &api, &workspace, &conversation, &mut draft).await;
942        match Self::await_approval(&ws, caller, draft).await {
943            GateDecision::Approved => {}
944            GateDecision::Rejected => return Err(SlackReactError::Rejected),
945            GateDecision::SentBack { note } => return Err(SlackReactError::SentBack { note }),
946        }
947
948        api.set_reaction(&request.conversation, &request.ts, &request.name, request.add)
949            .await
950            .map_err(|err| SlackReactError::Fetch(err.to_string()))
951    }
952
953    async fn search(
954        &self,
955        caller: &SessionSlot,
956        query: &str,
957        count: u32,
958        conversation: Option<&str>,
959    ) -> Result<(String, Vec<SlackSearchMatch>), SlackReadError> {
960        let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
961        let Some(conversation) = conversation else {
962            let matches = api
963                .search_messages(query, count, None)
964                .await
965                .map(|page| page.matches)
966                .map_err(|err| SlackReadError::Fetch(err.to_string()))?;
967            return Ok((label, matches));
968        };
969
970        // Slack's search language narrows by channel NAME; an id in `in:`
971        // finds nothing. A DM has no name to narrow on, so its hits are
972        // filtered locally instead. Either way the id is the filter, so a
973        // name that resolves to another conversation cannot leak a foreign
974        // hit.
975        let name = api
976            .list_conversations()
977            .await
978            .map_err(|err| SlackReadError::Fetch(err.to_string()))?
979            .into_iter()
980            .find(|known| known.id == conversation)
981            // `in:` takes a channel name. A DM has none, and a group DM's
982            // generated name is not one Slack's search understands, so those
983            // stay local-only rather than narrowing to nothing.
984            .filter(|known| !known.is_im && !known.is_mpim)
985            .and_then(|known| known.name)
986            .filter(|name| !name.is_empty());
987        let narrowed = name.map_or_else(|| query.to_owned(), |name| format!("in:{name} {query}"));
988
989        let mut matches = Vec::new();
990        let mut cursor: Option<String> = None;
991        for _ in 0..MAX_SEARCH_FILTER_PAGES {
992            let page = api
993                .search_messages(&narrowed, count, cursor.as_deref())
994                .await
995                .map_err(|err| SlackReadError::Fetch(err.to_string()))?;
996            matches
997                .extend(page.matches.into_iter().filter(|hit| hit.conversation_id == conversation));
998            if matches.len() >= count as usize {
999                break;
1000            }
1001            match page.next_cursor {
1002                Some(next) => cursor = Some(next),
1003                None => break,
1004            }
1005        }
1006        matches.truncate(count as usize);
1007        Ok((label, matches))
1008    }
1009
1010    async fn history(
1011        &self,
1012        caller: &SessionSlot,
1013        conversation: &str,
1014        limit: u32,
1015        oldest: Option<&str>,
1016        newest: Option<&str>,
1017    ) -> Result<(String, Vec<SlackHistoryMessage>), SlackReadError> {
1018        let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1019        let messages = api
1020            .history(conversation, oldest, newest, limit, None)
1021            .await
1022            .map(|page| page.messages)
1023            .map_err(|err| SlackReadError::Fetch(err.to_string()))?;
1024        Ok((label, messages))
1025    }
1026
1027    async fn replies(
1028        &self,
1029        caller: &SessionSlot,
1030        conversation: &str,
1031        ts: &str,
1032        limit: u32,
1033    ) -> Result<(String, Vec<SlackHistoryMessage>), SlackReadError> {
1034        let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1035        let messages = read_thread(api.as_ref(), conversation, ts, limit)
1036            .await
1037            .map_err(|err| SlackReadError::Fetch(err.to_string()))?;
1038        Ok((label, messages))
1039    }
1040
1041    async fn user(
1042        &self,
1043        caller: &SessionSlot,
1044        user: &str,
1045    ) -> Result<(String, SlackUser), SlackReadError> {
1046        let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1047        let user =
1048            api.user_info(user).await.map_err(|err| SlackReadError::Fetch(err.to_string()))?;
1049        Ok((label, user))
1050    }
1051
1052    async fn users(
1053        &self,
1054        caller: &SessionSlot,
1055        query: &str,
1056    ) -> Result<(String, Vec<SlackUser>), SlackReadError> {
1057        let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1058        let needle = query.trim().to_lowercase();
1059        // An empty needle would match the whole directory; the tool refuses
1060        // it, and a direct facade call gets nothing rather than everyone.
1061        if needle.is_empty() {
1062            return Ok((label, Vec::new()));
1063        }
1064        let mut found: Vec<SlackUser> = api
1065            .list_users()
1066            .await
1067            .map_err(|err| SlackReadError::Fetch(err.to_string()))?
1068            .into_iter()
1069            .filter(|user| !user.deleted && user_matches(user, &needle))
1070            .collect();
1071        found.sort_by(|left, right| left.name.cmp(&right.name));
1072        Ok((label, found))
1073    }
1074
1075    async fn me(&self, caller: &SessionSlot) -> Result<(String, AuthTest), SlackReadError> {
1076        let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1077        let auth = api.auth_test().await.map_err(|err| SlackReadError::Fetch(err.to_string()))?;
1078        Ok((label, auth))
1079    }
1080
1081    async fn pins(
1082        &self,
1083        caller: &SessionSlot,
1084        conversation: &str,
1085    ) -> Result<(String, Vec<SlackPin>), SlackReadError> {
1086        let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1087        let pins =
1088            api.pins(conversation).await.map_err(|err| SlackReadError::Fetch(err.to_string()))?;
1089        Ok((label, pins))
1090    }
1091
1092    async fn bookmarks(
1093        &self,
1094        caller: &SessionSlot,
1095        conversation: &str,
1096    ) -> Result<(String, Vec<SlackBookmark>), SlackReadError> {
1097        let (label, api) = self.resolve(caller).map_err(SlackReadError::Workspace)?;
1098        let bookmarks = api
1099            .bookmarks(conversation)
1100            .await
1101            .map_err(|err| SlackReadError::Fetch(err.to_string()))?;
1102        Ok((label, bookmarks))
1103    }
1104
1105    async fn fetch_attachment(
1106        &self,
1107        caller: &SessionSlot,
1108        request: SlackFetchRequest,
1109    ) -> Result<std::path::PathBuf, SlackAttachmentError> {
1110        let (_, api) = self.resolve(caller).map_err(SlackAttachmentError::Workspace)?;
1111
1112        // The directory is checked and created before Slack is contacted,
1113        // so a bad `dir` costs no round trips.
1114        if let Ok(meta) = std::fs::metadata(&request.dir)
1115            && !meta.is_dir()
1116        {
1117            return Err(SlackAttachmentError::NotADirectory(request.dir.clone()));
1118        }
1119        std::fs::create_dir_all(&request.dir)
1120            .map_err(|err| SlackAttachmentError::Io(format!("{}: {err}", request.dir.display())))?;
1121
1122        let file = api
1123            .file_info(&request.file_id)
1124            .await
1125            .map_err(|err| SlackAttachmentError::Fetch(err.to_string()))?;
1126        let bytes = api
1127            .get_bytes(&file.url_private)
1128            .await
1129            .map_err(|err| SlackAttachmentError::Fetch(err.to_string()))?;
1130
1131        let name = safe_file_name(&file.name, &file.id);
1132        let path = request.dir.join(name);
1133        // The name comes from Slack and the directory is not exclusive to
1134        // this call, so never clobber a file already sitting there.
1135        let mut handle = std::fs::OpenOptions::new()
1136            .write(true)
1137            .create_new(true)
1138            .open(&path)
1139            .map_err(|err| SlackAttachmentError::Io(err.to_string()))?;
1140        std::io::Write::write_all(&mut handle, &bytes)
1141            .map_err(|err| SlackAttachmentError::Io(err.to_string()))?;
1142        Ok(path)
1143    }
1144
1145    async fn post_attachment(
1146        &self,
1147        caller: &SessionSlot,
1148        request: SlackUploadRequest,
1149    ) -> Result<(), SlackAttachmentError> {
1150        let ws = self
1151            .workspace
1152            .upgrade()
1153            .ok_or(SlackAttachmentError::Workspace(SlackWorkspaceError::NotConfigured))?;
1154        let (label, api) = resolve_in(&ws, caller).map_err(SlackAttachmentError::Workspace)?;
1155
1156        let bytes = std::fs::read(&request.path)
1157            .map_err(|err| SlackAttachmentError::Io(err.to_string()))?;
1158        let name = request
1159            .path
1160            .file_name()
1161            .map_or_else(|| "file".to_owned(), |name| name.to_string_lossy().into_owned());
1162
1163        // The draft names where the bytes come from as well as what they
1164        // will be called. The name is caller-controlled and can look
1165        // innocuous, so approving it without the local path would be
1166        // approving something the user has not seen.
1167        let (conversation, conversation_label) =
1168            Self::resolve_conversation(&api, &label, &request.conversation).await;
1169        let mut draft = SlackDraft {
1170            id: Uuid::new_v4(),
1171            workspace: label.clone(),
1172            conversation: request.conversation.clone(),
1173            conversation_label,
1174            thread_ts: request.thread_ts.clone(),
1175            text: format!("[file] {name}\nfrom {}", request.path.display()),
1176            operation: forge_primitives::slack::SlackDraftOperation::Upload,
1177            thread: None,
1178            tagged: Vec::new(),
1179        };
1180        let workspace = draft.workspace.clone();
1181        Self::name_draft(&ws, &api, &workspace, &conversation, &mut draft).await;
1182        match Self::await_approval(&ws, caller, draft).await {
1183            GateDecision::Approved => {}
1184            GateDecision::Rejected => return Err(SlackAttachmentError::Rejected),
1185            GateDecision::SentBack { note } => {
1186                return Err(SlackAttachmentError::SentBack { note });
1187            }
1188        }
1189
1190        let (url, file_id) = api
1191            .get_upload_url(&name, bytes.len())
1192            .await
1193            .map_err(|err| SlackAttachmentError::Fetch(err.to_string()))?;
1194        api.post_bytes(&url, bytes)
1195            .await
1196            .map_err(|err| SlackAttachmentError::Fetch(err.to_string()))?;
1197        // The upload call can put the message in history; its own stamp
1198        // covers that call, and a second covers the shares read below. The
1199        // second is taken before the first drops: a stamp each covers one
1200        // call, and the map must never hold none between them, or a sweep
1201        // in the gap delivers the file message before the shares are
1202        // ledgered.
1203        let upload_call = ws.begin_slack_post(&label, &conversation);
1204        let uploaded = api
1205            .complete_upload(&file_id, &request.conversation, request.thread_ts.as_deref())
1206            .await
1207            .map_err(|err| SlackAttachmentError::Fetch(err.to_string()))?;
1208        let _shares = ws.begin_slack_post(&label, &conversation);
1209        drop(upload_call);
1210        // Slack names the message the upload was posted as on the file's
1211        // shares, keyed by the share's own conversation - which need not be
1212        // the string the request addressed. The upload's own answer carries
1213        // them when Slack fills them; `files.info` is the fallback.
1214        let mut shared: Vec<(String, String)> = uploaded.iter().flat_map(shared_messages).collect();
1215        if shared.is_empty() {
1216            match api.file_info(&file_id).await {
1217                Ok(file) => shared = shared_messages(&file),
1218                Err(error) => tracing::warn!(
1219                    target: "forge_workspace::slack",
1220                    event_name = "slack_attachment_shares_unreadable",
1221                    workspace = %label,
1222                    conversation = %request.conversation,
1223                    %error,
1224                    "reading a posted file's shares failed; the attachment stays unledgered until this call succeeds",
1225                ),
1226            }
1227        }
1228        if shared.is_empty() {
1229            // Neither the upload's answer nor `files.info` named the
1230            // message: say so rather than leaving the echo unexplained.
1231            tracing::warn!(
1232                target: "forge_workspace::slack",
1233                event_name = "slack_attachment_ledger_missing",
1234                workspace = %label,
1235                conversation = %request.conversation,
1236                "the upload's shares name no message; the attachment stays unledgered and can come back once",
1237            );
1238        }
1239        for (conversation, ts) in shared {
1240            ws.record_slack_own_post(&label, &conversation, &ts);
1241        }
1242        Ok(())
1243    }
1244
1245    fn subscribed_targets(&self, caller: &SessionSlot, label: &str) -> Vec<SlackSubscription> {
1246        let Some(ws) = self.workspace.upgrade() else { return Vec::new() };
1247        let Some(cx) = caller_context(&ws, caller) else { return Vec::new() };
1248        ws.slack_subscriptions_for_project(&cx.project_name)
1249            .into_iter()
1250            .filter(|sub| sub.workspace == label && sub.team_role == cx.worker_label)
1251            .collect()
1252    }
1253
1254    fn subscribe(
1255        &self,
1256        caller: &SessionSlot,
1257        request: SlackSubscribeRequest,
1258    ) -> Result<Vec<SlackSubscription>, SlackSubscribeError> {
1259        let ws = self.workspace.upgrade().ok_or(SlackSubscribeError::UnknownCallerProject)?;
1260        let (label, _) = resolve_in(&ws, caller).map_err(SlackSubscribeError::Workspace)?;
1261        let (project, team_role, durable) =
1262            resolve_identity(&ws, caller).ok_or(SlackSubscribeError::UnknownCallerProject)?;
1263        let targets = match request {
1264            SlackSubscribeRequest::DirectMessages => {
1265                vec![SlackSubscriptionTarget::DirectMessages]
1266            }
1267            // Left unnamed here: the sweep's directory walk fills names in
1268            // on its next tick, so subscribing makes no network call.
1269            SlackSubscribeRequest::Conversations(watches) => watches
1270                .into_iter()
1271                .map(|watch| SlackSubscriptionTarget::Conversation {
1272                    id: watch.id,
1273                    name: None,
1274                    mode: watch.mode,
1275                })
1276                .collect(),
1277            SlackSubscribeRequest::Mentions => vec![SlackSubscriptionTarget::Mentions],
1278        };
1279        let mut created = Vec::with_capacity(targets.len());
1280        let mut mentions_requested = false;
1281        for target in targets {
1282            // A conversation goes through the owner's own record, which hands
1283            // back the record it already has rather than adding a second row
1284            // for one channel, and updates the mode when the caller asks for
1285            // a different one. Mentions and the DM class are deliberately not
1286            // deduped - two sessions may each want their own feed.
1287            if let SlackSubscriptionTarget::Conversation { id, mode, .. } = &target {
1288                // A subscription starts from now rather than from the
1289                // channel's history - but only when the conversation has no
1290                // cursor yet, since the cursor is shared by every owner
1291                // watching it and resetting it would drop their pending
1292                // windows.
1293                if !ws.slack_has_cursor(&label, id) {
1294                    ws.set_slack_watermark(&label, id, &slack_ts_now());
1295                }
1296                created.push(ws.watch_slack_conversation(
1297                    &label,
1298                    &project,
1299                    team_role.as_deref(),
1300                    id,
1301                    *mode,
1302                    durable,
1303                ));
1304                continue;
1305            }
1306            if matches!(target, SlackSubscriptionTarget::Mentions) {
1307                mentions_requested = true;
1308            }
1309            let sub = SlackSubscription {
1310                id: Uuid::new_v4(),
1311                workspace: label.clone(),
1312                project: project.clone(),
1313                team_role: team_role.clone(),
1314                target,
1315                created_at: SystemTime::now(),
1316            };
1317            // A subscription starts from now, not from the channel's
1318            // history - but only when the conversation has no cursor yet.
1319            // The cursor belongs to the conversation and is shared by
1320            // every owner watching it: a second subscriber resetting it
1321            // would silently drop every owner's pending window.
1322            if let SlackSubscriptionTarget::Conversation { id, .. } = &sub.target
1323                && !ws.slack_has_cursor(&label, id)
1324            {
1325                ws.set_slack_watermark(&label, id, &slack_ts_now());
1326            }
1327            ws.add_slack_subscription(sub.clone(), durable);
1328            created.push(sub);
1329        }
1330        if mentions_requested && !ws.slack_has_cursor(&label, MENTION_CURSOR) {
1331            ws.set_slack_watermark(&label, MENTION_CURSOR, &slack_ts_now());
1332        }
1333        // A workspace that just gained its first subscription needs a pump.
1334        ws.start_slack_subsystem();
1335        Ok(created)
1336    }
1337
1338    fn unsubscribe(
1339        &self,
1340        caller: &SessionSlot,
1341        id: Uuid,
1342    ) -> Result<SlackSubscription, SlackUnsubscribeError> {
1343        let ws = self.workspace.upgrade().ok_or(SlackUnsubscribeError::UnknownCallerProject)?;
1344        let cx = caller_context(&ws, caller).ok_or(SlackUnsubscribeError::UnknownCallerProject)?;
1345        let removed =
1346            ws.remove_slack_subscription_owned_by(&cx.project_name, id, cx.worker_label.as_deref());
1347        ws.stop_slack_subsystem_if_idle();
1348        if let Some(removed) = removed {
1349            return Ok(removed);
1350        }
1351        // The owner-scoped removal matched nothing, which is either an id
1352        // the project never had or one another session owns; the caller is
1353        // told which.
1354        if ws.slack_subscriptions_for_project(&cx.project_name).iter().any(|s| s.id == id) {
1355            Err(SlackUnsubscribeError::NotOwnedByCaller)
1356        } else {
1357            Err(SlackUnsubscribeError::NoSuchSubscription)
1358        }
1359    }
1360}
1361
1362/// One recorded search: workspace, query, cap, conversation filter.
1363#[cfg(test)]
1364pub(crate) type SearchCall = (SessionSlot, String, u32, Option<String>);
1365
1366/// One recorded history read: workspace, conversation, limit, oldest, newest.
1367#[cfg(test)]
1368pub(crate) type HistoryCall = (SessionSlot, String, u32, Option<String>, Option<String>);
1369
1370/// One recorded thread read: workspace, conversation, parent ts, limit.
1371#[cfg(test)]
1372pub(crate) type RepliesCall = (SessionSlot, String, String, u32);
1373
1374/// Records calls + returns preloaded results so the tool tests can assert
1375/// the tool parses args, resolves the caller, and surfaces results/errors.
1376#[cfg(test)]
1377#[derive(Default)]
1378pub(crate) struct MockSlackFacade {
1379    pub conversations_calls: parking_lot::Mutex<Vec<SessionSlot>>,
1380    pub conversations_result:
1381        parking_lot::Mutex<Option<Result<Vec<SlackConversation>, SlackReadError>>>,
1382    pub subscribed_targets_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
1383    pub subscribed_targets: parking_lot::Mutex<Vec<SlackSubscription>>,
1384    pub search_calls: parking_lot::Mutex<Vec<SearchCall>>,
1385    pub search_result: parking_lot::Mutex<Option<Result<Vec<SlackSearchMatch>, SlackReadError>>>,
1386    pub history_calls: parking_lot::Mutex<Vec<HistoryCall>>,
1387    pub history_result:
1388        parking_lot::Mutex<Option<Result<Vec<SlackHistoryMessage>, SlackReadError>>>,
1389    pub replies_calls: parking_lot::Mutex<Vec<RepliesCall>>,
1390    pub replies_result:
1391        parking_lot::Mutex<Option<Result<Vec<SlackHistoryMessage>, SlackReadError>>>,
1392    pub user_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
1393    pub user_result: parking_lot::Mutex<Option<Result<SlackUser, SlackReadError>>>,
1394    pub users_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
1395    pub users_result: parking_lot::Mutex<Option<Result<Vec<SlackUser>, SlackReadError>>>,
1396    pub me_calls: parking_lot::Mutex<Vec<SessionSlot>>,
1397    pub me_result: parking_lot::Mutex<Option<Result<(String, AuthTest), SlackReadError>>>,
1398    pub pins_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
1399    pub pins_result: parking_lot::Mutex<Option<Result<Vec<SlackPin>, SlackReadError>>>,
1400    pub bookmarks_calls: parking_lot::Mutex<Vec<(SessionSlot, String)>>,
1401    pub bookmarks_result: parking_lot::Mutex<Option<Result<Vec<SlackBookmark>, SlackReadError>>>,
1402    pub fetch_calls: parking_lot::Mutex<Vec<SlackFetchRequest>>,
1403    pub fetch_result: parking_lot::Mutex<Option<Result<std::path::PathBuf, SlackAttachmentError>>>,
1404    pub upload_calls: parking_lot::Mutex<Vec<SlackUploadRequest>>,
1405    pub upload_result: parking_lot::Mutex<Option<Result<(), SlackAttachmentError>>>,
1406    pub post_calls: parking_lot::Mutex<Vec<SlackPostRequest>>,
1407    pub post_result: parking_lot::Mutex<Option<Result<SlackPostOutcome, SlackPostError>>>,
1408    pub edit_calls: parking_lot::Mutex<Vec<SlackEditRequest>>,
1409    pub edit_result: parking_lot::Mutex<Option<Result<(), SlackEditError>>>,
1410    pub react_calls: parking_lot::Mutex<Vec<SlackReactRequest>>,
1411    pub react_result: parking_lot::Mutex<Option<Result<(), SlackReactError>>>,
1412    pub subscribe_calls: parking_lot::Mutex<Vec<(SessionSlot, SlackSubscribeRequest)>>,
1413    pub subscribe_result:
1414        parking_lot::Mutex<Option<Result<Vec<SlackSubscription>, SlackSubscribeError>>>,
1415    pub unsubscribe_calls: parking_lot::Mutex<Vec<Uuid>>,
1416    pub unsubscribe_result:
1417        parking_lot::Mutex<Option<Result<SlackSubscription, SlackUnsubscribeError>>>,
1418}
1419
1420#[cfg(test)]
1421impl MockSlackFacade {
1422    pub(crate) fn new() -> Self {
1423        Self::default()
1424    }
1425    pub(crate) fn into_arc(self) -> Arc<dyn SlackFacade> {
1426        Arc::new(self)
1427    }
1428}
1429
1430#[cfg(test)]
1431#[async_trait::async_trait]
1432impl SlackFacade for MockSlackFacade {
1433    async fn conversations(
1434        &self,
1435        caller: &SessionSlot,
1436    ) -> Result<(String, Vec<SlackConversation>), SlackReadError> {
1437        self.conversations_calls.lock().push(caller.clone());
1438        self.conversations_result
1439            .lock()
1440            .clone()
1441            .unwrap_or_else(|| Ok(Vec::new()))
1442            .map(|rows| ("acme".to_owned(), rows))
1443    }
1444
1445    async fn search(
1446        &self,
1447        caller: &SessionSlot,
1448        query: &str,
1449        count: u32,
1450        conversation: Option<&str>,
1451    ) -> Result<(String, Vec<SlackSearchMatch>), SlackReadError> {
1452        self.search_calls.lock().push((
1453            caller.clone(),
1454            query.to_owned(),
1455            count,
1456            conversation.map(str::to_owned),
1457        ));
1458        self.search_result
1459            .lock()
1460            .clone()
1461            .unwrap_or_else(|| Ok(Vec::new()))
1462            .map(|rows| ("acme".to_owned(), rows))
1463    }
1464
1465    async fn history(
1466        &self,
1467        caller: &SessionSlot,
1468        conversation: &str,
1469        limit: u32,
1470        oldest: Option<&str>,
1471        newest: Option<&str>,
1472    ) -> Result<(String, Vec<SlackHistoryMessage>), SlackReadError> {
1473        self.history_calls.lock().push((
1474            caller.clone(),
1475            conversation.to_owned(),
1476            limit,
1477            oldest.map(str::to_owned),
1478            newest.map(str::to_owned),
1479        ));
1480        self.history_result
1481            .lock()
1482            .clone()
1483            .unwrap_or_else(|| Ok(Vec::new()))
1484            .map(|rows| ("acme".to_owned(), rows))
1485    }
1486
1487    async fn replies(
1488        &self,
1489        caller: &SessionSlot,
1490        conversation: &str,
1491        ts: &str,
1492        limit: u32,
1493    ) -> Result<(String, Vec<SlackHistoryMessage>), SlackReadError> {
1494        self.replies_calls.lock().push((
1495            caller.clone(),
1496            conversation.to_owned(),
1497            ts.to_owned(),
1498            limit,
1499        ));
1500        self.replies_result
1501            .lock()
1502            .clone()
1503            .unwrap_or_else(|| Ok(Vec::new()))
1504            .map(|rows| ("acme".to_owned(), rows))
1505    }
1506
1507    async fn user(
1508        &self,
1509        caller: &SessionSlot,
1510        user: &str,
1511    ) -> Result<(String, SlackUser), SlackReadError> {
1512        self.user_calls.lock().push((caller.clone(), user.to_owned()));
1513        self.user_result
1514            .lock()
1515            .clone()
1516            .unwrap_or_else(|| {
1517                Ok(SlackUser {
1518                    id: user.to_owned(),
1519                    name: "tester".to_owned(),
1520                    real_name: None,
1521                    tz: None,
1522                    deleted: false,
1523                    profile: None,
1524                })
1525            })
1526            .map(|user| ("acme".to_owned(), user))
1527    }
1528
1529    async fn users(
1530        &self,
1531        caller: &SessionSlot,
1532        query: &str,
1533    ) -> Result<(String, Vec<SlackUser>), SlackReadError> {
1534        self.users_calls.lock().push((caller.clone(), query.to_owned()));
1535        self.users_result
1536            .lock()
1537            .clone()
1538            .unwrap_or_else(|| Ok(Vec::new()))
1539            .map(|rows| ("acme".to_owned(), rows))
1540    }
1541
1542    async fn me(&self, caller: &SessionSlot) -> Result<(String, AuthTest), SlackReadError> {
1543        self.me_calls.lock().push(caller.clone());
1544        self.me_result.lock().clone().unwrap_or_else(|| {
1545            Ok((
1546                "acme".to_owned(),
1547                AuthTest {
1548                    team: "Test".to_owned(),
1549                    user: "tester".to_owned(),
1550                    team_id: "T1".to_owned(),
1551                    user_id: "U1".to_owned(),
1552                    url: "https://test.slack.com/".to_owned(),
1553                },
1554            ))
1555        })
1556    }
1557
1558    async fn pins(
1559        &self,
1560        caller: &SessionSlot,
1561        conversation: &str,
1562    ) -> Result<(String, Vec<SlackPin>), SlackReadError> {
1563        self.pins_calls.lock().push((caller.clone(), conversation.to_owned()));
1564        self.pins_result
1565            .lock()
1566            .clone()
1567            .unwrap_or_else(|| Ok(Vec::new()))
1568            .map(|rows| ("acme".to_owned(), rows))
1569    }
1570
1571    async fn bookmarks(
1572        &self,
1573        caller: &SessionSlot,
1574        conversation: &str,
1575    ) -> Result<(String, Vec<SlackBookmark>), SlackReadError> {
1576        self.bookmarks_calls.lock().push((caller.clone(), conversation.to_owned()));
1577        self.bookmarks_result
1578            .lock()
1579            .clone()
1580            .unwrap_or_else(|| Ok(Vec::new()))
1581            .map(|rows| ("acme".to_owned(), rows))
1582    }
1583
1584    async fn fetch_attachment(
1585        &self,
1586        _caller: &SessionSlot,
1587        request: SlackFetchRequest,
1588    ) -> Result<std::path::PathBuf, SlackAttachmentError> {
1589        self.fetch_calls.lock().push(request);
1590        self.fetch_result.lock().clone().unwrap_or_else(|| Err(SlackAttachmentError::Rejected))
1591    }
1592
1593    async fn post_attachment(
1594        &self,
1595        _caller: &SessionSlot,
1596        request: SlackUploadRequest,
1597    ) -> Result<(), SlackAttachmentError> {
1598        self.upload_calls.lock().push(request);
1599        self.upload_result.lock().clone().unwrap_or(Ok(()))
1600    }
1601
1602    async fn post(
1603        &self,
1604        _caller: &SessionSlot,
1605        request: SlackPostRequest,
1606    ) -> Result<SlackPostOutcome, SlackPostError> {
1607        self.post_calls.lock().push(request);
1608        self.post_result.lock().clone().unwrap_or(Ok(SlackPostOutcome {
1609            workspace: "acme".to_owned(),
1610            ts: vec!["1.0".to_owned()],
1611            conversation_name: "general".to_owned(),
1612            parts: 1,
1613        }))
1614    }
1615
1616    async fn edit(
1617        &self,
1618        _caller: &SessionSlot,
1619        request: SlackEditRequest,
1620    ) -> Result<(), SlackEditError> {
1621        self.edit_calls.lock().push(request);
1622        self.edit_result.lock().clone().unwrap_or(Ok(()))
1623    }
1624
1625    async fn react(
1626        &self,
1627        _caller: &SessionSlot,
1628        request: SlackReactRequest,
1629    ) -> Result<(), SlackReactError> {
1630        self.react_calls.lock().push(request);
1631        self.react_result.lock().clone().unwrap_or(Ok(()))
1632    }
1633
1634    fn subscribed_targets(&self, caller: &SessionSlot, label: &str) -> Vec<SlackSubscription> {
1635        self.subscribed_targets_calls.lock().push((caller.clone(), label.to_owned()));
1636        self.subscribed_targets.lock().clone()
1637    }
1638
1639    fn subscribe(
1640        &self,
1641        caller: &SessionSlot,
1642        request: SlackSubscribeRequest,
1643    ) -> Result<Vec<SlackSubscription>, SlackSubscribeError> {
1644        self.subscribe_calls.lock().push((caller.clone(), request));
1645        self.subscribe_result.lock().clone().unwrap_or_else(|| Ok(Vec::new()))
1646    }
1647
1648    fn unsubscribe(
1649        &self,
1650        _caller: &SessionSlot,
1651        id: Uuid,
1652    ) -> Result<SlackSubscription, SlackUnsubscribeError> {
1653        self.unsubscribe_calls.lock().push(id);
1654        self.unsubscribe_result
1655            .lock()
1656            .clone()
1657            .unwrap_or(Err(SlackUnsubscribeError::NoSuchSubscription))
1658    }
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663    use super::*;
1664    use crate::config::LoadedConfig;
1665    use crate::mcp::slack::Unsubscribe;
1666    use crate::mcp::test_support::text_of;
1667    use crate::slack::DraftResolution;
1668    use forge_connectors::slack::{
1669        AuthTest, MessagePage, PostedMessage, SearchPage, SlackApi, SlackError,
1670    };
1671    use forge_primitives::slack::{
1672        SlackBookmark, SlackConfig, SlackDraftAnswer, SlackDraftEnding, SlackDraftOperation,
1673        SlackFile, SlackFileShare, SlackFileShares, SlackPin, SlackPinMessage, SlackUserProfile,
1674    };
1675    use forge_sdk::mcp::tool::{Tool as _, ToolInput};
1676    use std::collections::{BTreeMap, HashMap};
1677    use tempfile::tempdir;
1678
1679    fn caller() -> SessionSlot {
1680        SessionSlot::lead("TestOrg", "forge")
1681    }
1682
1683    /// One recorded history read: conversation, oldest, newest, limit.
1684    type HistoryRead = (String, Option<String>, Option<String>, u32);
1685
1686    /// Records every outbound call, so a test can assert what did and did
1687    /// not reach Slack.
1688    #[derive(Default)]
1689    struct RecordingApi {
1690        posts: parking_lot::Mutex<Vec<(String, String, Option<String>)>>,
1691        updates: parking_lot::Mutex<Vec<(String, String, String)>>,
1692        deletes: parking_lot::Mutex<Vec<(String, String)>>,
1693        reactions: parking_lot::Mutex<Vec<(String, String, String, bool)>>,
1694        /// Messages Slack refuses to change, by ts: not the user's own.
1695        foreign: parking_lot::Mutex<std::collections::HashSet<String>>,
1696        /// Seeded files: id -> (name as the uploader chose it, bytes).
1697        files: parking_lot::Mutex<HashMap<String, (String, Vec<u8>)>>,
1698        uploads: parking_lot::Mutex<Vec<(String, String, Option<String>)>>,
1699        upload_urls: parking_lot::Mutex<usize>,
1700        /// Seeded reads: channel -> rows.
1701        pins: parking_lot::Mutex<HashMap<String, Vec<SlackPin>>>,
1702        bookmark_rows: parking_lot::Mutex<HashMap<String, Vec<SlackBookmark>>>,
1703        pin_reads: parking_lot::Mutex<Vec<String>>,
1704        bookmark_reads: parking_lot::Mutex<Vec<String>>,
1705        /// Seeded conversation and thread reads.
1706        history_rows: parking_lot::Mutex<HashMap<String, Vec<SlackHistoryMessage>>>,
1707        /// Make `conversations.list` fail, so the draft's fallback label is
1708        /// the one tested.
1709        list_failure: parking_lot::Mutex<bool>,
1710        reply_rows: parking_lot::Mutex<HashMap<String, Vec<SlackHistoryMessage>>>,
1711        /// Thread reads that fail, keyed `channel/ts`.
1712        reply_failures: parking_lot::Mutex<std::collections::HashSet<String>>,
1713        /// User ids whose lookup fails, so a tag stays unnamed.
1714        user_failures: parking_lot::Mutex<std::collections::HashSet<String>>,
1715        history_reads: parking_lot::Mutex<Vec<HistoryRead>>,
1716        reply_reads: parking_lot::Mutex<Vec<(String, String)>>,
1717        /// Search answers page by page; the queries are recorded so a test
1718        /// can prove the conversation name was narrowed into `in:`.
1719        search_pages: parking_lot::Mutex<Vec<Vec<SlackSearchMatch>>>,
1720        search_reads: parking_lot::Mutex<Vec<String>>,
1721        /// The directory a user resolver reads.
1722        users_rows: parking_lot::Mutex<Vec<SlackUser>>,
1723        /// The ts `chat.postMessage` answers with.
1724        post_ts: parking_lot::Mutex<Option<String>>,
1725        /// The channel `chat.postMessage` answers with; defaults to the
1726        /// channel the request named.
1727        post_channel: parking_lot::Mutex<Option<String>>,
1728        /// Answer no channel at all, the shape Slack omits it in.
1729        post_channel_absent: parking_lot::Mutex<bool>,
1730        /// Parks a post, an upload or a `files.info` until the test
1731        /// releases it.
1732        post_park: parking_lot::Mutex<Option<Arc<tokio::sync::Notify>>>,
1733        upload_park: parking_lot::Mutex<Option<Arc<tokio::sync::Notify>>>,
1734        file_info_park: parking_lot::Mutex<Option<Arc<tokio::sync::Notify>>>,
1735        /// How many posts have been answered, so an unseeded answer is a
1736        /// distinct ts per call - a multi-part post needs two.
1737        post_calls: parking_lot::Mutex<usize>,
1738        /// The 0-based post call that fails, for a partial multi-part post.
1739        post_fail_at: parking_lot::Mutex<Option<usize>>,
1740        /// Where each seeded file was shared: id -> shares.
1741        file_shares: parking_lot::Mutex<HashMap<String, SlackFileShares>>,
1742        /// The shares `files.completeUploadExternal` answers with, when a
1743        /// test wants the extra `files.info` read to be skipped.
1744        upload_shares: parking_lot::Mutex<Option<SlackFileShares>>,
1745        /// How many `files.info` calls were made.
1746        file_info_calls: parking_lot::Mutex<usize>,
1747        /// Seeded conversations, for the display name a held draft carries.
1748        conversations: parking_lot::Mutex<Vec<SlackConversation>>,
1749    }
1750
1751    impl RecordingApi {
1752        fn refuse_edits_of(&self, ts: &str) {
1753            self.foreign.lock().insert(ts.to_owned());
1754        }
1755        fn seed_file(&self, id: &str, name: &str, bytes: &[u8]) {
1756            self.files.lock().insert(id.to_owned(), (name.to_owned(), bytes.to_vec()));
1757        }
1758        fn seed_pins(&self, channel: &str, rows: Vec<SlackPin>) {
1759            self.pins.lock().insert(channel.to_owned(), rows);
1760        }
1761        fn seed_bookmarks(&self, channel: &str, rows: Vec<SlackBookmark>) {
1762            self.bookmark_rows.lock().insert(channel.to_owned(), rows);
1763        }
1764        fn seed_history(&self, channel: &str, rows: Vec<SlackHistoryMessage>) {
1765            self.history_rows.lock().insert(channel.to_owned(), rows);
1766        }
1767        fn fail_list(&self) {
1768            *self.list_failure.lock() = true;
1769        }
1770        fn seed_replies(&self, channel: &str, ts: &str, rows: Vec<SlackHistoryMessage>) {
1771            self.reply_rows.lock().insert(format!("{channel}/{ts}"), rows);
1772        }
1773        fn fail_replies(&self, channel: &str, ts: &str) {
1774            self.reply_failures.lock().insert(format!("{channel}/{ts}"));
1775        }
1776        fn fail_user_info(&self, user: &str) {
1777            self.user_failures.lock().insert(user.to_owned());
1778        }
1779        fn seed_search(&self, rows: Vec<SlackSearchMatch>) {
1780            self.search_pages.lock().push(rows);
1781        }
1782        fn seed_search_pages(&self, pages: Vec<Vec<SlackSearchMatch>>) {
1783            self.search_pages.lock().extend(pages);
1784        }
1785        fn seed_users(&self, rows: Vec<SlackUser>) {
1786            self.users_rows.lock().extend(rows);
1787        }
1788        fn history_reads(&self) -> Vec<HistoryRead> {
1789            self.history_reads.lock().clone()
1790        }
1791        fn reply_reads(&self) -> Vec<(String, String)> {
1792            self.reply_reads.lock().clone()
1793        }
1794        fn search_reads(&self) -> Vec<String> {
1795            self.search_reads.lock().clone()
1796        }
1797        fn posts(&self) -> Vec<(String, String, Option<String>)> {
1798            self.posts.lock().clone()
1799        }
1800        fn upload_url_count(&self) -> usize {
1801            *self.upload_urls.lock()
1802        }
1803        fn uploads(&self) -> Vec<(String, String, Option<String>)> {
1804            self.uploads.lock().clone()
1805        }
1806        fn seed_conversation(&self, id: &str, name: &str) {
1807            self.conversations.lock().push(SlackConversation {
1808                id: id.to_owned(),
1809                name: Some(name.to_owned()),
1810                is_channel: true,
1811                is_private: false,
1812                is_im: false,
1813                is_mpim: false,
1814                is_archived: false,
1815                user: None,
1816                purpose: None,
1817                topic: None,
1818            });
1819        }
1820        fn seed_dm(&self, id: &str, user: &str) {
1821            self.conversations.lock().push(SlackConversation {
1822                id: id.to_owned(),
1823                name: None,
1824                is_channel: false,
1825                is_private: false,
1826                is_im: true,
1827                is_mpim: false,
1828                is_archived: false,
1829                user: Some(user.to_owned()),
1830                purpose: None,
1831                topic: None,
1832            });
1833        }
1834        fn seed_mpim(&self, id: &str, name: &str) {
1835            self.conversations.lock().push(SlackConversation {
1836                id: id.to_owned(),
1837                name: Some(name.to_owned()),
1838                is_channel: false,
1839                is_private: false,
1840                is_im: false,
1841                is_mpim: true,
1842                is_archived: false,
1843                user: None,
1844                purpose: None,
1845                topic: None,
1846            });
1847        }
1848        fn answer_posts_with(&self, ts: &str) {
1849            *self.post_ts.lock() = Some(ts.to_owned());
1850        }
1851        /// The channel `chat.postMessage` answers with.
1852        fn answer_posts_in(&self, channel: &str) {
1853            *self.post_channel.lock() = Some(channel.to_owned());
1854        }
1855        /// Answer no channel at all, the shape Slack omits it in.
1856        fn answer_posts_without_channel(&self) {
1857            *self.post_channel_absent.lock() = true;
1858        }
1859        /// Park every post until the returned gate is notified.
1860        fn park_posts(&self) -> Arc<tokio::sync::Notify> {
1861            let gate = Arc::new(tokio::sync::Notify::new());
1862            *self.post_park.lock() = Some(Arc::clone(&gate));
1863            gate
1864        }
1865        /// Park every upload until the returned gate is notified.
1866        fn park_uploads(&self) -> Arc<tokio::sync::Notify> {
1867            let gate = Arc::new(tokio::sync::Notify::new());
1868            *self.upload_park.lock() = Some(Arc::clone(&gate));
1869            gate
1870        }
1871        /// Park every `files.info` until the returned gate is notified.
1872        fn park_file_info(&self) -> Arc<tokio::sync::Notify> {
1873            let gate = Arc::new(tokio::sync::Notify::new());
1874            *self.file_info_park.lock() = Some(Arc::clone(&gate));
1875            gate
1876        }
1877        /// Make the 0-based `index` post call fail.
1878        fn fail_post_at(&self, index: usize) {
1879            *self.post_fail_at.lock() = Some(index);
1880        }
1881        /// Seed where a file is shared, as `files.info` reports it: the
1882        /// message ts for the conversation the share names.
1883        fn seed_file_shares(&self, id: &str, conversation: &str, ts: &str) {
1884            let mut shares = SlackFileShares::default();
1885            shares
1886                .public
1887                .insert(conversation.to_owned(), vec![SlackFileShare { ts: ts.to_owned() }]);
1888            self.file_shares.lock().insert(id.to_owned(), shares);
1889        }
1890        /// Seed the shares `files.completeUploadExternal` itself answers
1891        /// with, so no `files.info` follow-up is needed.
1892        fn seed_upload_shares(&self, conversation: &str, ts: &str) {
1893            let mut shares = SlackFileShares::default();
1894            shares
1895                .public
1896                .insert(conversation.to_owned(), vec![SlackFileShare { ts: ts.to_owned() }]);
1897            *self.upload_shares.lock() = Some(shares);
1898        }
1899        fn file_info_calls(&self) -> usize {
1900            *self.file_info_calls.lock()
1901        }
1902    }
1903
1904    #[async_trait::async_trait]
1905    impl SlackApi for RecordingApi {
1906        async fn auth_test(&self) -> Result<AuthTest, SlackError> {
1907            Ok(AuthTest {
1908                team: "Test".to_owned(),
1909                user: "tester".to_owned(),
1910                team_id: "T1".to_owned(),
1911                user_id: "U1".to_owned(),
1912                url: "https://test.slack.com/".to_owned(),
1913            })
1914        }
1915
1916        async fn list_conversations(&self) -> Result<Vec<SlackConversation>, SlackError> {
1917            if *self.list_failure.lock() {
1918                return Err(SlackError::Transport {
1919                    method: "conversations.list".to_owned(),
1920                    detail: "connection reset".to_owned(),
1921                });
1922            }
1923            Ok(self.conversations.lock().clone())
1924        }
1925
1926        async fn history(
1927            &self,
1928            channel: &str,
1929            oldest: Option<&str>,
1930            newest: Option<&str>,
1931            limit: u32,
1932            _cursor: Option<&str>,
1933        ) -> Result<MessagePage, SlackError> {
1934            self.history_reads.lock().push((
1935                channel.to_owned(),
1936                oldest.map(str::to_owned),
1937                newest.map(str::to_owned),
1938                limit,
1939            ));
1940            Ok(MessagePage {
1941                messages: self.history_rows.lock().get(channel).cloned().unwrap_or_default(),
1942                next_cursor: None,
1943            })
1944        }
1945
1946        async fn replies(
1947            &self,
1948            channel: &str,
1949            ts: &str,
1950            _oldest: Option<&str>,
1951            _limit: u32,
1952            _cursor: Option<&str>,
1953        ) -> Result<MessagePage, SlackError> {
1954            self.reply_reads.lock().push((channel.to_owned(), ts.to_owned()));
1955            if self.reply_failures.lock().contains(&format!("{channel}/{ts}")) {
1956                return Err(SlackError::RateLimited {
1957                    method: "conversations.replies".to_owned(),
1958                    retry_after: std::time::Duration::from_secs(5),
1959                });
1960            }
1961            Ok(MessagePage {
1962                messages: self
1963                    .reply_rows
1964                    .lock()
1965                    .get(&format!("{channel}/{ts}"))
1966                    .cloned()
1967                    .unwrap_or_default(),
1968                next_cursor: None,
1969            })
1970        }
1971
1972        async fn post_message(
1973            &self,
1974            channel: &str,
1975            text: &str,
1976            thread_ts: Option<&str>,
1977        ) -> Result<PostedMessage, SlackError> {
1978            self.posts.lock().push((
1979                channel.to_owned(),
1980                text.to_owned(),
1981                thread_ts.map(str::to_owned),
1982            ));
1983            let attempt = {
1984                let mut calls = self.post_calls.lock();
1985                let attempt = *calls;
1986                *calls += 1;
1987                attempt
1988            };
1989            let gate = self.post_park.lock().clone();
1990            if let Some(gate) = gate {
1991                gate.notified().await;
1992            }
1993            if *self.post_fail_at.lock() == Some(attempt) {
1994                return Err(SlackError::Api {
1995                    method: "chat.postMessage".to_owned(),
1996                    error: "rate_limited".to_owned(),
1997                    needed: None,
1998                });
1999            }
2000            let ts = self.post_ts.lock().clone().unwrap_or_else(|| format!("{}.0", attempt + 1));
2001            let answered = if *self.post_channel_absent.lock() {
2002                None
2003            } else {
2004                Some(self.post_channel.lock().clone().unwrap_or_else(|| channel.to_owned()))
2005            };
2006            Ok(PostedMessage { channel: answered, ts })
2007        }
2008
2009        async fn update_message(
2010            &self,
2011            channel: &str,
2012            ts: &str,
2013            text: &str,
2014        ) -> Result<(), SlackError> {
2015            if self.foreign.lock().contains(ts) {
2016                return Err(SlackError::Api {
2017                    method: "chat.update".to_owned(),
2018                    error: "cant_update_message".to_owned(),
2019                    needed: None,
2020                });
2021            }
2022            self.updates.lock().push((channel.to_owned(), ts.to_owned(), text.to_owned()));
2023            Ok(())
2024        }
2025
2026        async fn delete_message(&self, channel: &str, ts: &str) -> Result<(), SlackError> {
2027            if self.foreign.lock().contains(ts) {
2028                return Err(SlackError::Api {
2029                    method: "chat.delete".to_owned(),
2030                    error: "cant_delete_message".to_owned(),
2031                    needed: None,
2032                });
2033            }
2034            self.deletes.lock().push((channel.to_owned(), ts.to_owned()));
2035            Ok(())
2036        }
2037
2038        async fn set_reaction(
2039            &self,
2040            channel: &str,
2041            ts: &str,
2042            name: &str,
2043            add: bool,
2044        ) -> Result<(), SlackError> {
2045            self.reactions.lock().push((channel.to_owned(), ts.to_owned(), name.to_owned(), add));
2046            Ok(())
2047        }
2048
2049        async fn file_info(&self, id: &str) -> Result<SlackFile, SlackError> {
2050            *self.file_info_calls.lock() += 1;
2051            let gate = self.file_info_park.lock().clone();
2052            if let Some(gate) = gate {
2053                gate.notified().await;
2054            }
2055            let files = self.files.lock();
2056            let Some((name, _)) = files.get(id) else {
2057                return Err(SlackError::Api {
2058                    method: "files.info".to_owned(),
2059                    error: "file_not_found".to_owned(),
2060                    needed: None,
2061                });
2062            };
2063            Ok(SlackFile {
2064                id: id.to_owned(),
2065                name: name.clone(),
2066                url_private: format!("https://files.test/{id}"),
2067                shares: self.file_shares.lock().get(id).cloned().unwrap_or_default(),
2068            })
2069        }
2070
2071        async fn get_bytes(&self, url: &str) -> Result<Vec<u8>, SlackError> {
2072            let id = url.rsplit('/').next().unwrap_or_default();
2073            self.files.lock().get(id).map(|(_, bytes)| bytes.clone()).ok_or_else(|| {
2074                SlackError::Transport {
2075                    method: "download".to_owned(),
2076                    detail: format!("no seeded file at {url}"),
2077                }
2078            })
2079        }
2080
2081        async fn post_bytes(&self, _url: &str, _body: Vec<u8>) -> Result<(), SlackError> {
2082            Ok(())
2083        }
2084
2085        async fn get_upload_url(
2086            &self,
2087            name: &str,
2088            _length: usize,
2089        ) -> Result<(String, String), SlackError> {
2090            *self.upload_urls.lock() += 1;
2091            Ok((format!("https://upload.test/{name}"), "F-uploaded".to_owned()))
2092        }
2093
2094        async fn complete_upload(
2095            &self,
2096            file_id: &str,
2097            channel: &str,
2098            thread_ts: Option<&str>,
2099        ) -> Result<Vec<SlackFile>, SlackError> {
2100            self.uploads.lock().push((
2101                file_id.to_owned(),
2102                channel.to_owned(),
2103                thread_ts.map(str::to_owned),
2104            ));
2105            let gate = self.upload_park.lock().clone();
2106            if let Some(gate) = gate {
2107                gate.notified().await;
2108            }
2109            Ok(vec![SlackFile {
2110                id: file_id.to_owned(),
2111                name: String::new(),
2112                url_private: String::new(),
2113                shares: self.upload_shares.lock().clone().unwrap_or_default(),
2114            }])
2115        }
2116
2117        async fn search_messages(
2118            &self,
2119            query: &str,
2120            _count: u32,
2121            cursor: Option<&str>,
2122        ) -> Result<SearchPage, SlackError> {
2123            self.search_reads.lock().push(query.to_owned());
2124            let pages = self.search_pages.lock();
2125            let index = cursor.and_then(|cursor| cursor.parse::<usize>().ok()).unwrap_or(0);
2126            let matches = pages.get(index).cloned().unwrap_or_default();
2127            Ok(SearchPage {
2128                matches,
2129                next_cursor: (index + 1 < pages.len()).then(|| (index + 1).to_string()),
2130            })
2131        }
2132
2133        async fn user_info(&self, user: &str) -> Result<SlackUser, SlackError> {
2134            if self.user_failures.lock().contains(user) {
2135                return Err(SlackError::RateLimited {
2136                    method: "users.info".to_owned(),
2137                    retry_after: std::time::Duration::from_secs(5),
2138                });
2139            }
2140            Ok(SlackUser {
2141                id: user.to_owned(),
2142                name: "tester".to_owned(),
2143                real_name: None,
2144                tz: None,
2145                deleted: false,
2146                profile: None,
2147            })
2148        }
2149
2150        async fn list_users(&self) -> Result<Vec<SlackUser>, SlackError> {
2151            Ok(self.users_rows.lock().clone())
2152        }
2153
2154        async fn pins(&self, channel: &str) -> Result<Vec<SlackPin>, SlackError> {
2155            self.pin_reads.lock().push(channel.to_owned());
2156            Ok(self.pins.lock().get(channel).cloned().unwrap_or_default())
2157        }
2158
2159        async fn bookmarks(&self, channel: &str) -> Result<Vec<SlackBookmark>, SlackError> {
2160            self.bookmark_reads.lock().push(channel.to_owned());
2161            Ok(self.bookmark_rows.lock().get(channel).cloned().unwrap_or_default())
2162        }
2163    }
2164
2165    /// A workspace whose one Slack workspace is the recording double, with
2166    /// a lead session recorded so the caller resolves to a project.
2167    fn facade_with_recording_slack() -> (
2168        Arc<dyn SlackFacade>,
2169        Arc<Workspace>,
2170        Arc<RecordingApi>,
2171        tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2172    ) {
2173        let (facade, ws, api, rx, _dir) = facade_with_recording_slack_inner(false);
2174        (facade, ws, api, rx)
2175    }
2176
2177    /// A recording facade, its workspace, the double, the update stream,
2178    /// and the tempdir that must outlive them.
2179    type RecordingSlack = (
2180        Arc<dyn SlackFacade>,
2181        Arc<Workspace>,
2182        Arc<RecordingApi>,
2183        tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2184        tempfile::TempDir,
2185    );
2186
2187    /// The same workspace with a store installed, for the paths that
2188    /// persist - the own-post ledger.
2189    fn facade_with_recording_slack_and_store() -> RecordingSlack {
2190        facade_with_recording_slack_inner(true)
2191    }
2192
2193    fn facade_with_recording_slack_inner(with_store: bool) -> RecordingSlack {
2194        let dir = tempdir().expect("tempdir");
2195        let mut config = LoadedConfig::empty_for_test();
2196        config.slack = vec![cfg("acme")];
2197        let api = Arc::new(RecordingApi::default());
2198        let mut apis: BTreeMap<String, Arc<dyn SlackApi>> = BTreeMap::new();
2199        apis.insert("acme".to_owned(), api.clone());
2200        let (ws, rx) = Workspace::testing_stub_with_slack(
2201            dir.path().to_path_buf(),
2202            config,
2203            Arc::new(crate::slack::SlackWorkspaces::from_apis(apis)),
2204            None,
2205        );
2206        if with_store {
2207            ws.install_db_for_test(
2208                crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
2209            );
2210        }
2211        ws.seed_test_project_with_slack_workspace("forge", "/tmp/slack-facade-scope", "acme");
2212        ws.record_connected_session("/tmp/slack-facade-scope", "caller-uuid", None);
2213        let facade = ProdSlackFacade::from_arc(&ws);
2214        (facade, ws, api, rx, dir)
2215    }
2216
2217    /// The session's slot names the scope, so a tool with no `workspace`
2218    /// argument resolves the project's binding and the result names it -
2219    /// the label stays visible, it just stops being an input.
2220    #[tokio::test]
2221    async fn a_tool_resolves_each_projects_binding_from_the_config_file() {
2222        // The production path: the bindings come from a real forge.toml, so
2223        // deleting the config lookup cannot leave this green - unlike the
2224        // overlay-seeded fixtures the other tests build.
2225        let dir = tempdir().expect("tempdir");
2226        let forge = dir.path().join("forge");
2227        std::fs::create_dir_all(&forge).expect("forge/");
2228        std::fs::write(
2229            forge.join("forge.toml"),
2230            r#"
2231[[orgs]]
2232name = "Busytools"
2233accounts = ["Acct"]
2234[[orgs.projects]]
2235name = "alpha"
2236path = "/tmp/alpha"
2237[orgs.projects.slack]
2238workspace = "acme"
2239[[orgs.projects]]
2240name = "beta"
2241path = "/tmp/beta"
2242[orgs.projects.slack]
2243workspace = "beta-ws"
2244[[orgs.projects]]
2245name = "gamma"
2246path = "/tmp/gamma"
2247[[accounts]]
2248display_name = "Acct"
2249token = "t"
2250models = ["claude-sonnet-5"]
2251provider = "anthropic"
2252[[slack]]
2253workspace = "acme"
2254token = "x"
2255[[slack]]
2256workspace = "beta-ws"
2257token = "y"
2258"#,
2259        )
2260        .expect("write forge.toml");
2261        let config = crate::config::load_from_dir(dir.path()).expect("the config loads");
2262        let mut apis: BTreeMap<String, Arc<dyn SlackApi>> = BTreeMap::new();
2263        apis.insert("acme".to_owned(), Arc::new(RecordingApi::default()));
2264        apis.insert("beta-ws".to_owned(), Arc::new(RecordingApi::default()));
2265        let (ws, _rx) = Workspace::testing_stub_with_slack(
2266            dir.path().to_path_buf(),
2267            config,
2268            Arc::new(crate::slack::SlackWorkspaces::from_apis(apis)),
2269            None,
2270        );
2271        let facade = ProdSlackFacade::from_arc(&ws);
2272
2273        for (project, expected) in [("alpha", "acme"), ("beta", "beta-ws")] {
2274            let tool = crate::mcp::slack::Me {
2275                facade: facade.clone(),
2276                slot: SessionSlot::lead("Busytools", project),
2277            };
2278            let out = forge_sdk::mcp::tool::Tool::call(
2279                &tool,
2280                forge_sdk::mcp::tool::ToolInput { value: serde_json::json!({}) },
2281            )
2282            .await;
2283            let text = crate::mcp::test_support::text_of(&out);
2284            assert!(!out.is_error, "{project} answers: {text}");
2285            assert_eq!(
2286                serde_json::from_str::<serde_json::Value>(text).expect("identity json")["workspace"],
2287                expected,
2288                "the slot's own project binding is the one resolved",
2289            );
2290        }
2291
2292        let unbound =
2293            crate::mcp::slack::List { facade, slot: SessionSlot::lead("Busytools", "gamma") };
2294        let out = forge_sdk::mcp::tool::Tool::call(
2295            &unbound,
2296            forge_sdk::mcp::tool::ToolInput { value: serde_json::json!({}) },
2297        )
2298        .await;
2299        let text = crate::mcp::test_support::text_of(&out);
2300        assert!(out.is_error, "a project with no binding refuses: {text}");
2301        assert!(text.contains("Busytools/gamma"), "names the scope: {text}");
2302        assert!(text.contains("[orgs.slack]"), "names the org key: {text}");
2303        assert!(text.contains("[orgs.projects.slack]"), "names the project key: {text}");
2304    }
2305
2306    /// An answer as the registry takes it. Every call site here is a view
2307    /// answering; the drop is a dead waiter, and it goes through the paths
2308    /// under test rather than through this.
2309    fn answered(approved: bool) -> DraftResolution {
2310        DraftResolution::Answered(if approved {
2311            SlackDraftAnswer::Approve
2312        } else {
2313            SlackDraftAnswer::Decline
2314        })
2315    }
2316
2317    fn sent_back(note: &str) -> DraftResolution {
2318        DraftResolution::Answered(SlackDraftAnswer::SendBack { note: note.to_owned() })
2319    }
2320
2321    /// Wait for the blocked post to register its draft, then hand back its
2322    /// id. The post is parked on a oneshot until this is resolved.
2323    async fn wait_for_draft(ws: &Arc<Workspace>) -> Uuid {
2324        for _ in 0..200 {
2325            if let Some(id) = ws.slack_drafts.lock().keys().next().copied() {
2326                return id;
2327            }
2328            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2329        }
2330        panic!("no draft was registered");
2331    }
2332
2333    /// Poll until `check` holds, failing the test rather than hanging.
2334    async fn until(check: impl Fn() -> bool) {
2335        for _ in 0..400 {
2336            if check() {
2337                return;
2338            }
2339            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2340        }
2341        panic!("the condition never held");
2342    }
2343
2344    fn post_request(conversation: &str, text: &str) -> SlackPostRequest {
2345        SlackPostRequest {
2346            conversation: conversation.to_owned(),
2347            thread_ts: None,
2348            text: text.to_owned(),
2349        }
2350    }
2351
2352    fn edit_request(conversation: &str, ts: &str, text: Option<&str>) -> SlackEditRequest {
2353        SlackEditRequest {
2354            conversation: conversation.to_owned(),
2355            ts: ts.to_owned(),
2356            text: text.map(str::to_owned),
2357        }
2358    }
2359
2360    fn react_request(conversation: &str, ts: &str, name: &str, add: bool) -> SlackReactRequest {
2361        SlackReactRequest {
2362            conversation: conversation.to_owned(),
2363            ts: ts.to_owned(),
2364            name: name.to_owned(),
2365            add,
2366        }
2367    }
2368
2369    fn fetch_request(file_id: &str, dir: &std::path::Path) -> SlackFetchRequest {
2370        SlackFetchRequest { file_id: file_id.to_owned(), dir: dir.to_path_buf() }
2371    }
2372
2373    fn upload_request(conversation: &str, path: &std::path::Path) -> SlackUploadRequest {
2374        SlackUploadRequest {
2375            conversation: conversation.to_owned(),
2376            thread_ts: None,
2377            path: path.to_path_buf(),
2378            title: None,
2379        }
2380    }
2381
2382    /// A name from Slack cannot escape the directory the caller chose, by
2383    /// either separator and however many parents it carries.
2384    #[test]
2385    fn a_file_name_from_slack_cannot_escape_its_directory() {
2386        assert_eq!(safe_file_name("notes.txt", "F1"), "notes.txt");
2387        assert_eq!(safe_file_name("../../escape.txt", "F1"), "escape.txt");
2388        assert_eq!(safe_file_name("..\\..\\escape.txt", "F1"), "escape.txt");
2389        assert_eq!(safe_file_name("/etc/passwd", "F1"), "passwd");
2390        assert_eq!(safe_file_name("..", "F1"), "F1", "a bare parent falls back to the id");
2391        assert_eq!(safe_file_name(".", "F1"), "F1");
2392        assert_eq!(safe_file_name("", "F1"), "F1");
2393        assert_eq!(safe_file_name("dir/", "F1"), "F1", "a trailing separator leaves nothing");
2394    }
2395
2396    #[tokio::test]
2397    async fn an_attachment_lands_on_disk_with_the_expected_bytes() {
2398        let (facade, _ws, api, _rx) = facade_with_recording_slack();
2399        api.seed_file("F1", "notes.txt", b"attachment body");
2400        let dir = tempdir().expect("tempdir");
2401
2402        let path = facade
2403            .fetch_attachment(&caller(), fetch_request("F1", dir.path()))
2404            .await
2405            .expect("fetched");
2406        assert_eq!(std::fs::read(&path).expect("read back"), b"attachment body");
2407        assert_eq!(path.file_name().and_then(|name| name.to_str()), Some("notes.txt"));
2408    }
2409
2410    #[tokio::test]
2411    async fn a_download_refuses_to_escape_the_chosen_directory() {
2412        // The name comes from Slack, so it is attacker-controlled input as
2413        // far as this code is concerned.
2414        let (facade, _ws, api, _rx) = facade_with_recording_slack();
2415        api.seed_file("F1", "../../escape.txt", b"nope");
2416        let dir = tempdir().expect("tempdir");
2417
2418        let path = facade
2419            .fetch_attachment(&caller(), fetch_request("F1", dir.path()))
2420            .await
2421            .expect("fetched");
2422        // `starts_with` is component-wise, so `dir/../../escape.txt` passes
2423        // it lexically while resolving above `dir`. Assert the parent
2424        // instead: the file lands IN the directory or the test is asleep.
2425        assert_eq!(
2426            path.parent(),
2427            Some(dir.path()),
2428            "the file must land directly in the chosen directory, never above it: {path:?}",
2429        );
2430    }
2431
2432    /// The name comes from Slack and the directory is not exclusive to
2433    /// this call, so a fetch must never clobber something already there.
2434    #[tokio::test]
2435    async fn a_download_refuses_to_clobber_an_existing_file() {
2436        let (facade, _ws, api, _rx) = facade_with_recording_slack();
2437        api.seed_file("F1", "notes.txt", b"new bytes");
2438        let dir = tempdir().expect("tempdir");
2439        let existing = dir.path().join("notes.txt");
2440        std::fs::write(&existing, b"precious").expect("write the existing file");
2441
2442        assert!(
2443            facade.fetch_attachment(&caller(), fetch_request("F1", dir.path())).await.is_err(),
2444            "an existing file must not be overwritten",
2445        );
2446        assert_eq!(
2447            std::fs::read(&existing).expect("read back"),
2448            b"precious",
2449            "and the existing file is untouched",
2450        );
2451    }
2452
2453    /// A fetch into a directory that does not exist yet just works: the
2454    /// directory is created, however many levels are missing.
2455    #[tokio::test]
2456    async fn a_fetch_creates_a_missing_target_directory() {
2457        let (facade, _ws, api, _rx) = facade_with_recording_slack();
2458        api.seed_file("F1", "notes.txt", b"attachment body");
2459        let root = tempdir().expect("tempdir");
2460        let dir = root.path().join("fresh/deeper");
2461
2462        let path =
2463            facade.fetch_attachment(&caller(), fetch_request("F1", &dir)).await.expect("fetched");
2464        assert_eq!(
2465            path.parent(),
2466            Some(dir.as_path()),
2467            "the file lands in the directory that was created: {path:?}",
2468        );
2469        assert_eq!(std::fs::read(&path).expect("read back"), b"attachment body");
2470    }
2471
2472    /// A `dir` that exists but is a file is refused with the typed error
2473    /// before Slack is contacted: `F1` is unseeded here, so an error other
2474    /// than `NotADirectory` means the fetch reached Slack first.
2475    #[tokio::test]
2476    async fn a_fetch_rejects_a_dir_that_is_a_file() {
2477        let (facade, _ws, _api, _rx) = facade_with_recording_slack();
2478        let root = tempdir().expect("tempdir");
2479        let not_a_dir = root.path().join("plain-file");
2480        std::fs::write(&not_a_dir, b"data").expect("write the file");
2481
2482        let err = facade
2483            .fetch_attachment(&caller(), fetch_request("F1", &not_a_dir))
2484            .await
2485            .expect_err("a file is not a directory");
2486        assert_eq!(
2487            err,
2488            SlackAttachmentError::NotADirectory(not_a_dir.clone()),
2489            "the error carries the path the caller chose",
2490        );
2491    }
2492
2493    /// When the write still fails after the checks, the error names the
2494    /// directory the caller chose instead of a bare errno.
2495    #[tokio::test]
2496    async fn a_fetch_failure_names_the_path() {
2497        let (facade, _ws, api, _rx) = facade_with_recording_slack();
2498        api.seed_file("F1", "notes.txt", b"body");
2499        let root = tempdir().expect("tempdir");
2500        let block = root.path().join("plain-file");
2501        std::fs::write(&block, b"data").expect("write the file");
2502        let dir = block.join("sub");
2503
2504        let err = facade
2505            .fetch_attachment(&caller(), fetch_request("F1", &dir))
2506            .await
2507            .expect_err("cannot land");
2508        let SlackAttachmentError::Io(message) = err else {
2509            panic!("expected the io error, got {err:?}");
2510        };
2511        assert!(
2512            message.contains(&dir.display().to_string()),
2513            "the error names the directory: {message}",
2514        );
2515    }
2516
2517    /// The approval prompt shows the conversation. The id is not a name
2518    /// anybody recognises, so it is resolved before the draft is held.
2519    #[tokio::test]
2520    async fn a_held_draft_names_the_conversation_not_its_id() {
2521        let (facade, ws, api, mut rx) = facade_with_recording_slack();
2522        api.seed_conversation("C1", "ops");
2523
2524        let task = tokio::spawn({
2525            let facade = facade.clone();
2526            async move { facade.post(&caller(), post_request("C1", "hello")).await }
2527        });
2528
2529        let mut seen = None;
2530        for _ in 0..200 {
2531            while let Ok(frame) = rx.try_recv() {
2532                if let crate::protocol::SessionUpdate::SlackPostPending { draft, .. } =
2533                    std::sync::Arc::unwrap_or_clone(frame.update)
2534                {
2535                    seen = Some(draft.conversation_label);
2536                }
2537            }
2538            if seen.is_some() {
2539                break;
2540            }
2541            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2542        }
2543        assert_eq!(
2544            seen.expect("the draft reaches the prompt"),
2545            "#ops",
2546            "the prompt names the channel the message goes to, as a channel",
2547        );
2548
2549        let id = wait_for_draft(&ws).await;
2550        ws.resolve_slack_draft(id, &caller(), answered(false));
2551        let _ = task.await;
2552    }
2553
2554    /// The draft the facade has parked, which is the copy the pending update
2555    /// carries.
2556    async fn parked_draft(ws: &Arc<Workspace>) -> forge_primitives::slack::SlackDraft {
2557        for _ in 0..200 {
2558            let draft = ws.slack_drafts.lock().values().next().map(|(_, draft, _)| draft.clone());
2559            if let Some(draft) = draft {
2560                return draft;
2561            }
2562            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2563        }
2564        panic!("no draft was registered");
2565    }
2566
2567    /// A thread read that answers one parent message.
2568    fn parent_message(ts: &str, user: &str, text: &str) -> SlackHistoryMessage {
2569        SlackHistoryMessage {
2570            ts: ts.to_owned(),
2571            user: Some(user.to_owned()),
2572            text: text.to_owned(),
2573            thread_ts: Some(ts.to_owned()),
2574            reply_count: 0,
2575            bot_name: None,
2576            parent_user_id: None,
2577            latest_reply: None,
2578            edited: None,
2579            files: Vec::new(),
2580        }
2581    }
2582
2583    fn threaded_post(conversation: &str, ts: &str, text: &str) -> SlackPostRequest {
2584        SlackPostRequest {
2585            conversation: conversation.to_owned(),
2586            thread_ts: Some(ts.to_owned()),
2587            text: text.to_owned(),
2588        }
2589    }
2590
2591    /// The card names the thread it replies into and the people it tags, so
2592    /// the reader is not shown a ts and raw ids.
2593    #[tokio::test]
2594    async fn a_held_draft_names_its_thread_and_the_people_it_tags() {
2595        let (facade, ws, api, _rx) = facade_with_recording_slack();
2596        api.seed_conversation("C1", "ops");
2597        api.seed_replies(
2598            "C1",
2599            "100.0",
2600            vec![parent_message("100.0", "U2", "the deploy\nis green")],
2601        );
2602
2603        let task = tokio::spawn({
2604            let facade = facade.clone();
2605            async move { facade.post(&caller(), threaded_post("C1", "100.0", "confirmed <@U9>")).await }
2606        });
2607
2608        let draft = parked_draft(&ws).await;
2609        assert_eq!(
2610            draft.thread,
2611            Some(forge_primitives::slack::SlackThreadName {
2612                author: "tester".to_owned(),
2613                excerpt: "the deploy is green".to_owned(),
2614            }),
2615            "the thread is named by its author and one flattened line",
2616        );
2617        assert_eq!(
2618            draft.tagged,
2619            vec![forge_primitives::slack::SlackPerson {
2620                id: "U9".to_owned(),
2621                name: Some("tester".to_owned()),
2622            }],
2623            "the tagged person is named, not left as an id",
2624        );
2625
2626        let id = wait_for_draft(&ws).await;
2627        ws.resolve_slack_draft(id, &caller(), answered(false));
2628        let _ = task.await;
2629    }
2630
2631    /// A mention inside the quoted parent is named by the same lookup the
2632    /// draft's own tags use, so the line reads as prose rather than markup.
2633    #[tokio::test]
2634    async fn a_parents_mention_reads_as_its_name_in_the_excerpt() {
2635        let (facade, ws, api, _rx) = facade_with_recording_slack();
2636        api.seed_conversation("C1", "ops");
2637        api.seed_replies(
2638            "C1",
2639            "100.0",
2640            vec![parent_message("100.0", "U2", "<@U9> the deploy is green")],
2641        );
2642
2643        let task = tokio::spawn({
2644            let facade = facade.clone();
2645            async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
2646        });
2647
2648        assert_eq!(
2649            parked_draft(&ws).await.thread.map(|thread| thread.excerpt),
2650            Some("@tester the deploy is green".to_owned()),
2651            "the parent's mention draws its name, not the raw id",
2652        );
2653        let id = wait_for_draft(&ws).await;
2654        ws.resolve_slack_draft(id, &caller(), answered(false));
2655        let _ = task.await;
2656    }
2657
2658    /// A mention the lookup cannot name keeps its id, exactly as a tag does,
2659    /// so nothing is invented.
2660    #[tokio::test]
2661    async fn a_parents_mention_keeps_its_id_when_the_lookup_fails() {
2662        let (facade, ws, api, _rx) = facade_with_recording_slack();
2663        api.seed_conversation("C1", "ops");
2664        api.fail_user_info("U9");
2665        api.seed_replies(
2666            "C1",
2667            "100.0",
2668            vec![parent_message("100.0", "U2", "<@U9> the deploy is green")],
2669        );
2670
2671        let task = tokio::spawn({
2672            let facade = facade.clone();
2673            async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
2674        });
2675
2676        assert_eq!(
2677            parked_draft(&ws).await.thread.map(|thread| thread.excerpt),
2678            Some("U9 the deploy is green".to_owned()),
2679            "an unnamed mention keeps its id rather than a guessed name",
2680        );
2681        let id = wait_for_draft(&ws).await;
2682        ws.resolve_slack_draft(id, &caller(), answered(false));
2683        let _ = task.await;
2684    }
2685
2686    /// Only the draft's own text tags people: a mention in the quoted parent
2687    /// is named in the excerpt but is not added to `tagged`.
2688    #[tokio::test]
2689    async fn only_the_drafts_own_mentions_are_tagged() {
2690        let (facade, ws, api, _rx) = facade_with_recording_slack();
2691        api.seed_conversation("C1", "ops");
2692        api.seed_replies(
2693            "C1",
2694            "100.0",
2695            vec![parent_message("100.0", "U2", "<@U8> the deploy is green")],
2696        );
2697
2698        let task = tokio::spawn({
2699            let facade = facade.clone();
2700            async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello <@U9>")).await }
2701        });
2702
2703        let draft = parked_draft(&ws).await;
2704        assert_eq!(
2705            draft.tagged,
2706            vec![SlackPerson { id: "U9".to_owned(), name: Some("tester".to_owned()) }],
2707            "a parent's mention is named in the excerpt, not tagged on the draft",
2708        );
2709        assert_eq!(
2710            draft.thread.map(|thread| thread.excerpt),
2711            Some("@tester the deploy is green".to_owned()),
2712            "the parent's mention still reads as its name",
2713        );
2714        let id = wait_for_draft(&ws).await;
2715        ws.resolve_slack_draft(id, &caller(), answered(false));
2716        let _ = task.await;
2717    }
2718
2719    /// The quote is bounded after its markup is flattened, so the bound is a
2720    /// budget of prose and the cut lands on a word.
2721    #[tokio::test]
2722    async fn a_quoted_parent_is_bounded_after_its_markup_is_flattened() {
2723        let (facade, ws, api, _rx) = facade_with_recording_slack();
2724        api.seed_conversation("C1", "ops");
2725        api.seed_replies(
2726            "C1",
2727            "100.0",
2728            vec![parent_message(
2729                "100.0",
2730                "U2",
2731                "*<https://example.com|the staging deploy>* is <@U9> and every check is green \
2732                 while the p99 is still high and the canary holds at one percent",
2733            )],
2734        );
2735
2736        let task = tokio::spawn({
2737            let facade = facade.clone();
2738            async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
2739        });
2740
2741        let excerpt = parked_draft(&ws).await.thread.expect("the thread is named").excerpt;
2742        assert!(excerpt.ends_with("..."), "the flattened line is cut: {excerpt:?}");
2743        assert!(
2744            excerpt.chars().count() <= ProdSlackFacade::THREAD_EXCERPT_CHARS + 3,
2745            "the bound holds after flattening: {excerpt:?}",
2746        );
2747        assert!(
2748            excerpt.chars().count() > ProdSlackFacade::THREAD_EXCERPT_CHARS - 5,
2749            "the budget counts prose, so markup did not consume the cut: {excerpt:?}",
2750        );
2751        assert!(
2752            !excerpt.contains('<') && !excerpt.contains('*'),
2753            "the bound is counted on prose, not raw markup: {excerpt:?}",
2754        );
2755        let kept = excerpt.strip_suffix("...").expect("the mark is there");
2756        assert!(!kept.ends_with(' '), "the cut lands at a word boundary: {excerpt:?}");
2757
2758        let id = wait_for_draft(&ws).await;
2759        ws.resolve_slack_draft(id, &caller(), answered(false));
2760        let _ = task.await;
2761    }
2762
2763    /// A root post has no thread to name, and its tags still resolve.
2764    #[tokio::test]
2765    async fn a_root_post_names_no_thread_but_still_names_its_tags() {
2766        let (facade, ws, api, _rx) = facade_with_recording_slack();
2767        api.seed_conversation("C1", "ops");
2768
2769        let task = tokio::spawn({
2770            let facade = facade.clone();
2771            async move { facade.post(&caller(), post_request("C1", "ping <@U9>")).await }
2772        });
2773
2774        let draft = parked_draft(&ws).await;
2775        assert_eq!(draft.thread, None, "a root message has no thread segment");
2776        assert_eq!(
2777            draft.tagged,
2778            vec![forge_primitives::slack::SlackPerson {
2779                id: "U9".to_owned(),
2780                name: Some("tester".to_owned()),
2781            }],
2782            "the tags resolve whether or not the message replies into a thread",
2783        );
2784
2785        let id = wait_for_draft(&ws).await;
2786        ws.resolve_slack_draft(id, &caller(), answered(false));
2787        let _ = task.await;
2788    }
2789
2790    /// A parent Slack no longer returns leaves the thread unnamed, and the
2791    /// draft still parks.
2792    #[tokio::test]
2793    async fn a_parent_slack_no_longer_returns_leaves_the_thread_unnamed() {
2794        let (facade, ws, api, _rx) = facade_with_recording_slack();
2795        api.seed_conversation("C1", "ops");
2796
2797        let task = tokio::spawn({
2798            let facade = facade.clone();
2799            async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
2800        });
2801
2802        assert_eq!(parked_draft(&ws).await.thread, None, "no parent means no name to draw");
2803        assert_eq!(
2804            api.reply_reads(),
2805            vec![("C1".to_owned(), "100.0".to_owned())],
2806            "the read was attempted against the conversation, not skipped",
2807        );
2808        let id = wait_for_draft(&ws).await;
2809        ws.resolve_slack_draft(id, &caller(), answered(false));
2810        let _ = task.await;
2811    }
2812
2813    /// The card names a channel the one way, and a DM by its partner: only a
2814    /// channel carries a `#`. A caller echoing the card's own label back
2815    /// still resolves, so a tool result can be fed into the next tool.
2816    #[tokio::test]
2817    async fn a_channel_is_prefixed_and_a_dm_and_a_group_dm_are_not() {
2818        let recording = Arc::new(RecordingApi::default());
2819        recording.seed_conversation("C1", "ops");
2820        recording.seed_dm("D1", "U9");
2821        recording.seed_mpim("G1", "mpdm-ved-alice-1");
2822        let api: Arc<dyn SlackApi> = Arc::clone(&recording) as Arc<dyn SlackApi>;
2823
2824        assert_eq!(
2825            ProdSlackFacade::resolve_conversation(&api, "acme", "C1").await,
2826            ("C1".to_owned(), "#ops".to_owned()),
2827            "a channel's label is the card's spelling",
2828        );
2829        assert_eq!(
2830            ProdSlackFacade::resolve_conversation(&api, "acme", "D1").await,
2831            ("D1".to_owned(), "U9".to_owned()),
2832            "a DM is named after its partner, never with a `#`",
2833        );
2834        assert_eq!(
2835            ProdSlackFacade::resolve_conversation(&api, "acme", "G1").await,
2836            ("G1".to_owned(), "mpdm-ved-alice-1".to_owned()),
2837            "a group DM keeps Slack's own generated name",
2838        );
2839        assert_eq!(
2840            ProdSlackFacade::resolve_conversation(&api, "acme", "#ops").await,
2841            ("C1".to_owned(), "#ops".to_owned()),
2842            "the card's own label resolves when an agent echoes it back",
2843        );
2844    }
2845
2846    /// When the directory cannot be read, the id stays the bare form Slack
2847    /// can address and the label keeps the caller's own spelling.
2848    #[tokio::test]
2849    async fn a_list_failure_keeps_the_callers_spelling_and_a_bare_id() {
2850        let recording = Arc::new(RecordingApi::default());
2851        recording.fail_list();
2852        let api: Arc<dyn SlackApi> = Arc::clone(&recording) as Arc<dyn SlackApi>;
2853
2854        assert_eq!(
2855            ProdSlackFacade::resolve_conversation(&api, "acme", "#ops").await,
2856            ("ops".to_owned(), "#ops".to_owned()),
2857            "the id is addressable; the label keeps what the caller said",
2858        );
2859        assert_eq!(
2860            ProdSlackFacade::resolve_conversation(&api, "acme", "U9").await,
2861            ("U9".to_owned(), "U9".to_owned()),
2862            "a partner id carries no `#` either way",
2863        );
2864    }
2865
2866    /// An edit addressed by a DM partner reads the thread from the id Slack
2867    /// knows, exactly as a post does.
2868    #[tokio::test]
2869    async fn an_edit_addressed_by_a_dm_partner_reads_the_resolved_id() {
2870        let (facade, ws, api, _rx) = facade_with_recording_slack();
2871        api.seed_dm("D1", "U9");
2872        api.seed_replies("D1", "100.0", vec![parent_message("100.0", "U2", "the message")]);
2873
2874        let task = tokio::spawn({
2875            let facade = facade.clone();
2876            async move { facade.edit(&caller(), edit_request("U9", "100.0", Some("rewritten"))).await }
2877        });
2878
2879        let draft = parked_draft(&ws).await;
2880        assert_eq!(
2881            draft.thread.map(|thread| thread.excerpt),
2882            Some("the message".to_owned()),
2883            "the edit names the message it acts on",
2884        );
2885        assert_eq!(
2886            api.reply_reads(),
2887            vec![("D1".to_owned(), "100.0".to_owned())],
2888            "the read names the conversation Slack knows",
2889        );
2890        let id = wait_for_draft(&ws).await;
2891        ws.resolve_slack_draft(id, &caller(), answered(false));
2892        let _ = task.await;
2893    }
2894
2895    /// An upload addressed by a DM partner reads its thread from the
2896    /// resolved id too: the fix covers every path that builds a draft.
2897    #[tokio::test]
2898    async fn an_upload_addressed_by_a_dm_partner_reads_the_resolved_id() {
2899        let (facade, ws, api, _rx) = facade_with_recording_slack();
2900        api.seed_dm("D1", "U9");
2901        api.seed_replies("D1", "100.0", vec![parent_message("100.0", "U2", "the parent")]);
2902        let dir = tempdir().expect("tempdir");
2903        let path = dir.path().join("notes.txt");
2904        std::fs::write(&path, b"body").expect("write the file");
2905        let mut request = upload_request("U9", &path);
2906        request.thread_ts = Some("100.0".to_owned());
2907
2908        let task = tokio::spawn({
2909            let facade = facade.clone();
2910            async move { facade.post_attachment(&caller(), request).await }
2911        });
2912
2913        assert!(parked_draft(&ws).await.thread.is_some(), "the upload names its thread");
2914        assert_eq!(
2915            api.reply_reads(),
2916            vec![("D1".to_owned(), "100.0".to_owned())],
2917            "the read names the conversation Slack knows",
2918        );
2919        let id = wait_for_draft(&ws).await;
2920        ws.resolve_slack_draft(id, &caller(), answered(false));
2921        let _ = task.await;
2922    }
2923
2924    /// A parent with no author at all cannot be named without inventing one,
2925    /// so no name is set.
2926    #[tokio::test]
2927    async fn a_parent_with_no_author_leaves_the_thread_unnamed() {
2928        let (facade, ws, api, _rx) = facade_with_recording_slack();
2929        api.seed_conversation("C1", "ops");
2930        api.seed_replies(
2931            "C1",
2932            "100.0",
2933            vec![SlackHistoryMessage {
2934                user: None,
2935                bot_name: None,
2936                ..parent_message("100.0", "U2", "the parent")
2937            }],
2938        );
2939
2940        let task = tokio::spawn({
2941            let facade = facade.clone();
2942            async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
2943        });
2944
2945        assert_eq!(parked_draft(&ws).await.thread, None, "no author means no name to draw");
2946        let id = wait_for_draft(&ws).await;
2947        ws.resolve_slack_draft(id, &caller(), answered(false));
2948        let _ = task.await;
2949    }
2950
2951    /// The bound counts characters, not bytes: a parent of two-byte glyphs
2952    /// is cut at the same place a Latin one would be.
2953    #[tokio::test]
2954    async fn a_multibyte_parent_is_cut_by_characters_not_bytes() {
2955        let (facade, ws, api, _rx) = facade_with_recording_slack();
2956        api.seed_conversation("C1", "ops");
2957        let text = "é".repeat(100);
2958        api.seed_replies("C1", "100.0", vec![parent_message("100.0", "U2", &text)]);
2959
2960        let task = tokio::spawn({
2961            let facade = facade.clone();
2962            async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
2963        });
2964
2965        let excerpt = parked_draft(&ws).await.thread.expect("the thread is named").excerpt;
2966        assert_eq!(
2967            excerpt.chars().count(),
2968            ProdSlackFacade::THREAD_EXCERPT_CHARS + 3,
2969            "the bound is characters plus the mark, not bytes",
2970        );
2971        assert!(excerpt.starts_with('é'), "the glyph survives whole: {excerpt:?}");
2972        let id = wait_for_draft(&ws).await;
2973        ws.resolve_slack_draft(id, &caller(), answered(false));
2974        let _ = task.await;
2975    }
2976
2977    /// A read anchored at a reply answers the thread's parent. Naming that
2978    /// would label an edit with a different message, so a ts that is not the
2979    /// one asked for leaves the card drawing the raw stamp.
2980    #[tokio::test]
2981    async fn a_message_inside_a_thread_is_not_named_as_the_thread_parent() {
2982        let (facade, ws, api, _rx) = facade_with_recording_slack();
2983        api.seed_conversation("C1", "ops");
2984        api.seed_replies("C1", "200.0", vec![parent_message("100.0", "U2", "the thread parent")]);
2985
2986        let task = tokio::spawn({
2987            let facade = facade.clone();
2988            async move { facade.edit(&caller(), edit_request("C1", "200.0", Some("rewritten"))).await }
2989        });
2990
2991        assert_eq!(
2992            parked_draft(&ws).await.thread,
2993            None,
2994            "the thread parent is not the message a reply's edit acts on",
2995        );
2996        let id = wait_for_draft(&ws).await;
2997        ws.resolve_slack_draft(id, &caller(), answered(false));
2998        let _ = task.await;
2999    }
3000
3001    /// A draft addressed the way the schema tells agents to address a DM -
3002    /// by the partner's user id - still names its thread: the read uses the
3003    /// resolved conversation id while the wire keeps what the caller named.
3004    #[tokio::test]
3005    async fn a_draft_addressed_by_a_dm_partner_names_the_thread_by_its_resolved_id() {
3006        let (facade, ws, api, _rx) = facade_with_recording_slack();
3007        api.seed_dm("D1", "U9");
3008        api.seed_replies("D1", "100.0", vec![parent_message("100.0", "U2", "the parent")]);
3009
3010        let task = tokio::spawn({
3011            let facade = facade.clone();
3012            async move { facade.post(&caller(), threaded_post("U9", "100.0", "hello")).await }
3013        });
3014
3015        let draft = parked_draft(&ws).await;
3016        assert_eq!(draft.conversation, "U9", "the wire keeps what the caller addressed");
3017        assert!(
3018            draft.thread.is_some(),
3019            "the thread is read from the resolved id, not the partner's"
3020        );
3021        assert_eq!(
3022            api.reply_reads(),
3023            vec![("D1".to_owned(), "100.0".to_owned())],
3024            "the read names the conversation Slack knows",
3025        );
3026
3027        let id = wait_for_draft(&ws).await;
3028        ws.resolve_slack_draft(id, &caller(), answered(false));
3029        let _ = task.await;
3030    }
3031
3032    /// A top-level message is named for the edit or the delete that acts on
3033    /// it: the read returns the message itself, so its author and opening
3034    /// words are the card's.
3035    #[tokio::test]
3036    async fn an_edit_or_a_delete_of_a_top_level_message_names_it() {
3037        for text in [Some("rewritten"), None] {
3038            let (facade, ws, api, _rx) = facade_with_recording_slack();
3039            api.seed_conversation("C1", "ops");
3040            api.seed_replies("C1", "100.0", vec![parent_message("100.0", "U2", "the message")]);
3041
3042            let task = tokio::spawn({
3043                let facade = facade.clone();
3044                async move { facade.edit(&caller(), edit_request("C1", "100.0", text)).await }
3045            });
3046
3047            let draft = parked_draft(&ws).await;
3048            assert_eq!(
3049                draft.thread.map(|thread| thread.excerpt),
3050                Some("the message".to_owned()),
3051                "the operation names the message it acts on",
3052            );
3053            let id = wait_for_draft(&ws).await;
3054            ws.resolve_slack_draft(id, &caller(), answered(false));
3055            let _ = task.await;
3056        }
3057    }
3058
3059    /// A failed name lookup still names the author by the id Slack sent -
3060    /// never an empty author, which would draw a bare `@`.
3061    #[tokio::test]
3062    async fn a_failed_author_lookup_draws_the_raw_id_as_the_author() {
3063        let (facade, ws, api, _rx) = facade_with_recording_slack();
3064        api.seed_conversation("C1", "ops");
3065        api.seed_replies("C1", "100.0", vec![parent_message("100.0", "U2", "the parent")]);
3066        api.fail_user_info("U2");
3067
3068        let task = tokio::spawn({
3069            let facade = facade.clone();
3070            async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
3071        });
3072
3073        let draft = parked_draft(&ws).await;
3074        assert_eq!(
3075            draft.thread.map(|thread| thread.author),
3076            Some("U2".to_owned()),
3077            "the raw id is drawn rather than an invented or empty name",
3078        );
3079        let id = wait_for_draft(&ws).await;
3080        ws.resolve_slack_draft(id, &caller(), answered(false));
3081        let _ = task.await;
3082    }
3083
3084    /// A bot names itself on the parent, so the card draws that name without
3085    /// spending a users.info call on the bot's id.
3086    #[tokio::test]
3087    async fn a_bot_parent_is_named_by_its_own_name() {
3088        let (facade, ws, api, _rx) = facade_with_recording_slack();
3089        api.seed_conversation("C1", "ops");
3090        api.seed_replies(
3091            "C1",
3092            "100.0",
3093            vec![SlackHistoryMessage {
3094                bot_name: Some("Deploy Bot".to_owned()),
3095                ..parent_message("100.0", "U3", "the deploy is green")
3096            }],
3097        );
3098        api.fail_user_info("U3");
3099
3100        let task = tokio::spawn({
3101            let facade = facade.clone();
3102            async move { facade.post(&caller(), threaded_post("C1", "100.0", "hello")).await }
3103        });
3104
3105        let draft = parked_draft(&ws).await;
3106        assert_eq!(
3107            draft.thread.map(|thread| thread.author),
3108            Some("Deploy Bot".to_owned()),
3109            "the bot's own name wins over a lookup",
3110        );
3111        let id = wait_for_draft(&ws).await;
3112        ws.resolve_slack_draft(id, &caller(), answered(false));
3113        let _ = task.await;
3114    }
3115
3116    /// When both lookups fail the draft is still held and drawable: no
3117    /// thread name, and the tag keeps its id instead of being dropped.
3118    #[tokio::test]
3119    async fn a_draft_still_parks_when_the_parent_and_a_tag_lookup_fail() {
3120        let (facade, ws, api, _rx) = facade_with_recording_slack();
3121        api.seed_conversation("C1", "ops");
3122        api.fail_replies("C1", "100.0");
3123        api.fail_user_info("U9");
3124
3125        let task = tokio::spawn({
3126            let facade = facade.clone();
3127            async move { facade.post(&caller(), threaded_post("C1", "100.0", "confirmed <@U9>")).await }
3128        });
3129
3130        let draft = parked_draft(&ws).await;
3131        assert_eq!(draft.thread, None, "a failed parent read is not invented over");
3132        assert_eq!(
3133            draft.tagged,
3134            vec![forge_primitives::slack::SlackPerson { id: "U9".to_owned(), name: None }],
3135            "an unresolved tag keeps its id, never dropped",
3136        );
3137
3138        let id = wait_for_draft(&ws).await;
3139        ws.resolve_slack_draft(id, &caller(), answered(false));
3140        let _ = task.await;
3141    }
3142
3143    /// The ts Slack answers a post with is the handle an edit, a delete or
3144    /// a reaction takes, and Slack has already sent it.
3145    #[tokio::test]
3146    async fn a_post_carries_the_ts_slack_answered_with() {
3147        let (facade, ws, api, _rx) = facade_with_recording_slack();
3148        api.answer_posts_with("1790186552.442169");
3149
3150        let task = tokio::spawn({
3151            let facade = facade.clone();
3152            async move { facade.post(&caller(), post_request("C1", "hello")).await }
3153        });
3154        let id = wait_for_draft(&ws).await;
3155        ws.resolve_slack_draft(id, &caller(), answered(true));
3156
3157        let outcome = task.await.expect("the post task does not panic").expect("the post landed");
3158        assert_eq!(
3159            outcome.ts,
3160            vec!["1790186552.442169".to_owned()],
3161            "the ts the wire sent reaches the caller",
3162        );
3163        assert_eq!(outcome.workspace, "acme", "and the label it posted into rides along");
3164    }
3165
3166    /// Every part Slack accepted is ledgered as it lands. A long draft is
3167    /// several messages, and one that failed halfway would otherwise echo
3168    /// the parts that did post back into the session.
3169    #[tokio::test]
3170    async fn a_multi_part_post_ledgers_every_landed_part() {
3171        let (facade, ws, _api, _rx, _dir) = facade_with_recording_slack_and_store();
3172        let text = "a".repeat(forge_connectors::slack::POST_LIMIT + 1);
3173
3174        let task = tokio::spawn({
3175            let facade = facade.clone();
3176            async move { facade.post(&caller(), post_request("C1", &text)).await }
3177        });
3178        let id = wait_for_draft(&ws).await;
3179        ws.resolve_slack_draft(id, &caller(), answered(true));
3180
3181        let outcome = task.await.expect("no panic").expect("the parts land");
3182        assert_eq!(outcome.ts, vec!["1.0".to_owned(), "2.0".to_owned()], "two parts were sent");
3183        for ts in &outcome.ts {
3184            assert!(ws.slack_own_post("acme", "C1", ts), "part {ts} must be refused to the sweep");
3185        }
3186        assert!(
3187            !ws.slack_own_post("acme", "C2", "1.0"),
3188            "and the ledger stays scoped to the conversation it was posted in",
3189        );
3190    }
3191
3192    /// A request can address a DM by user id or a channel by name, and
3193    /// Slack answers the conversation it actually posted into. The ledger
3194    /// has to key on that answer, or the sweep looks under the id and the
3195    /// agent's own DM comes back to it.
3196    #[tokio::test]
3197    async fn a_post_is_ledgered_under_the_channel_slack_posted_into() {
3198        let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3199        api.answer_posts_with("1700000000.000100");
3200        api.answer_posts_in("D456");
3201
3202        let task = tokio::spawn({
3203            let facade = facade.clone();
3204            async move { facade.post(&caller(), post_request("U123", "hello")).await }
3205        });
3206        let id = wait_for_draft(&ws).await;
3207        ws.resolve_slack_draft(id, &caller(), answered(true));
3208        let outcome = task.await.expect("no panic").expect("the post lands");
3209
3210        assert_eq!(outcome.ts, vec!["1700000000.000100".to_owned()]);
3211        assert!(
3212            ws.slack_own_post("acme", "D456", "1700000000.000100"),
3213            "the conversation the post landed in is refused to the sweep",
3214        );
3215        assert!(
3216            !ws.slack_own_post("acme", "U123", "1700000000.000100"),
3217            "the address it was sent to is not the conversation the sweep reads",
3218        );
3219    }
3220
3221    /// A port that records ts but not the answer's channel would false the
3222    /// assertion above; the same guard for the partial case is that the
3223    /// parts Slack took are recorded even when a later part fails.
3224    #[tokio::test]
3225    async fn a_partial_multi_part_post_ledgers_the_parts_slack_took() {
3226        let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3227        api.fail_post_at(1);
3228        let text = "a".repeat(forge_connectors::slack::POST_LIMIT + 1);
3229
3230        let task = tokio::spawn({
3231            let facade = facade.clone();
3232            async move { facade.post(&caller(), post_request("C1", &text)).await }
3233        });
3234        let id = wait_for_draft(&ws).await;
3235        ws.resolve_slack_draft(id, &caller(), answered(true));
3236        let outcome = task.await.expect("no panic");
3237
3238        assert!(
3239            matches!(outcome, Err(SlackPostError::Partial { posted: 1, .. })),
3240            "the failure names how much landed: {outcome:?}",
3241        );
3242        assert!(
3243            ws.slack_own_post("acme", "C1", "1.0"),
3244            "the part Slack took is refused to the sweep",
3245        );
3246    }
3247
3248    /// The mark is held across the post call: without it a sweep in the
3249    /// window delivers what the post is about to ledger.
3250    #[tokio::test]
3251    async fn a_post_marks_its_conversation_in_flight_until_the_ledger_write() {
3252        let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3253        let gate = api.park_posts();
3254
3255        let task = tokio::spawn({
3256            let facade = facade.clone();
3257            async move { facade.post(&caller(), post_request("C1", "hello")).await }
3258        });
3259        let id = wait_for_draft(&ws).await;
3260        ws.resolve_slack_draft(id, &caller(), answered(true));
3261
3262        let mut marked = false;
3263        for _ in 0..400 {
3264            if ws.slack_post_in_flight("acme", "C1").is_some() {
3265                marked = true;
3266                break;
3267            }
3268            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3269        }
3270        assert!(marked, "the facade marks the conversation before the post returns");
3271
3272        gate.notify_one();
3273        task.await.expect("no panic").expect("the post lands");
3274        assert!(
3275            ws.slack_post_in_flight("acme", "C1").is_none(),
3276            "and clears it once the ts is ledgered",
3277        );
3278    }
3279
3280    /// Each part carries its own mark: a part that has burned its budget
3281    /// must not leave the next one unprotected, which is what a stamp
3282    /// taken once per operation does.
3283    #[tokio::test]
3284    async fn each_post_part_takes_a_fresh_in_flight_mark() {
3285        let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3286        let gate = api.park_posts();
3287        let text = "a".repeat(forge_connectors::slack::POST_LIMIT + 1);
3288
3289        let task = tokio::spawn({
3290            let facade = facade.clone();
3291            async move { facade.post(&caller(), post_request("C1", &text)).await }
3292        });
3293        let id = wait_for_draft(&ws).await;
3294        ws.resolve_slack_draft(id, &caller(), answered(true));
3295
3296        until(|| ws.slack_post_in_flight("acme", "C1").is_some()).await;
3297        // As if the first part had used its whole request budget.
3298        let stale = std::time::Instant::now()
3299            .checked_sub(forge_connectors::slack::REQUEST_TIMEOUT)
3300            .and_then(|instant| instant.checked_sub(std::time::Duration::from_secs(1)))
3301            .expect("the clock is past the timeout");
3302        ws.slack_inflight
3303            .lock()
3304            .get_mut(&("acme".to_owned(), "C1".to_owned()))
3305            .expect("the part's entry")
3306            .iter_mut()
3307            .for_each(|stamp| stamp.since = stale);
3308        gate.notify_one();
3309
3310        // Part two is parked once its call has been recorded; its mark
3311        // must be fresh, not the expired one.
3312        until(|| api.posts().len() == 2).await;
3313        assert!(
3314            ws.slack_post_in_flight("acme", "C1").is_some(),
3315            "the second part takes a fresh mark of its own",
3316        );
3317        gate.notify_one();
3318        task.await.expect("no panic").expect("both parts land");
3319    }
3320
3321    /// A failed post leaves no mark behind: the guard drops on the error
3322    /// return, so the conversation is swept again next tick.
3323    #[tokio::test]
3324    async fn a_failed_post_clears_its_in_flight_mark() {
3325        let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3326        api.fail_post_at(0);
3327
3328        let task = tokio::spawn({
3329            let facade = facade.clone();
3330            async move { facade.post(&caller(), post_request("C1", "hello")).await }
3331        });
3332        let id = wait_for_draft(&ws).await;
3333        ws.resolve_slack_draft(id, &caller(), answered(true));
3334
3335        assert!(task.await.expect("no panic").is_err(), "the post fails");
3336        assert!(
3337            ws.slack_post_in_flight("acme", "C1").is_none(),
3338            "a failed post leaves no mark behind",
3339        );
3340    }
3341
3342    /// The upload site takes the same mark: it is held until the shares
3343    /// have been read and ledgered.
3344    #[tokio::test]
3345    async fn an_upload_marks_its_conversation_in_flight_until_the_ledger_write() {
3346        let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3347        let gate = api.park_uploads();
3348        let dir = tempdir().expect("tempdir");
3349        let source = dir.path().join("file.txt");
3350        std::fs::write(&source, b"payload").expect("write the source file");
3351
3352        let task = tokio::spawn({
3353            let facade = facade.clone();
3354            async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3355        });
3356        let id = wait_for_draft(&ws).await;
3357        ws.resolve_slack_draft(id, &caller(), answered(true));
3358
3359        let mut marked = false;
3360        for _ in 0..400 {
3361            if ws.slack_post_in_flight("acme", "C1").is_some() {
3362                marked = true;
3363                break;
3364            }
3365            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3366        }
3367        assert!(marked, "the facade marks the conversation before the upload completes");
3368
3369        gate.notify_one();
3370        task.await.expect("no panic").expect("an approved upload sends");
3371        assert!(
3372            ws.slack_post_in_flight("acme", "C1").is_none(),
3373            "and clears it once the upload's shares are ledgered",
3374        );
3375    }
3376
3377    /// The shares read after the upload carries a mark of its own: the
3378    /// file message is already in history while that call is in flight,
3379    /// and its ts is not ledgered until it returns.
3380    #[tokio::test]
3381    async fn the_upload_marks_its_conversation_while_the_shares_are_read() {
3382        let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3383        api.seed_file("F-uploaded", "file.txt", b"payload");
3384        let gate = api.park_file_info();
3385        let dir = tempdir().expect("tempdir");
3386        let source = dir.path().join("file.txt");
3387        std::fs::write(&source, b"payload").expect("write the source file");
3388
3389        let task = tokio::spawn({
3390            let facade = facade.clone();
3391            async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3392        });
3393        let id = wait_for_draft(&ws).await;
3394        ws.resolve_slack_draft(id, &caller(), answered(true));
3395
3396        until(|| api.file_info_calls() >= 1).await;
3397        assert!(
3398            ws.slack_post_in_flight("acme", "C1").is_some(),
3399            "the shares read holds a fresh mark of its own",
3400        );
3401
3402        gate.notify_one();
3403        task.await.expect("no panic").expect("the upload sends");
3404        assert!(
3405            ws.slack_post_in_flight("acme", "C1").is_none(),
3406            "and clears it once the shares are ledgered",
3407        );
3408    }
3409
3410    /// A share read that fails is a ledger gap, not a failed upload: the
3411    /// file is already posted, and reporting it as unsent would invite a
3412    /// duplicate.
3413    #[tokio::test]
3414    async fn an_upload_whose_shares_cannot_be_read_still_succeeds() {
3415        let (facade, ws, _api, _rx, _dir) = facade_with_recording_slack_and_store();
3416        let dir = tempdir().expect("tempdir");
3417        let source = dir.path().join("file.txt");
3418        std::fs::write(&source, b"payload").expect("write the source file");
3419
3420        let task = tokio::spawn({
3421            let facade = facade.clone();
3422            async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3423        });
3424        let id = wait_for_draft(&ws).await;
3425        ws.resolve_slack_draft(id, &caller(), answered(true));
3426
3427        assert!(
3428            task.await.expect("no panic").is_ok(),
3429            "an unreadable share is a ledger gap, not a failed upload",
3430        );
3431        assert!(
3432            ws.slack_post_in_flight("acme", "C1").is_none(),
3433            "and the mark is cleared whatever the share read did",
3434        );
3435    }
3436
3437    /// Slack's `chat.postMessage` answers the channel it posted into; the
3438    /// fallback is for the case it omits it, and it is right where it can
3439    /// be right: a conversation addressed by id.
3440    #[tokio::test]
3441    async fn a_post_answered_without_a_channel_is_ledgered_under_the_resolved_conversation() {
3442        let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3443        api.answer_posts_with("1700000000.000100");
3444        api.answer_posts_without_channel();
3445
3446        let task = tokio::spawn({
3447            let facade = facade.clone();
3448            async move { facade.post(&caller(), post_request("C1", "hello")).await }
3449        });
3450        let id = wait_for_draft(&ws).await;
3451        ws.resolve_slack_draft(id, &caller(), answered(true));
3452        task.await.expect("no panic").expect("the post lands");
3453
3454        assert!(
3455            ws.slack_own_post("acme", "C1", "1700000000.000100"),
3456            "the resolved conversation is what the fallback keys on",
3457        );
3458    }
3459
3460    /// An attachment posts a message of its own, and the upload call
3461    /// answers no message ts - the file's shares do. The share's own
3462    /// conversation is what the ledger keys on.
3463    #[tokio::test]
3464    async fn an_upload_ledgers_the_message_under_the_shares_own_conversation() {
3465        let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3466        api.seed_file("F-uploaded", "file.txt", b"payload");
3467        api.seed_file_shares("F-uploaded", "D456", "1700000000.000100");
3468        let dir = tempdir().expect("tempdir");
3469        let source = dir.path().join("file.txt");
3470        std::fs::write(&source, b"payload").expect("write the source file");
3471
3472        let task = tokio::spawn({
3473            let facade = facade.clone();
3474            async move { facade.post_attachment(&caller(), upload_request("U123", &source)).await }
3475        });
3476        let id = wait_for_draft(&ws).await;
3477        ws.resolve_slack_draft(id, &caller(), answered(true));
3478        task.await.expect("no panic").expect("an approved upload sends");
3479
3480        assert!(
3481            ws.slack_own_post("acme", "D456", "1700000000.000100"),
3482            "the share's own conversation holds the message, so that is what the ledger keys",
3483        );
3484        assert!(
3485            !ws.slack_own_post("acme", "U123", "1700000000.000100"),
3486            "the request string is not a conversation",
3487        );
3488    }
3489
3490    /// When the upload's own answer already carries the shares, the
3491    /// `files.info` follow-up is not worth a call.
3492    #[tokio::test]
3493    async fn an_upload_answer_that_carries_its_shares_costs_no_files_info_call() {
3494        let (facade, ws, api, _rx, _dir) = facade_with_recording_slack_and_store();
3495        api.seed_upload_shares("C1", "1700000000.000100");
3496        let dir = tempdir().expect("tempdir");
3497        let source = dir.path().join("file.txt");
3498        std::fs::write(&source, b"payload").expect("write the source file");
3499
3500        let task = tokio::spawn({
3501            let facade = facade.clone();
3502            async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3503        });
3504        let id = wait_for_draft(&ws).await;
3505        ws.resolve_slack_draft(id, &caller(), answered(true));
3506        task.await.expect("no panic").expect("an approved upload sends");
3507
3508        assert_eq!(api.file_info_calls(), 0, "the answer was enough; no follow-up read");
3509        assert!(
3510            ws.slack_own_post("acme", "C1", "1700000000.000100"),
3511            "and the message it named is refused to the sweep",
3512        );
3513    }
3514
3515    /// An edit keeps the message's ts, so the rewritten message is refused
3516    /// to the sweep whether forge posted it or the user did.
3517    #[tokio::test]
3518    async fn an_edit_ledgers_the_message_ts_it_rewrote() {
3519        let (facade, ws, _api, _rx, _dir) = facade_with_recording_slack_and_store();
3520
3521        let task = tokio::spawn({
3522            let facade = facade.clone();
3523            async move { facade.edit(&caller(), edit_request("C1", "100.0", Some("new text"))).await }
3524        });
3525        let id = wait_for_draft(&ws).await;
3526        ws.resolve_slack_draft(id, &caller(), answered(true));
3527        task.await.expect("no panic").expect("an approved replacement is sent");
3528
3529        assert!(
3530            ws.slack_own_post("acme", "C1", "100.0"),
3531            "the edited message's own ts is refused to the sweep",
3532        );
3533    }
3534
3535    /// The draft text is what the prompt shows. A file NAME is
3536    /// caller-controlled and can look innocuous, so approving it without
3537    /// the local path it comes from approves something the user has not
3538    /// seen.
3539    #[tokio::test]
3540    async fn an_upload_draft_names_the_local_path_it_will_send() {
3541        let (facade, ws, _api, mut rx) = facade_with_recording_slack();
3542        let dir = tempdir().expect("tempdir");
3543        let source = dir.path().join("file.txt");
3544        std::fs::write(&source, b"payload").expect("write the source file");
3545
3546        let task = tokio::spawn({
3547            let facade = facade.clone();
3548            let source = source.clone();
3549            async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3550        });
3551
3552        let mut seen = None;
3553        for _ in 0..200 {
3554            while let Ok(frame) = rx.try_recv() {
3555                if let crate::protocol::SessionUpdate::SlackPostPending { draft, .. } =
3556                    std::sync::Arc::unwrap_or_clone(frame.update)
3557                {
3558                    seen = Some(draft.text);
3559                }
3560            }
3561            if seen.is_some() {
3562                break;
3563            }
3564            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3565        }
3566        let text = seen.expect("the draft reaches the prompt");
3567        assert!(text.contains("file.txt"), "the file name is shown: {text}");
3568        assert!(
3569            text.contains(&source.display().to_string()),
3570            "and so is the local path it comes from: {text}",
3571        );
3572
3573        let id = wait_for_draft(&ws).await;
3574        ws.resolve_slack_draft(id, &caller(), answered(false));
3575        let _ = task.await;
3576    }
3577
3578    #[tokio::test]
3579    async fn an_upload_goes_through_the_gate() {
3580        let (facade, ws, api, _rx) = facade_with_recording_slack();
3581        let dir = tempdir().expect("tempdir");
3582        let source = dir.path().join("file.txt");
3583        std::fs::write(&source, b"payload").expect("write the source file");
3584
3585        let task = tokio::spawn({
3586            let facade = facade.clone();
3587            async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3588        });
3589        let id = wait_for_draft(&ws).await;
3590        ws.resolve_slack_draft(id, &caller(), answered(false));
3591
3592        assert!(task.await.expect("no panic").is_err(), "a rejected upload must not send");
3593        assert_eq!(api.upload_url_count(), 0, "nothing is requested from Slack either");
3594    }
3595
3596    #[tokio::test]
3597    async fn an_approved_upload_posts_the_bytes_then_completes() {
3598        let (facade, ws, api, _rx) = facade_with_recording_slack();
3599        let dir = tempdir().expect("tempdir");
3600        let source = dir.path().join("file.txt");
3601        std::fs::write(&source, b"payload").expect("write the source file");
3602
3603        let task = tokio::spawn({
3604            let facade = facade.clone();
3605            async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3606        });
3607        let id = wait_for_draft(&ws).await;
3608        ws.resolve_slack_draft(id, &caller(), answered(true));
3609        task.await.expect("no panic").expect("an approved upload sends");
3610
3611        assert_eq!(api.upload_url_count(), 1, "the upload URL is requested once");
3612        assert_eq!(api.uploads().len(), 1, "and completed once");
3613        assert_eq!(api.uploads()[0].1, "C1", "into the conversation that was named");
3614    }
3615
3616    /// The waiter going away is its own ending: nothing was answered, and a
3617    /// view still holding the dock hears it rather than silence.
3618    #[tokio::test]
3619    async fn a_waiter_that_dies_is_reported_as_abandoned() {
3620        let (facade, ws, _api, mut rx) = facade_with_recording_slack();
3621        let task = tokio::spawn({
3622            let facade = facade.clone();
3623            async move { facade.post(&caller(), post_request("C1", "hello")).await }
3624        });
3625        let _id = wait_for_draft(&ws).await;
3626
3627        task.abort();
3628        let _ = task.await;
3629
3630        let mut resolved = Vec::new();
3631        while let Ok(frame) = rx.try_recv() {
3632            if let crate::protocol::SessionUpdate::SlackDraftResolved { ending, .. } =
3633                std::sync::Arc::unwrap_or_clone(frame.update)
3634            {
3635                resolved.push(ending);
3636            }
3637        }
3638        assert_eq!(
3639            resolved,
3640            vec![SlackDraftEnding::Abandoned],
3641            "a dropped waiter clears the registry and says which ending took the draft",
3642        );
3643        assert!(ws.slack_drafts.lock().is_empty(), "and leaves nothing registered");
3644    }
3645
3646    #[tokio::test]
3647    async fn a_rejected_draft_never_reaches_slack() {
3648        let (facade, ws, api, _rx) = facade_with_recording_slack();
3649        let task = tokio::spawn({
3650            let facade = facade.clone();
3651            async move { facade.post(&caller(), post_request("C1", "hello")).await }
3652        });
3653        let id = wait_for_draft(&ws).await;
3654        ws.resolve_slack_draft(id, &caller(), answered(false));
3655
3656        let outcome = task.await.expect("no panic");
3657        assert_eq!(outcome, Err(SlackPostError::Rejected));
3658        assert!(api.posts().is_empty(), "a rejected draft must not post");
3659    }
3660
3661    /// A send-back is not a decline: the blocked call reads the reader's
3662    /// words and revises from them, and nothing goes out.
3663    #[tokio::test]
3664    async fn a_sent_back_post_hands_the_note_back_and_sends_nothing() {
3665        let (facade, ws, api, _rx) = facade_with_recording_slack();
3666        let task = tokio::spawn({
3667            let facade = facade.clone();
3668            async move { facade.post(&caller(), post_request("C1", "hello")).await }
3669        });
3670        let id = wait_for_draft(&ws).await;
3671        ws.resolve_slack_draft(id, &caller(), sent_back("say staging, not prod"));
3672
3673        let outcome = task.await.expect("no panic");
3674        assert_eq!(
3675            outcome,
3676            Err(SlackPostError::SentBack { note: "say staging, not prod".to_owned() }),
3677            "the note reaches the blocked call whole",
3678        );
3679        assert!(api.posts().is_empty(), "a sent-back draft must not post");
3680    }
3681
3682    /// Each gated operation has its own error, so each has its own sent-back
3683    /// arm: one arm left as a plain decline would send the agent guessing.
3684    #[tokio::test]
3685    async fn a_sent_back_edit_hands_the_note_back_and_leaves_the_message() {
3686        let (facade, ws, api, _rx) = facade_with_recording_slack();
3687        let task = tokio::spawn({
3688            let facade = facade.clone();
3689            async move { facade.edit(&caller(), edit_request("C1", "100.0", Some("new"))).await }
3690        });
3691        let id = wait_for_draft(&ws).await;
3692        ws.resolve_slack_draft(id, &caller(), sent_back("keep the original wording"));
3693
3694        assert_eq!(
3695            task.await.expect("no panic"),
3696            Err(SlackEditError::SentBack { note: "keep the original wording".to_owned() }),
3697        );
3698        assert!(api.updates.lock().is_empty(), "a sent-back replacement is never sent");
3699    }
3700
3701    #[tokio::test]
3702    async fn a_sent_back_delete_hands_the_note_back_and_keeps_the_message() {
3703        let (facade, ws, api, _rx) = facade_with_recording_slack();
3704        let task = tokio::spawn({
3705            let facade = facade.clone();
3706            async move { facade.edit(&caller(), edit_request("C1", "100.0", None)).await }
3707        });
3708        let id = wait_for_draft(&ws).await;
3709        ws.resolve_slack_draft(id, &caller(), sent_back("that one needs to stay"));
3710
3711        assert_eq!(
3712            task.await.expect("no panic"),
3713            Err(SlackEditError::SentBack { note: "that one needs to stay".to_owned() }),
3714        );
3715        assert!(api.deletes.lock().is_empty(), "a sent-back deletion never removes anything");
3716    }
3717
3718    #[tokio::test]
3719    async fn a_sent_back_reaction_hands_the_note_back_and_applies_nothing() {
3720        let (facade, ws, api, _rx) = facade_with_recording_slack();
3721        let task = tokio::spawn({
3722            let facade = facade.clone();
3723            async move { facade.react(&caller(), react_request("C1", "100.0", "thumbsup", true)).await }
3724        });
3725        let id = wait_for_draft(&ws).await;
3726        ws.resolve_slack_draft(id, &caller(), sent_back("that reads as sarcasm here"));
3727
3728        assert_eq!(
3729            task.await.expect("no panic"),
3730            Err(SlackReactError::SentBack { note: "that reads as sarcasm here".to_owned() }),
3731        );
3732        assert!(api.reactions.lock().is_empty(), "a sent-back reaction is never applied");
3733    }
3734
3735    #[tokio::test]
3736    async fn a_sent_back_upload_hands_the_note_back_and_sends_no_bytes() {
3737        let (facade, ws, api, _rx) = facade_with_recording_slack();
3738        let dir = tempdir().expect("tempdir");
3739        let source = dir.path().join("file.txt");
3740        std::fs::write(&source, b"payload").expect("write the source file");
3741
3742        let task = tokio::spawn({
3743            let facade = facade.clone();
3744            async move { facade.post_attachment(&caller(), upload_request("C1", &source)).await }
3745        });
3746        let id = wait_for_draft(&ws).await;
3747        ws.resolve_slack_draft(id, &caller(), sent_back("wrong file, send the report instead"));
3748
3749        assert_eq!(
3750            task.await.expect("no panic"),
3751            Err(SlackAttachmentError::SentBack {
3752                note: "wrong file, send the report instead".to_owned(),
3753            }),
3754        );
3755        assert_eq!(api.upload_url_count(), 0, "a sent-back upload never reaches Slack");
3756    }
3757
3758    /// A note belongs to the attempt it answered. The revised call parks its
3759    /// own hold, and nothing sends until the reader approves that one.
3760    #[tokio::test]
3761    async fn a_revised_call_after_a_send_back_parks_its_own_draft() {
3762        let (facade, ws, api, _rx) = facade_with_recording_slack();
3763        let first = tokio::spawn({
3764            let facade = facade.clone();
3765            async move { facade.post(&caller(), post_request("C1", "prod is down")).await }
3766        });
3767        let first_id = wait_for_draft(&ws).await;
3768        ws.resolve_slack_draft(first_id, &caller(), sent_back("say staging, not prod"));
3769        assert!(first.await.expect("no panic").is_err(), "the first attempt was sent back");
3770
3771        let second = tokio::spawn({
3772            let facade = facade.clone();
3773            async move { facade.post(&caller(), post_request("C1", "staging is down")).await }
3774        });
3775        let second_id = wait_for_draft(&ws).await;
3776
3777        assert_ne!(second_id, first_id, "the revision parks its own card");
3778        assert!(api.posts().is_empty(), "and nothing approves the revision on its behalf");
3779
3780        ws.resolve_slack_draft(second_id, &caller(), answered(true));
3781        second.await.expect("no panic").expect("the reader's approval is what sends it");
3782        assert_eq!(api.posts().len(), 1, "one message goes out");
3783        assert_eq!(api.posts()[0].1, "staging is down", "the revised words are what went out");
3784    }
3785
3786    /// A prompt nobody answers must read as "not approved". Dropping the
3787    /// sender is how that happens - the session went away, or the dock
3788    /// prompt was cleared without a decision - and it must never be taken
3789    /// for an approval.
3790    #[tokio::test]
3791    async fn a_draft_whose_answer_never_comes_posts_nothing() {
3792        let (facade, ws, api, _rx) = facade_with_recording_slack();
3793        let task = tokio::spawn({
3794            let facade = facade.clone();
3795            async move { facade.post(&caller(), post_request("C1", "hello")).await }
3796        });
3797        let id = wait_for_draft(&ws).await;
3798        drop(ws.slack_drafts.lock().remove(&id));
3799
3800        let outcome = task.await.expect("no panic");
3801        assert_eq!(outcome, Err(SlackPostError::Rejected));
3802        assert!(api.posts().is_empty(), "an unanswered draft must not post");
3803    }
3804
3805    /// A held draft waits for the user, not a clock. Advancing a day leaves
3806    /// the call parked, and the answer it later gets still posts - the
3807    /// behaviour a question, a permission and a hand-off already have.
3808    #[tokio::test(start_paused = true)]
3809    async fn a_held_draft_waits_for_the_user_rather_than_a_clock() {
3810        let (facade, ws, api, _rx) = facade_with_recording_slack();
3811        let task = tokio::spawn({
3812            let facade = facade.clone();
3813            async move { facade.post(&caller(), post_request("C1", "hello")).await }
3814        });
3815        let id = wait_for_draft(&ws).await;
3816
3817        tokio::time::advance(std::time::Duration::from_secs(24 * 60 * 60)).await;
3818        assert!(!task.is_finished(), "a held draft waits for the user, not a clock");
3819
3820        ws.resolve_slack_draft(id, &caller(), answered(true));
3821        let outcome = task.await.expect("no panic");
3822        assert!(outcome.is_ok(), "the answer still posts after a day of waiting: {outcome:?}");
3823        assert_eq!(api.posts().len(), 1, "the held message goes out once");
3824    }
3825
3826    /// The outcome carries what the caller cannot work out for itself: the
3827    /// ts Slack minted, the channel's own name (the input names an id), and
3828    /// how many messages the text was split into.
3829    #[tokio::test]
3830    async fn an_approved_draft_posts_exactly_once() {
3831        let (facade, ws, api, _rx) = facade_with_recording_slack();
3832        api.seed_conversation("C1", "ops");
3833        let task = tokio::spawn({
3834            let facade = facade.clone();
3835            async move { facade.post(&caller(), post_request("C1", "hello")).await }
3836        });
3837        let id = wait_for_draft(&ws).await;
3838        ws.resolve_slack_draft(id, &caller(), answered(true));
3839
3840        let outcome = task.await.expect("no panic").expect("an approved draft posts");
3841        assert_eq!(api.posts().len(), 1, "exactly one message goes out");
3842        assert_eq!(outcome.ts, vec!["1.0".to_owned()], "the ts the wire minted");
3843        assert_eq!(
3844            outcome.conversation_name, "ops",
3845            "the agent-facing name is the bare one every other tool reports",
3846        );
3847        assert_eq!(outcome.parts, 1, "a short draft is one part");
3848    }
3849
3850    /// A draft past the split point goes out in several messages, and the
3851    /// outcome's own count is the only thing that says so: a reader told
3852    /// "one post" would take a truncated send for the whole text.
3853    #[tokio::test]
3854    async fn a_split_draft_reports_its_part_count() {
3855        let (facade, ws, api, _rx) = facade_with_recording_slack();
3856        let long = "x".repeat(forge_connectors::slack::POST_LIMIT + 100);
3857        let task = tokio::spawn({
3858            let facade = facade.clone();
3859            async move { facade.post(&caller(), post_request("C1", &long)).await }
3860        });
3861        let id = wait_for_draft(&ws).await;
3862        ws.resolve_slack_draft(id, &caller(), answered(true));
3863
3864        let outcome = task.await.expect("no panic").expect("an approved draft posts");
3865        assert_eq!(outcome.parts, 2, "the split count travels with the ts");
3866        assert_eq!(outcome.ts.len(), 2, "and one ts per message really went out");
3867        assert_eq!(api.posts().len(), 2, "both parts reached Slack");
3868    }
3869
3870    #[tokio::test]
3871    async fn a_thread_reply_carries_the_parent_ts() {
3872        let (facade, ws, api, _rx) = facade_with_recording_slack();
3873        let task = tokio::spawn({
3874            let facade = facade.clone();
3875            async move {
3876                let mut request = post_request("C1", "hi");
3877                request.thread_ts = Some("100.0".to_owned());
3878                facade.post(&caller(), request).await
3879            }
3880        });
3881        let id = wait_for_draft(&ws).await;
3882        ws.resolve_slack_draft(id, &caller(), answered(true));
3883        task.await.expect("no panic").expect("posted");
3884
3885        assert_eq!(api.posts()[0].2.as_deref(), Some("100.0"));
3886    }
3887
3888    #[tokio::test]
3889    async fn a_foreign_message_is_refused_by_the_wire_and_surfaced_as_its_reason() {
3890        // Slack's rule: only the authenticated user's messages can be
3891        // changed. The refusal is the wire's verdict - a local lookup
3892        // would wrongly refuse an edit of the user's own thread reply,
3893        // which history cannot see.
3894        let (facade, ws, api, _rx) = facade_with_recording_slack();
3895        api.refuse_edits_of("100.0");
3896
3897        let task = tokio::spawn({
3898            let facade = facade.clone();
3899            async move { facade.edit(&caller(), edit_request("C1", "100.0", Some("new text"))).await }
3900        });
3901        let id = wait_for_draft(&ws).await;
3902        ws.resolve_slack_draft(id, &caller(), answered(true));
3903
3904        assert_eq!(
3905            task.await.expect("no panic"),
3906            Err(SlackEditError::NotOwnMessage),
3907            "the wire's cant_update_message reads as not-your-message",
3908        );
3909        assert!(api.updates.lock().is_empty(), "a refused update changes nothing");
3910    }
3911
3912    #[tokio::test]
3913    async fn editing_own_message_updates_it_once_approved() {
3914        let (facade, ws, api, _rx) = facade_with_recording_slack();
3915
3916        let task = tokio::spawn({
3917            let facade = facade.clone();
3918            async move { facade.edit(&caller(), edit_request("C1", "100.0", Some("new text"))).await }
3919        });
3920        let id = wait_for_draft(&ws).await;
3921        ws.resolve_slack_draft(id, &caller(), answered(true));
3922        task.await.expect("no panic").expect("an approved replacement is sent");
3923
3924        assert_eq!(api.updates.lock().len(), 1);
3925    }
3926
3927    #[tokio::test]
3928    async fn a_rejected_edit_leaves_the_message_untouched() {
3929        let (facade, ws, api, _rx) = facade_with_recording_slack();
3930
3931        let task = tokio::spawn({
3932            let facade = facade.clone();
3933            async move { facade.edit(&caller(), edit_request("C1", "100.0", Some("new text"))).await }
3934        });
3935        let id = wait_for_draft(&ws).await;
3936        ws.resolve_slack_draft(id, &caller(), answered(false));
3937
3938        assert_eq!(task.await.expect("no panic"), Err(SlackEditError::Rejected));
3939        assert!(api.updates.lock().is_empty(), "a rejected replacement is never sent");
3940    }
3941
3942    #[tokio::test]
3943    async fn deleting_own_message_removes_it_once_approved() {
3944        let (facade, ws, api, _rx) = facade_with_recording_slack();
3945
3946        let task = tokio::spawn({
3947            let facade = facade.clone();
3948            async move { facade.edit(&caller(), edit_request("C1", "100.0", None)).await }
3949        });
3950        let id = wait_for_draft(&ws).await;
3951        ws.resolve_slack_draft(id, &caller(), answered(true));
3952        task.await.expect("no panic").expect("an approved deletion is sent");
3953
3954        assert_eq!(api.deletes.lock().len(), 1);
3955    }
3956
3957    #[tokio::test]
3958    async fn a_rejected_delete_leaves_the_message_alone() {
3959        let (facade, ws, api, _rx) = facade_with_recording_slack();
3960
3961        let task = tokio::spawn({
3962            let facade = facade.clone();
3963            async move { facade.edit(&caller(), edit_request("C1", "100.0", None)).await }
3964        });
3965        let id = wait_for_draft(&ws).await;
3966        ws.resolve_slack_draft(id, &caller(), answered(false));
3967
3968        assert_eq!(task.await.expect("no panic"), Err(SlackEditError::Rejected));
3969        assert!(api.deletes.lock().is_empty(), "a rejected deletion is never sent");
3970    }
3971
3972    /// A reaction is authored content in the user's name, so both calls go
3973    /// through the same approval a post does.
3974    #[tokio::test]
3975    async fn reacting_adds_then_removes() {
3976        let (facade, ws, api, _rx) = facade_with_recording_slack();
3977
3978        let task = tokio::spawn({
3979            let facade = facade.clone();
3980            async move {
3981                facade
3982                    .react(&caller(), react_request("C1", "100.0", "white_check_mark", true))
3983                    .await?;
3984                facade
3985                    .react(&caller(), react_request("C1", "100.0", "white_check_mark", false))
3986                    .await
3987            }
3988        });
3989        let first = wait_for_draft(&ws).await;
3990        ws.resolve_slack_draft(first, &caller(), answered(true));
3991        let second = wait_for_draft(&ws).await;
3992        ws.resolve_slack_draft(second, &caller(), answered(true));
3993
3994        task.await.expect("no panic").expect("both reactions applied");
3995
3996        let reactions = api.reactions.lock().clone();
3997        assert_eq!(reactions.len(), 2);
3998        assert!(reactions[0].3, "the first call adds");
3999        assert!(!reactions[1].3, "the second removes");
4000    }
4001
4002    #[tokio::test]
4003    async fn a_rejected_reaction_is_never_sent() {
4004        let (facade, ws, api, _rx) = facade_with_recording_slack();
4005
4006        let task = tokio::spawn({
4007            let facade = facade.clone();
4008            async move {
4009                facade
4010                    .react(&caller(), react_request("C1", "100.0", "white_check_mark", true))
4011                    .await
4012            }
4013        });
4014        let id = wait_for_draft(&ws).await;
4015        ws.resolve_slack_draft(id, &caller(), answered(false));
4016
4017        assert_eq!(task.await.expect("no panic"), Err(SlackReactError::Rejected));
4018        assert!(api.reactions.lock().is_empty(), "a rejected reaction is never applied");
4019    }
4020
4021    /// Adding and removing a reaction are different acts, so the held draft
4022    /// names which one it is: the card words its verbs from this field, and
4023    /// a removal drawn as "React" asks the reader to approve the wrong act.
4024    #[tokio::test]
4025    async fn a_reaction_draft_names_whether_it_adds_or_removes() {
4026        let (facade, ws, _api, mut rx) = facade_with_recording_slack();
4027
4028        for (add, expected) in
4029            [(false, SlackDraftOperation::Unreact), (true, SlackDraftOperation::React)]
4030        {
4031            let task = tokio::spawn({
4032                let facade = facade.clone();
4033                async move {
4034                    facade.react(&caller(), react_request("C1", "100.0", "thumbsup", add)).await
4035                }
4036            });
4037            let mut seen = None;
4038            for _ in 0..200 {
4039                while let Ok(frame) = rx.try_recv() {
4040                    if let crate::protocol::SessionUpdate::SlackPostPending { draft, .. } =
4041                        std::sync::Arc::unwrap_or_clone(frame.update)
4042                    {
4043                        seen = Some(draft.operation);
4044                    }
4045                }
4046                if seen.is_some() {
4047                    break;
4048                }
4049                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
4050            }
4051            assert_eq!(seen, Some(expected), "the draft names the act it would do");
4052            let id = wait_for_draft(&ws).await;
4053            ws.resolve_slack_draft(id, &caller(), answered(false));
4054            let _ = task.await;
4055        }
4056    }
4057
4058    fn cfg(label: &str) -> SlackConfig {
4059        SlackConfig {
4060            workspace: label.to_owned(),
4061            token: "xoxp-test".to_owned(),
4062            poll_seconds: 30,
4063            dm_poll_seconds: 150,
4064            thread_idle_days: 14,
4065            stale_after_hours: 24,
4066        }
4067    }
4068
4069    /// A workspace whose `[[slack]]` holds exactly `label`, with a lead
4070    /// session recorded so the caller resolves to a project.
4071    fn workspace_with_one_slack_workspace(label: &str) -> (Arc<Workspace>, Arc<dyn SlackFacade>) {
4072        let dir = tempdir().expect("tempdir");
4073        let mut config = LoadedConfig::empty_for_test();
4074        config.slack = vec![cfg(label)];
4075        let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
4076            .expect("the stub config's [[slack]] entries are well-formed");
4077        ws.seed_test_project_with_slack_workspace("forge", "/tmp/slack-facade-scope", label);
4078        ws.record_connected_session("/tmp/slack-facade-scope", "caller-uuid", None);
4079        let facade = ProdSlackFacade::from_arc(&ws);
4080        (ws, facade)
4081    }
4082
4083    /// The same, with a store installed: the cursor tests need real
4084    /// watermarks. The tempdir is returned so it outlives the db.
4085    fn workspace_with_one_slack_workspace_and_store(
4086        label: &str,
4087    ) -> (tempfile::TempDir, Arc<Workspace>, Arc<dyn SlackFacade>) {
4088        let dir = tempdir().expect("tempdir");
4089        let mut config = LoadedConfig::empty_for_test();
4090        config.slack = vec![cfg(label)];
4091        let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
4092            .expect("the stub config's [[slack]] entries are well-formed");
4093        ws.install_db_for_test(
4094            crate::store::Db::open(&dir.path().join("db.redb")).expect("open db"),
4095        );
4096        ws.seed_test_project_with_slack_workspace("forge", "/tmp/slack-facade-scope", label);
4097        ws.record_connected_session("/tmp/slack-facade-scope", "caller-uuid", None);
4098        let facade = ProdSlackFacade::from_arc(&ws);
4099        (dir, ws, facade)
4100    }
4101
4102    /// `tokio::test`: a successful subscribe starts the workspace's pump,
4103    /// which needs a runtime.
4104    /// Deduplication is deliberately not done: two sessions may each want
4105    /// their own mention feed, and merging them would silently starve one.
4106    #[tokio::test]
4107    async fn subscribing_twice_to_mentions_yields_two_records() {
4108        let (facade, ws, _api, _rx) = facade_with_recording_slack();
4109
4110        facade.subscribe(&caller(), SlackSubscribeRequest::Mentions).expect("first");
4111        facade.subscribe(&caller(), SlackSubscribeRequest::Mentions).expect("second");
4112
4113        assert_eq!(ws.slack_subscriptions_for_project("forge").len(), 2);
4114    }
4115
4116    /// A conversation is deduped where a mention feed is not: a repeat hands
4117    /// back the record that exists rather than adding a second Inspector row
4118    /// for one channel, and a mode the caller asks for is the mode it gets,
4119    /// since there is no other route to change one.
4120    #[tokio::test]
4121    async fn subscribing_twice_to_a_conversation_yields_one_record() {
4122        let (facade, ws, _api, _rx) = facade_with_recording_slack();
4123        let watching = |mode| {
4124            SlackSubscribeRequest::Conversations(vec![SlackChannelWatch {
4125                id: "C1".to_owned(),
4126                mode,
4127            }])
4128        };
4129
4130        let first = facade.subscribe(&caller(), watching(SlackWatchMode::All)).expect("first");
4131        assert_eq!(first.len(), 1, "the first subscribe creates the record");
4132
4133        let second =
4134            facade.subscribe(&caller(), watching(SlackWatchMode::MentionsOnly)).expect("second");
4135        assert_eq!(second.len(), 1, "the repeat adds no second record");
4136        assert_eq!(
4137            second[0].id, first[0].id,
4138            "the repeat hands back the id it already has, so the caller can drop it without a lookup",
4139        );
4140        assert_eq!(
4141            second[0].target,
4142            SlackSubscriptionTarget::Conversation {
4143                id: "C1".to_owned(),
4144                name: None,
4145                mode: SlackWatchMode::MentionsOnly,
4146            },
4147            "and the record as it NOW stands, not the state before the mode moved",
4148        );
4149        let stored = ws.slack_subscriptions_for_project("forge");
4150        assert_eq!(stored.len(), 1, "one record, so the Inspector renders one row");
4151        assert_eq!(
4152            stored[0].target,
4153            SlackSubscriptionTarget::Conversation {
4154                id: "C1".to_owned(),
4155                name: None,
4156                mode: SlackWatchMode::MentionsOnly,
4157            },
4158            "and the mode asked for is the mode stored",
4159        );
4160    }
4161
4162    /// The unsubscribe hands back the row it removed, not another one: with
4163    /// two owned subscriptions and the SECOND removed, a row looked up by
4164    /// "the project's first" names the wrong target - and the store check
4165    /// catches a removal that took the wrong one.
4166    #[tokio::test]
4167    async fn unsubscribe_returns_the_row_it_removed_and_leaves_the_other() {
4168        let (facade, ws, _api, _rx) = facade_with_recording_slack();
4169        let first = facade.subscribe(&caller(), SlackSubscribeRequest::Mentions).expect("first");
4170        let second =
4171            facade.subscribe(&caller(), SlackSubscribeRequest::DirectMessages).expect("second");
4172        assert_eq!(second.len(), 1, "one record per target");
4173
4174        let removed = facade
4175            .unsubscribe(&caller(), second[0].id)
4176            .expect("the second subscription is the caller's own");
4177
4178        assert_eq!(removed.id, second[0].id, "the echoed row is the one that went");
4179        assert_eq!(
4180            removed.target,
4181            SlackSubscriptionTarget::DirectMessages,
4182            "carrying the target it watched, not the other row's",
4183        );
4184        let left = ws.slack_subscriptions_for_project("forge");
4185        assert_eq!(left.len(), 1, "only the untouched subscription remains");
4186        assert_eq!(left[0].id, first[0].id, "and it is the one the call did not name");
4187    }
4188
4189    /// The refusal names the case: a row another session owns reads as
4190    /// *not yours*, never as absent.
4191    #[tokio::test]
4192    async fn unsubscribe_refuses_another_owners_row() {
4193        let (facade, ws, _api, _rx) = facade_with_recording_slack();
4194        let worker = live_worker(&ws, "forge", "tester");
4195        let owned = facade
4196            .subscribe(&caller(), SlackSubscribeRequest::Mentions)
4197            .expect("the lead subscribes");
4198
4199        assert_eq!(
4200            facade.unsubscribe(&worker, owned[0].id),
4201            Err(SlackUnsubscribeError::NotOwnedByCaller),
4202            "the lead's subscription is not the worker's to remove, and the refusal says which case it is",
4203        );
4204        assert_eq!(
4205            ws.slack_subscriptions_for_project("forge").len(),
4206            1,
4207            "a refused unsubscribe removes nothing",
4208        );
4209    }
4210
4211    /// The other half of the refusal: an id the project never had is a
4212    /// different answer from one that exists under another session.
4213    #[test]
4214    fn unsubscribe_refuses_an_id_the_project_never_had() {
4215        let (facade, _ws, _api, _rx) = facade_with_recording_slack();
4216
4217        assert_eq!(
4218            facade.unsubscribe(&caller(), Uuid::from_u128(0xdead)),
4219            Err(SlackUnsubscribeError::NoSuchSubscription),
4220            "an id with no entry in the project is reported as absent",
4221        );
4222    }
4223
4224    /// Both clauses of that message are about the caller's own project:
4225    /// an id living in another project is not "in this project but not
4226    /// subscribed by you", it is not in this project at all.
4227    #[test]
4228    fn unsubscribe_refuses_an_id_from_another_project_as_absent() {
4229        let (facade, ws, _api, _rx) = facade_with_recording_slack();
4230        let other = SlackSubscription {
4231            id: Uuid::new_v4(),
4232            workspace: "acme".to_owned(),
4233            project: "otherproj".to_owned(),
4234            team_role: None,
4235            target: SlackSubscriptionTarget::Mentions,
4236            created_at: SystemTime::UNIX_EPOCH,
4237        };
4238        let other_id = other.id;
4239        ws.add_slack_subscription(other, false);
4240
4241        assert_eq!(
4242            facade.unsubscribe(&caller(), other_id),
4243            Err(SlackUnsubscribeError::NoSuchSubscription),
4244            "an id that lives in another project is absent here, not a subscription of this project's",
4245        );
4246    }
4247
4248    /// A live worker row with `label`, so a caller on that slot resolves
4249    /// to the project and owns subscriptions under its label.
4250    fn live_worker(ws: &Workspace, project: &str, label: &str) -> SessionSlot {
4251        let slot = SessionSlot::worker("TestOrg", project, label);
4252        let project_key = ws
4253            .list_projects()
4254            .into_iter()
4255            .find(|view| view.name == project)
4256            .expect("the seeded project")
4257            .key;
4258        ws.insert_live_worker(
4259            &project_key,
4260            crate::mcp::workers::types::WorkerEntry {
4261                label: label.into(),
4262                charter: "c".into(),
4263                slot: slot.clone(),
4264                session_id: None,
4265                status: forge_primitives::WorkerLiveness::Running,
4266                spawned_at: std::time::SystemTime::UNIX_EPOCH,
4267                spawned_by: SessionSlot::lead("TestOrg", project),
4268                needs_tag: false,
4269                is_git_repo_at_spawn: false,
4270                cwd: None,
4271                diagnostic: None,
4272                kick: None,
4273            },
4274        );
4275        slot
4276    }
4277
4278    /// The refusal is what a caller acts on, so it has to name the case:
4279    /// a row the project holds under another session reads as *not
4280    /// yours*, an id the project never had as *not there*. One string
4281    /// for both lets a caller take its own refused unsubscribe as proof
4282    /// the subscription is gone.
4283    #[tokio::test]
4284    async fn unsubscribe_refusals_name_the_case_through_the_tool() {
4285        let (ws, facade) = workspace_with_one_slack_workspace("acme");
4286        let worker = live_worker(&ws, "forge", "tester");
4287        let lead_sub = facade
4288            .subscribe(&caller(), SlackSubscribeRequest::Mentions)
4289            .expect("the lead subscribes");
4290        let tool = Unsubscribe { facade, slot: worker };
4291
4292        let not_yours = tool
4293            .call(ToolInput { value: serde_json::json!({ "id": lead_sub[0].id.to_string() }) })
4294            .await;
4295        assert!(not_yours.is_error, "a refused unsubscribe is an error");
4296        assert_eq!(
4297            text_of(&not_yours),
4298            format!(
4299                "subscription {} exists in this project but was not subscribed by you",
4300                lead_sub[0].id
4301            ),
4302            "a live subscription owned by another session must say so, not report it missing",
4303        );
4304
4305        let ghost = Uuid::from_u128(0xdead);
4306        let not_there =
4307            tool.call(ToolInput { value: serde_json::json!({ "id": ghost.to_string() }) }).await;
4308        assert!(not_there.is_error, "an unknown id is an error");
4309        assert_eq!(
4310            text_of(&not_there),
4311            format!("no subscription with id {ghost} in this project"),
4312            "an id the project never had must not read as another session's subscription",
4313        );
4314    }
4315
4316    /// A project with no binding is a configuration gap, not a missing
4317    /// argument: the refusal names the key to add rather than asking for a
4318    /// `workspace` the tool no longer takes.
4319    #[tokio::test]
4320    async fn a_caller_whose_project_binds_no_workspace_is_told_which_key_to_add() {
4321        let dir = tempdir().expect("tempdir");
4322        let mut config = LoadedConfig::empty_for_test();
4323        config.slack = vec![cfg("acme")];
4324        let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
4325            .expect("the stub config's [[slack]] entries are well-formed");
4326        ws.seed_test_project("forge", "/tmp/slack-facade-scope");
4327        ws.record_connected_session("/tmp/slack-facade-scope", "caller-uuid", None);
4328        let facade = ProdSlackFacade::from_arc(&ws);
4329
4330        let error = facade.pins(&caller(), "C1").await.expect_err("no binding to resolve");
4331        let SlackReadError::Workspace(error) = &error else {
4332            panic!("a missing binding is a workspace refusal: {error:?}");
4333        };
4334        assert_eq!(
4335            error,
4336            &SlackWorkspaceError::Unbound {
4337                org: "TestOrg".to_owned(),
4338                project: "forge".to_owned()
4339            },
4340            "the refusal names the scope that has no binding",
4341        );
4342    }
4343
4344    /// A binding to a label added by a reload is set but has no client:
4345    /// saying "add the key" would send the caller to a key that is there.
4346    #[tokio::test]
4347    async fn a_binding_to_a_workspace_added_since_boot_names_the_restart() {
4348        let dir = tempdir().expect("tempdir");
4349        let mut config = LoadedConfig::empty_for_test();
4350        config.slack = vec![cfg("acme")];
4351        let (ws, _rx) = Workspace::testing_stub_with_config(dir.path().to_path_buf(), config)
4352            .expect("the stub config's [[slack]] entries are well-formed");
4353        ws.seed_test_project_with_slack_workspace("forge", "/tmp/slack-facade-scope", "beta");
4354        ws.record_connected_session("/tmp/slack-facade-scope", "caller-uuid", None);
4355        let facade = ProdSlackFacade::from_arc(&ws);
4356
4357        let error = facade.pins(&caller(), "C1").await.expect_err("no client to read with");
4358        let SlackReadError::Workspace(error) = &error else {
4359            panic!("a missing client is a workspace refusal: {error:?}");
4360        };
4361        assert_eq!(
4362            error,
4363            &SlackWorkspaceError::BoundButNotLive { label: "beta".to_owned() },
4364            "the refusal says the workspace is bound but not loaded",
4365        );
4366        assert!(
4367            format_workspace_error(error).contains("needs a forge restart"),
4368            "and tells the caller what makes it load",
4369        );
4370    }
4371
4372    #[tokio::test]
4373    async fn subscribing_to_dms_then_a_channel_yields_two_records() {
4374        let (ws, facade) = workspace_with_one_slack_workspace("acme");
4375        let first =
4376            facade.subscribe(&caller(), SlackSubscribeRequest::DirectMessages).expect("dms");
4377        let second = facade
4378            .subscribe(
4379                &caller(),
4380                SlackSubscribeRequest::Conversations(vec![SlackChannelWatch {
4381                    id: "C1".to_owned(),
4382                    mode: SlackWatchMode::All,
4383                }]),
4384            )
4385            .expect("channels");
4386        assert_eq!(first.len() + second.len(), 2);
4387        assert_eq!(ws.slack_subscriptions_for_project("forge").len(), 2);
4388    }
4389
4390    /// A re-subscribe after the cursor cleared seeds fresh: the sweep
4391    /// baselines at the newest message seen instead of replaying the gap.
4392    #[tokio::test]
4393    async fn re_subscribing_after_a_gap_baselines_fresh() {
4394        let (_dir, ws, facade) = workspace_with_one_slack_workspace_and_store("acme");
4395        facade
4396            .subscribe(
4397                &caller(),
4398                SlackSubscribeRequest::Conversations(vec![SlackChannelWatch {
4399                    id: "C1".to_owned(),
4400                    mode: SlackWatchMode::All,
4401                }]),
4402            )
4403            .expect("the first subscribe");
4404        ws.set_slack_watermark("acme", "C1", "100.0");
4405        let id = ws.slack_subscriptions_for_project("forge")[0].id;
4406        assert!(ws.remove_slack_subscription_owned_by("forge", id, None).is_some());
4407
4408        facade
4409            .subscribe(
4410                &caller(),
4411                SlackSubscribeRequest::Conversations(vec![SlackChannelWatch {
4412                    id: "C1".to_owned(),
4413                    mode: SlackWatchMode::All,
4414                }]),
4415            )
4416            .expect("re-subscribe");
4417
4418        let cursor = {
4419            let db = ws.db.lock();
4420            crate::store::slack::watermark(db.as_ref().expect("db installed"), "acme", "C1")
4421                .expect("read")
4422        };
4423        assert_ne!(
4424            cursor,
4425            Some("100.0".to_owned()),
4426            "a fresh subscription starts from now, never from the stale cursor",
4427        );
4428        assert!(cursor.is_some(), "and it is seeded, so the sweep does not replay");
4429    }
4430
4431    /// The cursor belongs to the conversation and is shared by every
4432    /// owner watching it: a second subscriber seeds only when none
4433    /// exists, or every owner's pending window is silently dropped.
4434    #[tokio::test]
4435    async fn subscribing_to_a_watched_conversation_does_not_reset_its_cursor() {
4436        let (_dir, ws, facade) = workspace_with_one_slack_workspace_and_store("acme");
4437        ws.set_slack_watermark("acme", "C1", "100.0");
4438
4439        facade
4440            .subscribe(
4441                &caller(),
4442                SlackSubscribeRequest::Conversations(vec![SlackChannelWatch {
4443                    id: "C1".to_owned(),
4444                    mode: SlackWatchMode::All,
4445                }]),
4446            )
4447            .expect("subscribe to the watched conversation");
4448
4449        let db = ws.db.lock();
4450        let cursor =
4451            crate::store::slack::watermark(db.as_ref().expect("db installed"), "acme", "C1")
4452                .expect("read");
4453        assert_eq!(cursor, Some("100.0".to_owned()), "a second subscriber never moves the cursor");
4454    }
4455
4456    /// The mention cursor is one per workspace: a second Mentions
4457    /// subscription seeds nothing. A fresh workspace still seeds, because
4458    /// the mention sweep has no baseline arm and a missing cursor would
4459    /// replay its whole history.
4460    #[tokio::test]
4461    async fn a_second_mention_subscription_seeds_nothing_but_the_first_seeds() {
4462        let (_dir, ws, facade) = workspace_with_one_slack_workspace_and_store("acme");
4463
4464        facade
4465            .subscribe(&caller(), SlackSubscribeRequest::Mentions)
4466            .expect("the first mention subscriber");
4467        let first = {
4468            let db = ws.db.lock();
4469            crate::store::slack::watermark(
4470                db.as_ref().expect("db installed"),
4471                "acme",
4472                MENTION_CURSOR,
4473            )
4474            .expect("read")
4475        };
4476        assert!(first.is_some(), "the first Mentions subscription seeds the stream");
4477
4478        ws.set_slack_watermark("acme", MENTION_CURSOR, "100.0");
4479        facade
4480            .subscribe(&caller(), SlackSubscribeRequest::Mentions)
4481            .expect("the second Mentions subscription");
4482        let second = {
4483            let db = ws.db.lock();
4484            crate::store::slack::watermark(
4485                db.as_ref().expect("db installed"),
4486                "acme",
4487                MENTION_CURSOR,
4488            )
4489            .expect("read")
4490        };
4491        assert_eq!(
4492            second,
4493            Some("100.0".to_owned()),
4494            "the second subscriber never resets the shared mention cursor",
4495        );
4496    }
4497
4498    fn directory_user(
4499        id: &str,
4500        name: &str,
4501        real_name: Option<&str>,
4502        display_name: Option<&str>,
4503        deleted: bool,
4504    ) -> SlackUser {
4505        SlackUser {
4506            id: id.to_owned(),
4507            name: name.to_owned(),
4508            real_name: real_name.map(str::to_owned),
4509            tz: None,
4510            deleted,
4511            profile: display_name.map(|display_name| SlackUserProfile {
4512                display_name: Some(display_name.to_owned()),
4513            }),
4514        }
4515    }
4516
4517    /// The three names a person is known by are all the same query: a
4518    /// handle, a legal name and what Slack shows as the display name.
4519    #[tokio::test]
4520    async fn a_user_resolver_matches_the_handle_the_real_name_and_the_display_name() {
4521        let (facade, _ws, api, _rx) = facade_with_recording_slack();
4522        api.seed_users(vec![
4523            directory_user("U1", "ved", Some("Vedhavyas S"), Some("ved"), false),
4524            directory_user("U2", "gina", Some("Gina Abrams"), None, false),
4525            directory_user("U3", "csmith", None, Some("Chris"), false),
4526        ]);
4527
4528        let (_, by_handle) =
4529            facade.users(&caller(), "VED").await.expect("handle, case-insensitive");
4530        assert_eq!(by_handle.iter().map(|user| user.id.as_str()).collect::<Vec<_>>(), ["U1"]);
4531
4532        let (_, by_real_name) = facade.users(&caller(), "abrams").await.expect("real name");
4533        assert_eq!(by_real_name.iter().map(|user| user.id.as_str()).collect::<Vec<_>>(), ["U2"]);
4534
4535        let (_, by_display_name) = facade.users(&caller(), "chris").await.expect("display name");
4536        assert_eq!(
4537            by_display_name.iter().map(|user| user.id.as_str()).collect::<Vec<_>>(),
4538            ["U3"],
4539            "the display name matches even when neither the handle nor the real name does",
4540        );
4541    }
4542
4543    /// An empty query must not widen into the whole directory when the
4544    /// facade is called directly rather than through the tool.
4545    #[tokio::test]
4546    async fn a_user_resolver_returns_nothing_for_an_empty_query() {
4547        let (facade, _ws, api, _rx) = facade_with_recording_slack();
4548        api.seed_users(vec![directory_user("U1", "ved", None, None, false)]);
4549
4550        let (_, found) = facade.users(&caller(), "   ").await.expect("the guard is not a failure");
4551
4552        assert!(found.is_empty(), "an empty query matches nobody");
4553    }
4554
4555    /// The failure this resolver exists for is a wrong id silently
4556    /// notifying the wrong person, so several matches come back together
4557    /// and a deactivated member is never one of them.
4558    #[tokio::test]
4559    async fn a_user_resolver_returns_every_live_match_and_no_deleted_ones() {
4560        let (facade, _ws, api, _rx) = facade_with_recording_slack();
4561        api.seed_users(vec![
4562            directory_user("U2", "samuel", None, None, false),
4563            directory_user("U1", "sam", None, None, false),
4564            directory_user("U3", "sam", None, None, true),
4565            directory_user("U4", "other", None, None, false),
4566        ]);
4567
4568        let (_, found) = facade.users(&caller(), "sam").await.expect("the directory is read");
4569
4570        assert_eq!(
4571            found.iter().map(|user| user.id.as_str()).collect::<Vec<_>>(),
4572            ["U1", "U2"],
4573            "every live match, sorted by handle, never one picked silently",
4574        );
4575    }
4576
4577    /// A filtered search follows the cursor: the first page may hold no hit
4578    /// from the conversation, and stopping there would report none.
4579    #[tokio::test]
4580    async fn a_filtered_search_pages_until_it_finds_the_conversations_hits() {
4581        let (facade, _ws, api, _rx) = facade_with_recording_slack();
4582        api.seed_conversation("C1", "ops");
4583        api.seed_search_pages(vec![
4584            vec![search_hit("C2", "100.1")],
4585            vec![search_hit("C1", "100.2")],
4586        ]);
4587
4588        let (_, found) =
4589            facade.search(&caller(), "deploy", 20, Some("C1")).await.expect("the search runs");
4590
4591        assert_eq!(found.len(), 1, "the match was on the second page: {found:?}");
4592        assert_eq!(found[0].conversation_id, "C1");
4593        assert_eq!(
4594            api.search_reads(),
4595            ["in:ops deploy", "in:ops deploy"],
4596            "the filter followed the cursor instead of stopping at the first page",
4597        );
4598    }
4599
4600    /// The label is part of the answer: `auth.test` reports the team, and a
4601    /// `[[slack]]` label may well be a different string.
4602    #[tokio::test]
4603    async fn an_identity_lookup_names_the_resolved_label_and_the_token_user() {
4604        let (facade, _ws, _api, _rx) = facade_with_recording_slack();
4605
4606        let (label, auth) = facade.me(&caller()).await.expect("the token is proved");
4607
4608        assert_eq!(label, "acme", "the label the caller can name again");
4609        assert_eq!(auth.team, "Test", "the team comes from auth.test");
4610        assert_eq!(auth.user_id, "U1", "and so does the token's own user id");
4611    }
4612
4613    #[tokio::test]
4614    async fn a_pins_read_reaches_the_workspace_client_with_its_conversation() {
4615        let (facade, _ws, api, _rx) = facade_with_recording_slack();
4616        api.seed_pins(
4617            "C1",
4618            vec![SlackPin {
4619                created: 1_700_000_000,
4620                created_by: Some("U1".to_owned()),
4621                message: Some(SlackPinMessage {
4622                    ts: "1700000000.000100".to_owned(),
4623                    user: Some("U2".to_owned()),
4624                    text: "the pinned text".to_owned(),
4625                }),
4626            }],
4627        );
4628
4629        let (_, pins) = facade.pins(&caller(), "C1").await.expect("read");
4630        assert_eq!(pins.len(), 1, "the seeded row comes back");
4631        assert_eq!(
4632            pins[0].message.as_ref().expect("the message rides along").text,
4633            "the pinned text",
4634        );
4635        assert_eq!(api.pin_reads.lock().as_slice(), ["C1"], "the conversation is what was read");
4636    }
4637
4638    fn history_message(ts: &str, text: &str) -> SlackHistoryMessage {
4639        SlackHistoryMessage {
4640            ts: ts.to_owned(),
4641            user: Some("U1".to_owned()),
4642            text: text.to_owned(),
4643            thread_ts: None,
4644            reply_count: 0,
4645            bot_name: None,
4646            parent_user_id: None,
4647            latest_reply: None,
4648            edited: None,
4649            files: Vec::new(),
4650        }
4651    }
4652
4653    fn search_hit(conversation: &str, ts: &str) -> SlackSearchMatch {
4654        SlackSearchMatch {
4655            ts: ts.to_owned(),
4656            text: "a hit".to_owned(),
4657            conversation_id: conversation.to_owned(),
4658            conversation_name: None,
4659            username: None,
4660            user: None,
4661            thread_ts: None,
4662            files: Vec::new(),
4663        }
4664    }
4665
4666    #[tokio::test]
4667    async fn a_history_read_passes_its_bounds_and_its_page() {
4668        let (facade, _ws, api, _rx) = facade_with_recording_slack();
4669        api.seed_history("C1", vec![history_message("100.1", "hello")]);
4670
4671        let (_, messages) =
4672            facade.history(&caller(), "C1", 25, Some("100.0"), Some("200.0")).await.expect("read");
4673
4674        assert_eq!(messages.len(), 1, "the seeded page comes back");
4675        assert_eq!(messages[0].text, "hello");
4676        assert_eq!(
4677            api.history_reads().as_slice(),
4678            [("C1".to_owned(), Some("100.0".to_owned()), Some("200.0".to_owned()), 25)],
4679            "the conversation and both exclusive bounds reach the wire",
4680        );
4681    }
4682
4683    #[tokio::test]
4684    async fn a_thread_read_returns_the_parent_and_its_replies() {
4685        let (facade, _ws, api, _rx) = facade_with_recording_slack();
4686        api.seed_replies(
4687            "C1",
4688            "100.0",
4689            vec![history_message("100.0", "parent"), history_message("100.5", "reply")],
4690        );
4691
4692        let (_, messages) =
4693            facade.replies(&caller(), "C1", "100.0", 200).await.expect("the thread is read");
4694
4695        assert_eq!(messages.len(), 2, "the whole thread comes back");
4696        assert_eq!(messages[0].text, "parent", "the parent is first");
4697        assert_eq!(messages[1].text, "reply");
4698        assert_eq!(
4699            api.reply_reads().as_slice(),
4700            [("C1".to_owned(), "100.0".to_owned())],
4701            "the conversation and parent ts are what was read",
4702        );
4703    }
4704
4705    /// Slack's search language narrows by channel NAME, and an id in `in:`
4706    /// finds nothing. The name is added server-side, the id still filters
4707    /// what comes back, and a hit from another conversation never leaks.
4708    #[tokio::test]
4709    async fn a_search_filtered_by_a_named_conversation_narrows_and_drops_foreign_hits() {
4710        let (facade, _ws, api, _rx) = facade_with_recording_slack();
4711        api.seed_conversation("C1", "ops");
4712        api.seed_search(vec![search_hit("C1", "100.1"), search_hit("C2", "100.2")]);
4713
4714        let (_, found) =
4715            facade.search(&caller(), "deploy", 20, Some("C1")).await.expect("the search runs");
4716
4717        assert_eq!(found.len(), 1, "only the filtered conversation's hit survives: {found:?}");
4718        assert_eq!(found[0].conversation_id, "C1");
4719        assert_eq!(
4720            api.search_reads(),
4721            ["in:ops deploy"],
4722            "the name narrows the search server-side",
4723        );
4724    }
4725
4726    /// A DM has no name, and a group DM's generated name is not one search
4727    /// understands, so neither narrows the query: the id alone filters.
4728    #[tokio::test]
4729    async fn a_search_filtered_by_a_dm_or_group_dm_keeps_the_query_whole() {
4730        let (facade, _ws, api, _rx) = facade_with_recording_slack();
4731        api.seed_dm("D1", "U9");
4732        api.seed_mpim("G1", "mpdm-ved--gina-1");
4733        api.seed_search(vec![
4734            search_hit("D1", "100.1"),
4735            search_hit("G1", "100.2"),
4736            search_hit("C2", "100.3"),
4737        ]);
4738
4739        let (_, direct) =
4740            facade.search(&caller(), "deploy", 20, Some("D1")).await.expect("the search runs");
4741        assert_eq!(direct.len(), 1, "only the DM's hit survives: {direct:?}");
4742        assert_eq!(direct[0].conversation_id, "D1");
4743
4744        let (_, group) =
4745            facade.search(&caller(), "deploy", 20, Some("G1")).await.expect("the search runs");
4746        assert_eq!(group.len(), 1, "only the group DM's hit survives: {group:?}");
4747        assert_eq!(group[0].conversation_id, "G1");
4748
4749        assert_eq!(
4750            api.search_reads(),
4751            ["deploy", "deploy"],
4752            "neither a DM nor a group DM adds an `in:`",
4753        );
4754    }
4755
4756    #[tokio::test]
4757    async fn a_bookmarks_read_reaches_the_workspace_client_with_its_conversation() {
4758        let (facade, _ws, api, _rx) = facade_with_recording_slack();
4759        api.seed_bookmarks(
4760            "C1",
4761            vec![SlackBookmark {
4762                id: "Bk1".to_owned(),
4763                title: Some("Runbook".to_owned()),
4764                link: Some("https://example.com".to_owned()),
4765            }],
4766        );
4767
4768        let (_, bookmarks) = facade.bookmarks(&caller(), "C1").await.expect("read");
4769        assert_eq!(bookmarks.len(), 1, "the seeded row comes back");
4770        assert_eq!(bookmarks[0].title.as_deref(), Some("Runbook"));
4771        assert_eq!(api.bookmark_reads.lock().as_slice(), ["C1"]);
4772    }
4773
4774    /// The list marks only the CALLER's own subscriptions: a lead and a
4775    /// worker in the same project each see their own records, never each
4776    /// other's.
4777    #[tokio::test]
4778    async fn subscribed_targets_scopes_to_the_callers_role() {
4779        let (ws, facade) = workspace_with_one_slack_workspace("acme");
4780        let lead = caller();
4781        facade
4782            .subscribe(
4783                &lead,
4784                SlackSubscribeRequest::Conversations(vec![SlackChannelWatch {
4785                    id: "C1".to_owned(),
4786                    mode: SlackWatchMode::All,
4787                }]),
4788            )
4789            .expect("the lead subscribes to C1");
4790
4791        let worker_key = SessionSlot::worker("TestOrg", "forge", "tester");
4792        let project_key = ws
4793            .list_projects()
4794            .into_iter()
4795            .find(|view| view.name == "forge")
4796            .expect("the seeded project")
4797            .key;
4798        ws.insert_live_worker(
4799            &project_key,
4800            crate::mcp::workers::types::WorkerEntry {
4801                label: "tester".into(),
4802                charter: "c".into(),
4803                slot: worker_key.clone(),
4804                session_id: None,
4805                status: forge_primitives::WorkerLiveness::Running,
4806                spawned_at: std::time::SystemTime::UNIX_EPOCH,
4807                spawned_by: SessionSlot::lead("TestOrg", "forge"),
4808                needs_tag: false,
4809                is_git_repo_at_spawn: false,
4810                cwd: None,
4811                diagnostic: None,
4812                kick: None,
4813            },
4814        );
4815        facade
4816            .subscribe(&worker_key, SlackSubscribeRequest::DirectMessages)
4817            .expect("the worker subscribes to the DM class");
4818
4819        let lead_targets: Vec<SlackSubscriptionTarget> =
4820            facade.subscribed_targets(&lead, "acme").into_iter().map(|s| s.target).collect();
4821        assert_eq!(
4822            lead_targets,
4823            vec![SlackSubscriptionTarget::Conversation {
4824                id: "C1".to_owned(),
4825                name: None,
4826                mode: SlackWatchMode::All,
4827            }],
4828            "the lead sees only its own record",
4829        );
4830        let worker_targets: Vec<SlackSubscriptionTarget> =
4831            facade.subscribed_targets(&worker_key, "acme").into_iter().map(|s| s.target).collect();
4832        assert_eq!(
4833            worker_targets,
4834            vec![SlackSubscriptionTarget::DirectMessages],
4835            "the worker sees only its own record, never the lead's",
4836        );
4837    }
4838}