pub mod manager;
pub use manager::Session;
use crate::turso::{self, IntoParams, Row, TxGuard, Value, params};
use crate::{ChatMessage, ChatRole, Reasoning, ToolCall};
use anyhow::{Result, anyhow};
use chrono::{DateTime, Utc};
pub const SUMMARIZATION_THRESHOLD: usize = 65_000;
pub const PREVIOUS_CONVERSATION_SUMMARY_PREFIX: &str = "Previous conversation summary:\n\n";
#[must_use]
pub fn estimate_tokens(messages: &[ChatMessage]) -> usize {
messages
.iter()
.map(|m| m.content.len().div_ceil(4) + 4)
.sum()
}
crate::define_store! {
pub static SESSIONS: SessionStore,
db_name = "sessions",
schema = SCHEMA,
expect = "SESSIONS not initialized",
}
const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_key TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_key_id ON sessions(session_key, id);
CREATE TABLE IF NOT EXISTS session_metadata (
session_key TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
last_activity TEXT NOT NULL
);";
crate::columns! {
SESSION_MESSAGE_COLUMNS [SM] {
ROLE => "role",
CONTENT => "content",
}
}
crate::columns! {
SESSION_LIST_COLUMNS [SL] {
SESSION_KEY => "sm.session_key",
LAST_ACTIVITY => "sm.last_activity",
MESSAGE_COUNT => "COUNT(s.id)",
}
}
pub(crate) const TRANSIENT_SESSION_PREFIXES: &[&str] =
&["ticket_", "ask_", "maintainer_", "discovery_"];
#[derive(Debug, Clone)]
pub(crate) struct SessionMetadata {
pub key: String,
pub last_activity: DateTime<Utc>,
pub message_count: usize,
}
#[must_use]
fn parse_ts_or_now(s: &str, label: &str) -> DateTime<Utc> {
turso::parse_utc_timestamp(s).unwrap_or_else(|e| {
tracing::warn!(
field = %label,
value = %s,
error = %e,
"Failed to parse timestamp {label}, falling back to Utc::now()",
);
Utc::now()
})
}
fn session_metadata_from_row(key: &str, activity_str: &str, count: i64) -> SessionMetadata {
SessionMetadata {
key: key.to_string(),
last_activity: parse_ts_or_now(activity_str, "last_activity"),
message_count: usize::try_from(count).unwrap_or(0),
}
}
async fn insert_messages_in_transaction(
tx: &TxGuard<'_>,
session_key: &str,
messages: &[ChatMessage],
) -> Result<()> {
let now = turso::now();
for msg in messages {
tx.execute(
"INSERT INTO sessions (session_key, role, content, created_at) VALUES (?1, ?2, ?3, ?4)",
params![
session_key,
msg.role.to_string(),
msg.content.clone(),
now.clone()
],
)
.await?;
}
tx.execute(
"INSERT INTO session_metadata (session_key, created_at, last_activity) \
VALUES (?1, ?2, ?3) \
ON CONFLICT(session_key) DO UPDATE SET \
last_activity = excluded.last_activity",
params![session_key, now.clone(), now],
)
.await?;
Ok(())
}
async fn query_map_collect<T, E>(
conn: &turso::Connection,
sql: &str,
params: impl IntoParams + Send + 'static,
row_parser: impl FnMut(&Row) -> std::result::Result<T, E> + Send + 'static,
warn_context: &str,
session_key: Option<&str>,
) -> Vec<T>
where
T: Send + 'static,
E: std::fmt::Display + Send + Sync + 'static,
{
let rows = match conn.query_map(sql, params, row_parser).await {
Ok(rows) => rows,
Err(e) => {
tracing::warn!(error = %e, session_key, "{warn_context}: query failed, returning empty");
return Vec::new();
}
};
rows.into_iter()
.filter_map(|r| match r {
Ok(val) => Some(val),
Err(e) => {
tracing::warn!(error = %e, session_key, "{warn_context}: row decode failed, skipping");
None
}
})
.collect()
}
impl SessionStore {
pub(crate) async fn load(&self, session_key: &str) -> Vec<ChatMessage> {
query_map_collect(
&self.conn,
&format!("SELECT {SESSION_MESSAGE_COLUMNS} FROM sessions WHERE session_key = ?1 ORDER BY id ASC"),
params![session_key],
|row| {
Ok::<_, anyhow::Error>(ChatMessage {
role: row.get::<String>(COL_SM_ROLE)?.parse::<ChatRole>().map_err(|e| anyhow!(e))?,
content: row.get(COL_SM_CONTENT)?,
})
},
"load session",
Some(session_key),
)
.await
}
pub(crate) async fn append(&self, session_key: &str, message: &ChatMessage) -> Result<()> {
self.batch_append(session_key, std::slice::from_ref(message))
.await
}
async fn append_messages(
&self,
session_key: &str,
messages: &[ChatMessage],
replace: bool,
) -> Result<()> {
let tx = self.conn.begin_tx().await?;
if replace {
tx.execute(
"DELETE FROM sessions WHERE session_key = ?1",
params![session_key],
)
.await?;
}
insert_messages_in_transaction(&tx, session_key, messages).await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn batch_append(
&self,
session_key: &str,
messages: &[ChatMessage],
) -> Result<()> {
self.append_messages(session_key, messages, false).await
}
pub(crate) async fn replace_messages(
&self,
session_key: &str,
messages: &[ChatMessage],
) -> Result<()> {
self.append_messages(session_key, messages, true).await
}
pub(crate) async fn delete(&self, session_key: &str) -> Result<bool> {
let tx = self.conn.begin_tx().await?;
let deleted = tx
.execute(
"DELETE FROM sessions WHERE session_key = ?1",
params![session_key],
)
.await?;
tx.execute(
"DELETE FROM session_metadata WHERE session_key = ?1",
params![session_key],
)
.await?;
tx.commit().await?;
Ok(deleted > 0)
}
pub(crate) async fn list_sessions_with_metadata(&self) -> Vec<SessionMetadata> {
query_map_collect(
&self.conn,
&format!(
"SELECT {SESSION_LIST_COLUMNS} \
FROM session_metadata sm \
LEFT JOIN sessions s ON s.session_key = sm.session_key \
GROUP BY sm.session_key \
ORDER BY sm.last_activity DESC",
),
(),
|row| {
Ok::<_, anyhow::Error>(session_metadata_from_row(
&row.get::<String>(COL_SL_SESSION_KEY)?,
&row.get::<String>(COL_SL_LAST_ACTIVITY)?,
row.get::<i64>(COL_SL_MESSAGE_COUNT)?,
))
},
"list sessions",
None,
)
.await
}
}
pub async fn cleanup_old_transient_sessions(cutoff: &str) -> Result<u64> {
let session_store = store();
let tx = session_store.conn.begin_tx().await?;
let likes = TRANSIENT_SESSION_PREFIXES
.iter()
.map(|_| "session_key LIKE ?")
.collect::<Vec<_>>()
.join(" OR ");
let prefix_patterns = format!("({likes})");
let build_params = {
let mut p = vec![Value::Text(cutoff.to_string())];
p.extend(
TRANSIENT_SESSION_PREFIXES
.iter()
.map(|prefix| Value::Text(format!("{prefix}%"))),
);
p
};
tx.execute(
&format!(
"DELETE FROM sessions WHERE session_key IN ( \
SELECT session_key FROM session_metadata \
WHERE last_activity < ? AND {prefix_patterns})"
),
build_params.clone(),
)
.await?;
let deleted = tx
.execute(
&format!("DELETE FROM session_metadata WHERE last_activity < ? AND {prefix_patterns}"),
build_params.clone(),
)
.await?;
tx.commit().await?;
Ok(deleted)
}
#[must_use]
pub fn direct_session_key(channel: &str, user_name: &str, role: &str, ws_name: &str) -> String {
format!("{channel}_{user_name}_{role}_{ws_name}")
}
#[must_use]
pub(crate) fn ticket_session_key(ticket_id: &str, role: &str) -> String {
format!("ticket_{ticket_id}_{role}")
}
#[must_use]
pub fn manager_session_key(ws_name: &str) -> String {
format!("manager_{ws_name}")
}
#[must_use]
pub fn session_key(channel: &str, user_name: &str, role: &str, ws_name: &str) -> String {
if role == "manager" {
manager_session_key(ws_name)
} else {
direct_session_key(channel, user_name, role, ws_name)
}
}
#[must_use]
pub(crate) fn maintainer_session_key(ws_name: &str) -> String {
format!("maintainer_{}_{}", ws_name, crate::generate_suffix())
}
#[must_use]
pub(crate) fn ask_session_key(ws_name: &str, role: &str) -> String {
format!("ask_{}_{}_{}", ws_name, role, crate::generate_suffix())
}
#[must_use]
pub(crate) fn discovery_session_key(ws_name: &str, role: &str) -> String {
format!(
"discovery_{}_{}_{}",
ws_name,
role,
crate::generate_suffix()
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static TEST_ID: AtomicU32 = AtomicU32::new(0);
fn unique_key() -> String {
format!("s{}", TEST_ID.fetch_add(1, Ordering::Relaxed))
}
#[tokio::test]
async fn session_store_create_and_load() {
crate::util::test::init_test_stores().await;
let k = unique_key();
store()
.append(&k, &ChatMessage::user("hello"))
.await
.unwrap();
let msgs = store().load(&k).await;
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].content, "hello");
}
#[tokio::test]
async fn session_store_replace_messages() {
crate::util::test::init_test_stores().await;
let k = unique_key();
store().append(&k, &ChatMessage::user("old")).await.unwrap();
store()
.replace_messages(&k, &[ChatMessage::user("new")])
.await
.unwrap();
let msgs = store().load(&k).await;
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].content, "new");
}
#[tokio::test]
async fn session_store_delete() {
crate::util::test::init_test_stores().await;
let k = unique_key();
store().append(&k, &ChatMessage::user("a")).await.unwrap();
assert!(store().delete(&k).await.unwrap());
assert!(!store().delete(&k).await.unwrap());
}
}
#[cfg(test)]
mod transient_prefix_tests {
use super::*;
const SAFE_CHANNELS: &[&str] = &["telegram", "gui"];
#[test]
fn forward_no_collision_with_user_facing_sessions() {
for prefix in TRANSIENT_SESSION_PREFIXES {
let manager_key = manager_session_key("test-ws");
assert!(
!manager_key.starts_with(prefix),
"MANAGER SESSION KEY COLLISION: \
prefix='{prefix}' matches key='{manager_key}'. \
Fix: remove '{prefix}' from TRANSIENT_SESSION_PREFIXES \
or change the manager_session_key pattern.",
);
for channel in SAFE_CHANNELS {
let key = direct_session_key(channel, "testuser", "analyst", "test-ws");
assert!(
!key.starts_with(prefix),
"DIRECT SESSION KEY COLLISION: prefix='{prefix}' \
matches key='{key}' (channel='{channel}'). \
Fix: remove '{prefix}' from TRANSIENT_SESSION_PREFIXES \
or change the session key pattern.",
);
}
}
}
fn assert_transient_key(key: &str, expected_prefix: &str, builder_expr: &str) {
assert!(
key.starts_with(expected_prefix),
"{builder_expr} = '{key}' does not start with '{expected_prefix}'.\n\
Fix: update {builder_expr} to produce keys starting with '{expected_prefix}'.",
);
assert!(
TRANSIENT_SESSION_PREFIXES.contains(&expected_prefix),
"TRANSIENT_SESSION_PREFIXES is missing '{expected_prefix}' — \
{builder_expr} sessions will never be cleaned up.\n\
Fix: add \"{expected_prefix}\" to TRANSIENT_SESSION_PREFIXES.",
);
}
#[test]
fn reverse_transient_builders_use_registered_prefixes() {
assert_transient_key(
&ticket_session_key("abc123", "analyst"),
"ticket_",
"ticket_session_key('abc123', 'analyst')",
);
assert_transient_key(
&ask_session_key("ws", "coder"),
"ask_",
"ask_session_key('ws', 'coder')",
);
assert_transient_key(
&maintainer_session_key("ws"),
"maintainer_",
"maintainer_session_key('ws')",
);
assert_transient_key(
&discovery_session_key("ws", "analyst"),
"discovery_",
"discovery_session_key('ws', 'analyst')",
);
}
#[test]
fn session_key_manager_dispatch() {
let key = session_key("telegram", "alice", "manager", "my-workspace");
assert_eq!(key, "manager_my-workspace");
}
#[test]
fn session_key_non_manager_dispatch() {
let key = session_key("discord", "bob", "engineer", "my-workspace");
assert_eq!(key, "discord_bob_engineer_my-workspace");
}
#[test]
fn session_key_lowercase_manager() {
let key = session_key("gui", "carol", "Manager", "ws");
assert_ne!(key, "manager_ws", "capital-M 'Manager' should NOT match");
assert_eq!(key, "gui_carol_Manager_ws");
}
}
#[test]
fn parse_ts_or_now_invalid_fallback() {
let before = Utc::now();
let ts = parse_ts_or_now("garbage-input", "test_invalid");
let after = Utc::now();
assert!(
ts >= before - chrono::Duration::seconds(1),
"fallback ts {ts} should not be before {before}",
);
assert!(
ts <= after + chrono::Duration::seconds(1),
"fallback ts {ts} should not be after {after}",
);
}
#[derive(Debug)]
pub(crate) enum DecodedNativeHistoryMessage {
Assistant {
content: Option<String>,
tool_calls: Option<Vec<ToolCall>>,
reasoning: Option<Reasoning>,
},
ToolResult {
tool_call_id: Option<String>,
content: String,
},
}
pub(crate) fn decode_native_history_message(
message: &ChatMessage,
) -> Option<DecodedNativeHistoryMessage> {
let parsed = serde_json::from_str::<serde_json::Value>(&message.content).ok();
if message.role == ChatRole::Assistant
&& let Some(value) = parsed.as_ref()
{
let content = value
.get("content")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string);
let (r, rc, rd) =
crate::providers::reasoning_roundtrip::json_lossless_assistant_reasoning_fields(value);
let reasoning = Reasoning::from_optional_parts(r, rc, rd);
let tool_calls = value
.get("tool_calls")
.and_then(|v| serde_json::from_value::<Vec<ToolCall>>(v.clone()).ok())
.map(|mut parsed_calls| {
for call in &mut parsed_calls {
if let Some(s) = call.arguments.as_str()
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(s)
{
call.arguments = v;
}
}
parsed_calls
});
return Some(DecodedNativeHistoryMessage::Assistant {
content,
tool_calls,
reasoning,
});
}
if message.role == ChatRole::Tool
&& let Some(value) = parsed.as_ref()
{
return Some(DecodedNativeHistoryMessage::ToolResult {
tool_call_id: value
.get("tool_call_id")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string),
content: value
.get("content")
.and_then(serde_json::Value::as_str)
.map_or_else(|| message.content.clone(), ToString::to_string),
});
}
None
}