handoff-mcp-server 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -144,7 +144,7 @@ dependencies = [
144
144
 
145
145
  [[package]]
146
146
  name = "handoff-mcp"
147
- version = "0.6.2"
147
+ version = "0.7.1"
148
148
  dependencies = [
149
149
  "anyhow",
150
150
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.6.2"
3
+ version = "0.7.1"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
Binary file
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "description": "MCP server that gives AI coding agents persistent memory across sessions",
5
5
  "license": "MIT",
6
6
  "author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
@@ -6,7 +6,7 @@ use serde_json::Value;
6
6
  use crate::storage::config::read_config;
7
7
  use crate::storage::expand_tilde;
8
8
  use crate::storage::referrals::read_referral_summaries;
9
- use crate::storage::sessions::{read_active_sessions, read_open_sessions};
9
+ use crate::storage::sessions::{read_active_sessions, read_open_sessions, read_paused_sessions};
10
10
  use crate::storage::tasks::build_task_index;
11
11
 
12
12
  pub fn handle(arguments: &Value) -> Result<String> {
@@ -73,6 +73,9 @@ fn collect_project_info(project_path: &Path) -> Result<Value> {
73
73
  let sessions_dir = handoff_dir.join("sessions");
74
74
  let mut sessions = read_open_sessions(&sessions_dir)?;
75
75
  sessions.extend(read_active_sessions(&sessions_dir)?);
76
+ let paused = read_paused_sessions(&sessions_dir)?;
77
+ let paused_count = paused.len() as u32;
78
+ sessions.extend(paused);
76
79
 
77
80
  let (_, summary) =
78
81
  build_task_index(&handoff_dir.join("tasks"), config.settings.done_task_limit)?;
@@ -105,5 +108,6 @@ fn collect_project_info(project_path: &Path) -> Result<Value> {
105
108
  "blocked_tasks": blocked_tasks,
106
109
  "blockers": blockers,
107
110
  "unread_referrals": unread_referrals,
111
+ "paused_sessions": paused_count,
108
112
  }))
109
113
  }
@@ -5,7 +5,8 @@ use super::resolve_project_dir;
5
5
  use crate::storage::config::read_config;
6
6
  use crate::storage::referrals::read_referral_summaries;
7
7
  use crate::storage::sessions::{
8
- activate_open_sessions, activate_session_by_id, read_open_sessions,
8
+ activate_open_sessions, activate_session_by_id, read_active_sessions, read_open_sessions,
9
+ read_paused_sessions, resume_paused_session_by_id,
9
10
  };
10
11
  use crate::storage::tasks::build_task_index;
11
12
  use crate::storage::{ensure_handoff_exists, handoff_dir};
@@ -38,13 +39,50 @@ pub fn handle(arguments: &Value) -> Result<String> {
38
39
 
39
40
  let target_session_id = arguments.get("session_id").and_then(|v| v.as_str());
40
41
 
42
+ let active_sessions = read_active_sessions(&sessions_dir)?;
41
43
  let sessions = read_open_sessions(&sessions_dir)?;
44
+ let paused_sessions = read_paused_sessions(&sessions_dir)?;
42
45
 
43
46
  let selected_session = if let Some(sid) = target_session_id {
44
- activate_session_by_id(&sessions_dir, sid)?;
45
- sessions
46
- .into_iter()
47
- .find(|s| s.id.as_deref().is_some_and(|id| id == sid))
47
+ let already_active = active_sessions
48
+ .iter()
49
+ .any(|s| s.id.as_deref().is_some_and(|id| id == sid));
50
+ if already_active {
51
+ active_sessions
52
+ .into_iter()
53
+ .find(|s| s.id.as_deref().is_some_and(|id| id == sid))
54
+ } else if !active_sessions.is_empty() {
55
+ let active_ids: Vec<String> = active_sessions
56
+ .iter()
57
+ .filter_map(|s| s.id.clone())
58
+ .collect();
59
+ anyhow::bail!(
60
+ "Cannot activate session '{sid}': another session is already active ({}).\n\
61
+ Use save_context with close_session_id or pause_session_id to \
62
+ close/pause the active session first.",
63
+ active_ids.join(", ")
64
+ );
65
+ } else if activate_session_by_id(&sessions_dir, sid)?.is_some() {
66
+ sessions
67
+ .into_iter()
68
+ .find(|s| s.id.as_deref().is_some_and(|id| id == sid))
69
+ } else if resume_paused_session_by_id(&sessions_dir, sid)?.is_some() {
70
+ paused_sessions
71
+ .into_iter()
72
+ .find(|s| s.id.as_deref().is_some_and(|id| id == sid))
73
+ } else {
74
+ None
75
+ }
76
+ } else if !active_sessions.is_empty() {
77
+ active_sessions.into_iter().last()
78
+ } else if sessions.len() > 1 {
79
+ let open_ids: Vec<String> = sessions.iter().filter_map(|s| s.id.clone()).collect();
80
+ anyhow::bail!(
81
+ "Multiple open sessions found ({}).\n\
82
+ Specify session_id to choose one, or use save_context to \
83
+ close/pause the others first.",
84
+ open_ids.join(", ")
85
+ );
48
86
  } else {
49
87
  activate_open_sessions(&sessions_dir)?;
50
88
  sessions.into_iter().last()
@@ -126,5 +164,21 @@ pub fn handle(arguments: &Value) -> Result<String> {
126
164
  result["referrals"] = serde_json::to_value(&open_referrals)?;
127
165
  }
128
166
 
167
+ let current_paused = read_paused_sessions(&sessions_dir)?;
168
+ if !current_paused.is_empty() {
169
+ let summaries: Vec<Value> = current_paused
170
+ .iter()
171
+ .map(|s| {
172
+ serde_json::json!({
173
+ "id": s.id,
174
+ "summary": s.summary,
175
+ "ended_at": s.ended_at,
176
+ "branch": s.branch,
177
+ })
178
+ })
179
+ .collect();
180
+ result["paused_sessions"] = serde_json::json!(summaries);
181
+ }
182
+
129
183
  serde_json::to_string_pretty(&result).context("Failed to serialize context")
130
184
  }
@@ -10,7 +10,8 @@ use crate::storage::ensure_handoff_exists;
10
10
  use crate::storage::git::capture_git_state;
11
11
  use crate::storage::sessions::{
12
12
  close_active_sessions, close_open_sessions, close_session_by_id, enforce_history_limit,
13
- generate_session_id, write_open_session, SessionData,
13
+ generate_session_id, pause_active_sessions, pause_session_by_id, read_active_sessions,
14
+ write_open_session, SessionData,
14
15
  };
15
16
 
16
17
  pub fn handle(arguments: &Value) -> Result<String> {
@@ -26,6 +27,20 @@ pub fn handle(arguments: &Value) -> Result<String> {
26
27
  .ok_or_else(|| anyhow::anyhow!("'summary' is required"))?;
27
28
 
28
29
  let close_id = arguments.get("close_session_id").and_then(|v| v.as_str());
30
+ let pause_id = arguments.get("pause_session_id").and_then(|v| v.as_str());
31
+ let pause_all = arguments
32
+ .get("pause_active")
33
+ .and_then(|v| v.as_bool())
34
+ .unwrap_or(false);
35
+
36
+ let mut total_paused = 0usize;
37
+ if let Some(id) = pause_id {
38
+ if pause_session_by_id(&sessions_dir, id)?.is_some() {
39
+ total_paused = 1;
40
+ }
41
+ } else if pause_all {
42
+ total_paused = pause_active_sessions(&sessions_dir)?.len();
43
+ }
29
44
 
30
45
  let total_closed = if let Some(id) = close_id {
31
46
  let closed = close_session_by_id(&sessions_dir, id)?;
@@ -34,7 +49,20 @@ pub fn handle(arguments: &Value) -> Result<String> {
34
49
  } else {
35
50
  0
36
51
  }
52
+ } else if pause_id.is_some() || pause_all {
53
+ let closed_open = close_open_sessions(&sessions_dir)?;
54
+ closed_open.len()
37
55
  } else {
56
+ let active = read_active_sessions(&sessions_dir)?;
57
+ if active.len() > 1 {
58
+ let active_ids: Vec<String> = active.iter().filter_map(|s| s.id.clone()).collect();
59
+ anyhow::bail!(
60
+ "Multiple active sessions found ({}).\n\
61
+ Use close_session_id or pause_session_id to specify which to \
62
+ close/pause before saving a new session.",
63
+ active_ids.join(", ")
64
+ );
65
+ }
38
66
  let closed_active = close_active_sessions(&sessions_dir)?;
39
67
  let closed_open = close_open_sessions(&sessions_dir)?;
40
68
  closed_active.len() + closed_open.len()
@@ -81,13 +109,23 @@ pub fn handle(arguments: &Value) -> Result<String> {
81
109
  .unwrap_or_default()
82
110
  );
83
111
 
112
+ if total_paused > 0 {
113
+ msg.push_str(&format!("\nPaused {} session(s)", total_paused));
114
+ }
115
+ if let Some(id) = pause_id {
116
+ if total_paused == 0 {
117
+ msg.push_str(&format!(
118
+ "\nWarning: pause_session_id '{id}' not found among active sessions"
119
+ ));
120
+ }
121
+ }
84
122
  if total_closed > 0 {
85
123
  msg.push_str(&format!("\nClosed {} previous session(s)", total_closed));
86
124
  }
87
125
  if let Some(id) = close_id {
88
126
  if total_closed == 0 {
89
127
  msg.push_str(&format!(
90
- "\nWarning: close_session_id '{id}' not found among active/open sessions"
128
+ "\nWarning: close_session_id '{id}' not found among active/open/paused sessions"
91
129
  ));
92
130
  }
93
131
  }
@@ -2,7 +2,7 @@ use anyhow::{Context, Result};
2
2
  use serde_json::{json, Value};
3
3
 
4
4
  use crate::storage::config::read_config;
5
- use crate::storage::sessions::{read_active_sessions, read_open_sessions};
5
+ use crate::storage::sessions::{read_active_sessions, read_open_sessions, read_paused_sessions};
6
6
  use crate::storage::{ensure_handoff_exists, handoff_dir};
7
7
 
8
8
  pub fn handle_resource_read(uri: &str) -> Result<Value> {
@@ -26,6 +26,7 @@ fn read_sessions_resource(handoff: &std::path::Path) -> Result<Value> {
26
26
  let sessions_dir = handoff.join("sessions");
27
27
  let mut sessions = read_open_sessions(&sessions_dir)?;
28
28
  sessions.extend(read_active_sessions(&sessions_dir)?);
29
+ sessions.extend(read_paused_sessions(&sessions_dir)?);
29
30
 
30
31
  let contents: Vec<Value> = sessions
31
32
  .iter()
package/src/mcp/tools.rs CHANGED
@@ -28,7 +28,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
28
28
  },
29
29
  ToolDefinition {
30
30
  name: "handoff_load_context".to_string(),
31
- description: "Load handoff context for the current project. Call at session start to resume work.".to_string(),
31
+ description: "Load handoff context for the current project. Call at session start to resume work. Can also resume a paused session by ID.".to_string(),
32
32
  input_schema: json!({
33
33
  "type": "object",
34
34
  "properties": {
@@ -38,7 +38,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
38
38
  },
39
39
  "session_id": {
40
40
  "type": "string",
41
- "description": "Session ID to activate and load. If omitted, activates all open sessions and returns the latest."
41
+ "description": "Session ID to activate and load. Searches open sessions first, then paused sessions. If omitted, activates all open sessions and returns the latest."
42
42
  }
43
43
  }
44
44
  }),
@@ -59,7 +59,15 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
59
59
  },
60
60
  "close_session_id": {
61
61
  "type": "string",
62
- "description": "Session ID to close. If omitted, all active and open sessions are closed (default behavior)."
62
+ "description": "Session ID to close. If omitted (and no pause options set), all active and open sessions are closed."
63
+ },
64
+ "pause_session_id": {
65
+ "type": "string",
66
+ "description": "Session ID to pause instead of close. The paused session can be resumed later via load_context with the same session_id. Use this when switching to different work temporarily."
67
+ },
68
+ "pause_active": {
69
+ "type": "boolean",
70
+ "description": "If true, pause all active sessions instead of closing them. Cannot be combined with close_session_id."
63
71
  },
64
72
  "decisions": {
65
73
  "type": "array",
@@ -258,11 +258,37 @@ pub fn close_open_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
258
258
  }
259
259
 
260
260
  pub fn close_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<PathBuf>> {
261
- // Try active first, then open
261
+ // Try active first, then open, then paused
262
262
  if let Some(path) = transition_session_by_id(sessions_dir, session_id, "active", "closed")? {
263
263
  return Ok(Some(path));
264
264
  }
265
- transition_session_by_id(sessions_dir, session_id, "open", "closed")
265
+ if let Some(path) = transition_session_by_id(sessions_dir, session_id, "open", "closed")? {
266
+ return Ok(Some(path));
267
+ }
268
+ transition_session_by_id(sessions_dir, session_id, "paused", "closed")
269
+ }
270
+
271
+ pub fn pause_active_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
272
+ transition_sessions(sessions_dir, "active", "paused")
273
+ }
274
+
275
+ pub fn pause_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<PathBuf>> {
276
+ transition_session_by_id(sessions_dir, session_id, "active", "paused")
277
+ }
278
+
279
+ pub fn resume_paused_session_by_id(
280
+ sessions_dir: &Path,
281
+ session_id: &str,
282
+ ) -> Result<Option<PathBuf>> {
283
+ transition_session_by_id(sessions_dir, session_id, "paused", "active")
284
+ }
285
+
286
+ pub fn read_paused_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
287
+ read_sessions_by_status(sessions_dir, "paused")
288
+ }
289
+
290
+ pub fn close_paused_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
291
+ transition_sessions(sessions_dir, "paused", "closed")
266
292
  }
267
293
 
268
294
  pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
@@ -357,3 +357,179 @@ fn close_session_by_id_nonexistent_returns_none() {
357
357
  let result = close_session_by_id(&sessions_dir, "s-nonexistent").unwrap();
358
358
  assert!(result.is_none());
359
359
  }
360
+
361
+ #[test]
362
+ fn pause_active_sessions_renames_to_paused() {
363
+ let dir = setup();
364
+ let sessions_dir = dir.path().join("sessions");
365
+ fs::create_dir_all(&sessions_dir).unwrap();
366
+
367
+ let s = make_session("working on feature", "2026-06-15T10:00:00Z");
368
+ write_open_session(&sessions_dir, &s).unwrap();
369
+ activate_open_sessions(&sessions_dir).unwrap();
370
+
371
+ let paused = pause_active_sessions(&sessions_dir).unwrap();
372
+ assert_eq!(paused.len(), 1);
373
+ assert!(paused[0].to_string_lossy().contains(".paused.json"));
374
+ assert!(paused[0].exists());
375
+
376
+ assert!(read_active_sessions(&sessions_dir).unwrap().is_empty());
377
+ let paused_sessions = read_paused_sessions(&sessions_dir).unwrap();
378
+ assert_eq!(paused_sessions.len(), 1);
379
+ assert_eq!(paused_sessions[0].summary, "working on feature");
380
+ }
381
+
382
+ #[test]
383
+ fn pause_session_by_id_pauses_only_targeted() {
384
+ let dir = setup();
385
+ let sessions_dir = dir.path().join("sessions");
386
+ fs::create_dir_all(&sessions_dir).unwrap();
387
+
388
+ let s1 = make_session("session one", "2026-06-15T10:00:00Z");
389
+ let s2 = make_session("session two", "2026-06-15T11:00:00Z");
390
+ write_open_session(&sessions_dir, &s1).unwrap();
391
+ write_open_session(&sessions_dir, &s2).unwrap();
392
+ activate_open_sessions(&sessions_dir).unwrap();
393
+
394
+ let active = read_active_sessions(&sessions_dir).unwrap();
395
+ assert_eq!(active.len(), 2);
396
+ let target_id = active[0].id.as_ref().unwrap().clone();
397
+
398
+ let result = pause_session_by_id(&sessions_dir, &target_id).unwrap();
399
+ assert!(result.is_some());
400
+
401
+ let remaining_active = read_active_sessions(&sessions_dir).unwrap();
402
+ assert_eq!(remaining_active.len(), 1);
403
+
404
+ let paused = read_paused_sessions(&sessions_dir).unwrap();
405
+ assert_eq!(paused.len(), 1);
406
+ assert_eq!(paused[0].id.as_deref().unwrap(), target_id);
407
+ }
408
+
409
+ #[test]
410
+ fn resume_paused_session_by_id_reactivates() {
411
+ let dir = setup();
412
+ let sessions_dir = dir.path().join("sessions");
413
+ fs::create_dir_all(&sessions_dir).unwrap();
414
+
415
+ let s = make_session("paused work", "2026-06-15T10:00:00Z");
416
+ write_open_session(&sessions_dir, &s).unwrap();
417
+ activate_open_sessions(&sessions_dir).unwrap();
418
+ pause_active_sessions(&sessions_dir).unwrap();
419
+
420
+ let paused = read_paused_sessions(&sessions_dir).unwrap();
421
+ assert_eq!(paused.len(), 1);
422
+ let sid = paused[0].id.as_ref().unwrap().clone();
423
+
424
+ let result = resume_paused_session_by_id(&sessions_dir, &sid).unwrap();
425
+ assert!(result.is_some());
426
+ assert!(result.unwrap().to_string_lossy().contains(".active.json"));
427
+
428
+ assert!(read_paused_sessions(&sessions_dir).unwrap().is_empty());
429
+ let active = read_active_sessions(&sessions_dir).unwrap();
430
+ assert_eq!(active.len(), 1);
431
+ assert_eq!(active[0].summary, "paused work");
432
+ }
433
+
434
+ #[test]
435
+ fn close_session_by_id_closes_paused() {
436
+ let dir = setup();
437
+ let sessions_dir = dir.path().join("sessions");
438
+ fs::create_dir_all(&sessions_dir).unwrap();
439
+
440
+ let s = make_session("will close from paused", "2026-06-15T10:00:00Z");
441
+ write_open_session(&sessions_dir, &s).unwrap();
442
+ activate_open_sessions(&sessions_dir).unwrap();
443
+ pause_active_sessions(&sessions_dir).unwrap();
444
+
445
+ let paused = read_paused_sessions(&sessions_dir).unwrap();
446
+ let sid = paused[0].id.as_ref().unwrap().clone();
447
+
448
+ let result = close_session_by_id(&sessions_dir, &sid).unwrap();
449
+ assert!(result.is_some());
450
+ assert!(result.unwrap().to_string_lossy().contains(".closed.json"));
451
+
452
+ assert!(read_paused_sessions(&sessions_dir).unwrap().is_empty());
453
+ }
454
+
455
+ #[test]
456
+ fn close_paused_sessions_closes_all() {
457
+ let dir = setup();
458
+ let sessions_dir = dir.path().join("sessions");
459
+ fs::create_dir_all(&sessions_dir).unwrap();
460
+
461
+ let s1 = make_session("paused one", "2026-06-15T10:00:00Z");
462
+ let s2 = make_session("paused two", "2026-06-15T11:00:00Z");
463
+ write_open_session(&sessions_dir, &s1).unwrap();
464
+ write_open_session(&sessions_dir, &s2).unwrap();
465
+ activate_open_sessions(&sessions_dir).unwrap();
466
+ pause_active_sessions(&sessions_dir).unwrap();
467
+
468
+ assert_eq!(read_paused_sessions(&sessions_dir).unwrap().len(), 2);
469
+
470
+ let closed = close_paused_sessions(&sessions_dir).unwrap();
471
+ assert_eq!(closed.len(), 2);
472
+ assert!(read_paused_sessions(&sessions_dir).unwrap().is_empty());
473
+ }
474
+
475
+ #[test]
476
+ fn full_lifecycle_with_pause_and_resume() {
477
+ let dir = setup();
478
+ let sessions_dir = dir.path().join("sessions");
479
+ fs::create_dir_all(&sessions_dir).unwrap();
480
+
481
+ let s1 = make_session("feature work", "2026-06-15T10:00:00Z");
482
+ write_open_session(&sessions_dir, &s1).unwrap();
483
+ activate_open_sessions(&sessions_dir).unwrap();
484
+
485
+ let active = read_active_sessions(&sessions_dir).unwrap();
486
+ assert_eq!(active.len(), 1);
487
+ let s1_id = active[0].id.as_ref().unwrap().clone();
488
+
489
+ pause_session_by_id(&sessions_dir, &s1_id).unwrap();
490
+ assert!(read_active_sessions(&sessions_dir).unwrap().is_empty());
491
+ assert_eq!(read_paused_sessions(&sessions_dir).unwrap().len(), 1);
492
+
493
+ let s2 = make_session("urgent fix", "2026-06-15T12:00:00Z");
494
+ write_open_session(&sessions_dir, &s2).unwrap();
495
+ activate_open_sessions(&sessions_dir).unwrap();
496
+
497
+ let active2 = read_active_sessions(&sessions_dir).unwrap();
498
+ assert_eq!(active2.len(), 1);
499
+ assert_eq!(active2[0].summary, "urgent fix");
500
+ assert_eq!(read_paused_sessions(&sessions_dir).unwrap().len(), 1);
501
+
502
+ close_active_sessions(&sessions_dir).unwrap();
503
+
504
+ resume_paused_session_by_id(&sessions_dir, &s1_id).unwrap();
505
+ let active3 = read_active_sessions(&sessions_dir).unwrap();
506
+ assert_eq!(active3.len(), 1);
507
+ assert_eq!(active3[0].summary, "feature work");
508
+ assert!(read_paused_sessions(&sessions_dir).unwrap().is_empty());
509
+ }
510
+
511
+ #[test]
512
+ fn enforce_history_limit_ignores_paused() {
513
+ let dir = setup();
514
+ let sessions_dir = dir.path().join("sessions");
515
+ fs::create_dir_all(&sessions_dir).unwrap();
516
+
517
+ for i in 1..=3 {
518
+ let name = format!("20260610-{i:06}-s{i}.closed.json");
519
+ fs::write(
520
+ sessions_dir.join(&name),
521
+ format!(r#"{{"version":2,"summary":"closed {i}"}}"#),
522
+ )
523
+ .unwrap();
524
+ }
525
+
526
+ let s = make_session("paused session", "2026-06-15T10:00:00Z");
527
+ write_open_session(&sessions_dir, &s).unwrap();
528
+ activate_open_sessions(&sessions_dir).unwrap();
529
+ pause_active_sessions(&sessions_dir).unwrap();
530
+
531
+ enforce_history_limit(&sessions_dir, 2).unwrap();
532
+
533
+ let paused = read_paused_sessions(&sessions_dir).unwrap();
534
+ assert_eq!(paused.len(), 1, "paused sessions should not be removed");
535
+ }
@@ -949,3 +949,362 @@ fn save_context_warns_on_unknown_close_session_id() {
949
949
  "should warn about unknown close_session_id: {text}"
950
950
  );
951
951
  }
952
+
953
+ // --- pause/resume session tests ---
954
+
955
+ #[test]
956
+ fn save_context_with_pause_session_id() {
957
+ let dir = setup_project();
958
+ let pd = dir.path().to_string_lossy().to_string();
959
+
960
+ call_tool(
961
+ "handoff_save_context",
962
+ json!({ "project_dir": &pd, "summary": "session one" }),
963
+ );
964
+
965
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
966
+ let text = get_text(&resp);
967
+ let parsed: Value = serde_json::from_str(&text).unwrap();
968
+ let session_id = parsed["session_id"].as_str().unwrap().to_string();
969
+
970
+ let resp = call_tool(
971
+ "handoff_save_context",
972
+ json!({
973
+ "project_dir": &pd,
974
+ "summary": "session two (switching work)",
975
+ "pause_session_id": session_id
976
+ }),
977
+ );
978
+
979
+ let text = get_text(&resp);
980
+ assert!(!is_error(&resp), "error: {text}");
981
+ assert!(
982
+ text.contains("Paused 1 session(s)"),
983
+ "should report paused: {text}"
984
+ );
985
+
986
+ let sessions_dir = dir.path().join(".handoff/sessions");
987
+ let paused: Vec<_> = std::fs::read_dir(&sessions_dir)
988
+ .unwrap()
989
+ .filter_map(|e| e.ok())
990
+ .filter(|e| e.file_name().to_string_lossy().ends_with(".paused.json"))
991
+ .collect();
992
+ assert_eq!(paused.len(), 1, "should have 1 paused session");
993
+ }
994
+
995
+ #[test]
996
+ fn save_context_with_pause_active() {
997
+ let dir = setup_project();
998
+ let pd = dir.path().to_string_lossy().to_string();
999
+
1000
+ call_tool(
1001
+ "handoff_save_context",
1002
+ json!({ "project_dir": &pd, "summary": "session one" }),
1003
+ );
1004
+ call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1005
+
1006
+ let resp = call_tool(
1007
+ "handoff_save_context",
1008
+ json!({
1009
+ "project_dir": &pd,
1010
+ "summary": "session two",
1011
+ "pause_active": true
1012
+ }),
1013
+ );
1014
+
1015
+ let text = get_text(&resp);
1016
+ assert!(!is_error(&resp), "error: {text}");
1017
+ assert!(
1018
+ text.contains("Paused 1 session(s)"),
1019
+ "should report paused: {text}"
1020
+ );
1021
+ }
1022
+
1023
+ #[test]
1024
+ fn load_context_resumes_paused_session() {
1025
+ let dir = setup_project();
1026
+ let pd = dir.path().to_string_lossy().to_string();
1027
+
1028
+ call_tool(
1029
+ "handoff_save_context",
1030
+ json!({
1031
+ "project_dir": &pd,
1032
+ "summary": "original work",
1033
+ "handoff_notes": [{ "note": "Continue feature X", "category": "suggestion" }]
1034
+ }),
1035
+ );
1036
+
1037
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1038
+ let text = get_text(&resp);
1039
+ let parsed: Value = serde_json::from_str(&text).unwrap();
1040
+ let original_sid = parsed["session_id"].as_str().unwrap().to_string();
1041
+
1042
+ call_tool(
1043
+ "handoff_save_context",
1044
+ json!({
1045
+ "project_dir": &pd,
1046
+ "summary": "switching to urgent work",
1047
+ "pause_session_id": &original_sid
1048
+ }),
1049
+ );
1050
+
1051
+ let resp = call_tool(
1052
+ "handoff_load_context",
1053
+ json!({ "project_dir": &pd, "session_id": &original_sid }),
1054
+ );
1055
+ let text = get_text(&resp);
1056
+ let parsed: Value = serde_json::from_str(&text).unwrap();
1057
+
1058
+ assert_eq!(
1059
+ parsed["session_id"].as_str().unwrap(),
1060
+ original_sid,
1061
+ "should resume the paused session"
1062
+ );
1063
+ assert!(
1064
+ parsed["last_session"]["summary"]
1065
+ .as_str()
1066
+ .unwrap()
1067
+ .contains("original work"),
1068
+ "should load the original session data"
1069
+ );
1070
+ }
1071
+
1072
+ #[test]
1073
+ fn load_context_shows_paused_sessions() {
1074
+ let dir = setup_project();
1075
+ let pd = dir.path().to_string_lossy().to_string();
1076
+
1077
+ call_tool(
1078
+ "handoff_save_context",
1079
+ json!({ "project_dir": &pd, "summary": "work A" }),
1080
+ );
1081
+ call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1082
+
1083
+ call_tool(
1084
+ "handoff_save_context",
1085
+ json!({
1086
+ "project_dir": &pd,
1087
+ "summary": "work B",
1088
+ "pause_active": true
1089
+ }),
1090
+ );
1091
+
1092
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1093
+ let text = get_text(&resp);
1094
+ let parsed: Value = serde_json::from_str(&text).unwrap();
1095
+
1096
+ let paused = parsed["paused_sessions"]
1097
+ .as_array()
1098
+ .expect("should have paused_sessions");
1099
+ assert_eq!(paused.len(), 1);
1100
+ assert_eq!(paused[0]["summary"], "work A");
1101
+ }
1102
+
1103
+ #[test]
1104
+ fn save_context_pause_unknown_id_warns() {
1105
+ let dir = setup_project();
1106
+ let pd = dir.path().to_string_lossy().to_string();
1107
+
1108
+ let resp = call_tool(
1109
+ "handoff_save_context",
1110
+ json!({
1111
+ "project_dir": &pd,
1112
+ "summary": "new session",
1113
+ "pause_session_id": "s-99999999-999999-999999"
1114
+ }),
1115
+ );
1116
+
1117
+ let text = get_text(&resp);
1118
+ assert!(
1119
+ text.contains("not found"),
1120
+ "should warn about unknown pause_session_id: {text}"
1121
+ );
1122
+ }
1123
+
1124
+ #[test]
1125
+ fn full_pause_resume_lifecycle() {
1126
+ let dir = setup_project();
1127
+ let pd = dir.path().to_string_lossy().to_string();
1128
+
1129
+ call_tool(
1130
+ "handoff_save_context",
1131
+ json!({
1132
+ "project_dir": &pd,
1133
+ "summary": "feature work"
1134
+ }),
1135
+ );
1136
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1137
+ let text = get_text(&resp);
1138
+ let parsed: Value = serde_json::from_str(&text).unwrap();
1139
+ let feature_sid = parsed["session_id"].as_str().unwrap().to_string();
1140
+
1141
+ call_tool(
1142
+ "handoff_save_context",
1143
+ json!({
1144
+ "project_dir": &pd,
1145
+ "summary": "urgent fix",
1146
+ "pause_session_id": &feature_sid
1147
+ }),
1148
+ );
1149
+
1150
+ let resp2 = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1151
+ let text2 = get_text(&resp2);
1152
+ let parsed2: Value = serde_json::from_str(&text2).unwrap();
1153
+ assert!(
1154
+ parsed2["last_session"]["summary"]
1155
+ .as_str()
1156
+ .unwrap()
1157
+ .contains("urgent fix"),
1158
+ "should load the new session"
1159
+ );
1160
+ assert!(
1161
+ parsed2["paused_sessions"].as_array().unwrap().len() == 1,
1162
+ "should show 1 paused session"
1163
+ );
1164
+
1165
+ let urgent_sid = parsed2["session_id"].as_str().unwrap().to_string();
1166
+ call_tool(
1167
+ "handoff_save_context",
1168
+ json!({
1169
+ "project_dir": &pd,
1170
+ "summary": "urgent fix done",
1171
+ "close_session_id": &urgent_sid
1172
+ }),
1173
+ );
1174
+
1175
+ let resp3 = call_tool(
1176
+ "handoff_load_context",
1177
+ json!({ "project_dir": &pd, "session_id": &feature_sid }),
1178
+ );
1179
+ let text3 = get_text(&resp3);
1180
+ let parsed3: Value = serde_json::from_str(&text3).unwrap();
1181
+ assert_eq!(
1182
+ parsed3["session_id"].as_str().unwrap(),
1183
+ feature_sid,
1184
+ "should resume the paused feature session"
1185
+ );
1186
+ assert!(
1187
+ parsed3
1188
+ .get("paused_sessions")
1189
+ .and_then(|v| v.as_array())
1190
+ .is_none()
1191
+ || parsed3["paused_sessions"].as_array().unwrap().is_empty(),
1192
+ "no more paused sessions after resume"
1193
+ );
1194
+ }
1195
+
1196
+ // --- active session uniqueness tests ---
1197
+
1198
+ #[test]
1199
+ fn load_context_rejects_activate_when_another_session_active() {
1200
+ let dir = setup_project();
1201
+ let pd = dir.path().to_string_lossy().to_string();
1202
+
1203
+ // Create session A and activate it
1204
+ call_tool(
1205
+ "handoff_save_context",
1206
+ json!({ "project_dir": &pd, "summary": "session A" }),
1207
+ );
1208
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1209
+ let text = get_text(&resp);
1210
+ let parsed: Value = serde_json::from_str(&text).unwrap();
1211
+ let sid_a = parsed["session_id"].as_str().unwrap().to_string();
1212
+
1213
+ // Create session B (without closing A — use close_session_id to close only A's open)
1214
+ // Actually: save_context default closes active + open, so A gets closed.
1215
+ // We need to simulate the scenario: write a second open session manually
1216
+ // while A is still active. We'll use pause to keep A alive.
1217
+ call_tool(
1218
+ "handoff_save_context",
1219
+ json!({
1220
+ "project_dir": &pd,
1221
+ "summary": "session B",
1222
+ "pause_session_id": &sid_a
1223
+ }),
1224
+ );
1225
+
1226
+ // Now: A is paused, B is open. Load B (activates it).
1227
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1228
+ assert!(!is_error(&resp));
1229
+ let text = get_text(&resp);
1230
+ let parsed: Value = serde_json::from_str(&text).unwrap();
1231
+ let sid_b = parsed["session_id"].as_str().unwrap().to_string();
1232
+
1233
+ // Try to resume A while B is active — should fail
1234
+ let resp = call_tool(
1235
+ "handoff_load_context",
1236
+ json!({ "project_dir": &pd, "session_id": &sid_a }),
1237
+ );
1238
+ assert!(
1239
+ is_error(&resp),
1240
+ "should reject activating A while B is active: {}",
1241
+ get_text(&resp)
1242
+ );
1243
+ let text = get_text(&resp);
1244
+ assert!(
1245
+ text.contains("already active"),
1246
+ "error should mention active session: {text}"
1247
+ );
1248
+ assert!(
1249
+ text.contains(&sid_b),
1250
+ "error should mention the blocking session ID: {text}"
1251
+ );
1252
+ }
1253
+
1254
+ #[test]
1255
+ fn load_context_allows_reloading_already_active_session() {
1256
+ let dir = setup_project();
1257
+ let pd = dir.path().to_string_lossy().to_string();
1258
+
1259
+ call_tool(
1260
+ "handoff_save_context",
1261
+ json!({ "project_dir": &pd, "summary": "session A" }),
1262
+ );
1263
+
1264
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1265
+ let text = get_text(&resp);
1266
+ let parsed: Value = serde_json::from_str(&text).unwrap();
1267
+ let sid = parsed["session_id"].as_str().unwrap().to_string();
1268
+
1269
+ // Loading the same session again should succeed (idempotent)
1270
+ let resp2 = call_tool(
1271
+ "handoff_load_context",
1272
+ json!({ "project_dir": &pd, "session_id": &sid }),
1273
+ );
1274
+ assert!(
1275
+ !is_error(&resp2),
1276
+ "reloading the same active session should succeed: {}",
1277
+ get_text(&resp2)
1278
+ );
1279
+ let text2 = get_text(&resp2);
1280
+ let parsed2: Value = serde_json::from_str(&text2).unwrap();
1281
+ assert_eq!(parsed2["session_id"].as_str().unwrap(), sid);
1282
+ }
1283
+
1284
+ #[test]
1285
+ fn load_context_returns_active_session_without_open() {
1286
+ let dir = setup_project();
1287
+ let pd = dir.path().to_string_lossy().to_string();
1288
+
1289
+ call_tool(
1290
+ "handoff_save_context",
1291
+ json!({ "project_dir": &pd, "summary": "my session" }),
1292
+ );
1293
+
1294
+ // First load activates the open session
1295
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1296
+ let text = get_text(&resp);
1297
+ let parsed: Value = serde_json::from_str(&text).unwrap();
1298
+ let sid = parsed["session_id"].as_str().unwrap().to_string();
1299
+
1300
+ // Second load (no session_id) should return the active session
1301
+ let resp2 = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
1302
+ assert!(!is_error(&resp2), "error: {}", get_text(&resp2));
1303
+ let text2 = get_text(&resp2);
1304
+ let parsed2: Value = serde_json::from_str(&text2).unwrap();
1305
+ assert_eq!(
1306
+ parsed2["session_id"].as_str().unwrap(),
1307
+ sid,
1308
+ "should return the same active session"
1309
+ );
1310
+ }