1use serde::{Deserialize, Serialize};
8
9use abk::checkpoint::{
10 get_storage_manager,
11 models::{CheckpointMetadata, ChatMessage, SessionMetadata as AbkSessionMetadata},
12 storage::ProjectMetadata as AbkProjectMetadata,
13 CheckpointStorageManager,
14};
15use abk::cli::ResumeInfo;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SessionSummary {
20 pub session_id: String,
21 pub project_name: String,
22 pub project_path: String,
23 pub project_id: String,
25 pub checkpoint_count: usize,
26 pub created_at: chrono::DateTime<chrono::Utc>,
27 pub last_accessed: chrono::DateTime<chrono::Utc>,
28 pub description: Option<String>,
29 pub is_current_project: bool,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CheckpointSummary {
35 pub checkpoint_id: String,
36 pub session_id: String,
37 pub iteration: u32,
38 pub workflow_step: String,
39 pub created_at: chrono::DateTime<chrono::Utc>,
40}
41
42fn session_to_summary(
47 session: &AbkSessionMetadata,
48 project: &AbkProjectMetadata,
49 is_current: bool,
50) -> SessionSummary {
51 SessionSummary {
52 session_id: session.session_id.clone(),
53 project_name: project.name.clone(),
54 project_path: project.project_path.to_string_lossy().to_string(),
55 project_id: project.project_hash.clone(),
56 checkpoint_count: session.checkpoint_count as usize,
57 created_at: session.created_at,
58 last_accessed: session.last_accessed,
59 description: session.description.clone(),
60 is_current_project: is_current,
61 }
62}
63
64fn checkpoint_to_summary(cp: &CheckpointMetadata) -> CheckpointSummary {
65 CheckpointSummary {
66 checkpoint_id: cp.checkpoint_id.clone(),
67 session_id: cp.session_id.clone(),
68 iteration: cp.iteration,
69 workflow_step: format!("{:?}", cp.workflow_step),
70 created_at: cp.created_at,
71 }
72}
73
74fn config_working_dir(config_toml: &str) -> std::path::PathBuf {
79 if let Ok(value) = toml::from_str::<toml::Value>(config_toml) {
80 if let Some(agent) = value.get("agent") {
81 if let Some(wd) = agent.get("working_dir").and_then(|v| v.as_str()) {
82 return std::path::PathBuf::from(wd);
83 }
84 }
85 }
86 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
87}
88
89fn paths_match(a: &std::path::Path, b: &std::path::Path) -> bool {
91 let ca = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
92 let cb = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
93 ca == cb
94}
95
96async fn make_storage_manager(
105 config_toml: &str,
106 home_dir: Option<&std::path::Path>,
107 agent_name: &str,
108) -> anyhow::Result<CheckpointStorageManager> {
109 let backend_config = parse_storage_backend_config(config_toml);
111
112 match (home_dir, backend_config) {
113 (Some(dir), Some(backend)) => {
115 CheckpointStorageManager::with_home_dir_and_backend(
116 dir.to_path_buf(),
117 agent_name,
118 backend,
119 )
120 .await
121 .map_err(|e| anyhow::anyhow!("Failed to create storage manager with backend: {}", e))
122 }
123 (Some(dir), None) => {
125 CheckpointStorageManager::with_home_dir(dir.to_path_buf(), agent_name)
126 .map_err(|e| anyhow::anyhow!("Failed to create storage manager: {}", e))
127 }
128 (None, _) => {
130 get_storage_manager()
131 .map_err(|e| anyhow::anyhow!("Failed to get storage manager: {}", e))
132 }
133 }
134}
135
136fn parse_storage_backend_config(config_toml: &str) -> Option<abk::checkpoint::StorageBackendConfig> {
140 let value = toml::from_str::<toml::Value>(config_toml).ok()?;
141 let checkpointing = value.get("checkpointing")?;
142 let storage_backend = checkpointing.get("storage_backend")?;
143 let backend: abk::checkpoint::StorageBackendConfig =
144 storage_backend.clone().try_into().ok()?;
145
146 match backend.backend_type {
148 abk::checkpoint::StorageBackendType::DocumentDB
149 | abk::checkpoint::StorageBackendType::MongoDB => Some(backend),
150 abk::checkpoint::StorageBackendType::File => None,
151 }
152}
153
154pub async fn list_all_sessions(
166 config_toml: &str,
167 home_dir: Option<&std::path::Path>,
168) -> anyhow::Result<Vec<SessionSummary>> {
169 let current_dir = config_working_dir(config_toml);
170 let manager = make_storage_manager(config_toml, home_dir, "trustee").await?;
171
172 let projects = manager
173 .list_projects()
174 .await
175 .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
176
177 let mut summaries = Vec::new();
178
179 for project in &projects {
180 let project_storage = match manager.get_project_storage_with_id(&project.project_path, &project.project_hash).await {
183 Ok(ps) => ps,
184 Err(e) => {
185 tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
186 continue;
187 }
188 };
189
190 let sessions = match project_storage.list_sessions().await {
191 Ok(s) => s,
192 Err(e) => {
193 tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
194 continue;
195 }
196 };
197
198 let is_current = paths_match(&project.project_path, ¤t_dir);
199
200 for session in sessions {
201 if session.checkpoint_count > 0 {
203 summaries.push(session_to_summary(&session, project, is_current));
204 }
205 }
206 }
207
208 summaries.sort_by(|a, b| match (a.is_current_project, b.is_current_project) {
210 (true, false) => std::cmp::Ordering::Less,
211 (false, true) => std::cmp::Ordering::Greater,
212 _ => b.last_accessed.cmp(&a.last_accessed),
213 });
214
215 Ok(summaries)
216}
217
218pub async fn get_session_detail(
225 _config_toml: &str,
226 session_id: &str,
227 home_dir: Option<&std::path::Path>,
228) -> anyhow::Result<Option<(SessionSummary, Vec<CheckpointSummary>)>> {
229 let manager = make_storage_manager(_config_toml, home_dir, "trustee").await?;
230
231 let projects = manager
232 .list_projects()
233 .await
234 .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
235
236 for project in &projects {
237 let project_storage = match manager.get_project_storage_with_id(&project.project_path, &project.project_hash).await {
238 Ok(ps) => ps,
239 Err(e) => {
240 tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
241 continue;
242 }
243 };
244
245 let sessions = match project_storage.list_sessions().await {
246 Ok(s) => s,
247 Err(e) => {
248 tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
249 continue;
250 }
251 };
252
253 if let Some(session) = sessions.iter().find(|s| s.session_id == session_id) {
254 let session_storage = project_storage
255 .create_session(session_id)
256 .await
257 .map_err(|e| anyhow::anyhow!("Failed to get session storage: {}", e))?;
258
259 let checkpoints = session_storage
260 .list_checkpoints()
261 .await
262 .map_err(|e| anyhow::anyhow!("Failed to list checkpoints: {}", e))?;
263
264 let summary = session_to_summary(session, project, false);
265 let cp_summaries: Vec<CheckpointSummary> =
266 checkpoints.iter().map(checkpoint_to_summary).collect();
267
268 return Ok(Some((summary, cp_summaries)));
269 }
270 }
271
272 Ok(None)
273}
274
275pub async fn create_resume_info(
283 _config_toml: &str,
284 session_id: &str,
285 home_dir: Option<&std::path::Path>,
286) -> anyhow::Result<Option<ResumeInfo>> {
287 let manager = make_storage_manager(_config_toml, home_dir, "trustee").await?;
288
289 let projects = manager
290 .list_projects()
291 .await
292 .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
293
294 for project in &projects {
295 let project_storage = match manager.get_project_storage_with_id(&project.project_path, &project.project_hash).await {
296 Ok(ps) => ps,
297 Err(e) => {
298 tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
299 continue;
300 }
301 };
302
303 let sessions = match project_storage.list_sessions().await {
304 Ok(s) => s,
305 Err(e) => {
306 tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
307 continue;
308 }
309 };
310
311 if sessions.iter().any(|s| s.session_id == session_id) {
312 let session_storage = project_storage
313 .create_session(session_id)
314 .await
315 .map_err(|e| anyhow::anyhow!("Failed to get session storage: {}", e))?;
316
317 let checkpoints = session_storage
318 .list_checkpoints()
319 .await
320 .map_err(|e| anyhow::anyhow!("Failed to list checkpoints: {}", e))?;
321
322 if let Some(latest) = checkpoints.iter().max_by_key(|cp| cp.created_at) {
323 return Ok(Some(ResumeInfo {
324 session_id: session_id.to_string(),
325 checkpoint_id: latest.checkpoint_id.clone(),
326 iteration: latest.iteration,
327 project_path: Some(project.project_path.clone()),
328 }));
329 }
330
331 return Ok(None);
333 }
334 }
335
336 Ok(None)
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct HistoryMessage {
353 pub role: String,
355 pub content: String,
357 pub reasoning: Option<String>,
359 pub tool_calls: Option<Vec<HistoryToolCall>>,
361 pub name: Option<String>,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct HistoryToolCall {
368 pub name: String,
369 pub hint: String,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct SessionHistory {
376 pub session_id: String,
377 pub checkpoint_id: String,
378 pub task_description: String,
379 pub iteration: u32,
380 pub total_messages: usize,
381 pub messages: Vec<HistoryMessage>,
382}
383
384pub async fn load_session_history(
392 session_id: &str,
393 home_dir: Option<&std::path::Path>,
394) -> anyhow::Result<Option<SessionHistory>> {
395 let manager = make_storage_manager("", home_dir, "trustee").await?;
396
397 let projects = manager
398 .list_projects()
399 .await
400 .map_err(|e| anyhow::anyhow!("Failed to list projects: {}", e))?;
401
402 for project in &projects {
403 let project_storage = match manager.get_project_storage_with_id(&project.project_path, &project.project_hash).await {
404 Ok(ps) => ps,
405 Err(e) => {
406 tracing::debug!("Skipping project {}: {}", project.project_path.display(), e);
407 continue;
408 }
409 };
410
411 let sessions = match project_storage.list_sessions().await {
412 Ok(s) => s,
413 Err(e) => {
414 tracing::debug!("Failed to list sessions for {}: {}", project.project_path.display(), e);
415 continue;
416 }
417 };
418
419 if !sessions.iter().any(|s| s.session_id == session_id) {
420 continue;
421 }
422
423 let session_storage = project_storage
424 .create_session(session_id)
425 .await
426 .map_err(|e| anyhow::anyhow!("Failed to get session storage: {}", e))?;
427
428 let checkpoints = session_storage
429 .list_checkpoints()
430 .await
431 .map_err(|e| anyhow::anyhow!("Failed to list checkpoints: {}", e))?;
432
433 let latest = match checkpoints.iter().max_by_key(|cp| cp.created_at) {
434 Some(cp) => cp,
435 None => return Ok(None),
436 };
437
438 let checkpoint_id = latest.checkpoint_id.clone();
439 let iteration = latest.iteration;
440
441 let checkpoint = session_storage
442 .load_checkpoint(&checkpoint_id)
443 .await
444 .map_err(|e| anyhow::anyhow!("Failed to load checkpoint: {}", e))?;
445
446 let task_description = checkpoint.agent_state.task_description.clone();
447 let total_messages = checkpoint.conversation_state.messages.len();
448
449 let messages = convert_messages(&checkpoint.conversation_state.messages);
450
451 return Ok(Some(SessionHistory {
452 session_id: session_id.to_string(),
453 checkpoint_id,
454 task_description,
455 iteration,
456 total_messages,
457 messages,
458 }));
459 }
460
461 Ok(None)
462}
463
464fn convert_messages(messages: &[ChatMessage]) -> Vec<HistoryMessage> {
470 const MAX_CONTENT_LEN: usize = 10_000;
471
472 let mut result = Vec::new();
473
474 for msg in messages {
475 if msg.role == "system" {
477 continue;
478 }
479
480 if msg.role == "tool" {
483 continue;
484 }
485
486 let content = if msg.content.len() > MAX_CONTENT_LEN {
491 format!("{}...\n[truncated]", abk::text::truncate_at_boundary(&msg.content, MAX_CONTENT_LEN))
492 } else {
493 msg.content.clone()
494 };
495
496 let tool_calls = msg.tool_calls.as_ref().map(|calls| {
498 calls
499 .iter()
500 .map(|tc| {
501 let hint = summarize_tool_args(&tc.function.name, &tc.function.arguments);
502 HistoryToolCall {
503 name: tc.function.name.clone(),
504 hint,
505 }
506 })
507 .collect::<Vec<_>>()
508 });
509
510 if msg.role == "assistant" && content.is_empty() && tool_calls.is_some() {
513 result.push(HistoryMessage {
514 role: "assistant".to_string(),
515 content: String::new(),
516 reasoning: msg.reasoning.clone(),
517 tool_calls,
518 name: None,
519 });
520 continue;
521 }
522
523 result.push(HistoryMessage {
524 role: msg.role.clone(),
525 content,
526 reasoning: msg.reasoning.clone(),
527 tool_calls,
528 name: msg.name.clone(),
529 });
530 }
531
532 result
533}
534
535fn summarize_tool_args(name: &str, args: &str) -> String {
540 let parsed: serde_json::Value = match serde_json::from_str(args) {
541 Ok(v) => v,
542 Err(_) => return args.chars().take(200).collect(),
543 };
544
545 let obj = match parsed.as_object() {
546 Some(o) => o,
547 None => return args.chars().take(200).collect(),
548 };
549
550 match name {
551 "bash" | "execute_command" => {
552 obj.get("command").and_then(|v| v.as_str()).map(|s| s.to_string())
553 }
554 "read" | "read_file" => {
555 obj.get("file_path").and_then(|v| v.as_str()).map(|s| s.to_string())
556 }
557 "write" | "write_file" | "edit" => {
558 obj.get("file_path").and_then(|v| v.as_str()).map(|s| s.to_string())
559 }
560 "grep" | "search" => {
561 obj.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string())
562 }
563 "glob" => {
564 obj.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string())
565 }
566 "todowrite" => {
567 let count = obj.get("todos").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
568 Some(format!("{} items", count))
569 }
570 "websearch" | "webfetch" => {
571 obj.get("query").or_else(|| obj.get("url")).and_then(|v| v.as_str()).map(|s| s.to_string())
572 }
573 _ => {
574 obj.values().next().and_then(|v| match v {
575 serde_json::Value::String(s) => Some(s.clone()),
576 _ => Some(v.to_string()),
577 })
578 }
579 }
580 .unwrap_or_default()
581 .chars()
582 .take(200)
583 .collect()
584}
585
586#[cfg(test)]
587mod history_truncation_tests {
588 use super::*;
589
590 fn msg(role: &str, content: String) -> ChatMessage {
591 ChatMessage {
592 role: role.to_string(),
593 content,
594 reasoning: None,
595 timestamp: chrono::Utc::now(),
596 token_count: None,
597 tool_calls: None,
598 tool_call_id: None,
599 name: None,
600 }
601 }
602
603 #[test]
607 fn persian_long_message_history_does_not_panic() {
608 let long = "ب".repeat(6_000); let out = convert_messages(&[msg("user", long.clone())]);
610 let first = &out[0].content;
611 assert!(first.ends_with("...\n[truncated]"), "marker missing: {:?}", &first[first.len().saturating_sub(30)..]);
612 let body = &first[..first.len() - "...\n[truncated]".len()];
613 assert!(body.len() <= 10_000, "body over budget: {}", body.len());
614 assert!(long.starts_with(body), "body must be a prefix of the original");
615 }
616
617 #[test]
619 fn ascii_long_message_truncates_identically_to_legacy() {
620 let out = convert_messages(&[msg("user", "A".repeat(20_000))]);
621 assert_eq!(
622 out[0].content,
623 format!("{}...\n[truncated]", "A".repeat(10_000))
624 );
625 }
626
627 #[test]
629 fn short_persian_message_unchanged() {
630 let out = convert_messages(&[msg("user", "سلام دنیا".to_string())]);
631 assert_eq!(out[0].content, "سلام دنیا");
632 }
633
634 #[test]
637 fn four_byte_char_straddling_cut_is_excluded() {
638 let long = "\u{1F680}".repeat(3_000); let out = convert_messages(&[msg("user", long.clone())]);
640 let first = &out[0].content;
641 let body = &first[..first.len() - "...\n[truncated]".len()];
642 assert_eq!(body.chars().count(), 2_500); assert!(long.starts_with(body));
644 }
645}