mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(engine): Phase 5 — conversation surface separated from execution
Conversation is now a UI layer, not an execution boundary. Multiple
threads can run concurrently within one conversation; threads can
outlive their originating conversation.
New types (types/conversation.rs):
- ConversationSurface: channel + user + entries + active_threads
- ConversationEntry: sender (User/Agent/System) + content + origin_thread_id
- ConversationId, EntryId (UUID newtypes)
- EntrySender enum (User, Agent{thread_id}, System)
ConversationManager (runtime/conversation.rs):
- get_or_create_conversation(channel, user) — indexed by (channel, user)
- handle_user_message() — injects into active foreground thread or spawns new
- record_thread_outcome() — adds agent/system entries, untracks completed threads
- get_conversation(), list_conversations()
This enables the key architectural insight: a user can ask "what's the
weather?" while a deployment thread is still running. Both produce entries
in the same conversation.
85 tests passing, zero clippy warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
@@ -53,10 +53,15 @@ pub use capability::policy::{PolicyDecision, PolicyEngine};
|
||||
|
||||
// ── Re-exports: runtime ───────────────────────────────────────
|
||||
|
||||
pub use runtime::conversation::ConversationManager;
|
||||
pub use runtime::manager::ThreadManager;
|
||||
pub use runtime::messaging::ThreadOutcome;
|
||||
pub use runtime::tree::ThreadTree;
|
||||
|
||||
pub use types::conversation::{
|
||||
ConversationEntry, ConversationId, ConversationSurface, EntrySender,
|
||||
};
|
||||
|
||||
// ── Re-exports: executor ──────────────────────────────────────
|
||||
|
||||
pub use executor::ExecutionLoop;
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
//! Conversation manager — routes UI messages to threads.
|
||||
//!
|
||||
//! The ConversationManager is the bridge between channel I/O (user messages,
|
||||
//! status updates) and the thread execution model. It maintains conversation
|
||||
//! surfaces and decides whether to spawn new threads or inject messages into
|
||||
//! existing ones.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::runtime::manager::ThreadManager;
|
||||
use crate::runtime::messaging::ThreadOutcome;
|
||||
use crate::types::conversation::{
|
||||
ConversationEntry, ConversationId, ConversationSurface,
|
||||
};
|
||||
use crate::types::error::EngineError;
|
||||
use crate::types::message::ThreadMessage;
|
||||
use crate::types::project::ProjectId;
|
||||
use crate::types::thread::{ThreadConfig, ThreadId, ThreadType};
|
||||
|
||||
/// Manages conversation surfaces and routes messages to threads.
|
||||
///
|
||||
/// Each channel message arrives here. The manager decides whether to:
|
||||
/// 1. Spawn a new foreground thread for the message
|
||||
/// 2. Inject the message into an existing active thread
|
||||
/// 3. Create a new conversation if none exists for this channel+user
|
||||
pub struct ConversationManager {
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
conversations: RwLock<HashMap<ConversationId, ConversationSurface>>,
|
||||
/// Maps (channel, user_id) → conversation ID for lookup.
|
||||
channel_user_index: RwLock<HashMap<(String, String), ConversationId>>,
|
||||
}
|
||||
|
||||
impl ConversationManager {
|
||||
pub fn new(thread_manager: Arc<ThreadManager>) -> Self {
|
||||
Self {
|
||||
thread_manager,
|
||||
conversations: RwLock::new(HashMap::new()),
|
||||
channel_user_index: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or create a conversation for a channel+user pair.
|
||||
pub async fn get_or_create_conversation(
|
||||
&self,
|
||||
channel: &str,
|
||||
user_id: &str,
|
||||
) -> ConversationId {
|
||||
// Check index first
|
||||
let key = (channel.to_string(), user_id.to_string());
|
||||
{
|
||||
let index = self.channel_user_index.read().await;
|
||||
if let Some(conv_id) = index.get(&key) {
|
||||
return *conv_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new conversation
|
||||
let conv = ConversationSurface::new(channel, user_id);
|
||||
let conv_id = conv.id;
|
||||
|
||||
let mut convs = self.conversations.write().await;
|
||||
let mut index = self.channel_user_index.write().await;
|
||||
convs.insert(conv_id, conv);
|
||||
index.insert(key, conv_id);
|
||||
|
||||
debug!(conversation_id = %conv_id, channel, user_id, "created conversation");
|
||||
conv_id
|
||||
}
|
||||
|
||||
/// Handle an incoming user message.
|
||||
///
|
||||
/// If the conversation has an active foreground thread, the message is
|
||||
/// injected into it. Otherwise, a new foreground thread is spawned.
|
||||
///
|
||||
/// Returns the thread ID that is handling the message.
|
||||
pub async fn handle_user_message(
|
||||
&self,
|
||||
conversation_id: ConversationId,
|
||||
content: &str,
|
||||
project_id: ProjectId,
|
||||
user_id: &str,
|
||||
thread_config: ThreadConfig,
|
||||
) -> Result<ThreadId, EngineError> {
|
||||
let mut convs = self.conversations.write().await;
|
||||
let conv = convs
|
||||
.get_mut(&conversation_id)
|
||||
.ok_or(EngineError::Store {
|
||||
reason: format!("conversation {conversation_id} not found"),
|
||||
})?;
|
||||
|
||||
// Record the user entry
|
||||
conv.add_entry(ConversationEntry::user(content));
|
||||
|
||||
// Check for an active foreground thread
|
||||
let active_foreground = self.find_active_foreground(conv).await;
|
||||
|
||||
match active_foreground {
|
||||
Some(thread_id) => {
|
||||
// Inject into existing thread
|
||||
debug!(
|
||||
conversation_id = %conversation_id,
|
||||
thread_id = %thread_id,
|
||||
"injecting message into active thread"
|
||||
);
|
||||
self.thread_manager
|
||||
.inject_message(thread_id, ThreadMessage::user(content))
|
||||
.await?;
|
||||
Ok(thread_id)
|
||||
}
|
||||
None => {
|
||||
// Spawn new foreground thread
|
||||
let thread_id = self
|
||||
.thread_manager
|
||||
.spawn_thread(
|
||||
content, // use message as goal
|
||||
ThreadType::Foreground,
|
||||
project_id,
|
||||
thread_config,
|
||||
None,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
conv.track_thread(thread_id);
|
||||
conv.add_entry(ConversationEntry::system_for_thread(
|
||||
thread_id,
|
||||
"Thread started",
|
||||
));
|
||||
|
||||
debug!(
|
||||
conversation_id = %conversation_id,
|
||||
thread_id = %thread_id,
|
||||
"spawned new foreground thread"
|
||||
);
|
||||
Ok(thread_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a thread's outcome in its conversation.
|
||||
pub async fn record_thread_outcome(
|
||||
&self,
|
||||
conversation_id: ConversationId,
|
||||
thread_id: ThreadId,
|
||||
outcome: &ThreadOutcome,
|
||||
) {
|
||||
let mut convs = self.conversations.write().await;
|
||||
if let Some(conv) = convs.get_mut(&conversation_id) {
|
||||
match outcome {
|
||||
ThreadOutcome::Completed { response } => {
|
||||
if let Some(text) = response {
|
||||
conv.add_entry(ConversationEntry::agent(thread_id, text));
|
||||
}
|
||||
conv.untrack_thread(thread_id);
|
||||
}
|
||||
ThreadOutcome::Stopped => {
|
||||
conv.add_entry(ConversationEntry::system_for_thread(
|
||||
thread_id,
|
||||
"Thread stopped",
|
||||
));
|
||||
conv.untrack_thread(thread_id);
|
||||
}
|
||||
ThreadOutcome::MaxIterations => {
|
||||
conv.add_entry(ConversationEntry::system_for_thread(
|
||||
thread_id,
|
||||
"Thread reached max iterations",
|
||||
));
|
||||
conv.untrack_thread(thread_id);
|
||||
}
|
||||
ThreadOutcome::Failed { error } => {
|
||||
conv.add_entry(ConversationEntry::system_for_thread(
|
||||
thread_id,
|
||||
format!("Thread failed: {error}"),
|
||||
));
|
||||
conv.untrack_thread(thread_id);
|
||||
}
|
||||
ThreadOutcome::NeedApproval {
|
||||
action_name,
|
||||
call_id: _,
|
||||
parameters: _,
|
||||
} => {
|
||||
conv.add_entry(ConversationEntry::system_for_thread(
|
||||
thread_id,
|
||||
format!("Approval needed for action: {action_name}"),
|
||||
));
|
||||
// Thread stays active — waiting for approval
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a snapshot of a conversation.
|
||||
pub async fn get_conversation(
|
||||
&self,
|
||||
conversation_id: ConversationId,
|
||||
) -> Option<ConversationSurface> {
|
||||
let convs = self.conversations.read().await;
|
||||
convs.get(&conversation_id).cloned()
|
||||
}
|
||||
|
||||
/// List all conversations for a user.
|
||||
pub async fn list_conversations(&self, user_id: &str) -> Vec<ConversationSurface> {
|
||||
let convs = self.conversations.read().await;
|
||||
convs
|
||||
.values()
|
||||
.filter(|c| c.user_id == user_id)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find an active foreground thread in a conversation.
|
||||
async fn find_active_foreground(&self, conv: &ConversationSurface) -> Option<ThreadId> {
|
||||
for &tid in &conv.active_threads {
|
||||
if self.thread_manager.is_running(tid).await {
|
||||
return Some(tid);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::capability::lease::LeaseManager;
|
||||
use crate::capability::policy::PolicyEngine;
|
||||
use crate::capability::registry::CapabilityRegistry;
|
||||
use crate::traits::effect::EffectExecutor;
|
||||
use crate::traits::llm::{LlmBackend, LlmCallConfig, LlmOutput};
|
||||
use crate::types::conversation::EntrySender;
|
||||
use crate::types::capability::{ActionDef, CapabilityLease};
|
||||
use crate::types::event::ThreadEvent;
|
||||
use crate::types::memory::{DocId, MemoryDoc};
|
||||
use crate::types::project::Project;
|
||||
use crate::types::step::{ActionResult, LlmResponse, Step, TokenUsage};
|
||||
use crate::types::thread::ThreadState;
|
||||
use crate::traits::store::Store;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
// ── Mocks (same as manager tests) ───────────────────────
|
||||
|
||||
struct MockLlm(Mutex<Vec<LlmOutput>>);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LlmBackend for MockLlm {
|
||||
async fn complete(
|
||||
&self, _: &[ThreadMessage], _: &[ActionDef], _: &LlmCallConfig,
|
||||
) -> Result<LlmOutput, EngineError> {
|
||||
let mut r = self.0.lock().unwrap();
|
||||
if r.is_empty() {
|
||||
Ok(LlmOutput { response: LlmResponse::Text("done".into()), usage: TokenUsage::default() })
|
||||
} else {
|
||||
Ok(r.remove(0))
|
||||
}
|
||||
}
|
||||
fn model_name(&self) -> &str { "mock" }
|
||||
}
|
||||
|
||||
struct MockEffects;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl EffectExecutor for MockEffects {
|
||||
async fn execute_action(&self, _: &str, _: serde_json::Value, _: &CapabilityLease, _: &crate::traits::effect::ThreadExecutionContext) -> Result<ActionResult, EngineError> {
|
||||
Ok(ActionResult { call_id: String::new(), action_name: String::new(), output: serde_json::json!({}), is_error: false, duration: Duration::from_millis(1) })
|
||||
}
|
||||
async fn available_actions(&self, _: &[CapabilityLease]) -> Result<Vec<ActionDef>, EngineError> { Ok(vec![]) }
|
||||
}
|
||||
|
||||
struct MockStore;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Store for MockStore {
|
||||
async fn save_thread(&self, _: &crate::types::thread::Thread) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn load_thread(&self, _: ThreadId) -> Result<Option<crate::types::thread::Thread>, EngineError> { Ok(None) }
|
||||
async fn list_threads(&self, _: ProjectId) -> Result<Vec<crate::types::thread::Thread>, EngineError> { Ok(vec![]) }
|
||||
async fn update_thread_state(&self, _: ThreadId, _: ThreadState) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn save_step(&self, _: &Step) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn load_steps(&self, _: ThreadId) -> Result<Vec<Step>, EngineError> { Ok(vec![]) }
|
||||
async fn append_events(&self, _: &[ThreadEvent]) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn load_events(&self, _: ThreadId) -> Result<Vec<ThreadEvent>, EngineError> { Ok(vec![]) }
|
||||
async fn save_project(&self, _: &Project) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn load_project(&self, _: ProjectId) -> Result<Option<Project>, EngineError> { Ok(None) }
|
||||
async fn save_memory_doc(&self, _: &MemoryDoc) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn load_memory_doc(&self, _: DocId) -> Result<Option<MemoryDoc>, EngineError> { Ok(None) }
|
||||
async fn list_memory_docs(&self, _: ProjectId) -> Result<Vec<MemoryDoc>, EngineError> { Ok(vec![]) }
|
||||
async fn save_lease(&self, _: &CapabilityLease) -> Result<(), EngineError> { Ok(()) }
|
||||
async fn load_active_leases(&self, _: ThreadId) -> Result<Vec<CapabilityLease>, EngineError> { Ok(vec![]) }
|
||||
async fn revoke_lease(&self, _: crate::types::capability::LeaseId, _: &str) -> Result<(), EngineError> { Ok(()) }
|
||||
}
|
||||
|
||||
fn make_conv_manager() -> (Arc<ThreadManager>, ConversationManager) {
|
||||
let tm = Arc::new(ThreadManager::new(
|
||||
Arc::new(MockLlm(Mutex::new(vec![
|
||||
LlmOutput { response: LlmResponse::Text("Hello!".into()), usage: TokenUsage::default() },
|
||||
]))),
|
||||
Arc::new(MockEffects),
|
||||
Arc::new(MockStore),
|
||||
Arc::new(CapabilityRegistry::new()),
|
||||
Arc::new(LeaseManager::new()),
|
||||
Arc::new(PolicyEngine::new()),
|
||||
));
|
||||
let cm = ConversationManager::new(Arc::clone(&tm));
|
||||
(tm, cm)
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_or_create_conversation() {
|
||||
let (_, cm) = make_conv_manager();
|
||||
let c1 = cm.get_or_create_conversation("telegram", "user1").await;
|
||||
let c2 = cm.get_or_create_conversation("telegram", "user1").await;
|
||||
assert_eq!(c1, c2); // same channel+user returns same conversation
|
||||
|
||||
let c3 = cm.get_or_create_conversation("slack", "user1").await;
|
||||
assert_ne!(c1, c3); // different channel → different conversation
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_message_spawns_thread() {
|
||||
let (tm, cm) = make_conv_manager();
|
||||
let conv_id = cm.get_or_create_conversation("web", "user1").await;
|
||||
let project = ProjectId::new();
|
||||
|
||||
let tid = cm
|
||||
.handle_user_message(conv_id, "Hello", project, "user1", ThreadConfig::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Thread was spawned
|
||||
let conv = cm.get_conversation(conv_id).await.unwrap();
|
||||
assert!(conv.active_threads.contains(&tid));
|
||||
assert_eq!(conv.entries.len(), 2); // user message + "Thread started"
|
||||
|
||||
// Wait for thread to complete
|
||||
let outcome = tm.join_thread(tid).await.unwrap();
|
||||
assert!(matches!(outcome, ThreadOutcome::Completed { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_outcome_adds_entry() {
|
||||
let (_, cm) = make_conv_manager();
|
||||
let conv_id = cm.get_or_create_conversation("cli", "user1").await;
|
||||
let tid = ThreadId::new();
|
||||
|
||||
// Manually track a thread
|
||||
{
|
||||
let mut convs = cm.conversations.write().await;
|
||||
let conv = convs.get_mut(&conv_id).unwrap();
|
||||
conv.track_thread(tid);
|
||||
}
|
||||
|
||||
// Record completion
|
||||
cm.record_thread_outcome(
|
||||
conv_id,
|
||||
tid,
|
||||
&ThreadOutcome::Completed {
|
||||
response: Some("Done!".into()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let conv = cm.get_conversation(conv_id).await.unwrap();
|
||||
assert!(conv.active_threads.is_empty());
|
||||
assert_eq!(conv.entries.len(), 1);
|
||||
assert_eq!(conv.entries[0].content, "Done!");
|
||||
|
||||
// Check sender is agent
|
||||
assert!(matches!(
|
||||
conv.entries[0].sender,
|
||||
EntrySender::Agent { thread_id } if thread_id == tid
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_conversations_filters_by_user() {
|
||||
let (_, cm) = make_conv_manager();
|
||||
cm.get_or_create_conversation("web", "alice").await;
|
||||
cm.get_or_create_conversation("telegram", "alice").await;
|
||||
cm.get_or_create_conversation("web", "bob").await;
|
||||
|
||||
let alice_convs = cm.list_conversations("alice").await;
|
||||
assert_eq!(alice_convs.len(), 2);
|
||||
|
||||
let bob_convs = cm.list_conversations("bob").await;
|
||||
assert_eq!(bob_convs.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@
|
||||
//! - [`ThreadTree`] — parent-child relationship tracking
|
||||
//! - [`messaging`] — inter-thread signal channel
|
||||
|
||||
pub mod conversation;
|
||||
pub mod manager;
|
||||
pub mod messaging;
|
||||
pub mod tree;
|
||||
|
||||
pub use conversation::ConversationManager;
|
||||
pub use manager::ThreadManager;
|
||||
pub use messaging::ThreadOutcome;
|
||||
pub use tree::ThreadTree;
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Conversation surface — the UI layer, separate from execution.
|
||||
//!
|
||||
//! A conversation is a stream of entries visible to the user. Threads
|
||||
//! (the execution units) run independently and produce entries that
|
||||
//! appear in conversations. One conversation can have multiple active
|
||||
//! threads; one thread can outlive its originating conversation.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::types::thread::ThreadId;
|
||||
|
||||
/// Strongly-typed conversation identifier.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ConversationId(pub Uuid);
|
||||
|
||||
impl ConversationId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConversationId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConversationId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Strongly-typed entry identifier.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct EntryId(pub Uuid);
|
||||
|
||||
impl EntryId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EntryId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Who sent a conversation entry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EntrySender {
|
||||
/// The human user.
|
||||
User,
|
||||
/// The agent (from a specific thread).
|
||||
Agent { thread_id: ThreadId },
|
||||
/// System notification (thread started, completed, etc.).
|
||||
System,
|
||||
}
|
||||
|
||||
/// A single entry in a conversation — a message visible to the user.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversationEntry {
|
||||
pub id: EntryId,
|
||||
pub sender: EntrySender,
|
||||
pub content: String,
|
||||
/// Which thread produced this entry (if any).
|
||||
pub origin_thread_id: Option<ThreadId>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Optional metadata (channel-specific formatting, attachments, etc.).
|
||||
pub metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
impl ConversationEntry {
|
||||
/// Create a user entry.
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: EntryId::new(),
|
||||
sender: EntrySender::User,
|
||||
content: content.into(),
|
||||
origin_thread_id: None,
|
||||
timestamp: Utc::now(),
|
||||
metadata: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an agent entry from a thread.
|
||||
pub fn agent(thread_id: ThreadId, content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: EntryId::new(),
|
||||
sender: EntrySender::Agent { thread_id },
|
||||
content: content.into(),
|
||||
origin_thread_id: Some(thread_id),
|
||||
timestamp: Utc::now(),
|
||||
metadata: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a system notification entry.
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: EntryId::new(),
|
||||
sender: EntrySender::System,
|
||||
content: content.into(),
|
||||
origin_thread_id: None,
|
||||
timestamp: Utc::now(),
|
||||
metadata: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a system notification linked to a thread.
|
||||
pub fn system_for_thread(thread_id: ThreadId, content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: EntryId::new(),
|
||||
sender: EntrySender::System,
|
||||
content: content.into(),
|
||||
origin_thread_id: Some(thread_id),
|
||||
timestamp: Utc::now(),
|
||||
metadata: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A conversation surface — the UI-facing view of a chat.
|
||||
///
|
||||
/// Conversations are NOT execution boundaries. They are streams of entries
|
||||
/// that may come from multiple concurrent threads. A user can start a new
|
||||
/// thread while another is still running, and both produce entries in the
|
||||
/// same conversation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversationSurface {
|
||||
pub id: ConversationId,
|
||||
/// Which channel this conversation is on (e.g. "telegram", "web", "cli").
|
||||
pub channel: String,
|
||||
/// The user who owns this conversation.
|
||||
pub user_id: String,
|
||||
/// All entries in chronological order.
|
||||
pub entries: Vec<ConversationEntry>,
|
||||
/// Currently active (non-terminal) thread IDs.
|
||||
pub active_threads: Vec<ThreadId>,
|
||||
/// Metadata (channel-specific state, external thread IDs, etc.).
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ConversationSurface {
|
||||
pub fn new(channel: impl Into<String>, user_id: impl Into<String>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: ConversationId::new(),
|
||||
channel: channel.into(),
|
||||
user_id: user_id.into(),
|
||||
entries: Vec::new(),
|
||||
active_threads: Vec::new(),
|
||||
metadata: serde_json::Value::Null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an entry and update the timestamp.
|
||||
pub fn add_entry(&mut self, entry: ConversationEntry) {
|
||||
self.entries.push(entry);
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Register a thread as active in this conversation.
|
||||
pub fn track_thread(&mut self, thread_id: ThreadId) {
|
||||
if !self.active_threads.contains(&thread_id) {
|
||||
self.active_threads.push(thread_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a thread from the active list (it completed or failed).
|
||||
pub fn untrack_thread(&mut self, thread_id: ThreadId) {
|
||||
self.active_threads.retain(|id| *id != thread_id);
|
||||
}
|
||||
|
||||
/// Get the most recent entry, if any.
|
||||
pub fn last_entry(&self) -> Option<&ConversationEntry> {
|
||||
self.entries.last()
|
||||
}
|
||||
|
||||
/// Get all entries from a specific thread.
|
||||
pub fn entries_for_thread(&self, thread_id: ThreadId) -> Vec<&ConversationEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| e.origin_thread_id == Some(thread_id))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn conversation_lifecycle() {
|
||||
let mut conv = ConversationSurface::new("telegram", "user_123");
|
||||
assert!(conv.entries.is_empty());
|
||||
assert!(conv.active_threads.is_empty());
|
||||
|
||||
// User sends a message
|
||||
conv.add_entry(ConversationEntry::user("Hello!"));
|
||||
assert_eq!(conv.entries.len(), 1);
|
||||
|
||||
// Thread starts
|
||||
let tid = ThreadId::new();
|
||||
conv.track_thread(tid);
|
||||
conv.add_entry(ConversationEntry::system_for_thread(
|
||||
tid,
|
||||
"Thread started",
|
||||
));
|
||||
assert_eq!(conv.active_threads.len(), 1);
|
||||
|
||||
// Agent responds
|
||||
conv.add_entry(ConversationEntry::agent(tid, "Hi there!"));
|
||||
assert_eq!(conv.entries.len(), 3);
|
||||
|
||||
// Thread completes
|
||||
conv.untrack_thread(tid);
|
||||
conv.add_entry(ConversationEntry::system_for_thread(tid, "Thread completed"));
|
||||
assert!(conv.active_threads.is_empty());
|
||||
assert_eq!(conv.entries.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_concurrent_threads() {
|
||||
let mut conv = ConversationSurface::new("web", "user_456");
|
||||
|
||||
let t1 = ThreadId::new();
|
||||
let t2 = ThreadId::new();
|
||||
|
||||
conv.track_thread(t1);
|
||||
conv.track_thread(t2);
|
||||
assert_eq!(conv.active_threads.len(), 2);
|
||||
|
||||
conv.add_entry(ConversationEntry::agent(t1, "Research result A"));
|
||||
conv.add_entry(ConversationEntry::agent(t2, "Research result B"));
|
||||
conv.add_entry(ConversationEntry::agent(t1, "More from A"));
|
||||
|
||||
let t1_entries = conv.entries_for_thread(t1);
|
||||
assert_eq!(t1_entries.len(), 2);
|
||||
|
||||
let t2_entries = conv.entries_for_thread(t2);
|
||||
assert_eq!(t2_entries.len(), 1);
|
||||
|
||||
conv.untrack_thread(t1);
|
||||
assert_eq!(conv.active_threads.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_thread_is_idempotent() {
|
||||
let mut conv = ConversationSurface::new("cli", "user");
|
||||
let tid = ThreadId::new();
|
||||
conv.track_thread(tid);
|
||||
conv.track_thread(tid);
|
||||
assert_eq!(conv.active_threads.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! validation logic.
|
||||
|
||||
pub mod capability;
|
||||
pub mod conversation;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod memory;
|
||||
|
||||
Reference in New Issue
Block a user