Skip to main content

trustee_core/
sessions.rs

1//! Session discovery for the API/Web layer.
2//!
3//! Wraps `abk::checkpoint` to provide serializable session listing, detail,
4//! and resume-info creation — used by the Trustee REST API so users can
5//! browse and resume checkpoint sessions from the Web UI.
6
7use serde::{Deserialize, Serialize};
8
9use abk::checkpoint::{
10    get_storage_manager,
11    models::{CheckpointMetadata, ChatMessage, SessionMetadata as AbkSessionMetadata},
12    storage::ProjectMetadata as AbkProjectMetadata,
13};
14use abk::cli::ResumeInfo;
15
16/// Compact session info suitable for JSON API responses.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SessionSummary {
19    pub session_id: String,
20    pub project_name: String,
21    pub project_path: String,
22    pub checkpoint_count: usize,
23    pub created_at: chrono::DateTime<chrono::Utc>,
24    pub last_accessed: chrono::DateTime<chrono::Utc>,
25    pub description: Option<String>,
26    pub is_current_project: bool,
27}
28
29/// Compact checkpoint info for the session detail endpoint.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct CheckpointSummary {
32    pub checkpoint_id: String,
33    pub session_id: String,
34    pub iteration: u32,
35    pub workflow_step: String,
36    pub created_at: chrono::DateTime<chrono::Utc>,
37}
38
39// -----------------------------------------------------------------------
40// Conversion helpers
41// -----------------------------------------------------------------------
42
43fn session_to_summary(
44    session: &AbkSessionMetadata,
45    project: &AbkProjectMetadata,
46    is_current: bool,
47) -> SessionSummary {
48    SessionSummary {
49        session_id: session.session_id.clone(),
50        project_name: project.name.clone(),
51        project_path: project.project_path.to_string_lossy().to_string(),
52        checkpoint_count: session.checkpoint_count as usize,
53        created_at: session.created_at,
54        last_accessed: session.last_accessed,
55        description: session.description.clone(),
56        is_current_project: is_current,
57    }
58}
59
60fn checkpoint_to_summary(cp: &CheckpointMetadata) -> CheckpointSummary {
61    CheckpointSummary {
62        checkpoint_id: cp.checkpoint_id.clone(),
63        session_id: cp.session_id.clone(),
64        iteration: cp.iteration,
65        workflow_step: format!("{:?}", cp.workflow_step),
66        created_at: cp.created_at,
67    }
68}
69
70/// Derive the working directory from a trustee config TOML string.
71///
72/// Looks for `[agent] working_dir`. Falls back to the current directory
73/// of the process if not specified or unparseable.
74fn config_working_dir(config_toml: &str) -> std::path::PathBuf {
75    if let Ok(value) = toml::from_str::<toml::Value>(config_toml) {
76        if let Some(agent) = value.get("agent") {
77            if let Some(wd) = agent.get("working_dir").and_then(|v| v.as_str()) {
78                return std::path::PathBuf::from(wd);
79            }
80        }
81    }
82    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
83}
84
85/// Check whether two paths refer to the same project by canonicalising.
86fn paths_match(a: &std::path::Path, b: &std::path::Path) -> bool {
87    let ca = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
88    let cb = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
89    ca == cb
90}
91
92// -----------------------------------------------------------------------
93// Public API
94// -----------------------------------------------------------------------
95
96/// List all sessions across all projects that have at least one checkpoint.
97///
98/// Sessions from the current project (derived from `config_toml`) are listed
99/// first, then everything else sorted by `last_accessed` descending.
100pub async fn list_all_sessions(config_toml: &str) -> anyhow::Result<Vec<SessionSummary>> {
101    let current_dir = config_working_dir(config_toml);
102    let manager = get_storage_manager()
103        .map_err(|e| anyhow::anyhow!("Failed to get storage manager: {}", e))?;
104
105    let projects = manager
106        .list_projects()
107        .await
108        .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
109
110    let mut summaries = Vec::new();
111
112    for project in &projects {
113        // Skip projects whose path can't be resolved (e.g. deleted dirs,
114        // permission denied) — don't let one bad project kill the whole list.
115        let project_storage = match manager.get_project_storage(&project.project_path).await {
116            Ok(ps) => ps,
117            Err(e) => {
118                tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
119                continue;
120            }
121        };
122
123        let sessions = match project_storage.list_sessions().await {
124            Ok(s) => s,
125            Err(e) => {
126                tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
127                continue;
128            }
129        };
130
131        let is_current = paths_match(&project.project_path, &current_dir);
132
133        for session in sessions {
134            // Only include sessions that have checkpoints (resumable)
135            if session.checkpoint_count > 0 {
136                summaries.push(session_to_summary(&session, project, is_current));
137            }
138        }
139    }
140
141    // Sort: current project first, then by last_accessed descending
142    summaries.sort_by(|a, b| match (a.is_current_project, b.is_current_project) {
143        (true, false) => std::cmp::Ordering::Less,
144        (false, true) => std::cmp::Ordering::Greater,
145        _ => b.last_accessed.cmp(&a.last_accessed),
146    });
147
148    Ok(summaries)
149}
150
151/// Get detailed information about a specific session, including its checkpoints.
152///
153/// Searches all projects for the given `session_id`.
154/// Returns `None` if the session is not found.
155pub async fn get_session_detail(
156    _config_toml: &str,
157    session_id: &str,
158) -> anyhow::Result<Option<(SessionSummary, Vec<CheckpointSummary>)>> {
159    let manager = get_storage_manager()
160        .map_err(|e| anyhow::anyhow!("Failed to get storage manager: {}", e))?;
161
162    let projects = manager
163        .list_projects()
164        .await
165        .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
166
167    for project in &projects {
168        let project_storage = match manager.get_project_storage(&project.project_path).await {
169            Ok(ps) => ps,
170            Err(e) => {
171                tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
172                continue;
173            }
174        };
175
176        let sessions = match project_storage.list_sessions().await {
177            Ok(s) => s,
178            Err(e) => {
179                tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
180                continue;
181            }
182        };
183
184        if let Some(session) = sessions.iter().find(|s| s.session_id == session_id) {
185            let session_storage = project_storage
186                .create_session(session_id)
187                .await
188                .map_err(|e| anyhow::anyhow!("Failed to get session storage: {}", e))?;
189
190            let checkpoints = session_storage
191                .list_checkpoints()
192                .await
193                .map_err(|e| anyhow::anyhow!("Failed to list checkpoints: {}", e))?;
194
195            let summary = session_to_summary(session, project, false);
196            let cp_summaries: Vec<CheckpointSummary> =
197                checkpoints.iter().map(checkpoint_to_summary).collect();
198
199            return Ok(Some((summary, cp_summaries)));
200        }
201    }
202
203    Ok(None)
204}
205
206/// Create a `ResumeInfo` from the latest checkpoint of a given session.
207///
208/// Searches all projects for `session_id`, finds the most recent checkpoint,
209/// and returns a `ResumeInfo` suitable for setting on `Session::resume_info`.
210/// Returns `None` if the session or its checkpoints are not found.
211pub async fn create_resume_info(
212    _config_toml: &str,
213    session_id: &str,
214) -> anyhow::Result<Option<ResumeInfo>> {
215    let manager = get_storage_manager()
216        .map_err(|e| anyhow::anyhow!("Failed to get storage manager: {}", e))?;
217
218    let projects = manager
219        .list_projects()
220        .await
221        .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
222
223    for project in &projects {
224        let project_storage = match manager.get_project_storage(&project.project_path).await {
225            Ok(ps) => ps,
226            Err(e) => {
227                tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
228                continue;
229            }
230        };
231
232        let sessions = match project_storage.list_sessions().await {
233            Ok(s) => s,
234            Err(e) => {
235                tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
236                continue;
237            }
238        };
239
240        if sessions.iter().any(|s| s.session_id == session_id) {
241            let session_storage = project_storage
242                .create_session(session_id)
243                .await
244                .map_err(|e| anyhow::anyhow!("Failed to get session storage: {}", e))?;
245
246            let checkpoints = session_storage
247                .list_checkpoints()
248                .await
249                .map_err(|e| anyhow::anyhow!("Failed to list checkpoints: {}", e))?;
250
251            if let Some(latest) = checkpoints.iter().max_by_key(|cp| cp.created_at) {
252                return Ok(Some(ResumeInfo {
253                    session_id: session_id.to_string(),
254                    checkpoint_id: latest.checkpoint_id.clone(),
255                    iteration: latest.iteration,
256                    project_path: Some(project.project_path.clone()),
257                }));
258            }
259
260            // Session found but no checkpoints
261            return Ok(None);
262        }
263    }
264
265    Ok(None)
266}
267
268// -----------------------------------------------------------------------
269// Conversation history loading
270// -----------------------------------------------------------------------
271
272/// A single chat message rendered in the Web UI conversation history.
273///
274/// Each message maps to how the TUI/Web already renders things:
275/// - `user` messages → right-aligned chat bubble
276/// - `assistant` messages → left-aligned agent bubble (markdown)
277/// - `assistant` with `tool_calls` → tool-pending/tool-done lines
278/// - `tool` messages → hidden (tool results are shown via tool_calls)
279/// - `reasoning` → collapsible reasoning section
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct HistoryMessage {
282    /// "user", "assistant", or "tool"
283    pub role: String,
284    /// Main message text
285    pub content: String,
286    /// Reasoning/thinking content (if any)
287    pub reasoning: Option<String>,
288    /// Tool calls (assistant messages that invoke tools)
289    pub tool_calls: Option<Vec<HistoryToolCall>>,
290    /// Tool name (for tool-role messages)
291    pub name: Option<String>,
292}
293
294/// A tool call within a message.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct HistoryToolCall {
297    pub name: String,
298    /// Short description of what the tool was called with (for display)
299    pub hint: String,
300}
301
302/// Metadata about the session/task for the history header.
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct SessionHistory {
305    pub session_id: String,
306    pub checkpoint_id: String,
307    pub task_description: String,
308    pub iteration: u32,
309    pub total_messages: usize,
310    pub messages: Vec<HistoryMessage>,
311}
312
313/// Load conversation history from a session's latest checkpoint.
314///
315/// Returns messages suitable for rendering in the Web UI. System messages
316/// are filtered out, tool results are summarized, and reasoning is
317/// preserved separately.
318pub async fn load_session_history(
319    session_id: &str,
320) -> anyhow::Result<Option<SessionHistory>> {
321    let manager = get_storage_manager()
322        .map_err(|e| anyhow::anyhow!("Failed to get storage manager: {}", e))?;
323
324    let projects = manager
325        .list_projects()
326        .await
327        .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
328
329    for project in &projects {
330        let project_storage = match manager.get_project_storage(&project.project_path).await {
331            Ok(ps) => ps,
332            Err(e) => {
333                tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
334                continue;
335            }
336        };
337
338        let sessions = match project_storage.list_sessions().await {
339            Ok(s) => s,
340            Err(e) => {
341                tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
342                continue;
343            }
344        };
345
346        if !sessions.iter().any(|s| s.session_id == session_id) {
347            continue;
348        }
349
350        let session_storage = project_storage
351            .create_session(session_id)
352            .await
353            .map_err(|e| anyhow::anyhow!("Failed to get session storage: {}", e))?;
354
355        let checkpoints = session_storage
356            .list_checkpoints()
357            .await
358            .map_err(|e| anyhow::anyhow!("Failed to list checkpoints: {}", e))?;
359
360        let latest = match checkpoints.iter().max_by_key(|cp| cp.created_at) {
361            Some(cp) => cp,
362            None => return Ok(None),
363        };
364
365        let checkpoint_id = latest.checkpoint_id.clone();
366        let iteration = latest.iteration;
367
368        let checkpoint = session_storage
369            .load_checkpoint(&checkpoint_id)
370            .await
371            .map_err(|e| anyhow::anyhow!("Failed to load checkpoint: {}", e))?;
372
373        let task_description = checkpoint.agent_state.task_description.clone();
374        let total_messages = checkpoint.conversation_state.messages.len();
375
376        let messages = convert_messages(&checkpoint.conversation_state.messages);
377
378        return Ok(Some(SessionHistory {
379            session_id: session_id.to_string(),
380            checkpoint_id,
381            task_description,
382            iteration,
383            total_messages,
384            messages,
385        }));
386    }
387
388    Ok(None)
389}
390
391/// Convert ABK `ChatMessage`s to Web UI-friendly `HistoryMessage`s.
392///
393/// - System messages are dropped (not useful in UI)
394/// - Tool-role messages are dropped (tool results shown via assistant's tool_calls)
395/// - Very long content is truncated to avoid massive payloads
396fn convert_messages(messages: &[ChatMessage]) -> Vec<HistoryMessage> {
397    const MAX_CONTENT_LEN: usize = 10_000;
398
399    let mut result = Vec::new();
400
401    for msg in messages {
402        // Skip system messages — not useful in the conversation view
403        if msg.role == "system" {
404            continue;
405        }
406
407        // Skip tool-role messages — tool results are shown via the
408        // assistant's tool_calls and a compact tool-done line
409        if msg.role == "tool" {
410            continue;
411        }
412
413        let content = if msg.content.len() > MAX_CONTENT_LEN {
414            format!("{}...\n[truncated]", &msg.content[..MAX_CONTENT_LEN])
415        } else {
416            msg.content.clone()
417        };
418
419        // Convert tool calls if present
420        let tool_calls = msg.tool_calls.as_ref().map(|calls| {
421            calls
422                .iter()
423                .map(|tc| {
424                    let hint = summarize_tool_args(&tc.function.name, &tc.function.arguments);
425                    HistoryToolCall {
426                        name: tc.function.name.clone(),
427                        hint,
428                    }
429                })
430                .collect::<Vec<_>>()
431        });
432
433        // For assistant messages that only contain tool calls (empty content),
434        // we still emit them so tool call lines render
435        if msg.role == "assistant" && content.is_empty() && tool_calls.is_some() {
436            result.push(HistoryMessage {
437                role: "assistant".to_string(),
438                content: String::new(),
439                reasoning: msg.reasoning.clone(),
440                tool_calls,
441                name: None,
442            });
443            continue;
444        }
445
446        result.push(HistoryMessage {
447            role: msg.role.clone(),
448            content,
449            reasoning: msg.reasoning.clone(),
450            tool_calls,
451            name: msg.name.clone(),
452        });
453    }
454
455    result
456}
457
458/// Create a short human-readable hint from tool call arguments.
459///
460/// e.g. `{"command": "ls -la /tmp"}` → `ls -la /tmp`
461///      `{"file_path": "/foo/bar.rs"}` → `/foo/bar.rs`
462fn summarize_tool_args(name: &str, args: &str) -> String {
463    let parsed: serde_json::Value = match serde_json::from_str(args) {
464        Ok(v) => v,
465        Err(_) => return args.chars().take(200).collect(),
466    };
467
468    let obj = match parsed.as_object() {
469        Some(o) => o,
470        None => return args.chars().take(200).collect(),
471    };
472
473    match name {
474        "bash" | "execute_command" => {
475            obj.get("command").and_then(|v| v.as_str()).map(|s| s.to_string())
476        }
477        "read" | "read_file" => {
478            obj.get("file_path").and_then(|v| v.as_str()).map(|s| s.to_string())
479        }
480        "write" | "write_file" | "edit" => {
481            obj.get("file_path").and_then(|v| v.as_str()).map(|s| s.to_string())
482        }
483        "grep" | "search" => {
484            obj.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string())
485        }
486        "glob" => {
487            obj.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string())
488        }
489        "todowrite" => {
490            let count = obj.get("todos").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
491            Some(format!("{} items", count))
492        }
493        "websearch" | "webfetch" => {
494            obj.get("query").or_else(|| obj.get("url")).and_then(|v| v.as_str()).map(|s| s.to_string())
495        }
496        _ => {
497            obj.values().next().and_then(|v| match v {
498                serde_json::Value::String(s) => Some(s.clone()),
499                _ => Some(v.to_string()),
500            })
501        }
502    }
503    .unwrap_or_default()
504    .chars()
505    .take(200)
506    .collect()
507}