sync from monorepo @ 2452e92e

This commit is contained in:
2026-05-08 01:59:04 +02:00
commit b03dc15371
459 changed files with 129586 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
# Package: dirigent_matrix
Matrix integration for Dirigent session sharing.
## Quick Facts
- **Type**: Library
- **Main Entry**: src/lib.rs
- **Dependencies**: matrix-sdk, dirigent_protocol, tokio, serde, thiserror, async-trait
- **Status**: Phase 1 -- Bot-mode session sharing
## Purpose
Provides bidirectional bridging between Dirigent sessions and Matrix rooms.
A session can be "shared" to a Matrix room, allowing Matrix users to send
messages to the agent and see responses in real-time.
## Architecture
### MatrixService (`service.rs`)
Central singleton owning the matrix-sdk Client. Handles:
- Bot authentication (login with username/password, session restore via SQLite store)
- Background sync loop for receiving Matrix events
- Share registry (tracks active session shares by connector_id + session_id)
- Room message dispatch to appropriate shares
### MatrixSessionShare (`share.rs`)
Bidirectional bridge for one (connector_id, session_id) to one Matrix room:
- **Dirigent to Matrix**: Subscribes to connector events, forwards completed assistant messages as m.notice
- **Matrix to Dirigent**: Receives room messages via MatrixService dispatch, sends ConnectorCommandProxy through mpsc channel
- Implements the `SessionShare` trait from dirigent_protocol
### MatrixConfig (`config.rs`)
Configuration parsed from `[matrix]` section in dirigent.toml:
- Homeserver URL, username, password source (env var or inline)
- Device ID for session persistence across restarts
- Display name, default invite list, store path
### Room Management (`room.rs`)
- Private, non-federated room creation for session shares
- Room naming conventions (`"Dirigent: <title>"`)
## Configuration
Identity and credentials live in an Account; sharing behavior in `[matrix]`:
```toml
[accounts.matrix-bot]
type = "matrix"
homeserver = "https://matrix.example.com"
username = "dirigent_bot"
device_id = "DIRIGENT_01"
display_name = "Dirigent Bot"
[accounts.matrix-bot.credentials.password]
source = "env"
key = "DIRIGENT_MATRIX_PASSWORD"
[matrix]
account = "matrix-bot"
default_invite = ["@user:example.com"]
store_path = "matrix/bot/store"
```
## Key Types
- `MatrixService` -- Singleton service, owns Client and share registry
- `MatrixSessionShare` -- Bidirectional session-to-room bridge
- `MatrixBehaviorConfig` -- Sharing behavior (account ref, invites, store path)
- `ConnectorCommandProxy` -- Message proxy decoupling from dirigent_core types
- `CreateRoomOptions` -- Room creation parameters
## Integration with CoreRuntime
The MatrixService is wired into CoreRuntime as an optional component (like archivist):
- `CoreRuntime::start_matrix_service()` -- Resolves Account from config, creates and starts service
- `CoreRuntime::create_matrix_share()` -- Creates room, starts bridge, spawns command proxy task
- `CoreRuntime::matrix_service()` -- Accessor for the running service
## Event Flow
```
Connector emits Event::MessageCompleted (role=assistant)
-> MatrixSessionShare event forwarder task
-> Sends m.notice to Matrix room
Matrix user sends message in room
-> MatrixService sync loop receives SyncRoomMessageEvent
-> Looks up share by room_id
-> share.inject_message(text) -> ConnectorCommandProxy
-> Proxy task translates to ConnectorCommand::SendMessage
-> Connector processes message
```
## Related Packages
- **dirigent_protocol**: SessionShare trait, Event types consumed by share forwarder
- **dirigent_core**: CoreRuntime integration, ConnectorCommand, ConnectorHandle
- **dirigent_config**: Path resolution (DIRIGENT_DATA_DIR for SQLite store)
+47
View File
@@ -0,0 +1,47 @@
[package]
name = "dirigent_matrix"
version = "0.1.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[features]
default = ["bundled-sqlite"]
bundled-sqlite = ["matrix-sdk/bundled-sqlite"]
[dependencies]
# Matrix SDK
matrix-sdk = { version = "0.9", default-features = false, features = ["rustls-tls", "sqlite"] }
# Internal dependencies
dirigent_protocol = { path = "../dirigent_protocol" }
dirigent_auth = { path = "../dirigent_auth" }
# Async runtime
tokio = { version = "1.42", features = ["sync", "time", "macros", "rt"] }
# Markdown rendering
pulldown-cmark = "0.12"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Logging
tracing = "0.1"
# Error handling
thiserror = "2.0"
# Async traits
async-trait = "0.1"
# UUID
uuid = { version = "1.11", features = ["v7"] }
# Timestamps (for StreamSummary::active_since)
chrono = { version = "0.4", features = ["serde"] }
[dev-dependencies]
tokio = { version = "1.42", features = ["full"] }
+73
View File
@@ -0,0 +1,73 @@
use serde::{Deserialize, Serialize};
/// How Dirigent connects to Matrix.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MatrixConnectionMode {
/// Dedicated bot user with username/password login (existing behavior).
#[default]
Bot,
/// Appservice-provisioned virtual user with stored access token.
Provisioned,
}
/// A persistent Matrix room defined in configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistentRoom {
/// Human-readable label for this room (shown in room picker).
pub label: String,
/// Matrix room ID (e.g. "!abc:matrix.org").
pub room_id: String,
}
/// Matrix sharing behavior — separate from identity.
///
/// Identity and credentials come from an Account referenced by name.
/// This struct only defines sharing behavior.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatrixBehaviorConfig {
/// Account name (key in [accounts.*]) for the Matrix connection.
pub account: String,
/// Connection mode: "bot" (default) or "provisioned".
#[serde(default)]
pub mode: MatrixConnectionMode,
/// Matrix user IDs to invite to newly created share rooms.
#[serde(default)]
pub default_invite: Vec<String>,
/// Directory for matrix-sdk SQLite store (relative to DIRIGENT_DATA_DIR).
#[serde(default = "default_store_path")]
pub store_path: String,
/// Pre-defined rooms that always appear in the room selection UI.
#[serde(default)]
pub rooms: Vec<PersistentRoom>,
}
fn default_store_path() -> String {
"matrix/bot/store".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_mode_is_bot() {
let json = r#"{"account": "matrix-bot"}"#;
let config: MatrixBehaviorConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.mode, MatrixConnectionMode::Bot);
}
#[test]
fn test_provisioned_mode() {
let json = r#"{"account": "matrix-virt", "mode": "provisioned"}"#;
let config: MatrixBehaviorConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.mode, MatrixConnectionMode::Provisioned);
}
#[test]
fn test_bot_mode_explicit() {
let json = r#"{"account": "matrix-bot", "mode": "bot"}"#;
let config: MatrixBehaviorConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.mode, MatrixConnectionMode::Bot);
}
}
+39
View File
@@ -0,0 +1,39 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MatrixError {
#[error("Matrix SDK error: {0}")]
Sdk(#[from] matrix_sdk::Error),
#[error("Matrix HTTP error: {0}")]
Http(#[from] matrix_sdk::HttpError),
#[error("Matrix client build error: {0}")]
ClientBuild(#[from] matrix_sdk::ClientBuildError),
#[error("Not logged in")]
NotLoggedIn,
#[error("Room not found: {0}")]
RoomNotFound(String),
#[error("Share not found: connector={connector_id} session={session_id}")]
ShareNotFound {
connector_id: String,
session_id: String,
},
#[error("Share already exists: connector={connector_id} session={session_id}")]
ShareAlreadyExists {
connector_id: String,
session_id: String,
},
#[error("Configuration error: {0}")]
Config(String),
#[error("Channel closed")]
ChannelClosed,
}
pub type Result<T> = std::result::Result<T, MatrixError>;
+17
View File
@@ -0,0 +1,17 @@
//! Matrix integration for Dirigent session sharing
//!
//! This package provides bidirectional bridging between Dirigent sessions
//! and Matrix rooms. A session can be "shared" to a Matrix room, allowing
//! Matrix users to interact with the agent and see responses in real-time.
pub mod config;
pub mod error;
pub mod room;
pub mod service;
pub mod share;
pub use config::MatrixBehaviorConfig;
pub use error::{MatrixError, Result};
pub use room::CreateRoomOptions;
pub use service::MatrixService;
pub use share::{ConnectorCommandProxy, MatrixSessionShare};
+81
View File
@@ -0,0 +1,81 @@
//! Matrix room creation and management helpers.
use matrix_sdk::{
ruma::{
api::client::room::create_room::v3::{
CreationContent, Request as CreateRoomRequest, RoomPreset,
},
OwnedRoomId, OwnedUserId,
},
Client,
};
use tracing::debug;
/// Options for creating a new Matrix room for session sharing.
pub struct CreateRoomOptions {
/// Human-readable room name.
pub name: String,
/// Optional room topic.
pub topic: Option<String>,
/// Matrix user IDs (as strings, e.g. "@user:example.com") to invite at
/// creation time. Invalid IDs are silently skipped.
pub invite: Vec<String>,
}
/// Create a private, non-federated Matrix room for bridging a Dirigent session.
///
/// The room is configured as a `PrivateChat` (invite-only, shared history) and
/// the `m.federate` flag is disabled so the room does not appear on remote
/// homeservers.
///
/// # Errors
///
/// Returns [`crate::MatrixError::NotLoggedIn`] if the client is not authenticated.
/// Other errors propagate from the Matrix SDK.
pub async fn create_share_room(
client: &Client,
options: CreateRoomOptions,
) -> crate::Result<OwnedRoomId> {
if !client.logged_in() {
return Err(crate::MatrixError::NotLoggedIn);
}
let invite: Vec<OwnedUserId> = options
.invite
.iter()
.filter_map(|id| id.parse::<OwnedUserId>().ok())
.collect();
let mut request = CreateRoomRequest::new();
request.name = Some(options.name.clone());
request.topic = options.topic.clone();
request.invite = invite;
request.preset = Some(RoomPreset::PrivateChat);
// Disable federation so the room stays on the local homeserver.
let mut creation_content = CreationContent::new();
creation_content.federate = false;
request.creation_content =
Some(matrix_sdk::ruma::serde::Raw::new(&creation_content).map_err(|e| {
crate::MatrixError::Config(format!(
"Failed to serialize creation content: {}",
e
))
})?);
debug!(room_name = %options.name, "Creating Matrix share room");
let room = client.create_room(request).await?;
Ok(room.room_id().to_owned())
}
/// Generate a human-readable Matrix room name for a session.
///
/// Format: `"Dirigent: <session_title>"` or `"Dirigent: <connector_id>"` when
/// no title is available.
pub fn room_name_for_session(connector_id: &str, session_title: Option<&str>) -> String {
match session_title.filter(|t| !t.is_empty()) {
Some(title) => format!("Dirigent: {}", title),
None => format!("Dirigent: {}", connector_id),
}
}
+436
View File
@@ -0,0 +1,436 @@
use std::{
collections::HashMap,
path::PathBuf,
sync::Arc,
};
use matrix_sdk::{
config::SyncSettings,
ruma::{
events::room::message::{MessageType, SyncRoomMessageEvent},
OwnedUserId,
},
Client, Room,
};
use tokio::sync::RwLock;
use tracing::{debug, error, info, warn};
use crate::{config::MatrixBehaviorConfig, error::MatrixError, share::MatrixSessionShare, Result};
/// Key for the share registry: (connector_id, session_id)
type ShareKey = (String, String);
/// Central Matrix service.
///
/// Owns the SDK [`Client`], handles login/session-restore, manages the sync
/// loop, and maintains a registry of [`MatrixSessionShare`]s that bridge
/// Dirigent sessions to Matrix rooms.
pub struct MatrixService {
account: dirigent_auth::Account,
homeserver: String,
username: String,
display_name_str: String,
device_id: String,
behavior: MatrixBehaviorConfig,
data_dir: PathBuf,
client: Arc<RwLock<Option<Client>>>,
shares: Arc<RwLock<HashMap<ShareKey, MatrixSessionShare>>>,
}
impl MatrixService {
/// Create a new (not yet logged-in) service from an Account and behavior config.
pub fn from_account(
account: &dirigent_auth::Account,
behavior: MatrixBehaviorConfig,
data_dir: PathBuf,
) -> Result<Self> {
let homeserver = account
.property_str("homeserver")
.ok_or_else(|| MatrixError::Config("Account missing 'homeserver' property".into()))?
.to_string();
let username = account
.profile
.username
.clone()
.ok_or_else(|| {
MatrixError::Config("Account missing 'username' in profile".into())
})?;
let device_id = account
.property_str_or("device_id", "DIRIGENT_01")
.to_string();
let display_name_str = account.display_name().to_string();
Ok(Self {
account: account.clone(),
homeserver,
username,
display_name_str,
device_id,
behavior,
data_dir,
client: Arc::new(RwLock::new(None)),
shares: Arc::new(RwLock::new(HashMap::new())),
})
}
/// Return the current behavior configuration.
pub fn behavior(&self) -> &MatrixBehaviorConfig {
&self.behavior
}
/// Return a clone of the inner [`Client`], if logged in.
pub async fn client_cloned(&self) -> Option<Client> {
self.client.read().await.clone()
}
/// Resolve a Matrix [`Room`] handle from its string room id.
///
/// Returns:
/// - `Ok(Some(room))` — client is logged in and knows the room
/// - `Ok(None)` — client is logged in but the room isn't known (not
/// joined / never invited / wrong id)
/// - `Err(MatrixError::NotLoggedIn)` — no client yet
/// - `Err(MatrixError::Config(..))` — `room_id` isn't a valid Matrix id
///
/// Exposed for consumers (e.g. `dirigent_core`'s `MatrixFactory`)
/// that need to look up a pre-existing room without taking a
/// `matrix_sdk` dependency of their own.
pub async fn room_by_id(&self, room_id: &str) -> Result<Option<Room>> {
let client = self
.client_cloned()
.await
.ok_or(MatrixError::NotLoggedIn)?;
let parsed: matrix_sdk::ruma::OwnedRoomId = room_id
.parse()
.map_err(|e: matrix_sdk::ruma::IdParseError| {
MatrixError::Config(format!("invalid room_id '{}': {}", room_id, e))
})?;
Ok(client.get_room(&parsed))
}
// -----------------------------------------------------------------------
// Authentication
// -----------------------------------------------------------------------
/// Build the SDK client (with SQLite store) and authenticate.
///
/// Attempts to restore a previously persisted session first. Falls back
/// to a fresh username/password login when no session is found.
pub async fn login(&self) -> Result<()> {
let store_path = self.data_dir.join(&self.behavior.store_path);
std::fs::create_dir_all(&store_path).map_err(|e| {
MatrixError::Config(format!("Failed to create store directory: {}", e))
})?;
let client = Client::builder()
.homeserver_url(&self.homeserver)
.sqlite_store(&store_path, None)
.build()
.await?;
// Try to restore an existing session from the store.
if client.logged_in() {
info!(
homeserver = %self.homeserver,
username = %self.username,
"Restored existing Matrix session"
);
*self.client.write().await = Some(client);
return Ok(());
}
// No stored session — authenticate based on the configured mode.
match self.behavior.mode {
crate::config::MatrixConnectionMode::Bot => {
// Existing flow: login with bot username/password.
let password = self
.account
.resolve_credential("password")
.map_err(|e| {
MatrixError::Config(format!("Failed to resolve password: {}", e))
})?;
info!(
homeserver = %self.homeserver,
username = %self.username,
"Performing fresh Matrix login (bot mode)"
);
client
.matrix_auth()
.login_username(&self.username, &password)
.device_id(&self.device_id)
.initial_device_display_name(&self.display_name_str)
.send()
.await?;
}
crate::config::MatrixConnectionMode::Provisioned => {
// Restore session from a stored virtual-user access token.
let token = self
.account
.resolve_credential("token")
.map_err(|e| {
MatrixError::Config(format!("Failed to resolve token: {}", e))
})?;
let user_id_str = self
.account
.property_str("user_id")
.ok_or_else(|| {
MatrixError::Config(
"Provisioned mode requires 'user_id' property on account".into(),
)
})?;
info!(
homeserver = %self.homeserver,
user_id = %user_id_str,
"Restoring provisioned session with access token"
);
use matrix_sdk::matrix_auth::MatrixSession;
use matrix_sdk::ruma::{OwnedDeviceId, OwnedUserId};
let user_id: OwnedUserId = user_id_str.try_into().map_err(|_| {
MatrixError::Config(format!("Invalid user_id: {}", user_id_str))
})?;
let device_id: OwnedDeviceId = self.device_id.clone().into();
let session = MatrixSession {
meta: matrix_sdk::SessionMeta {
user_id,
device_id,
},
tokens: matrix_sdk::matrix_auth::MatrixSessionTokens {
access_token: token,
refresh_token: None,
},
};
client.matrix_auth().restore_session(session).await?;
}
}
info!(
homeserver = %self.homeserver,
username = %self.username,
"Matrix login successful"
);
*self.client.write().await = Some(client);
Ok(())
}
// -----------------------------------------------------------------------
// Sync loop
// -----------------------------------------------------------------------
/// Start the background sync task.
///
/// Registers an event handler for incoming room messages and spawns a
/// task that runs `client.sync()` indefinitely. Returns immediately
/// after spawning.
///
/// # Errors
///
/// Returns [`MatrixError::NotLoggedIn`] if `login()` was not called first.
pub async fn start_sync(&self) -> Result<()> {
let client = self
.client
.read()
.await
.clone()
.ok_or(MatrixError::NotLoggedIn)?;
if !client.logged_in() {
return Err(MatrixError::NotLoggedIn);
}
let shares = Arc::clone(&self.shares);
let bot_user_id: Option<OwnedUserId> = client.user_id().map(|u| u.to_owned());
// Register the room message event handler.
client.add_event_handler({
let shares = Arc::clone(&shares);
let bot_user_id = bot_user_id.clone();
move |ev: SyncRoomMessageEvent, room: Room| {
let shares = Arc::clone(&shares);
let bot_user_id = bot_user_id.clone();
async move {
on_room_message(ev, room, shares, bot_user_id).await;
}
}
});
// Spawn the sync loop.
tokio::spawn(async move {
info!("Starting Matrix sync loop");
if let Err(e) = client.sync(SyncSettings::default()).await {
error!("Matrix sync loop exited with error: {}", e);
}
});
Ok(())
}
// -----------------------------------------------------------------------
// Share registry
// -----------------------------------------------------------------------
/// Register a share, making it eligible to receive Matrix messages.
///
/// # Errors
///
/// Returns [`MatrixError::ShareAlreadyExists`] if a share for the same
/// `(connector_id, session_id)` pair is already registered.
pub async fn register_share(&self, share: MatrixSessionShare) -> Result<()> {
let key = (share.connector_id.clone(), share.session_id.clone());
let mut map = self.shares.write().await;
if map.contains_key(&key) {
return Err(MatrixError::ShareAlreadyExists {
connector_id: key.0,
session_id: key.1,
});
}
map.insert(key, share);
Ok(())
}
/// Remove a share from the registry and shut it down.
///
/// # Errors
///
/// Returns [`MatrixError::ShareNotFound`] if no matching share exists.
pub async fn remove_share(&self, connector_id: &str, session_id: &str) -> Result<()> {
let key = (connector_id.to_owned(), session_id.to_owned());
let share = self
.shares
.write()
.await
.remove(&key)
.ok_or_else(|| MatrixError::ShareNotFound {
connector_id: connector_id.to_owned(),
session_id: session_id.to_owned(),
})?;
share.shutdown().await;
Ok(())
}
/// Return the number of currently registered shares.
pub async fn share_count(&self) -> usize {
self.shares.read().await.len()
}
/// Return the room IDs and keys for all currently registered shares.
///
/// Returns a list of `(connector_id, session_id, room_id)` tuples.
pub async fn list_shares(&self) -> Vec<(String, String, String)> {
self.shares
.read()
.await
.iter()
.map(|((cid, sid), s)| (cid.clone(), sid.clone(), s.room_id.clone()))
.collect()
}
/// Look up a share by connector and session ID.
///
/// Returns `None` when not found.
pub async fn get_share(
&self,
connector_id: &str,
session_id: &str,
) -> Option<(String, bool)> {
let key = (connector_id.to_owned(), session_id.to_owned());
let map = self.shares.read().await;
if let Some(share) = map.get(&key) {
let room_id = share.room_id.clone();
// is_active requires an await; we can't hold the read guard across
// the await point. Drop the guard first.
drop(map);
let active = {
// Re-acquire to call is_active (we still have shares Arc)
let map = self.shares.read().await;
if let Some(s) = map.get(&key) {
s.is_active().await
} else {
false
}
};
Some((room_id, active))
} else {
None
}
}
/// Shut down all shares and signal the service to stop.
pub async fn shutdown(&self) {
let mut map = self.shares.write().await;
for (_, share) in map.drain() {
share.shutdown().await;
}
// Release the client so the sync loop can terminate naturally.
*self.client.write().await = None;
}
}
// ---------------------------------------------------------------------------
// Event handler: Matrix → Dirigent
// ---------------------------------------------------------------------------
/// Called by the SDK for every incoming room message.
///
/// Looks up which registered share owns this room, then calls
/// `share.inject_message(text)` so the text flows into the Dirigent session.
/// Messages from the bot itself are skipped.
async fn on_room_message(
ev: SyncRoomMessageEvent,
room: Room,
shares: Arc<RwLock<HashMap<ShareKey, MatrixSessionShare>>>,
bot_user_id: Option<OwnedUserId>,
) {
// We only care about original (non-redacted, non-edited) messages.
let original = match ev.as_original() {
Some(o) => o,
None => return,
};
// Skip bot's own messages.
if let Some(bot_id) = &bot_user_id {
if original.sender == *bot_id {
return;
}
}
// Extract plain-text body.
let text = match &original.content.msgtype {
MessageType::Text(t) => t.body.clone(),
MessageType::Notice(_) => return, // ignore notices (including our own)
_ => return,
};
let room_id_str = room.room_id().as_str().to_owned();
// Find the share that owns this room.
let map = shares.read().await;
let matching = map
.values()
.find(|s| s.room_id == room_id_str);
if let Some(share) = matching {
debug!(
room_id = %room_id_str,
connector_id = %share.connector_id,
session_id = %share.session_id,
"Injecting Matrix message into Dirigent session"
);
share.inject_message(&text).await;
} else {
warn!(
room_id = %room_id_str,
"Received message in unregistered room, ignoring"
);
}
}
+723
View File
@@ -0,0 +1,723 @@
use std::sync::Arc;
use chrono::{DateTime, Utc};
use matrix_sdk::Room;
use tokio::sync::{broadcast, mpsc, oneshot, Mutex, RwLock};
use tracing::{debug, error, warn};
use uuid::Uuid;
use dirigent_protocol::accumulator::{AccumulatedMessage, AccumulatedPart, MessageAccumulator, ToolCallData};
use dirigent_protocol::{ContentBlock, Event, MessageRole, SessionUpdate};
/// Command proxy sent from Matrix → Dirigent direction.
///
/// The caller who wires up the share is responsible for translating this
/// into a real `ConnectorCommand::SendMessage` (or equivalent) using their
/// own connector/session handle.
#[derive(Debug, Clone)]
pub struct ConnectorCommandProxy {
pub session_id: String,
pub text: String,
}
/// A bidirectional bridge between a Dirigent session and a Matrix room.
///
/// **Dirigent → Matrix**: Subscribes to a broadcast event stream, forwards
/// completed assistant messages and session errors into the Matrix room as
/// `m.notice` messages.
///
/// **Matrix → Dirigent**: The `inject_message` method is called by
/// `MatrixService` when a Matrix message arrives; it sends a
/// `ConnectorCommandProxy` through an mpsc channel that the owner can read.
pub struct MatrixSessionShare {
/// Connector that owns the session.
pub connector_id: String,
/// Session being bridged (native connector session ID).
pub session_id: String,
/// Scroll ID for the archived session this share is scoped to.
///
/// Required by `SessionStream::scope()` to select `StreamScope::Session`.
pub scroll_id: Uuid,
/// Matrix room ID (as a string, e.g. "!abc:example.com").
pub room_id: String,
/// Room handle used by the stream `on_event` path when no legacy
/// forwarder task is running. Behind an `Option` so `start()` — which
/// consumes the `Room` when spawning the legacy forwarder — can leave
/// it empty.
room_for_stream: Option<Room>,
/// Shared message accumulator so streaming chunks survive across
/// multiple `on_event` calls (and the legacy forwarder task).
accumulator: Arc<Mutex<MessageAccumulator>>,
/// When this share was activated (for `StreamSummary::active_since`).
active_since: DateTime<Utc>,
/// Sender side of the Matrix→Dirigent command channel.
command_tx: mpsc::Sender<ConnectorCommandProxy>,
/// Shutdown signal for the event-forwarder task.
shutdown_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
/// Whether the forwarder task is still running.
is_active: Arc<RwLock<bool>>,
}
impl MatrixSessionShare {
/// Construct and start a new `MatrixSessionShare`.
///
/// Spawns a background task that reads from `event_rx`, filters for
/// events belonging to `(connector_id, session_id)`, and forwards
/// relevant ones into the Matrix `room`.
///
/// Returns the share and the receiver end of the Matrix→Dirigent channel.
pub fn start(
connector_id: String,
session_id: String,
room_id: String,
room: Room,
event_rx: broadcast::Receiver<Event>,
) -> (Self, mpsc::Receiver<ConnectorCommandProxy>) {
// Legacy start() keeps ownership of the Room inside the forwarder
// task; `scroll_id` isn't known by the legacy call path so we
// default it to `Uuid::nil()`. The stream-path consumers use
// `new_for_stream` instead and supply a real scroll_id.
Self::start_with_scroll(
connector_id,
session_id,
Uuid::nil(),
room_id,
room,
event_rx,
)
}
/// Same as `start`, but lets callers attach the `scroll_id` of the
/// archived session — required for `SessionStream::scope()` to be
/// meaningful when the share is also driven as a stream.
pub fn start_with_scroll(
connector_id: String,
session_id: String,
scroll_id: Uuid,
room_id: String,
room: Room,
event_rx: broadcast::Receiver<Event>,
) -> (Self, mpsc::Receiver<ConnectorCommandProxy>) {
let (command_tx, command_rx) = mpsc::channel(32);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let is_active = Arc::new(RwLock::new(true));
let accumulator = Arc::new(Mutex::new(MessageAccumulator::new()));
let share = MatrixSessionShare {
connector_id: connector_id.clone(),
session_id: session_id.clone(),
scroll_id,
room_id: room_id.clone(),
// Legacy path: the forwarder task owns the Room, so we don't
// hold a second handle on the struct. `on_event` would double-
// drive delivery if both were active.
room_for_stream: None,
accumulator: accumulator.clone(),
active_since: Utc::now(),
command_tx,
shutdown_tx: Arc::new(RwLock::new(Some(shutdown_tx))),
is_active: is_active.clone(),
};
// Spawn the event-forwarder task
tokio::spawn(run_event_forwarder(
connector_id,
session_id,
room,
event_rx,
shutdown_rx,
is_active,
accumulator,
));
(share, command_rx)
}
/// Construct a share wired for the stream path (`SessionStream`) only.
///
/// No legacy event-forwarder task is spawned — the
/// `StreamRegistry` worker will drive `on_event` instead. The Room
/// handle is retained on the struct so `on_event` can deliver to it.
///
/// Returns the share plus the receiver end of the Matrix→Dirigent
/// command channel (identical semantics to `start`).
pub fn new_for_stream(
connector_id: String,
session_id: String,
scroll_id: Uuid,
room_id: String,
room: Room,
) -> (Self, mpsc::Receiver<ConnectorCommandProxy>) {
let (command_tx, command_rx) = mpsc::channel(32);
let (shutdown_tx, _shutdown_rx) = oneshot::channel();
let is_active = Arc::new(RwLock::new(true));
let accumulator = Arc::new(Mutex::new(MessageAccumulator::new()));
let share = MatrixSessionShare {
connector_id,
session_id,
scroll_id,
room_id,
room_for_stream: Some(room),
accumulator,
active_since: Utc::now(),
command_tx,
shutdown_tx: Arc::new(RwLock::new(Some(shutdown_tx))),
is_active,
};
(share, command_rx)
}
/// Inject a message received from Matrix into the Dirigent session.
///
/// Called by `MatrixService` when a room message arrives (filtered to
/// skip the bot's own messages). Sends a `ConnectorCommandProxy` through
/// the internal mpsc channel.
pub async fn inject_message(&self, text: &str) {
let proxy = ConnectorCommandProxy {
session_id: self.session_id.clone(),
text: text.to_owned(),
};
if let Err(e) = self.command_tx.send(proxy).await {
warn!(
connector_id = %self.connector_id,
session_id = %self.session_id,
"Failed to inject Matrix message into session (channel closed): {}",
e
);
}
}
/// Signal the event-forwarder task to stop and wait for it to finish.
pub async fn shutdown(&self) {
let tx = self.shutdown_tx.write().await.take();
if let Some(tx) = tx {
let _ = tx.send(());
}
// Give the task a moment to notice the shutdown signal.
// The is_active flag is set to false by the task itself when it exits.
}
/// Whether the event-forwarder task is still running.
pub async fn is_active(&self) -> bool {
*self.is_active.read().await
}
}
// ---------------------------------------------------------------------------
// Internal event-forwarder task
// ---------------------------------------------------------------------------
async fn run_event_forwarder(
connector_id: String,
session_id: String,
room: Room,
mut event_rx: broadcast::Receiver<Event>,
shutdown_rx: oneshot::Receiver<()>,
is_active: Arc<RwLock<bool>>,
accumulator: Arc<Mutex<MessageAccumulator>>,
) {
// Fuse the shutdown signal so we can use it inside tokio::select!
let mut shutdown_rx = shutdown_rx;
loop {
tokio::select! {
_ = &mut shutdown_rx => {
debug!(
connector_id = %connector_id,
session_id = %session_id,
"MatrixSessionShare forwarder received shutdown signal"
);
break;
}
result = event_rx.recv() => {
match result {
Err(broadcast::error::RecvError::Closed) => {
debug!(
connector_id = %connector_id,
session_id = %session_id,
"Event broadcast channel closed, stopping forwarder"
);
break;
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(
connector_id = %connector_id,
session_id = %session_id,
"Event forwarder lagged by {} messages",
n
);
continue;
}
Ok(event) => {
let mut acc = accumulator.lock().await;
handle_event(
&event,
&connector_id,
&session_id,
&room,
&mut *acc,
).await;
}
}
}
}
}
*is_active.write().await = false;
}
/// Handle a single protocol event: forward relevant ones to the Matrix room.
async fn handle_event(
event: &Event,
connector_id: &str,
session_id: &str,
room: &Room,
accumulator: &mut MessageAccumulator,
) {
use matrix_sdk::ruma::events::room::message::RoomMessageEventContent;
match event {
// -- Streaming accumulation: gather chunks into the accumulator --
Event::SessionUpdate {
connector_id: cid,
session_id: sid,
update,
} if cid == connector_id && sid == session_id => {
// Send typing indicator on first chunk of a new message
let is_agent_chunk = matches!(
update,
SessionUpdate::AgentMessageChunk { .. }
| SessionUpdate::AgentThoughtChunk { .. }
| SessionUpdate::ToolCall { .. }
);
let msg_id = match update {
SessionUpdate::AgentMessageChunk { message_id, .. }
| SessionUpdate::AgentThoughtChunk { message_id, .. }
| SessionUpdate::ToolCall { message_id, .. }
| SessionUpdate::ToolCallUpdate { message_id, .. } => Some(message_id.as_str()),
_ => None,
};
if is_agent_chunk {
if let Some(mid) = msg_id {
if !accumulator.has_buffer(mid) {
// First chunk for this message — start typing indicator
let _ = room.typing_notice(true).await;
}
}
}
match update {
SessionUpdate::AgentMessageChunk {
message_id,
content,
..
} => {
accumulator.add_chunk(message_id, session_id, &connector_id, "assistant", content.clone());
}
SessionUpdate::AgentThoughtChunk {
message_id,
content,
..
} => {
if let ContentBlock::Text { text } = content {
accumulator.add_thinking(message_id, session_id, &connector_id, text);
}
}
SessionUpdate::ToolCall {
message_id,
tool_call,
..
} => {
accumulator.add_or_update_tool_call(
message_id,
ToolCallData {
id: tool_call.id.clone(),
tool_name: tool_call.tool_name.clone(),
input: tool_call.raw_input.clone().unwrap_or_default(),
output: tool_call.raw_output.clone(),
},
);
}
SessionUpdate::ToolCallUpdate {
message_id,
tool_call,
..
} => {
accumulator.add_or_update_tool_call(
message_id,
ToolCallData {
id: tool_call.id.clone(),
tool_name: tool_call.tool_name.clone(),
input: tool_call.raw_input.clone().unwrap_or_default(),
output: tool_call.raw_output.clone(),
},
);
}
_ => {} // UserMessageChunk, Unknown -- not forwarded
}
}
// -- Streaming finalization: send accumulated content on TurnComplete --
Event::TurnComplete {
connector_id: cid,
session_id: sid,
message_id,
..
} if cid == connector_id && sid == session_id => {
if let Some(accumulated) = accumulator.finalize(message_id) {
if accumulated.role == "assistant" && !accumulated.is_empty() {
send_accumulated_to_matrix(&accumulated, room).await;
}
}
// Stop typing indicator
let _ = room.typing_notice(false).await;
debug!(
connector_id = %connector_id,
session_id = %session_id,
message_id = %message_id,
"TurnComplete received for bridged session"
);
}
// -- Non-streaming fallback: send content from MessageCompleted --
Event::MessageCompleted {
connector_id: cid,
message,
} if cid == connector_id && message.session_id == session_id => {
if message.role != MessageRole::Assistant {
debug!(
connector_id = %connector_id,
session_id = %session_id,
role = ?message.role,
"Skipping non-assistant MessageCompleted"
);
return;
}
// Non-streaming path: content populated directly in MessageCompleted.
// Skip if the accumulator already has data for this message (streaming
// path handles delivery via TurnComplete instead).
if !message.content.is_empty() && !accumulator.has_buffer(&message.id) {
let accumulated = AccumulatedMessage::from_message_parts(
message.id.clone(),
message.session_id.clone(),
connector_id.to_string(),
"assistant".to_string(),
&message.content,
);
send_accumulated_to_matrix(&accumulated, room).await;
}
}
Event::SessionError {
connector_id: cid,
session_id: sid,
error_message,
is_recoverable,
..
} if cid == connector_id && sid == session_id => {
let notice = if *is_recoverable {
format!("\u{26a0}\u{fe0f} Session warning: {}", error_message)
} else {
format!("\u{274c} Session error (unrecoverable): {}", error_message)
};
let content = RoomMessageEventContent::notice_plain(notice);
if let Err(e) = room.send(content).await {
error!(
connector_id = %connector_id,
session_id = %session_id,
"Failed to send session error to Matrix room: {}",
e
);
}
}
// Events for *this* session but not handled above (expected, low noise)
Event::SessionIdle { session_id: sid, .. }
| Event::SessionMetadataUpdated {
session_id: sid, ..
}
if sid == session_id =>
{
debug!(
connector_id = %connector_id,
session_id = %session_id,
event = event_name(event),
"Ignoring non-forwarded event for this session"
);
}
// Events for a *different* session/connector -- expected, just noise
Event::MessageCompleted {
connector_id: cid,
message,
} if cid != connector_id || message.session_id != session_id => {
// Different session's message -- expected on a shared broadcast channel
}
Event::TurnComplete {
connector_id: cid,
session_id: sid,
..
} if cid != connector_id || sid != session_id => {
// Different session -- expected
}
Event::SessionError {
connector_id: cid,
session_id: sid,
..
} if cid != connector_id || sid != session_id => {
// Different session -- expected
}
Event::SessionUpdate {
connector_id: cid,
session_id: sid,
..
} if cid != connector_id || sid != session_id => {
// Different session -- expected
}
// Connector lifecycle, inspector, system events -- expected on broadcast
Event::ConnectorCreated { .. }
| Event::ConnectorRemoved { .. }
| Event::ConnectorStateChanged { .. }
| Event::Connected
| Event::Disconnected
| Event::InspectorSnapshot { .. }
| Event::InspectorNodeRegistered { .. }
| Event::InspectorNodeRemoved { .. }
| Event::InspectorStateChanged { .. }
| Event::InspectorPropertiesUpdated { .. }
| Event::SystemTaskStatusChanged { .. }
| Event::SessionsListed { .. }
| Event::SessionCreated { .. }
| Event::SessionUpdated { .. }
| Event::SessionDeleted { .. }
| Event::SessionClosed { .. }
| Event::MessagesListed { .. }
| Event::SessionSystemMessageSet { .. }
| Event::SessionMetadataReceived { .. }
| Event::SessionTransferred { .. }
| Event::ForwardingPanic { .. }
| Event::AgentRequest { .. }
| Event::AcpClientConnected { .. }
| Event::AcpClientDisconnected { .. }
| Event::AcpClientSessionOpened { .. }
| Event::AcpClientSessionRouted { .. }
| Event::MessageStarted { .. }
| Event::MessageFailed { .. }
| Event::Error { .. }
| Event::SessionRegistered { .. } => {
// Expected broadcast traffic, not relevant to this share
}
other => {
warn!(
connector_id = %connector_id,
session_id = %session_id,
event = event_name(other),
"Unhandled event type in Matrix forwarder"
);
}
}
}
/// Render markdown text to HTML for Matrix consumption.
fn markdown_to_html(markdown: &str) -> String {
use pulldown_cmark::{Options, Parser};
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
let parser = Parser::new_ext(markdown, options);
let mut html = String::new();
pulldown_cmark::html::push_html(&mut html, parser);
html
}
/// Send an accumulated message to a Matrix room, one message per content part.
async fn send_accumulated_to_matrix(msg: &AccumulatedMessage, room: &Room) {
use matrix_sdk::ruma::events::room::message::RoomMessageEventContent;
for part in &msg.parts {
let content = match part {
AccumulatedPart::Text { text } if !text.is_empty() => {
let html = markdown_to_html(text);
Some(RoomMessageEventContent::text_html(text.clone(), html))
}
AccumulatedPart::Thinking { text } if !text.is_empty() => {
Some(RoomMessageEventContent::notice_plain(format!(
"\u{1f4ad} {text}"
)))
}
AccumulatedPart::Tool { data } => {
let mut notice = format!("\u{1f527} Tool: {}", data.tool_name);
if let Some(out) = &data.output {
let out_str = if let Some(s) = out.as_str() {
s.to_string()
} else {
serde_json::to_string_pretty(out).unwrap_or_default()
};
if !out_str.is_empty() {
let truncated = if out_str.len() > 500 {
format!("{}... (truncated)", &out_str[..500])
} else {
out_str
};
notice.push_str(&format!("\nOutput: {truncated}"));
}
}
Some(RoomMessageEventContent::notice_plain(notice))
}
_ => None,
};
if let Some(content) = content {
if let Err(e) = room.send(content).await {
error!("Failed to send message part to Matrix room: {}", e);
}
}
}
}
/// Return a human-readable name for an event variant (for logging).
fn event_name(event: &Event) -> &'static str {
match event {
Event::SessionsListed { .. } => "SessionsListed",
Event::SessionCreated { .. } => "SessionCreated",
Event::SessionUpdated { .. } => "SessionUpdated",
Event::SessionMetadataUpdated { .. } => "SessionMetadataUpdated",
Event::SessionDeleted { .. } => "SessionDeleted",
Event::SessionClosed { .. } => "SessionClosed",
Event::SessionSystemMessageSet { .. } => "SessionSystemMessageSet",
Event::SessionIdle { .. } => "SessionIdle",
Event::SessionMetadataReceived { .. } => "SessionMetadataReceived",
Event::TurnComplete { .. } => "TurnComplete",
Event::SessionError { .. } => "SessionError",
Event::SessionTransferred { .. } => "SessionTransferred",
Event::ForwardingPanic { .. } => "ForwardingPanic",
Event::SessionUpdate { .. } => "SessionUpdate",
Event::AgentRequest { .. } => "AgentRequest",
Event::AcpClientConnected { .. } => "AcpClientConnected",
Event::AcpClientDisconnected { .. } => "AcpClientDisconnected",
Event::AcpClientSessionOpened { .. } => "AcpClientSessionOpened",
Event::AcpClientSessionRouted { .. } => "AcpClientSessionRouted",
Event::MessagesListed { .. } => "MessagesListed",
Event::MessageStarted { .. } => "MessageStarted",
Event::MessageCompleted { .. } => "MessageCompleted",
Event::MessageFailed { .. } => "MessageFailed",
Event::ConnectorCreated { .. } => "ConnectorCreated",
Event::ConnectorRemoved { .. } => "ConnectorRemoved",
Event::ConnectorStateChanged { .. } => "ConnectorStateChanged",
Event::Connected => "Connected",
Event::Disconnected => "Disconnected",
Event::Error { .. } => "Error",
Event::InspectorSnapshot { .. } => "InspectorSnapshot",
Event::InspectorNodeRegistered { .. } => "InspectorNodeRegistered",
Event::InspectorNodeRemoved { .. } => "InspectorNodeRemoved",
Event::InspectorStateChanged { .. } => "InspectorStateChanged",
Event::InspectorPropertiesUpdated { .. } => "InspectorPropertiesUpdated",
Event::SessionRegistered { .. } => "SessionRegistered",
Event::SystemTaskStatusChanged { .. } => "SystemTaskStatusChanged",
}
}
// ---------------------------------------------------------------------------
// SessionShare trait implementation
// ---------------------------------------------------------------------------
#[async_trait::async_trait]
impl dirigent_protocol::sharing::SessionShare for MatrixSessionShare {
fn summary(&self) -> dirigent_protocol::sharing::ShareSummary {
dirigent_protocol::sharing::ShareSummary {
id: format!("matrix:{}:{}", self.connector_id, self.session_id),
connector_id: self.connector_id.clone(),
session_id: self.session_id.clone(),
backend: "matrix".to_string(),
destination: self.room_id.clone(),
active: self.is_active.try_read().map(|g| *g).unwrap_or(false),
}
}
fn is_active(&self) -> bool {
self.is_active.try_read().map(|g| *g).unwrap_or(false)
}
async fn shutdown(&self) {
// Delegate to the existing shutdown method (same implementation)
let tx = self.shutdown_tx.write().await.take();
if let Some(tx) = tx {
let _ = tx.send(());
}
}
}
// ---------------------------------------------------------------------------
// SessionStream trait implementation (Phase 4 migration, Task 18)
// ---------------------------------------------------------------------------
//
// Dual-impl: `MatrixSessionShare` keeps its bi-directional `SessionShare`
// impl (room management, `inject_message`) while also gaining a
// uni-directional `SessionStream` impl so the `StreamRegistry` can drive it
// via the central `SharingBus`.
//
// When driven as a stream, the caller (factory) is expected to construct
// the share via `new_for_stream(..)` so a Room handle is stored on the
// struct. `on_event` then translates bus events back to the legacy
// `handle_event` dispatcher, preserving the accumulator state across
// calls via the shared `Arc<Mutex<MessageAccumulator>>`.
#[async_trait::async_trait]
impl dirigent_protocol::streaming::SessionStream for MatrixSessionShare {
fn summary(&self) -> dirigent_protocol::streaming::StreamSummary {
dirigent_protocol::streaming::StreamSummary {
name: format!("{}:{}", self.connector_id, self.session_id),
kind: dirigent_protocol::streaming::StreamKind::Matrix,
target: format!("matrix:{}", self.room_id),
active_since: self.active_since,
}
}
fn scope(&self) -> dirigent_protocol::streaming::StreamScope {
dirigent_protocol::streaming::StreamScope::Session {
scroll_id: self.scroll_id,
}
}
async fn on_event(
&self,
event: &dirigent_protocol::streaming::BusEvent,
) -> dirigent_protocol::streaming::StreamOutcome {
// If we were started via the legacy `start()` path, the Room
// handle lives inside the forwarder task and this method has no
// way to deliver. Treat that as a deliberate skip rather than a
// transport failure.
let room = match &self.room_for_stream {
Some(r) => r,
None => return dirigent_protocol::streaming::StreamOutcome::Skipped,
};
let mut acc = self.accumulator.lock().await;
handle_event(
&*event.event,
&self.connector_id,
&self.session_id,
room,
&mut *acc,
)
.await;
dirigent_protocol::streaming::StreamOutcome::Ok
}
async fn shutdown(&self) {
// Delegate to the bi-directional shutdown; both impls share the
// same underlying oneshot + is_active signal.
let tx = self.shutdown_tx.write().await.take();
if let Some(tx) = tx {
let _ = tx.send(());
}
*self.is_active.write().await = false;
}
}