mod support;
use support::{
conversation_not_found, namespace_mismatch, normalized_terms, now_ms, require_namespace,
retrieval_store_error, validate_memory, validate_sources, validate_summary,
validate_transcript_messages,
};
pub(crate) use support::{is_transient_context, semantic_memory_message, summary_message};
use std::{
collections::BTreeMap,
future::Future,
num::{NonZeroU16, NonZeroU64},
pin::Pin,
sync::{Arc, Mutex},
};
use runifold_core::{CheckpointId, Usage};
use runifold_model::Message;
use runifold_retrieval::RetrievalContext;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use crate::{AgentError, AgentOutcome};
const MAX_NAMESPACE_BYTES: usize = 128;
const MAX_SUMMARY_BYTES: usize = 262_144;
const MAX_MEMORY_BYTES: usize = 262_144;
pub(crate) const TRANSIENT_CONTEXT_METADATA: &str = "runifold.context.transient";
#[cfg(not(target_arch = "wasm32"))]
pub type ConversationStoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
#[cfg(target_arch = "wasm32")]
pub type ConversationStoreFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct ConversationId(CheckpointId);
impl ConversationId {
pub fn new() -> Self {
Self(CheckpointId::new())
}
pub const fn from_checkpoint_id(id: CheckpointId) -> Self {
Self(id)
}
pub const fn as_checkpoint_id(self) -> CheckpointId {
self.0
}
}
impl Default for ConversationId {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct MemoryNamespace(String);
impl MemoryNamespace {
pub fn parse(value: impl Into<String>) -> Result<Self, ConversationStoreError> {
let value = value.into();
if value.is_empty()
|| value.len() > MAX_NAMESPACE_BYTES
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
{
return Err(ConversationStoreError::invalid_input(
"memory namespace must contain 1..=128 portable ASCII characters",
));
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct ConversationVersion(u64);
impl ConversationVersion {
pub const fn new(value: u64) -> Self {
Self(value)
}
pub const fn get(self) -> u64 {
self.0
}
fn next(self) -> Result<Self, ConversationStoreError> {
self.0.checked_add(1).map(Self).ok_or_else(|| {
ConversationStoreError::new(
ConversationStoreErrorKind::Conflict,
"conversation version overflow",
)
})
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct ConversationSequence(NonZeroU64);
impl ConversationSequence {
pub fn new(value: u64) -> Result<Self, ConversationStoreError> {
NonZeroU64::new(value).map(Self).ok_or_else(|| {
ConversationStoreError::invalid_input("conversation sequence must be positive")
})
}
pub const fn get(self) -> u64 {
self.0.get()
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ConversationTranscriptEntry {
pub sequence: ConversationSequence,
pub message: Message,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ConversationSummary {
pub summary_id: CheckpointId,
pub content: String,
pub through_sequence: ConversationSequence,
pub transcript_version: ConversationVersion,
pub created_at_ms: u64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ConversationView {
pub conversation_id: ConversationId,
pub namespace: MemoryNamespace,
pub version: ConversationVersion,
pub summary: Option<ConversationSummary>,
pub summary_buffer: Vec<ConversationTranscriptEntry>,
pub summary_backlog: u64,
pub window: Vec<ConversationTranscriptEntry>,
}
impl ConversationView {
pub fn requires_summary(&self) -> bool {
!self.summary_buffer.is_empty()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConversationWindow(NonZeroU16);
impl ConversationWindow {
pub fn new(value: u16) -> Result<Self, ConversationStoreError> {
NonZeroU16::new(value)
.filter(|value| value.get() <= 4_096)
.map(Self)
.ok_or_else(|| {
ConversationStoreError::invalid_input("conversation window must be in 1..=4096")
})
}
pub const fn get(self) -> u16 {
self.0.get()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConversationSummaryBatch(NonZeroU16);
impl ConversationSummaryBatch {
pub fn new(value: u16) -> Result<Self, ConversationStoreError> {
NonZeroU16::new(value)
.filter(|value| value.get() <= 4_096)
.map(Self)
.ok_or_else(|| {
ConversationStoreError::invalid_input(
"conversation summary batch must be in 1..=4096",
)
})
}
pub const fn get(self) -> u16 {
self.0.get()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConversationContextPolicy {
pub window: ConversationWindow,
pub summary_batch: ConversationSummaryBatch,
pub semantic_memory_limit: Option<NonZeroU16>,
}
impl ConversationContextPolicy {
pub const fn new(window: ConversationWindow) -> Self {
Self {
window,
summary_batch: ConversationSummaryBatch(window.0),
semantic_memory_limit: None,
}
}
#[must_use]
pub const fn with_summary_batch(mut self, summary_batch: ConversationSummaryBatch) -> Self {
self.summary_batch = summary_batch;
self
}
pub fn with_semantic_memory(mut self, limit: u16) -> Result<Self, ConversationStoreError> {
self.semantic_memory_limit = NonZeroU16::new(limit).filter(|value| value.get() <= 256);
if self.semantic_memory_limit.is_none() {
return Err(ConversationStoreError::invalid_input(
"semantic memory context limit must be in 1..=256",
));
}
Ok(self)
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ConversationAppend {
pub conversation_id: ConversationId,
pub expected_version: ConversationVersion,
pub messages: Vec<Message>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ConversationSummaryCommit {
pub conversation_id: ConversationId,
pub expected_version: ConversationVersion,
pub through_sequence: ConversationSequence,
pub content: String,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct SemanticMemoryId(CheckpointId);
impl SemanticMemoryId {
pub fn new() -> Self {
Self(CheckpointId::new())
}
pub const fn from_checkpoint_id(id: CheckpointId) -> Self {
Self(id)
}
pub const fn as_checkpoint_id(self) -> CheckpointId {
self.0
}
}
impl Default for SemanticMemoryId {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct SemanticMemorySource {
pub conversation_id: ConversationId,
pub from_sequence: ConversationSequence,
pub through_sequence: ConversationSequence,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct SemanticMemory {
pub memory_id: SemanticMemoryId,
pub namespace: MemoryNamespace,
pub content: String,
pub sources: Vec<SemanticMemorySource>,
pub metadata: BTreeMap<String, Value>,
pub revision: u64,
pub created_at_ms: u64,
pub updated_at_ms: u64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct SemanticMemoryUpsert {
pub memory_id: SemanticMemoryId,
pub namespace: MemoryNamespace,
pub content: String,
pub sources: Vec<SemanticMemorySource>,
pub metadata: BTreeMap<String, Value>,
pub expected_revision: Option<u64>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SemanticMemoryUpsertOutcome {
pub memory: SemanticMemory,
pub usage: Usage,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SemanticMemoryQuery {
pub namespace: MemoryNamespace,
pub text: String,
pub limit: NonZeroU16,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SemanticMemorySearchOutcome {
pub memories: Vec<SemanticMemory>,
pub usage: Usage,
}
impl SemanticMemoryQuery {
pub fn new(
namespace: MemoryNamespace,
text: impl Into<String>,
limit: u16,
) -> Result<Self, ConversationStoreError> {
let text = text.into();
let Some(limit) = NonZeroU16::new(limit).filter(|value| value.get() <= 256) else {
return Err(ConversationStoreError::invalid_input(
"semantic memory query requires text and a limit in 1..=256",
));
};
if text.trim().is_empty() {
return Err(ConversationStoreError::invalid_input(
"semantic memory query requires text and a limit in 1..=256",
));
}
Ok(Self {
namespace,
text,
limit,
})
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ConversationCreateOutcome {
Created,
Duplicate,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AgentConversationOutcome {
pub outcome: AgentOutcome,
pub conversation_version: ConversationVersion,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum AgentConversationError {
#[error("conversation store failed: {0}")]
Store(#[from] ConversationStoreError),
#[error(
"conversation `{conversation_id:?}` requires summarization of {buffered_entries} buffered entries"
)]
SummaryRequired {
conversation_id: ConversationId,
buffered_entries: u64,
},
#[error(
"conversation `{conversation_id:?}` still has {remaining_entries} entries requiring summarization after the configured pass limit"
)]
SummaryPassLimitExceeded {
conversation_id: ConversationId,
remaining_entries: u64,
},
#[error("conversation summarization failed: {0}")]
Summarization(#[from] super::ConversationSummarizerError),
#[error("conversational Agent execution failed: {0}")]
Run(#[source] AgentError),
#[error("Agent completed but conversation commit failed: {source}")]
Commit {
#[source]
source: ConversationStoreError,
outcome: Box<AgentOutcome>,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ConversationStoreErrorKind {
InvalidInput,
NotFound,
Conflict,
NamespaceMismatch,
Storage,
}
#[derive(Clone, Debug, Error, Eq, PartialEq)]
#[error("{kind:?}: {message}")]
pub struct ConversationStoreError {
pub kind: ConversationStoreErrorKind,
pub message: String,
}
impl ConversationStoreError {
pub fn new(kind: ConversationStoreErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
fn invalid_input(message: impl Into<String>) -> Self {
Self::new(ConversationStoreErrorKind::InvalidInput, message)
}
}
pub trait ConversationStore: Send + Sync {
fn create(
&self,
conversation_id: ConversationId,
namespace: MemoryNamespace,
) -> ConversationStoreFuture<'_, Result<ConversationCreateOutcome, ConversationStoreError>>;
fn load_view(
&self,
conversation_id: ConversationId,
namespace: MemoryNamespace,
window: ConversationWindow,
summary_batch: ConversationSummaryBatch,
) -> ConversationStoreFuture<'_, Result<ConversationView, ConversationStoreError>>;
fn list_transcript(
&self,
conversation_id: ConversationId,
namespace: MemoryNamespace,
after: Option<ConversationSequence>,
limit: ConversationWindow,
) -> ConversationStoreFuture<'_, Result<Vec<ConversationTranscriptEntry>, ConversationStoreError>>;
fn append(
&self,
namespace: MemoryNamespace,
command: ConversationAppend,
) -> ConversationStoreFuture<'_, Result<ConversationVersion, ConversationStoreError>>;
fn commit_summary(
&self,
namespace: MemoryNamespace,
command: ConversationSummaryCommit,
) -> ConversationStoreFuture<'_, Result<ConversationSummary, ConversationStoreError>>;
fn upsert_memory(
&self,
command: SemanticMemoryUpsert,
) -> ConversationStoreFuture<'_, Result<SemanticMemory, ConversationStoreError>>;
fn upsert_memory_scoped(
&self,
command: SemanticMemoryUpsert,
context: RetrievalContext,
) -> ConversationStoreFuture<'_, Result<SemanticMemoryUpsertOutcome, ConversationStoreError>>
{
Box::pin(async move {
context
.check_live()
.map_err(|error| retrieval_store_error(&error))?;
let memory = self.upsert_memory(command).await?;
Ok(SemanticMemoryUpsertOutcome {
memory,
usage: Usage::default(),
})
})
}
fn search_memory(
&self,
query: SemanticMemoryQuery,
) -> ConversationStoreFuture<'_, Result<Vec<SemanticMemory>, ConversationStoreError>>;
fn search_memory_scoped(
&self,
query: SemanticMemoryQuery,
context: RetrievalContext,
) -> ConversationStoreFuture<'_, Result<SemanticMemorySearchOutcome, ConversationStoreError>>
{
Box::pin(async move {
context
.check_live()
.map_err(|error| retrieval_store_error(&error))?;
let memories = self.search_memory(query).await?;
Ok(SemanticMemorySearchOutcome {
memories,
usage: Usage::default(),
})
})
}
}
#[derive(Clone, Debug, Default)]
pub struct InMemoryConversationStore {
state: Arc<Mutex<ConversationState>>,
}
#[derive(Debug, Default)]
struct ConversationState {
conversations: BTreeMap<ConversationId, StoredConversation>,
memories: BTreeMap<SemanticMemoryId, SemanticMemory>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct StoredConversation {
namespace: MemoryNamespace,
version: ConversationVersion,
transcript: Vec<ConversationTranscriptEntry>,
summary: Option<ConversationSummary>,
}
const PERSISTENT_SNAPSHOT_VERSION: u32 = 1;
#[derive(Deserialize, Serialize)]
struct PersistentConversationSnapshot {
version: u32,
conversations: Vec<(ConversationId, StoredConversation)>,
memories: Vec<(SemanticMemoryId, SemanticMemory)>,
}
impl InMemoryConversationStore {
pub fn new() -> Self {
Self::default()
}
#[doc(hidden)]
pub fn export_persistent_snapshot(&self) -> Result<Vec<u8>, ConversationStoreError> {
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let snapshot = PersistentConversationSnapshot {
version: PERSISTENT_SNAPSHOT_VERSION,
conversations: state
.conversations
.iter()
.map(|(id, conversation)| (*id, conversation.clone()))
.collect(),
memories: state
.memories
.iter()
.map(|(id, memory)| (*id, memory.clone()))
.collect(),
};
serde_json::to_vec(&snapshot).map_err(|error| {
ConversationStoreError::new(
ConversationStoreErrorKind::Storage,
format!("conversation snapshot encoding failed: {error}"),
)
})
}
#[doc(hidden)]
pub fn from_persistent_snapshot(encoded: &[u8]) -> Result<Self, ConversationStoreError> {
let snapshot: PersistentConversationSnapshot =
serde_json::from_slice(encoded).map_err(|error| {
ConversationStoreError::new(
ConversationStoreErrorKind::Storage,
format!("conversation snapshot decoding failed: {error}"),
)
})?;
if snapshot.version != PERSISTENT_SNAPSHOT_VERSION {
return Err(ConversationStoreError::new(
ConversationStoreErrorKind::Storage,
format!(
"unsupported conversation snapshot version {}",
snapshot.version
),
));
}
Ok(Self {
state: Arc::new(Mutex::new(ConversationState {
conversations: snapshot.conversations.into_iter().collect(),
memories: snapshot.memories.into_iter().collect(),
})),
})
}
}
impl ConversationStore for InMemoryConversationStore {
fn create(
&self,
conversation_id: ConversationId,
namespace: MemoryNamespace,
) -> ConversationStoreFuture<'_, Result<ConversationCreateOutcome, ConversationStoreError>>
{
Box::pin(async move {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(existing) = state.conversations.get(&conversation_id) {
return if existing.namespace == namespace {
Ok(ConversationCreateOutcome::Duplicate)
} else {
Err(namespace_mismatch())
};
}
state.conversations.insert(
conversation_id,
StoredConversation {
namespace,
version: ConversationVersion::default(),
transcript: Vec::new(),
summary: None,
},
);
Ok(ConversationCreateOutcome::Created)
})
}
fn load_view(
&self,
conversation_id: ConversationId,
namespace: MemoryNamespace,
window: ConversationWindow,
summary_batch: ConversationSummaryBatch,
) -> ConversationStoreFuture<'_, Result<ConversationView, ConversationStoreError>> {
Box::pin(async move {
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let stored = state
.conversations
.get(&conversation_id)
.ok_or_else(conversation_not_found)?;
require_namespace(&stored.namespace, &namespace)?;
let summarized_through = stored
.summary
.as_ref()
.map_or(0, |summary| summary.through_sequence.get());
let unsummarized = stored
.transcript
.iter()
.filter(|entry| entry.sequence.get() > summarized_through)
.cloned()
.collect::<Vec<_>>();
let window_start = unsummarized.len().saturating_sub(usize::from(window.get()));
let summary_end = window_start.min(usize::from(summary_batch.get()));
Ok(ConversationView {
conversation_id,
namespace,
version: stored.version,
summary: stored.summary.clone(),
summary_buffer: unsummarized[..summary_end].to_vec(),
summary_backlog: u64::try_from(window_start.saturating_sub(summary_end))
.unwrap_or(u64::MAX),
window: unsummarized[window_start..].to_vec(),
})
})
}
fn append(
&self,
namespace: MemoryNamespace,
command: ConversationAppend,
) -> ConversationStoreFuture<'_, Result<ConversationVersion, ConversationStoreError>> {
Box::pin(async move {
validate_transcript_messages(&command.messages)?;
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let stored = state
.conversations
.get_mut(&command.conversation_id)
.ok_or_else(conversation_not_found)?;
require_namespace(&stored.namespace, &namespace)?;
if stored.version != command.expected_version {
return Err(ConversationStoreError::new(
ConversationStoreErrorKind::Conflict,
"conversation transcript version precondition failed",
));
}
let next_version = stored.version.next()?;
for message in command.messages {
let sequence = u64::try_from(stored.transcript.len())
.ok()
.and_then(|value| value.checked_add(1))
.and_then(NonZeroU64::new)
.map(ConversationSequence)
.ok_or_else(|| {
ConversationStoreError::new(
ConversationStoreErrorKind::Conflict,
"conversation transcript sequence overflow",
)
})?;
stored
.transcript
.push(ConversationTranscriptEntry { sequence, message });
}
stored.version = next_version;
Ok(next_version)
})
}
fn list_transcript(
&self,
conversation_id: ConversationId,
namespace: MemoryNamespace,
after: Option<ConversationSequence>,
limit: ConversationWindow,
) -> ConversationStoreFuture<'_, Result<Vec<ConversationTranscriptEntry>, ConversationStoreError>>
{
Box::pin(async move {
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let stored = state
.conversations
.get(&conversation_id)
.ok_or_else(conversation_not_found)?;
require_namespace(&stored.namespace, &namespace)?;
let after = after.map_or(0, ConversationSequence::get);
Ok(stored
.transcript
.iter()
.filter(|entry| entry.sequence.get() > after)
.take(usize::from(limit.get()))
.cloned()
.collect())
})
}
fn commit_summary(
&self,
namespace: MemoryNamespace,
command: ConversationSummaryCommit,
) -> ConversationStoreFuture<'_, Result<ConversationSummary, ConversationStoreError>> {
Box::pin(async move {
validate_summary(&command.content)?;
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let stored = state
.conversations
.get_mut(&command.conversation_id)
.ok_or_else(conversation_not_found)?;
require_namespace(&stored.namespace, &namespace)?;
if stored.version != command.expected_version {
return Err(ConversationStoreError::new(
ConversationStoreErrorKind::Conflict,
"conversation summary version precondition failed",
));
}
let last_sequence = u64::try_from(stored.transcript.len()).unwrap_or(u64::MAX);
let previous = stored
.summary
.as_ref()
.map_or(0, |summary| summary.through_sequence.get());
if command.through_sequence.get() <= previous
|| command.through_sequence.get() > last_sequence
{
return Err(ConversationStoreError::invalid_input(
"conversation summary must cover a newer existing transcript prefix",
));
}
let summary = ConversationSummary {
summary_id: CheckpointId::new(),
content: command.content,
through_sequence: command.through_sequence,
transcript_version: stored.version,
created_at_ms: now_ms(),
};
stored.summary = Some(summary.clone());
Ok(summary)
})
}
fn upsert_memory(
&self,
command: SemanticMemoryUpsert,
) -> ConversationStoreFuture<'_, Result<SemanticMemory, ConversationStoreError>> {
Box::pin(async move {
validate_memory(&command)?;
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let current = state.memories.get(&command.memory_id);
let revision = match (current, command.expected_revision) {
(None, None) => 0,
(Some(current), Some(expected))
if current.revision == expected && current.namespace == command.namespace =>
{
expected.checked_add(1).ok_or_else(|| {
ConversationStoreError::new(
ConversationStoreErrorKind::Conflict,
"semantic memory revision overflow",
)
})?
}
(Some(current), _) if current.namespace != command.namespace => {
return Err(namespace_mismatch());
}
_ => {
return Err(ConversationStoreError::new(
ConversationStoreErrorKind::Conflict,
"semantic memory revision precondition failed",
));
}
};
validate_sources(&state.conversations, &command)?;
let now = now_ms();
let created_at_ms = current.map_or(now, |memory| memory.created_at_ms);
let memory = SemanticMemory {
memory_id: command.memory_id,
namespace: command.namespace,
content: command.content,
sources: command.sources,
metadata: command.metadata,
revision,
created_at_ms,
updated_at_ms: now,
};
state.memories.insert(memory.memory_id, memory.clone());
Ok(memory)
})
}
fn search_memory(
&self,
query: SemanticMemoryQuery,
) -> ConversationStoreFuture<'_, Result<Vec<SemanticMemory>, ConversationStoreError>> {
Box::pin(async move {
let query_terms = normalized_terms(&query.text);
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut ranked = state
.memories
.values()
.filter(|memory| memory.namespace == query.namespace)
.filter_map(|memory| {
let terms = normalized_terms(&memory.content);
let score = query_terms.intersection(&terms).count();
(score > 0).then_some((score, memory))
})
.collect::<Vec<_>>();
ranked.sort_by(|(left_score, left), (right_score, right)| {
right_score
.cmp(left_score)
.then_with(|| right.updated_at_ms.cmp(&left.updated_at_ms))
.then_with(|| left.memory_id.cmp(&right.memory_id))
});
Ok(ranked
.into_iter()
.take(usize::from(query.limit.get()))
.map(|(_, memory)| memory.clone())
.collect())
})
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use futures_executor::block_on;
use runifold_model::{ContentPart, Message, Role};
use super::*;
fn namespace(value: &str) -> MemoryNamespace {
MemoryNamespace::parse(value).unwrap()
}
fn assistant(text: &str) -> Message {
Message::new(Role::Assistant, vec![ContentPart::text(text)]).unwrap()
}
fn transcript() -> Vec<Message> {
vec![
Message::user("u1"),
assistant("a1"),
Message::user("u2"),
assistant("a2"),
Message::user("u3"),
assistant("a3"),
]
}
#[test]
fn transcript_summary_buffer_and_window_remain_distinct() {
let store = InMemoryConversationStore::new();
let conversation_id = ConversationId::new();
let namespace = namespace("tenant.user");
block_on(store.create(conversation_id, namespace.clone())).unwrap();
let version = block_on(store.append(
namespace.clone(),
ConversationAppend {
conversation_id,
expected_version: ConversationVersion::default(),
messages: transcript(),
},
))
.unwrap();
let view = block_on(store.load_view(
conversation_id,
namespace.clone(),
ConversationWindow::new(2).unwrap(),
ConversationSummaryBatch::new(4).unwrap(),
))
.unwrap();
assert_eq!(view.summary_buffer.len(), 4);
assert_eq!(view.summary_backlog, 0);
assert_eq!(view.window.len(), 2);
assert!(view.requires_summary());
let summary = block_on(store.commit_summary(
namespace.clone(),
ConversationSummaryCommit {
conversation_id,
expected_version: version,
through_sequence: ConversationSequence::new(4).unwrap(),
content: "The first two exchanges".into(),
},
))
.unwrap();
let compacted = block_on(store.load_view(
conversation_id,
namespace.clone(),
ConversationWindow::new(2).unwrap(),
ConversationSummaryBatch::new(4).unwrap(),
))
.unwrap();
assert_eq!(compacted.summary, Some(summary));
assert!(compacted.summary_buffer.is_empty());
assert_eq!(compacted.summary_backlog, 0);
assert_eq!(compacted.window.len(), 2);
let immutable = block_on(store.list_transcript(
conversation_id,
namespace,
None,
ConversationWindow::new(16).unwrap(),
))
.unwrap();
assert_eq!(immutable.len(), 6);
assert_eq!(immutable[0].message, Message::user("u1"));
}
#[test]
fn conversation_view_bounds_summary_batch_and_reports_remaining_backlog() {
let store = InMemoryConversationStore::new();
let conversation_id = ConversationId::new();
let namespace = MemoryNamespace::parse("tenant.bounded").unwrap();
block_on(store.create(conversation_id, namespace.clone())).unwrap();
let messages = (1..=10)
.map(|sequence| Message::user(format!("message-{sequence}")))
.collect();
block_on(store.append(
namespace.clone(),
ConversationAppend {
conversation_id,
expected_version: ConversationVersion::default(),
messages,
},
))
.unwrap();
let view = block_on(store.load_view(
conversation_id,
namespace,
ConversationWindow::new(2).unwrap(),
ConversationSummaryBatch::new(3).unwrap(),
))
.unwrap();
assert_eq!(
view.summary_buffer
.iter()
.map(|entry| entry.sequence.get())
.collect::<Vec<_>>(),
vec![1, 2, 3]
);
assert_eq!(view.summary_backlog, 5);
assert_eq!(
view.window
.iter()
.map(|entry| entry.sequence.get())
.collect::<Vec<_>>(),
vec![9, 10]
);
}
#[test]
fn transcript_append_is_versioned_and_rejects_system_messages() {
let store = InMemoryConversationStore::new();
let conversation_id = ConversationId::new();
let namespace = namespace("tenant.user");
block_on(store.create(conversation_id, namespace.clone())).unwrap();
let version = block_on(store.append(
namespace.clone(),
ConversationAppend {
conversation_id,
expected_version: ConversationVersion::default(),
messages: vec![Message::user("hello")],
},
))
.unwrap();
assert_eq!(version, ConversationVersion::new(1));
let conflict = block_on(store.append(
namespace.clone(),
ConversationAppend {
conversation_id,
expected_version: ConversationVersion::default(),
messages: vec![Message::user("stale")],
},
))
.unwrap_err();
assert_eq!(conflict.kind, ConversationStoreErrorKind::Conflict);
let invalid = block_on(store.append(
namespace,
ConversationAppend {
conversation_id,
expected_version: version,
messages: vec![Message::system("do not persist policy")],
},
))
.unwrap_err();
assert_eq!(invalid.kind, ConversationStoreErrorKind::InvalidInput);
}
#[test]
fn semantic_memory_is_explicit_cross_conversation_and_provenanced() {
let store = InMemoryConversationStore::new();
let namespace = namespace("tenant.user");
let source_id = ConversationId::new();
let other_id = ConversationId::new();
block_on(store.create(source_id, namespace.clone())).unwrap();
block_on(store.create(other_id, namespace.clone())).unwrap();
block_on(store.append(
namespace.clone(),
ConversationAppend {
conversation_id: source_id,
expected_version: ConversationVersion::default(),
messages: vec![
Message::user("I prefer Rust"),
assistant("Preference recorded"),
],
},
))
.unwrap();
let memory_id = SemanticMemoryId::new();
let memory = block_on(store.upsert_memory(SemanticMemoryUpsert {
memory_id,
namespace: namespace.clone(),
content: "The user prefers Rust for systems programming".into(),
sources: vec![SemanticMemorySource {
conversation_id: source_id,
from_sequence: ConversationSequence::new(1).unwrap(),
through_sequence: ConversationSequence::new(2).unwrap(),
}],
metadata: BTreeMap::new(),
expected_revision: None,
}))
.unwrap();
assert_eq!(memory.revision, 0);
let found = block_on(store.search_memory(
SemanticMemoryQuery::new(namespace.clone(), "Rust preference", 4).unwrap(),
))
.unwrap();
assert_eq!(found, vec![memory]);
assert!(
block_on(store.list_transcript(
other_id,
namespace,
None,
ConversationWindow::new(4).unwrap(),
))
.unwrap()
.is_empty()
);
}
}