Skip to main content

everruns_core/
channel.rs

1// Multi-platform channel abstractions
2//
3// Design Decision: Channel adapters are the boundary between platform-specific
4// protocols (Slack, Discord, Teams, Telegram) and the platform-agnostic core.
5// Each adapter translates inbound platform events into InboundChannelEvent and
6// receives OutboundChannelMessage for delivery. The core never imports
7// platform-specific types.
8//
9// Design Decision: ThreadContext carries participant tracking across all
10// platforms. Multi-user threads (e.g. a Slack thread with 3 people, a Discord
11// channel) share a single session with per-message ExternalActor attribution.
12// ThreadContext is the "who's in this conversation" view; ExternalActor is the
13// "who sent this message" view.
14//
15// Design Decision: Async agent invocations are first-class. The
16// ChannelDeliveryAdapter trait models the webhook→ack→async-response pattern
17// generically. Platform adapters implement `deliver()` to post results back
18// when the agent finishes (minutes to hours later).
19//
20// Design Decision: Platform-contributed tools (e.g. slack_add_reaction,
21// discord_create_thread) are a known gap. The Capability trait already supports
22// tools(), but no channel adapter contributes tools yet. Tracked for future
23// work — see TODO(platform-tools) below.
24
25use crate::message::ExternalActor;
26use crate::typed_id::SessionId;
27use async_trait::async_trait;
28use serde::{Deserialize, Serialize};
29
30use std::collections::HashMap;
31#[cfg(feature = "openapi")]
32use utoipa::ToSchema;
33
34// ============================================
35// Thread & Participant tracking
36// ============================================
37
38/// A participant in a multi-user thread.
39///
40/// Wraps ExternalActor with thread-level metadata (when they joined,
41/// their role in the thread). Participants are accumulated over the
42/// lifetime of a session.
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44pub struct Participant {
45    /// The external actor identity (platform user ID, display name, source).
46    pub actor: ExternalActor,
47    /// When this participant first appeared in the thread.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub first_seen_at: Option<chrono::DateTime<chrono::Utc>>,
50    /// Platform-specific role (e.g. "owner", "member", "guest").
51    /// Not all platforms expose this.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub role: Option<String>,
54}
55
56/// Thread-level context for multi-user conversations.
57///
58/// A ThreadContext is created when a session is bound to a platform thread
59/// and accumulates participants as messages arrive. Platform adapters update
60/// this when new users join a thread.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub struct ThreadContext {
63    /// Platform-specific thread identifier (e.g. Slack thread_ts, Discord channel_id).
64    pub thread_ref: String,
65    /// Source platform (e.g. "slack", "discord", "teams").
66    pub platform: String,
67    /// Platform-specific channel/workspace context.
68    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
69    pub platform_metadata: HashMap<String, String>,
70    /// Known participants in this thread, keyed by actor_id for O(1) lookup.
71    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
72    pub participants: HashMap<String, Participant>,
73    /// What the user is currently looking at on the platform, when it reports
74    /// that (Slack: `app_context_changed`). Last write wins — it is a current
75    /// position, not a history.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub current_view: Option<ChannelViewContext>,
78}
79
80/// Session KV key holding the persisted [`ThreadContext`].
81///
82/// One key per session, not a prefix: a session belongs to exactly one channel
83/// thread. `session_storage` reserves it from the user-facing `kv_store` tool
84/// (see `is_internal_session_kv_key`) so a session or tool actor cannot forge
85/// its own participant list or the "user is viewing" hint — both of which reach
86/// the model as context (TM-TOOL/TM-AGENT).
87pub const THREAD_CONTEXT_KV_KEY: &str = "channel:thread_context";
88
89/// Where the user's attention is on the platform, as the platform reports it.
90///
91/// Deliberately opaque ids and nothing resolved. The agent has not been granted
92/// access to whatever the user happens to be looking at, so this is a hint that
93/// it should ask about, not a fact it can act on — see [`ThreadContext::view_summary`].
94#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
95pub struct ChannelViewContext {
96    /// Platform channel/conversation id the user is viewing.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub channel_id: Option<String>,
99    /// Platform team/workspace id, when reported.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub team_id: Option<String>,
102    /// When the platform reported this position.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub observed_at: Option<chrono::DateTime<chrono::Utc>>,
105}
106
107impl ChannelViewContext {
108    /// True when there is nothing worth telling the model.
109    pub fn is_empty(&self) -> bool {
110        self.channel_id.is_none() && self.team_id.is_none()
111    }
112}
113
114impl ThreadContext {
115    /// Create a new thread context for a platform thread.
116    pub fn new(thread_ref: impl Into<String>, platform: impl Into<String>) -> Self {
117        Self {
118            thread_ref: thread_ref.into(),
119            platform: platform.into(),
120            platform_metadata: HashMap::new(),
121            participants: HashMap::new(),
122            current_view: None,
123        }
124    }
125
126    /// Record a participant. Updates first_seen_at only if new.
127    /// Returns true if this is a newly seen participant.
128    pub fn track_participant(&mut self, actor: &ExternalActor) -> bool {
129        use std::collections::hash_map::Entry;
130        match self.participants.entry(actor.actor_id.clone()) {
131            Entry::Vacant(entry) => {
132                entry.insert(Participant {
133                    actor: actor.clone(),
134                    first_seen_at: Some(chrono::Utc::now()),
135                    role: None,
136                });
137                true
138            }
139            Entry::Occupied(mut entry) => {
140                // Update display name if it changed (user renamed)
141                if actor.actor_name != entry.get().actor.actor_name {
142                    entry.get_mut().actor.actor_name = actor.actor_name.clone();
143                }
144                false
145            }
146        }
147    }
148
149    /// Number of distinct participants.
150    pub fn participant_count(&self) -> usize {
151        self.participants.len()
152    }
153
154    /// Build a summary line for LLM context injection.
155    /// e.g. "Thread participants: Alice, Bob, Charlie"
156    pub fn participants_summary(&self) -> String {
157        if self.participants.is_empty() {
158            return String::new();
159        }
160        let mut names: Vec<String> = self
161            .participants
162            .values()
163            .map(|p| p.actor.display_label().to_string())
164            .collect();
165        names.sort();
166        format!("Thread participants: {}", names.join(", "))
167    }
168
169    /// Record where the user is now looking. Last write wins.
170    ///
171    /// Returns true when this actually changed the stored position, so callers
172    /// can skip a write when the platform re-reports the same place.
173    pub fn set_current_view(&mut self, view: ChannelViewContext) -> bool {
174        let view = (!view.is_empty()).then_some(view);
175        if self.current_view == view {
176            return false;
177        }
178        self.current_view = view;
179        true
180    }
181
182    /// One line describing where the user is looking, for model context.
183    ///
184    /// Phrased as a hint the agent must ask about rather than a fact it can act
185    /// on. The platform reports what the *user* is viewing, which the agent may
186    /// have no access to and no tool for; stating it as available context would
187    /// invite the model to claim knowledge of a channel it cannot read. The id
188    /// stays opaque for the same reason — resolving it to a name would mean
189    /// fetching a channel the agent was never granted.
190    pub fn view_summary(&self) -> String {
191        let Some(view) = self.current_view.as_ref() else {
192            return String::new();
193        };
194        let Some(channel_id) = view.channel_id.as_deref() else {
195            return String::new();
196        };
197        format!(
198            "The user is currently viewing {} channel {}. You have not been given \
199             access to it — ask before assuming you can read it.",
200            self.platform, channel_id
201        )
202    }
203}
204
205/// Decode a persisted thread context record.
206///
207/// A malformed record decodes to `None` rather than erroring: losing
208/// accumulated participants degrades the prompt, but failing a turn over it
209/// would take the whole conversation down for a context line.
210///
211/// The codec is shared by both writers (the channel webhook, through whatever
212/// storage handle it has) and the reader (prompt assembly, through
213/// `SessionStorageStore`), so the two cannot drift on shape.
214pub fn decode_thread_context(raw: &str) -> Option<ThreadContext> {
215    match serde_json::from_str(raw) {
216        Ok(ctx) => Some(ctx),
217        Err(error) => {
218            tracing::warn!(%error, "Discarding malformed thread context record");
219            None
220        }
221    }
222}
223
224/// Encode a thread context for persistence. See [`decode_thread_context`].
225pub fn encode_thread_context(context: &ThreadContext) -> crate::error::Result<String> {
226    serde_json::to_string(context).map_err(|e| crate::error::AgentLoopError::store(e.to_string()))
227}
228
229/// Load the persisted thread context for a session, if any.
230///
231/// An unreadable record is treated as absent, for the reason in
232/// [`decode_thread_context`].
233pub async fn load_thread_context(
234    store: &dyn crate::session_services::SessionStorageStore,
235    session_id: SessionId,
236) -> Option<ThreadContext> {
237    match store.get_value(session_id, THREAD_CONTEXT_KV_KEY).await {
238        Ok(Some(raw)) => decode_thread_context(&raw),
239        Ok(None) => None,
240        Err(error) => {
241            tracing::warn!(%session_id, %error, "Failed to read persisted thread context");
242            None
243        }
244    }
245}
246
247/// Persist the thread context for a session, replacing any previous record.
248pub async fn save_thread_context(
249    store: &dyn crate::session_services::SessionStorageStore,
250    session_id: SessionId,
251    context: &ThreadContext,
252) -> crate::error::Result<()> {
253    let encoded = encode_thread_context(context)?;
254    store
255        .set_value(session_id, THREAD_CONTEXT_KV_KEY, &encoded)
256        .await
257}
258
259// ============================================
260// Inbound channel events
261// ============================================
262
263/// A platform-agnostic inbound event from a channel.
264///
265/// Platform adapters parse their native webhook payloads into this type.
266/// The server routes it to the correct session and creates the appropriate
267/// input.message event.
268#[derive(Debug, Clone)]
269pub struct InboundChannelEvent {
270    /// Who sent this message.
271    pub actor: ExternalActor,
272    /// Message text content (may be empty for attachment-only messages).
273    pub text: String,
274    /// Attached content (images, files) as platform-agnostic parts.
275    pub attachments: Vec<InboundAttachment>,
276    /// Platform-specific dedup key (e.g. Slack event_ts, Discord message_id).
277    /// Used to prevent duplicate processing on webhook retries.
278    pub dedup_key: String,
279    /// Thread reference for routing to the correct session.
280    /// None for DMs or platforms without threading.
281    pub thread_ref: Option<String>,
282    /// Platform-specific metadata for session tag construction.
283    pub routing_metadata: HashMap<String, String>,
284}
285
286/// Attachment from an inbound platform message.
287#[derive(Debug, Clone)]
288pub enum InboundAttachment {
289    /// Image with a fetchable URL.
290    Image {
291        url: String,
292        alt_text: Option<String>,
293    },
294    /// Non-image file described as text.
295    FileDescription {
296        name: String,
297        mime_type: Option<String>,
298    },
299}
300
301// ============================================
302// Outbound channel messages
303// ============================================
304
305/// A platform-agnostic outbound message to deliver to a channel.
306///
307/// The delivery adapter translates this into platform-specific API calls
308/// (e.g. Slack chat.postMessage, Discord channel message create).
309#[derive(Debug, Clone)]
310pub struct OutboundChannelMessage {
311    /// The session this message belongs to.
312    pub session_id: SessionId,
313    /// Text content to deliver.
314    pub text: String,
315    /// Thread reference for reply targeting.
316    pub thread_ref: String,
317    /// Whether this is a progress report (vs. a final answer).
318    pub is_progress_report: bool,
319    /// Id of the input message this reply answers, when the platform can stamp
320    /// it onto the posted message for later correlation. `None` leaves the
321    /// message unstamped rather than inventing a key.
322    pub correlation_id: Option<String>,
323}
324
325// ============================================
326// Channel delivery adapter (async agent responses)
327// ============================================
328
329/// Reply mode for channel delivery — controls which agent output reaches the channel.
330///
331/// Generalizes SlackReplyMode to work across all platforms.
332#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
333#[serde(rename_all = "snake_case")]
334pub enum ChannelReplyMode {
335    /// Forward all completed assistant messages to the channel.
336    #[default]
337    AllMessages,
338    /// Only deliver explicit report_progress tool outputs.
339    ReportProgressOnly,
340}
341
342/// Trait for platform-specific delivery of agent responses.
343///
344/// Implementations handle the "last mile" of posting messages back to
345/// Slack, Discord, Teams, etc. The generic delivery dispatcher calls
346/// these methods; platform adapters implement them.
347///
348/// Lifecycle:
349/// 1. Webhook arrives → adapter parses InboundChannelEvent
350/// 2. Server routes to session, creates input.message, triggers agent
351/// 3. Core delivery dispatcher records a pending delivery for this session/turn
352/// 4. Agent runs asynchronously (seconds to hours)
353/// 5. When agent output is ready, the delivery dispatcher calls `deliver()`
354/// 6. When the turn completes or is cancelled, the dispatcher clears the pending delivery
355#[async_trait]
356pub trait ChannelDeliveryAdapter: Send + Sync {
357    /// Platform identifier (e.g. "slack", "discord").
358    fn platform(&self) -> &str;
359
360    /// Deliver a message to the platform channel.
361    ///
362    /// Called by the generic delivery dispatcher when agent output is ready.
363    /// Implementations should handle retries internally for transient failures.
364    async fn deliver(
365        &self,
366        message: &OutboundChannelMessage,
367        context: &DeliveryContext,
368    ) -> DeliveryResult;
369
370    /// Send an immediate acknowledgement to the channel.
371    ///
372    /// Called right after webhook ingestion for async agent invocations.
373    /// e.g. Slack's "On it." message in report_progress_only mode.
374    /// Platforms that don't need an ack can return Ok(()).
375    async fn send_ack(
376        &self,
377        thread_ref: &str,
378        text: &str,
379        context: &DeliveryContext,
380    ) -> DeliveryResult;
381
382    /// Format a progress report for this platform.
383    ///
384    /// Different platforms have different formatting (Slack mrkdwn, Discord markdown, etc.)
385    fn format_progress_report(
386        &self,
387        report: &crate::progress_reporting::ProgressReportPayload,
388    ) -> String;
389
390    /// Progressive delivery, when the platform supports it.
391    ///
392    /// A capability probe rather than three more required methods: `None` — the
393    /// default — means the dispatcher uses discrete delivery, so a platform
394    /// without streaming stays honest instead of stubbing an API it does not
395    /// have (EVE-974).
396    fn streaming(&self) -> Option<&dyn ChannelStreamDelivery> {
397        None
398    }
399
400    /// Live status and thread title, when the platform has an agent surface.
401    ///
402    /// Same capability-probe shape as `streaming`, for the same reason: `None`
403    /// — the default — means the dispatcher skips status and title entirely,
404    /// rather than every adapter stubbing methods for affordances its platform
405    /// does not have (EVE-975).
406    fn agent_surface(&self) -> Option<&dyn ChannelAgentSurface> {
407        None
408    }
409}
410
411/// The agent-pane affordances a platform may offer alongside the reply itself:
412/// a live status line while a turn runs, and a thread title.
413///
414/// Both are advisory. A failure here must never fail the turn — the reply is the
415/// product and the status is decoration — so the dispatcher logs and continues.
416#[async_trait]
417pub trait ChannelAgentSurface: Send + Sync {
418    /// Set the live status line for a thread. An empty `status` clears it.
419    async fn set_status(&self, status: &str, context: &DeliveryContext) -> DeliveryResult;
420
421    /// Set the thread's title.
422    async fn set_title(&self, title: &str, context: &DeliveryContext) -> DeliveryResult;
423}
424
425/// Progressive delivery of one message as it is produced.
426///
427/// A stream is per *output message*, not per turn: a turn that produces three
428/// messages with tool calls between them is three streams, so the reader sees
429/// three replies rather than one concatenated blob.
430#[async_trait]
431pub trait ChannelStreamDelivery: Send + Sync {
432    /// Open a stream. The returned handle identifies it until `stop`.
433    async fn start(&self, context: &DeliveryContext) -> Result<String, String>;
434
435    /// Append newly produced text to an open stream.
436    async fn append(&self, handle: &str, text: &str, context: &DeliveryContext) -> DeliveryResult;
437
438    /// Close the stream.
439    ///
440    /// Must run for every `start`, including on failure and cancellation: an
441    /// unstopped stream is a message left spinning in the client forever, which
442    /// is worse than never having streamed at all.
443    async fn stop(&self, handle: &str, context: &DeliveryContext) -> DeliveryResult;
444}
445
446/// Context needed by a delivery adapter to post messages.
447///
448/// Stored when a delivery is registered, consumed when events arrive.
449/// Platform adapters extend this with platform-specific fields via `extra`.
450#[derive(Clone)]
451pub struct DeliveryContext {
452    /// Bot/app authentication token for the platform API.
453    pub auth_token: String,
454    /// Platform-specific channel/conversation ID.
455    pub channel_id: String,
456    /// Thread reference for reply targeting.
457    pub thread_ref: String,
458    /// Reply mode controlling which output is delivered.
459    pub reply_mode: ChannelReplyMode,
460    /// Platform-specific extra context (e.g. team_id, workspace URL).
461    pub extra: HashMap<String, String>,
462}
463
464impl std::fmt::Debug for DeliveryContext {
465    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466        f.debug_struct("DeliveryContext")
467            .field("auth_token", &"[REDACTED]")
468            .field("channel_id", &self.channel_id)
469            .field("thread_ref", &self.thread_ref)
470            .field("reply_mode", &self.reply_mode)
471            .field("extra", &self.extra)
472            .finish()
473    }
474}
475
476/// Result of a delivery attempt.
477#[derive(Debug)]
478pub enum DeliveryResult {
479    /// Message delivered successfully.
480    Ok,
481    /// Transient failure — caller should retry with backoff.
482    TransientError(String),
483    /// Permanent failure — do not retry (e.g. invalid token, channel deleted).
484    PermanentError(String),
485}
486
487// ============================================
488// Session strategy (generalized from Slack)
489// ============================================
490
491/// Channel-agnostic session routing tag builder.
492///
493/// Given platform metadata from an InboundChannelEvent, produces the
494/// session tags used to find or create the correct session.
495///
496/// The tag segment is deliberately NOT the binding's name: these tags key live
497/// sessions, so `Thread` must keep emitting `thread`, `Conversation` `channel`,
498/// and `Requester` `user`. Renaming a segment silently orphans every session
499/// routed under the old one (EVE-1005).
500///
501/// `Endpoint` and `Ephemeral` return `None`: they are not keyed off inbound
502/// message metadata at all. Their tags come from the exposure that owns the
503/// invocation — see `trigger_session_tags` in the agent-triggers domain.
504pub fn build_session_routing_tag(
505    platform: &str,
506    binding: &SessionBinding,
507    metadata: &HashMap<String, String>,
508) -> Option<String> {
509    match binding {
510        SessionBinding::Thread => metadata
511            .get("thread_ref")
512            .map(|t| format!("{}:thread:{}", platform, t)),
513        SessionBinding::Conversation => metadata
514            .get("channel_id")
515            .map(|c| format!("{}:channel:{}", platform, c)),
516        SessionBinding::Requester => metadata
517            .get("user_id")
518            .map(|u| format!("{}:user:{}", platform, u)),
519        SessionBinding::Endpoint | SessionBinding::Ephemeral => None,
520    }
521}
522
523/// Resolve the binding actually used for one inbound event.
524///
525/// The declared binding is a default the transport may override per event,
526/// because the surface is a property of the event rather than of configuration.
527/// Slack's assistant pane is the existing case: a pane is inherently one thread,
528/// so `Conversation` and `Requester` have no meaning there — but rejecting them
529/// at write time would be wrong, since the same exposure also serves channels
530/// where they are legitimate (`knowledge/integrations/slack-modernization.md`).
531///
532/// Expressing that as one function keeps the pane from being a special case in
533/// the Slack adapter, and gives the next transport somewhere to put the same
534/// rule instead of re-deriving it (EVE-1005).
535pub fn resolve_session_binding(
536    declared: SessionBinding,
537    event_override: Option<SessionBinding>,
538) -> SessionBinding {
539    event_override.unwrap_or(declared)
540}
541
542/// What identity keys a session, for every exposure and every transport.
543///
544/// One enum replaces the former `SessionStrategy` (messaging channels) and
545/// `InvocationSessionMode` (triggers and request/reply endpoints), which asked
546/// the same question with disjoint vocabularies and forced every new surface to
547/// pick a side (EVE-1005).
548///
549/// **The serialized values are deliberately the legacy ones.** Every variant
550/// renames in Rust but serializes exactly as it did before, with the new name
551/// accepted as a read alias. Persisted `channel_config` JSONB therefore needs no
552/// migration, and the API and UI keep exchanging the values they already do.
553/// Moving the wire vocabulary is a separate, migration-bearing change.
554///
555/// `Requester` keys on the **transport's own external actor id** — the Slack
556/// user id, the Public Chat visitor id — never on an Everruns principal. Those
557/// actors are unrelated to Everruns accounts (a Public Chat visitor is anonymous
558/// or Google-signed-in), so there is one consistent answer rather than a split
559/// variant: whatever the transport calls the requester, scoped by the
560/// `{platform}:` tag prefix that already namespaces it.
561#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
562#[cfg_attr(feature = "openapi", derive(ToSchema))]
563#[cfg_attr(feature = "openapi", schema(example = "per_thread"))]
564pub enum SessionBinding {
565    /// One session per thread. Was `per_thread`.
566    #[default]
567    #[serde(rename = "per_thread", alias = "thread")]
568    Thread,
569    /// One session per channel/conversation/room. Was `per_channel`.
570    #[serde(rename = "per_channel", alias = "conversation")]
571    Conversation,
572    /// One session per external actor. Was `per_user`.
573    #[serde(rename = "per_user", alias = "requester")]
574    Requester,
575    /// One durable session shared by every invocation of the exposure.
576    /// Was `shared_session`.
577    #[serde(rename = "shared_session", alias = "endpoint")]
578    Endpoint,
579    /// A fresh session per invocation. Was `session_per_invocation`.
580    #[serde(rename = "session_per_invocation", alias = "ephemeral")]
581    Ephemeral,
582}
583
584impl SessionBinding {
585    /// Bindings keyed off an inbound message's metadata.
586    pub const MESSAGE_KEYED: [SessionBinding; 3] = [
587        SessionBinding::Thread,
588        SessionBinding::Conversation,
589        SessionBinding::Requester,
590    ];
591
592    /// Bindings available where nothing is listening on a thread — triggers and
593    /// request/reply endpoints.
594    pub const INVOCATION_KEYED: [SessionBinding; 2] =
595        [SessionBinding::Endpoint, SessionBinding::Ephemeral];
596
597    /// Whether this binding is keyed off inbound message metadata.
598    pub fn is_message_keyed(self) -> bool {
599        Self::MESSAGE_KEYED.contains(&self)
600    }
601}
602
603// TODO(platform-tools): Channel adapters should optionally contribute
604// platform-specific tools via the Capability trait. Examples:
605// - Slack: add_reaction, post_to_channel, create_thread, upload_file
606// - Discord: create_thread, add_reaction, pin_message
607// - Teams: send_adaptive_card, create_tab
608// The plumbing exists (Capability::tools()), but no adapter uses it yet.
609// When implementing, tools should receive platform context via ToolContext
610// (which already has session access for looking up channel config).
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    #[test]
617    fn test_thread_context_track_participant() {
618        let mut ctx = ThreadContext::new("1234.5678", "slack");
619        let mut actor = ExternalActor {
620            actor_id: "U001".into(),
621            actor_name: Some("Alice".into()),
622            source: "slack".into(),
623            metadata: Some(HashMap::from([("team".into(), "T1".into())])),
624        };
625        assert!(ctx.track_participant(&actor));
626        let participant = &ctx.participants["U001"];
627        assert_eq!(participant.actor, actor);
628        assert!(participant.first_seen_at.is_some());
629        assert!(!ctx.track_participant(&actor));
630        assert_eq!(ctx.participant_count(), 1);
631
632        // A fixed earlier instant detects timestamp replacement without sleeps.
633        let first_seen = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap();
634        let participant = ctx.participants.get_mut("U001").unwrap();
635        participant.first_seen_at = Some(first_seen);
636        participant.role = Some("owner".into());
637        actor.actor_name = Some("Alice B.".into());
638        assert!(!ctx.track_participant(&actor));
639        assert_eq!(ctx.participant_count(), 1);
640        assert_eq!(
641            ctx.participants["U001"],
642            Participant {
643                actor,
644                first_seen_at: Some(first_seen),
645                role: Some("owner".into()),
646            }
647        );
648    }
649
650    #[test]
651    fn test_thread_context_participants_summary() {
652        let mut ctx = ThreadContext::new("thread_1", "discord");
653        assert_eq!(ctx.participants_summary(), "");
654        for (actor_id, name) in [
655            ("U003", Some("Zoe")),
656            ("U002", None),
657            ("U001", Some("Alice")),
658        ] {
659            ctx.track_participant(&ExternalActor {
660                actor_id: actor_id.into(),
661                actor_name: name.map(str::to_string),
662                source: "discord".into(),
663                metadata: None,
664            });
665        }
666        assert_eq!(
667            ctx.participants_summary(),
668            "Thread participants: Alice, U002, Zoe"
669        );
670    }
671
672    #[test]
673    fn test_build_session_routing_tags() {
674        let metadata = HashMap::from([
675            ("thread_ref".into(), "1234.5678".into()),
676            ("channel_id".into(), "C0123".into()),
677            ("user_id".into(), "U999".into()),
678        ]);
679        for (binding, platform, key, expected) in [
680            (
681                SessionBinding::Thread,
682                "slack",
683                "thread_ref",
684                "slack:thread:1234.5678",
685            ),
686            (
687                SessionBinding::Conversation,
688                "discord",
689                "channel_id",
690                "discord:channel:C0123",
691            ),
692            (
693                SessionBinding::Requester,
694                "teams",
695                "user_id",
696                "teams:user:U999",
697            ),
698        ] {
699            assert_eq!(
700                build_session_routing_tag(platform, &binding, &metadata).as_deref(),
701                Some(expected)
702            );
703            let mut missing = metadata.clone();
704            missing.remove(key);
705            assert_eq!(
706                build_session_routing_tag(platform, &binding, &missing),
707                None
708            );
709            assert_eq!(
710                build_session_routing_tag(platform, &binding, &HashMap::new()),
711                None
712            );
713        }
714    }
715
716    /// EVE-1005: the tag segment is the old strategy word, not the new binding
717    /// name. A rename here silently orphans every live session keyed under it,
718    /// so the exact strings are pinned rather than derived.
719    #[test]
720    fn session_binding_tags_keep_their_legacy_segments() {
721        let metadata = HashMap::from([
722            ("thread_ref".into(), "T1".into()),
723            ("channel_id".into(), "C1".into()),
724            ("user_id".into(), "U1".into()),
725        ]);
726        for (binding, expected) in [
727            (SessionBinding::Thread, Some("slack:thread:T1")),
728            (SessionBinding::Conversation, Some("slack:channel:C1")),
729            (SessionBinding::Requester, Some("slack:user:U1")),
730            // Not keyed off inbound metadata: the exposure that owns the
731            // invocation supplies these tags.
732            (SessionBinding::Endpoint, None),
733            (SessionBinding::Ephemeral, None),
734        ] {
735            assert_eq!(
736                build_session_routing_tag("slack", &binding, &metadata).as_deref(),
737                expected,
738                "{binding:?}"
739            );
740        }
741    }
742
743    /// EVE-1005: the declared binding is a default the event may override.
744    #[test]
745    fn resolve_session_binding_lets_the_event_override_the_declaration() {
746        // No override: configuration wins, whatever it says.
747        for declared in SessionBinding::MESSAGE_KEYED {
748            assert_eq!(resolve_session_binding(declared, None), declared);
749        }
750        // The Slack pane case: a one-thread surface forces Thread even though
751        // the exposure legitimately declares Conversation for its channels.
752        assert_eq!(
753            resolve_session_binding(SessionBinding::Conversation, Some(SessionBinding::Thread)),
754            SessionBinding::Thread
755        );
756        assert_eq!(
757            resolve_session_binding(SessionBinding::Requester, Some(SessionBinding::Thread)),
758            SessionBinding::Thread
759        );
760    }
761
762    #[test]
763    fn test_channel_reply_mode_wire_contract() {
764        assert_eq!(ChannelReplyMode::default(), ChannelReplyMode::AllMessages);
765        for (mode, wire) in [
766            (ChannelReplyMode::AllMessages, "\"all_messages\""),
767            (
768                ChannelReplyMode::ReportProgressOnly,
769                "\"report_progress_only\"",
770            ),
771        ] {
772            assert_eq!(serde_json::to_string(&mode).unwrap(), wire);
773            assert_eq!(
774                serde_json::from_str::<ChannelReplyMode>(wire).unwrap(),
775                mode
776            );
777        }
778    }
779
780    /// EVE-1005: every value persisted in `channel_config` JSONB before the
781    /// enums were unified must still deserialize, and must still serialize back
782    /// to the same string. This is what makes the change migration-free; if it
783    /// fails, stored channel configs are unreadable.
784    #[test]
785    fn test_session_binding_wire_contract() {
786        assert_eq!(SessionBinding::default(), SessionBinding::Thread);
787        for (binding, wire) in [
788            // Legacy `SessionBinding` values.
789            (SessionBinding::Thread, "\"per_thread\""),
790            (SessionBinding::Conversation, "\"per_channel\""),
791            (SessionBinding::Requester, "\"per_user\""),
792            // Legacy `SessionBinding` values.
793            (SessionBinding::Endpoint, "\"shared_session\""),
794            (SessionBinding::Ephemeral, "\"session_per_invocation\""),
795        ] {
796            assert_eq!(
797                serde_json::to_string(&binding).unwrap(),
798                wire,
799                "{binding:?} must still serialize to its legacy value"
800            );
801            assert_eq!(
802                serde_json::from_str::<SessionBinding>(wire).unwrap(),
803                binding,
804                "{wire} must still deserialize"
805            );
806        }
807    }
808
809    /// The new vocabulary is accepted on read, so a config written with the
810    /// binding names is understood even though nothing emits them yet.
811    #[test]
812    fn test_session_binding_accepts_new_names_as_aliases() {
813        for (alias, binding) in [
814            ("\"thread\"", SessionBinding::Thread),
815            ("\"conversation\"", SessionBinding::Conversation),
816            ("\"requester\"", SessionBinding::Requester),
817            ("\"endpoint\"", SessionBinding::Endpoint),
818            ("\"ephemeral\"", SessionBinding::Ephemeral),
819        ] {
820            assert_eq!(
821                serde_json::from_str::<SessionBinding>(alias).unwrap(),
822                binding
823            );
824        }
825    }
826    // ============================================
827    // Persisted thread context (EVE-977)
828    // ============================================
829
830    fn actor(id: &str, name: &str) -> ExternalActor {
831        ExternalActor {
832            actor_id: id.to_string(),
833            actor_name: Some(name.to_string()),
834            source: "slack".to_string(),
835            metadata: None,
836        }
837    }
838
839    /// The bug: a ThreadContext built per message only ever saw one speaker, so
840    /// the summary never named the thread. Accumulation is the whole point.
841    #[test]
842    fn participants_accumulate_across_a_round_trip() {
843        let mut ctx = ThreadContext::new("1700.1", "slack");
844        assert!(ctx.track_participant(&actor("U1", "Alice")));
845
846        // Survive a restart: encode, drop, decode.
847        let encoded = encode_thread_context(&ctx).expect("encode");
848        let mut restored = decode_thread_context(&encoded).expect("decode");
849
850        assert!(restored.track_participant(&actor("U2", "Bob")));
851        assert!(
852            !restored.track_participant(&actor("U1", "Alice")),
853            "re-seen actor is not new"
854        );
855
856        assert_eq!(restored.participant_count(), 2);
857        assert_eq!(
858            restored.participants_summary(),
859            "Thread participants: Alice, Bob"
860        );
861    }
862
863    /// A malformed record degrades to "no context", never an error: losing the
864    /// participant line must not take the conversation down with it.
865    #[test]
866    fn malformed_record_decodes_to_none() {
867        assert!(decode_thread_context("not json").is_none());
868        assert!(decode_thread_context("").is_none());
869    }
870
871    /// Re-reporting the same position is not a change, so it does not cause a write.
872    #[test]
873    fn setting_the_same_view_twice_reports_no_change() {
874        let mut ctx = ThreadContext::new("1700.1", "slack");
875        let view = ChannelViewContext {
876            channel_id: Some("C123".to_string()),
877            team_id: Some("T1".to_string()),
878            observed_at: None,
879        };
880
881        assert!(
882            ctx.set_current_view(view.clone()),
883            "first report is a change"
884        );
885        assert!(!ctx.set_current_view(view), "identical report is not");
886
887        let moved = ChannelViewContext {
888            channel_id: Some("C999".to_string()),
889            team_id: Some("T1".to_string()),
890            observed_at: None,
891        };
892        assert!(ctx.set_current_view(moved), "a real move is a change");
893    }
894
895    /// An empty report clears rather than storing a hollow record.
896    #[test]
897    fn empty_view_clears_the_current_position() {
898        let mut ctx = ThreadContext::new("1700.1", "slack");
899        ctx.set_current_view(ChannelViewContext {
900            channel_id: Some("C123".to_string()),
901            ..Default::default()
902        });
903        assert!(ctx.current_view.is_some());
904
905        assert!(ctx.set_current_view(ChannelViewContext::default()));
906        assert!(ctx.current_view.is_none());
907        assert_eq!(ctx.view_summary(), "");
908    }
909
910    /// The view line must read as a hint to ask about, not as granted access —
911    /// the agent has no tool for a channel the user merely happens to be in.
912    #[test]
913    fn view_summary_does_not_imply_access() {
914        let mut ctx = ThreadContext::new("1700.1", "slack");
915        ctx.set_current_view(ChannelViewContext {
916            channel_id: Some("C123".to_string()),
917            team_id: None,
918            observed_at: None,
919        });
920
921        let summary = ctx.view_summary();
922        assert!(summary.contains("C123"), "{summary}");
923        assert!(summary.contains("slack"), "{summary}");
924        assert!(
925            summary.contains("have not been given access"),
926            "must not present the channel as readable: {summary}"
927        );
928        assert!(summary.contains("ask before"), "{summary}");
929    }
930
931    /// No position reported means no line at all — not an empty or hedging one.
932    #[test]
933    fn no_view_yields_no_line() {
934        let ctx = ThreadContext::new("1700.1", "slack");
935        assert_eq!(ctx.view_summary(), "");
936        assert_eq!(ctx.participants_summary(), "");
937    }
938
939    /// Round-tripping keeps the reported position, not just the participants.
940    #[test]
941    fn current_view_survives_encoding() {
942        let mut ctx = ThreadContext::new("1700.1", "slack");
943        ctx.set_current_view(ChannelViewContext {
944            channel_id: Some("C123".to_string()),
945            team_id: Some("T1".to_string()),
946            observed_at: None,
947        });
948
949        let restored = decode_thread_context(&encode_thread_context(&ctx).unwrap()).unwrap();
950        assert_eq!(restored.current_view, ctx.current_view);
951    }
952}