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:
2026-03-21 23:21:54 -07:00
co-authored by Claude Opus 4.6
parent 4bc7ffdf0c
commit 0827235c9c
5 changed files with 664 additions and 0 deletions
+5
View File
@@ -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);
}
}
+1
View File
@@ -4,6 +4,7 @@
//! validation logic.
pub mod capability;
pub mod conversation;
pub mod error;
pub mod event;
pub mod memory;