Skip to main content

rustyclaw_core/tasks/
thread.rs

1//! Task threads — independent conversation contexts for multi-tasking.
2//!
3//! Each thread has its own conversation history that compacts when the user
4//! switches away, with summaries merged into global context.
5//!
6//! Threads unify two concepts:
7//! - Simple conversation threads (user-created via /thread new)
8//! - Spawned sub-agent tasks (created via sessions_spawn)
9//!
10//! Both share the same UI representation in the sidebar.
11
12use super::model::{TaskId, TaskStatus};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::sync::Arc;
16use std::time::SystemTime;
17use tokio::sync::RwLock;
18
19/// A message in a task thread's conversation.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ThreadMessage {
22    pub role: MessageRole,
23    pub content: String,
24    pub timestamp: SystemTime,
25    /// Tool calls/results are stored separately for compaction
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub tool_interactions: Vec<ToolInteraction>,
28}
29
30/// Message roles.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum MessageRole {
34    System,
35    User,
36    Assistant,
37    Tool,
38}
39
40/// A tool call and its result.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ToolInteraction {
43    pub tool_name: String,
44    pub arguments: String,
45    pub result: Option<String>,
46    pub success: bool,
47}
48
49/// A task thread — an independent conversation context.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct TaskThread {
52    /// Associated task ID
53    pub task_id: TaskId,
54
55    /// User-provided label for this thread
56    pub label: String,
57
58    /// Optional description (for spawned tasks)
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub description: Option<String>,
61
62    /// Task status (for spawned tasks, None = simple thread)
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub status: Option<TaskStatus>,
65
66    /// Full conversation history (compacted when switching away)
67    pub messages: Vec<ThreadMessage>,
68
69    /// Compact summary of this thread's work (generated on switch-away)
70    pub compact_summary: Option<String>,
71
72    /// Whether this thread is the foreground thread
73    pub is_foreground: bool,
74
75    /// When the thread was created
76    pub created_at: SystemTime,
77
78    /// Last activity time
79    pub last_activity: SystemTime,
80
81    /// Model to use for this thread (None = inherit from session)
82    pub model: Option<String>,
83
84    /// Whether to include this thread's summary in other threads' context
85    pub share_context: bool,
86}
87
88impl TaskThread {
89    /// Create a new thread.
90    pub fn new(label: impl Into<String>) -> Self {
91        let now = SystemTime::now();
92        Self {
93            task_id: TaskId::new(),
94            label: label.into(),
95            description: None,
96            status: None,
97            messages: Vec::new(),
98            compact_summary: None,
99            is_foreground: true,
100            created_at: now,
101            last_activity: now,
102            model: None,
103            share_context: true,
104        }
105    }
106
107    /// Create a new thread for a spawned task.
108    pub fn new_task(label: impl Into<String>, description: impl Into<String>) -> Self {
109        let mut thread = Self::new(label);
110        thread.description = Some(description.into());
111        thread.status = Some(TaskStatus::Running {
112            progress: None,
113            message: None,
114        });
115        thread
116    }
117
118    /// Add a message to the thread.
119    pub fn add_message(&mut self, role: MessageRole, content: impl Into<String>) {
120        self.messages.push(ThreadMessage {
121            role,
122            content: content.into(),
123            timestamp: SystemTime::now(),
124            tool_interactions: Vec::new(),
125        });
126        self.last_activity = SystemTime::now();
127    }
128
129    /// Get the message count.
130    pub fn message_count(&self) -> usize {
131        self.messages.len()
132    }
133
134    /// Estimate token count (rough approximation: 4 chars ≈ 1 token).
135    pub fn estimated_tokens(&self) -> usize {
136        self.messages.iter().map(|m| m.content.len() / 4).sum()
137    }
138
139    /// Generate a compaction prompt for this thread.
140    pub fn compaction_prompt(&self) -> String {
141        let mut history = String::new();
142        for msg in &self.messages {
143            let role = match msg.role {
144                MessageRole::System => "System",
145                MessageRole::User => "User",
146                MessageRole::Assistant => "Assistant",
147                MessageRole::Tool => "Tool",
148            };
149            history.push_str(&format!("{}: {}\n\n", role, msg.content));
150        }
151
152        format!(
153            r#"Summarize the following conversation thread titled "{}".
154
155Focus on:
156- Key decisions made
157- Important information discovered
158- Current state of the task
159- Any pending actions or blockers
160
161Keep the summary concise but complete enough for context continuity.
162
163---
164
165{}
166
167---
168
169Summary:"#,
170            self.label, history
171        )
172    }
173
174    /// Apply a compact summary and clear old messages.
175    pub fn apply_compaction(&mut self, summary: String) {
176        self.compact_summary = Some(summary);
177        // Keep only the most recent messages (configurable later)
178        let keep_recent = 3;
179        if self.messages.len() > keep_recent {
180            self.messages = self.messages.split_off(self.messages.len() - keep_recent);
181        }
182    }
183
184    /// Build context for this thread including compact summary.
185    pub fn build_context(&self) -> String {
186        let mut ctx = String::new();
187
188        if let Some(ref summary) = self.compact_summary {
189            ctx.push_str(&format!(
190                "## Thread Summary: {}\n\n{}\n\n",
191                self.label, summary
192            ));
193        }
194
195        ctx.push_str("## Recent Messages\n\n");
196        for msg in &self.messages {
197            let role = match msg.role {
198                MessageRole::System => "System",
199                MessageRole::User => "User",
200                MessageRole::Assistant => "Assistant",
201                MessageRole::Tool => "Tool",
202            };
203            ctx.push_str(&format!("**{}:** {}\n\n", role, msg.content));
204        }
205
206        ctx
207    }
208}
209
210/// Manager for task threads within a session.
211#[derive(Debug, Default)]
212pub struct ThreadManager {
213    /// All threads for this session
214    threads: HashMap<TaskId, TaskThread>,
215
216    /// Currently foreground thread
217    foreground_id: Option<TaskId>,
218}
219
220impl ThreadManager {
221    /// Create a new thread manager.
222    pub fn new() -> Self {
223        Self::default()
224    }
225
226    /// Create a new thread and make it foreground.
227    pub fn create_thread(&mut self, label: impl Into<String>) -> TaskId {
228        let thread = TaskThread::new(label);
229        let id = thread.task_id;
230
231        // Background the current foreground
232        if let Some(fg_id) = self.foreground_id {
233            if let Some(fg) = self.threads.get_mut(&fg_id) {
234                fg.is_foreground = false;
235            }
236        }
237
238        self.threads.insert(id, thread);
239        self.foreground_id = Some(id);
240        id
241    }
242
243    /// Get the foreground thread.
244    pub fn foreground(&self) -> Option<&TaskThread> {
245        self.foreground_id.and_then(|id| self.threads.get(&id))
246    }
247
248    /// Get mutable foreground thread.
249    pub fn foreground_mut(&mut self) -> Option<&mut TaskThread> {
250        self.foreground_id.and_then(|id| self.threads.get_mut(&id))
251    }
252
253    /// Set the description of the foreground thread.
254    pub fn set_foreground_description(&mut self, description: &str) {
255        if let Some(thread) = self.foreground_mut() {
256            thread.description = Some(description.to_string());
257        }
258    }
259
260    /// Switch to a different thread (returns the old foreground if any).
261    pub fn switch_to(&mut self, id: TaskId) -> Option<TaskId> {
262        if !self.threads.contains_key(&id) {
263            return None;
264        }
265
266        let old_fg = self.foreground_id;
267
268        // Background the old foreground
269        if let Some(fg_id) = old_fg {
270            if fg_id != id {
271                if let Some(fg) = self.threads.get_mut(&fg_id) {
272                    fg.is_foreground = false;
273                }
274            }
275        }
276
277        // Foreground the new one
278        if let Some(thread) = self.threads.get_mut(&id) {
279            thread.is_foreground = true;
280            thread.last_activity = SystemTime::now();
281        }
282
283        self.foreground_id = Some(id);
284        old_fg
285    }
286
287    /// Get all threads.
288    pub fn all_threads(&self) -> Vec<&TaskThread> {
289        self.threads.values().collect()
290    }
291
292    /// Get a thread by ID.
293    pub fn get(&self, id: TaskId) -> Option<&TaskThread> {
294        self.threads.get(&id)
295    }
296
297    /// Get a thread by ID mutably.
298    pub fn get_mut(&mut self, id: TaskId) -> Option<&mut TaskThread> {
299        self.threads.get_mut(&id)
300    }
301
302    /// Remove a thread.
303    pub fn remove(&mut self, id: TaskId) -> Option<TaskThread> {
304        if self.foreground_id == Some(id) {
305            self.foreground_id = None;
306        }
307        self.threads.remove(&id)
308    }
309
310    /// Rename a thread.
311    pub fn rename(&mut self, id: TaskId, new_label: impl Into<String>) -> bool {
312        if let Some(thread) = self.threads.get_mut(&id) {
313            thread.label = new_label.into();
314            true
315        } else {
316            false
317        }
318    }
319
320    /// Build combined context from all threads with share_context=true.
321    pub fn build_global_context(&self) -> String {
322        let mut ctx = String::new();
323
324        for thread in self.threads.values() {
325            if thread.share_context && !thread.is_foreground {
326                if let Some(ref summary) = thread.compact_summary {
327                    ctx.push_str(&format!(
328                        "## Background Task: {}\n\n{}\n\n---\n\n",
329                        thread.label, summary
330                    ));
331                }
332            }
333        }
334
335        ctx
336    }
337
338    /// Find the best matching thread for a user message.
339    /// Returns thread ID if a better match exists than current foreground.
340    /// Uses keyword matching: counts label words present in message.
341    pub fn find_best_match(&self, message: &str) -> Option<TaskId> {
342        let message_lower = message.to_lowercase();
343        let foreground = self.foreground_id;
344
345        let mut best_match: Option<(TaskId, usize)> = None;
346        let mut foreground_score = 0usize;
347
348        for (id, thread) in &self.threads {
349            // Skip "Main" — it's the catch-all
350            if thread.label.eq_ignore_ascii_case("main") {
351                continue;
352            }
353
354            // Count how many label words appear in the message
355            let score: usize = thread
356                .label
357                .split_whitespace()
358                .filter(|word| word.len() >= 3) // Skip short words
359                .filter(|word| message_lower.contains(&word.to_lowercase()))
360                .count();
361
362            if Some(*id) == foreground {
363                foreground_score = score;
364            } else if score > 0 {
365                if best_match.is_none() || score > best_match.unwrap().1 {
366                    best_match = Some((*id, score));
367                }
368            }
369        }
370
371        // Only switch if the other thread is a better match
372        match best_match {
373            Some((id, score)) if score > foreground_score => Some(id),
374            _ => None,
375        }
376    }
377
378    /// Count active threads.
379    pub fn count(&self) -> usize {
380        self.threads.len()
381    }
382
383    /// List thread info for display.
384    pub fn list_info(&self) -> Vec<ThreadInfo> {
385        self.threads
386            .values()
387            .map(|t| ThreadInfo {
388                id: t.task_id,
389                label: t.label.clone(),
390                description: t.description.clone(),
391                status: t.status.clone(),
392                is_foreground: t.is_foreground,
393                message_count: t.messages.len(),
394                has_summary: t.compact_summary.is_some(),
395            })
396            .collect()
397    }
398}
399
400/// Summary info about a thread for display (unified tasks + threads).
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct ThreadInfo {
403    pub id: TaskId,
404    pub label: String,
405    /// Description (for spawned tasks)
406    pub description: Option<String>,
407    /// Task status (None = simple thread, Some = spawned task)
408    pub status: Option<TaskStatus>,
409    pub is_foreground: bool,
410    pub message_count: usize,
411    pub has_summary: bool,
412}
413
414/// Shared thread manager.
415pub type SharedThreadManager = Arc<RwLock<ThreadManager>>;
416
417// ── Persistence ─────────────────────────────────────────────────────────────
418
419/// Serializable state for thread persistence.
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct ThreadManagerState {
422    pub threads: Vec<TaskThread>,
423    pub foreground_id: Option<u64>,
424}
425
426impl ThreadManager {
427    /// Save thread state to a file.
428    pub fn save_to_file(&self, path: &std::path::Path) -> std::io::Result<()> {
429        let state = ThreadManagerState {
430            threads: self.threads.values().cloned().collect(),
431            foreground_id: self.foreground_id.map(|id| id.0),
432        };
433        let json = serde_json::to_string_pretty(&state)
434            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
435        std::fs::write(path, json)
436    }
437
438    /// Load thread state from a file.
439    pub fn load_from_file(path: &std::path::Path) -> std::io::Result<Self> {
440        let json = std::fs::read_to_string(path)?;
441        let state: ThreadManagerState = serde_json::from_str(&json)
442            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
443
444        let mut threads = HashMap::new();
445        for thread in state.threads {
446            threads.insert(thread.task_id, thread);
447        }
448
449        Ok(Self {
450            threads,
451            foreground_id: state.foreground_id.map(TaskId),
452        })
453    }
454
455    /// Load from file or create new with default thread.
456    pub fn load_or_default(path: &std::path::Path) -> Self {
457        match Self::load_from_file(path) {
458            Ok(mgr) if !mgr.threads.is_empty() => mgr,
459            _ => {
460                let mut mgr = Self::new();
461                mgr.create_thread("Main");
462                mgr
463            }
464        }
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_thread_creation() {
474        let thread = TaskThread::new("Test task");
475        assert_eq!(thread.label, "Test task");
476        assert!(thread.messages.is_empty());
477        assert!(thread.is_foreground);
478    }
479
480    #[test]
481    fn test_thread_manager() {
482        let mut mgr = ThreadManager::new();
483
484        let id1 = mgr.create_thread("Task 1");
485        assert!(mgr.foreground().is_some());
486        assert_eq!(mgr.foreground().unwrap().label, "Task 1");
487
488        let id2 = mgr.create_thread("Task 2");
489        assert_eq!(mgr.foreground().unwrap().label, "Task 2");
490        assert!(!mgr.get(id1).unwrap().is_foreground);
491
492        mgr.switch_to(id1);
493        assert_eq!(mgr.foreground().unwrap().label, "Task 1");
494    }
495
496    #[test]
497    fn test_message_adding() {
498        let mut thread = TaskThread::new("Test");
499        thread.add_message(MessageRole::User, "Hello");
500        thread.add_message(MessageRole::Assistant, "Hi there!");
501        assert_eq!(thread.message_count(), 2);
502    }
503}