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}