Skip to main content

opendev_tools_impl/
session.rs

1//! Past sessions tool — browse and search historical conversation sessions.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use opendev_config::Paths;
7use opendev_history::SessionManager;
8use opendev_models::SessionMetadata;
9use opendev_runtime::redact_secrets;
10use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
11
12/// Tool for browsing and searching past conversation sessions.
13///
14/// Uses project-scoped session directories via `opendev_config::Paths`
15/// and the `SessionManager` API from `opendev-history`.
16#[derive(Debug)]
17pub struct PastSessionsTool;
18
19#[async_trait::async_trait]
20impl BaseTool for PastSessionsTool {
21    fn name(&self) -> &str {
22        "past_sessions"
23    }
24
25    fn description(&self) -> &str {
26        "Browse and search past conversation sessions for this project. \
27         NOT for checking subagent status — subagent results arrive automatically."
28    }
29
30    fn parameter_schema(&self) -> serde_json::Value {
31        serde_json::json!({
32            "type": "object",
33            "properties": {
34                "action": {
35                    "type": "string",
36                    "enum": ["list", "read", "search", "info"],
37                    "description": "Action to perform"
38                },
39                "session_id": {
40                    "type": "string",
41                    "description": "Session ID (for read/info)"
42                },
43                "query": {
44                    "type": "string",
45                    "description": "Search query (for search)"
46                },
47                "limit": {
48                    "type": "integer",
49                    "description": "Max results (default: 20 list, 50 read, 10 search)"
50                },
51                "offset": {
52                    "type": "integer",
53                    "description": "Skip N items for pagination"
54                },
55                "include_archived": {
56                    "type": "boolean",
57                    "description": "Include archived sessions (default: false)"
58                }
59            },
60            "required": ["action"]
61        })
62    }
63
64    async fn execute(
65        &self,
66        args: HashMap<String, serde_json::Value>,
67        ctx: &ToolContext,
68    ) -> ToolResult {
69        // Guard: subagents cannot access past sessions
70        if ctx.is_subagent {
71            return ToolResult::fail(
72                "past_sessions is not available to subagents. \
73                 Focus on completing your assigned task.",
74            );
75        }
76
77        let action = match args.get("action").and_then(|v| v.as_str()) {
78            Some(a) => a,
79            None => return ToolResult::fail("action is required"),
80        };
81
82        // Resolve project-scoped session directory
83        let paths = Paths::new(None);
84        let session_dir = paths.project_sessions_dir(&ctx.working_dir);
85
86        // Guard: don't create directories as a side effect
87        if !session_dir.exists() {
88            return ToolResult::ok("No past sessions found for this project.".to_string());
89        }
90
91        // Construct SessionManager (dir already exists so create_dir_all is a no-op)
92        let manager = match SessionManager::new(session_dir) {
93            Ok(m) => m,
94            Err(e) => return ToolResult::fail(format!("Failed to open session store: {e}")),
95        };
96
97        let current_session_id = ctx.session_id.as_deref();
98
99        match action {
100            "list" => action_list(&manager, &args, current_session_id),
101            "read" => action_read(&manager, &args, current_session_id),
102            "search" => action_search(&manager, &args),
103            "info" => action_info(&manager, &args, current_session_id),
104            _ => ToolResult::fail(format!(
105                "Unknown action: {action}. Available: list, read, search, info"
106            )),
107        }
108    }
109}
110
111/// Validate session_id: reject path traversal characters.
112/// Returns `Some(ToolResult)` on failure, `None` on success.
113fn validate_session_id(id: &str) -> Option<ToolResult> {
114    if id.is_empty() || id.contains("..") || id.contains('/') || id.contains('\\') {
115        Some(ToolResult::fail("Invalid session ID"))
116    } else {
117        None
118    }
119}
120
121/// Guard: reject reads of the current session.
122/// Returns `Some(ToolResult)` if blocked, `None` if allowed.
123fn guard_current_session(session_id: &str, current: Option<&str>) -> Option<ToolResult> {
124    if let Some(current_id) = current
125        && session_id == current_id
126    {
127        Some(ToolResult::ok(
128            "This is your current session — its messages are already in your context.".to_string(),
129        ))
130    } else {
131        None
132    }
133}
134
135fn format_timestamp(dt: &DateTime<Utc>) -> String {
136    dt.format("%Y-%m-%d %H:%M").to_string()
137}
138
139// --- Action implementations ---
140
141fn action_list(
142    manager: &SessionManager,
143    args: &HashMap<String, serde_json::Value>,
144    current_session_id: Option<&str>,
145) -> ToolResult {
146    let include_archived = args
147        .get("include_archived")
148        .and_then(|v| v.as_bool())
149        .unwrap_or(false);
150    let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
151    let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
152
153    let mut sessions: Vec<SessionMetadata> = manager.list_sessions(include_archived);
154
155    // Exclude current session
156    if let Some(current_id) = current_session_id {
157        sessions.retain(|s| s.id != current_id);
158    }
159
160    // Sort by most recent first
161    sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
162
163    if sessions.is_empty() {
164        return ToolResult::ok("No past sessions found.".to_string());
165    }
166
167    let total = sessions.len();
168    let page: Vec<&SessionMetadata> = sessions.iter().skip(offset).take(limit).collect();
169
170    if page.is_empty() {
171        return ToolResult::ok(format!("No sessions at offset {offset} (total: {total})."));
172    }
173
174    let mut output = format!(
175        "Past sessions ({total} total, showing {}-{}):\n\n",
176        offset + 1,
177        offset + page.len(),
178    );
179    output.push_str(&format!(
180        "{:<14} {:<40} {:>5} {:<17} {:>12}\n",
181        "ID", "Title", "Msgs", "Updated", "Changes"
182    ));
183    output.push_str(&"-".repeat(90));
184    output.push('\n');
185
186    for meta in &page {
187        let title = meta
188            .title
189            .as_deref()
190            .unwrap_or("(untitled)")
191            .chars()
192            .take(38)
193            .collect::<String>();
194        let changes = format!("{}+/{}", meta.summary_additions, meta.summary_deletions);
195        output.push_str(&format!(
196            "{:<14} {:<40} {:>5} {:<17} {:>10}-\n",
197            meta.id,
198            title,
199            meta.message_count,
200            format_timestamp(&meta.updated_at),
201            changes,
202        ));
203    }
204
205    if total > offset + page.len() {
206        output.push_str(&format!(
207            "\nUse offset={} to see more.",
208            offset + page.len()
209        ));
210    }
211
212    let mut metadata = HashMap::new();
213    metadata.insert("total".into(), serde_json::json!(total));
214    ToolResult::ok_with_metadata(output, metadata)
215}
216
217fn action_read(
218    manager: &SessionManager,
219    args: &HashMap<String, serde_json::Value>,
220    current_session_id: Option<&str>,
221) -> ToolResult {
222    let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
223        Some(s) => s,
224        None => return ToolResult::fail("session_id is required for read"),
225    };
226
227    if let Some(r) = validate_session_id(session_id) {
228        return r;
229    }
230    if let Some(r) = guard_current_session(session_id, current_session_id) {
231        return r;
232    }
233
234    let session = match manager.load_session(session_id) {
235        Ok(s) => s,
236        Err(e) => return ToolResult::fail(format!("Session not found or corrupted: {e}")),
237    };
238
239    let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
240    let total_messages = session.messages.len();
241
242    // Default offset: show the last `limit` messages
243    let default_offset = total_messages.saturating_sub(limit);
244    let offset = args
245        .get("offset")
246        .and_then(|v| v.as_u64())
247        .map(|v| v as usize)
248        .unwrap_or(default_offset);
249
250    let page: Vec<_> = session.messages.iter().skip(offset).take(limit).collect();
251
252    if page.is_empty() {
253        return ToolResult::ok(format!(
254            "Session {session_id}: no messages at offset {offset} (total: {total_messages})."
255        ));
256    }
257
258    let title = session
259        .metadata
260        .get("title")
261        .and_then(|v| v.as_str())
262        .unwrap_or("(untitled)");
263
264    let mut output = format!(
265        "Session: {session_id} — \"{title}\"\n\
266         Messages {}-{} of {total_messages}:\n\n",
267        offset + 1,
268        offset + page.len(),
269    );
270
271    for (i, msg) in page.iter().enumerate() {
272        let idx = offset + i + 1;
273        let role = &msg.role;
274        // Truncate content to 500 chars
275        let content = &msg.content;
276        let truncated: String = if content.chars().count() > 500 {
277            let s: String = content.chars().take(500).collect();
278            format!("{s}...[truncated]")
279        } else {
280            content.to_string()
281        };
282        output.push_str(&format!("[{idx}] {role}:\n{truncated}\n\n"));
283    }
284
285    if offset + page.len() < total_messages {
286        output.push_str(&format!(
287            "Use offset={} to see more messages.",
288            offset + page.len()
289        ));
290    }
291
292    // Redact secrets from the entire output
293    let redacted = redact_secrets(&output);
294
295    let mut metadata = HashMap::new();
296    metadata.insert("total_messages".into(), serde_json::json!(total_messages));
297    ToolResult::ok_with_metadata(redacted, metadata)
298}
299
300fn action_search(
301    manager: &SessionManager,
302    args: &HashMap<String, serde_json::Value>,
303) -> ToolResult {
304    let query = match args.get("query").and_then(|v| v.as_str()) {
305        Some(q) if !q.trim().is_empty() => q,
306        _ => return ToolResult::fail("query is required and must be non-empty for search"),
307    };
308
309    let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
310
311    let results = manager.search_sessions(query);
312
313    if results.is_empty() {
314        return ToolResult::ok(format!("No sessions matching \"{query}\"."));
315    }
316
317    let total = results.len();
318    let shown = results.iter().take(limit);
319
320    let mut output = format!("Search results for \"{query}\" ({total} sessions matched):\n\n");
321
322    for (session_id, match_indices) in shown {
323        let match_count = match_indices.len();
324
325        // Try to load session for a context snippet
326        let snippet = if let Ok(session) = manager.load_session(session_id) {
327            if let Some(&first_idx) = match_indices.first() {
328                if let Some(msg) = session.messages.get(first_idx) {
329                    let content = &msg.content;
330                    let preview: String = content.chars().take(100).collect();
331                    format!("  {preview}...")
332                } else {
333                    String::new()
334                }
335            } else {
336                String::new()
337            }
338        } else {
339            String::new()
340        };
341
342        output.push_str(&format!("• {session_id} ({match_count} matches)\n"));
343        if !snippet.is_empty() {
344            output.push_str(&redact_secrets(&snippet));
345            output.push('\n');
346        }
347        output.push('\n');
348    }
349
350    if total > limit {
351        output.push_str(&format!("...and {} more sessions.", total - limit));
352    }
353
354    ToolResult::ok(output)
355}
356
357fn action_info(
358    manager: &SessionManager,
359    args: &HashMap<String, serde_json::Value>,
360    current_session_id: Option<&str>,
361) -> ToolResult {
362    let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
363        Some(s) => s,
364        None => return ToolResult::fail("session_id is required for info"),
365    };
366
367    if let Some(r) = validate_session_id(session_id) {
368        return r;
369    }
370    if let Some(r) = guard_current_session(session_id, current_session_id) {
371        return r;
372    }
373
374    let session = match manager.load_session(session_id) {
375        Ok(s) => s,
376        Err(e) => return ToolResult::fail(format!("Session not found or corrupted: {e}")),
377    };
378
379    let title = session
380        .metadata
381        .get("title")
382        .and_then(|v| v.as_str())
383        .unwrap_or("(untitled)");
384    let working_dir = session.working_directory.as_deref().unwrap_or("(unknown)");
385    let archived = if session.is_archived() { "yes" } else { "no" };
386    let parent = session.parent_id.as_deref().unwrap_or("none");
387    let subagent_count = session.subagent_sessions.len();
388    let file_changes = session.file_changes.len();
389    let summary = session.get_file_changes_summary();
390
391    let output = format!(
392        "Session: {session_id}\n\
393         Title: {title}\n\
394         Created: {}\n\
395         Updated: {}\n\
396         Messages: {}\n\
397         Working directory: {working_dir}\n\
398         File changes: {file_changes} (+{} lines, -{} lines across {} files)\n\
399         Parent session: {parent}\n\
400         Subagent sessions: {subagent_count}\n\
401         Archived: {archived}",
402        format_timestamp(&session.created_at),
403        format_timestamp(&session.updated_at),
404        session.messages.len(),
405        summary.total_lines_added,
406        summary.total_lines_removed,
407        summary.total,
408    );
409
410    ToolResult::ok(output)
411}
412
413#[cfg(test)]
414#[path = "session_tests.rs"]
415mod tests;