1use 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#[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 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 let paths = Paths::new(None);
84 let session_dir = paths.project_sessions_dir(&ctx.working_dir);
85
86 if !session_dir.exists() {
88 return ToolResult::ok("No past sessions found for this project.".to_string());
89 }
90
91 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
111fn 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
121fn 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
139fn 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 if let Some(current_id) = current_session_id {
157 sessions.retain(|s| s.id != current_id);
158 }
159
160 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 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 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 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 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;