handoff-mcp-server 0.4.0 → 0.6.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.
@@ -9,6 +9,7 @@ fn setup() -> TempDir {
9
9
  fn make_session(summary: &str, ended_at: &str) -> SessionData {
10
10
  SessionData {
11
11
  version: 2,
12
+ id: None,
12
13
  ended_at: Some(ended_at.to_string()),
13
14
  summary: summary.to_string(),
14
15
  branch: Some("main".to_string()),
@@ -25,17 +26,17 @@ fn make_session(summary: &str, ended_at: &str) -> SessionData {
25
26
  }
26
27
 
27
28
  #[test]
28
- fn write_active_session_creates_file() {
29
+ fn write_open_session_creates_file() {
29
30
  let dir = setup();
30
31
  let sessions_dir = dir.path().join("sessions");
31
32
  fs::create_dir_all(&sessions_dir).unwrap();
32
33
 
33
34
  let data = make_session("test session", "2026-06-13T14:30:00Z");
34
- let path = write_active_session(&sessions_dir, &data).unwrap();
35
+ let path = write_open_session(&sessions_dir, &data).unwrap();
35
36
 
36
37
  assert!(path.exists());
37
38
  assert!(
38
- path.to_string_lossy().ends_with(".active.json"),
39
+ path.to_string_lossy().ends_with(".open.json"),
39
40
  "filename: {}",
40
41
  path.display()
41
42
  );
@@ -47,26 +48,26 @@ fn write_active_session_creates_file() {
47
48
  }
48
49
 
49
50
  #[test]
50
- fn read_active_sessions_empty() {
51
+ fn read_open_sessions_empty() {
51
52
  let dir = setup();
52
53
  let sessions_dir = dir.path().join("sessions");
53
54
  fs::create_dir_all(&sessions_dir).unwrap();
54
55
 
55
- let sessions = read_active_sessions(&sessions_dir).unwrap();
56
+ let sessions = read_open_sessions(&sessions_dir).unwrap();
56
57
  assert!(sessions.is_empty());
57
58
  }
58
59
 
59
60
  #[test]
60
- fn read_active_sessions_returns_active_only() {
61
+ fn read_open_sessions_returns_open_only() {
61
62
  let dir = setup();
62
63
  let sessions_dir = dir.path().join("sessions");
63
64
  fs::create_dir_all(&sessions_dir).unwrap();
64
65
 
65
- let s1 = make_session("active one", "2026-06-13T10:00:00Z");
66
- write_active_session(&sessions_dir, &s1).unwrap();
66
+ let s1 = make_session("open one", "2026-06-13T10:00:00Z");
67
+ write_open_session(&sessions_dir, &s1).unwrap();
67
68
 
68
- let s2 = make_session("active two", "2026-06-13T11:00:00Z");
69
- write_active_session(&sessions_dir, &s2).unwrap();
69
+ let s2 = make_session("open two", "2026-06-13T11:00:00Z");
70
+ write_open_session(&sessions_dir, &s2).unwrap();
70
71
 
71
72
  fs::write(
72
73
  sessions_dir.join("20260612-090000-old.closed.json"),
@@ -74,10 +75,26 @@ fn read_active_sessions_returns_active_only() {
74
75
  )
75
76
  .unwrap();
76
77
 
77
- let sessions = read_active_sessions(&sessions_dir).unwrap();
78
+ let sessions = read_open_sessions(&sessions_dir).unwrap();
78
79
  assert_eq!(sessions.len(), 2);
79
80
  }
80
81
 
82
+ #[test]
83
+ fn activate_open_sessions_renames_to_active() {
84
+ let dir = setup();
85
+ let sessions_dir = dir.path().join("sessions");
86
+ fs::create_dir_all(&sessions_dir).unwrap();
87
+
88
+ let s1 = make_session("session one", "2026-06-13T10:00:00Z");
89
+ let open_path = write_open_session(&sessions_dir, &s1).unwrap();
90
+
91
+ let activated = activate_open_sessions(&sessions_dir).unwrap();
92
+ assert_eq!(activated.len(), 1);
93
+ assert!(!open_path.exists());
94
+ assert!(activated[0].exists());
95
+ assert!(activated[0].to_string_lossy().contains(".active.json"));
96
+ }
97
+
81
98
  #[test]
82
99
  fn close_active_sessions_renames_to_closed() {
83
100
  let dir = setup();
@@ -85,7 +102,9 @@ fn close_active_sessions_renames_to_closed() {
85
102
  fs::create_dir_all(&sessions_dir).unwrap();
86
103
 
87
104
  let s1 = make_session("session one", "2026-06-13T10:00:00Z");
88
- let active_path = write_active_session(&sessions_dir, &s1).unwrap();
105
+ write_open_session(&sessions_dir, &s1).unwrap();
106
+ let activated = activate_open_sessions(&sessions_dir).unwrap();
107
+ let active_path = &activated[0];
89
108
 
90
109
  let closed = close_active_sessions(&sessions_dir).unwrap();
91
110
  assert_eq!(closed.len(), 1);
@@ -95,22 +114,51 @@ fn close_active_sessions_renames_to_closed() {
95
114
  }
96
115
 
97
116
  #[test]
98
- fn close_active_then_create_new() {
117
+ fn close_open_sessions_renames_to_closed() {
118
+ let dir = setup();
119
+ let sessions_dir = dir.path().join("sessions");
120
+ fs::create_dir_all(&sessions_dir).unwrap();
121
+
122
+ let s1 = make_session("session one", "2026-06-13T10:00:00Z");
123
+ let open_path = write_open_session(&sessions_dir, &s1).unwrap();
124
+
125
+ let closed = close_open_sessions(&sessions_dir).unwrap();
126
+ assert_eq!(closed.len(), 1);
127
+ assert!(!open_path.exists());
128
+ assert!(closed[0].exists());
129
+ assert!(closed[0].to_string_lossy().contains(".closed.json"));
130
+ }
131
+
132
+ #[test]
133
+ fn full_session_lifecycle_open_active_closed() {
99
134
  let dir = setup();
100
135
  let sessions_dir = dir.path().join("sessions");
101
136
  fs::create_dir_all(&sessions_dir).unwrap();
102
137
 
103
138
  let s1 = make_session("first session", "2026-06-13T10:00:00Z");
104
- write_active_session(&sessions_dir, &s1).unwrap();
139
+ write_open_session(&sessions_dir, &s1).unwrap();
105
140
 
106
- close_active_sessions(&sessions_dir).unwrap();
141
+ let open = read_open_sessions(&sessions_dir).unwrap();
142
+ assert_eq!(open.len(), 1);
143
+ assert_eq!(open[0].summary, "first session");
107
144
 
108
- let s2 = make_session("second session", "2026-06-13T14:00:00Z");
109
- write_active_session(&sessions_dir, &s2).unwrap();
145
+ activate_open_sessions(&sessions_dir).unwrap();
110
146
 
147
+ assert!(read_open_sessions(&sessions_dir).unwrap().is_empty());
111
148
  let active = read_active_sessions(&sessions_dir).unwrap();
112
149
  assert_eq!(active.len(), 1);
113
- assert_eq!(active[0].summary, "second session");
150
+ assert_eq!(active[0].summary, "first session");
151
+
152
+ close_active_sessions(&sessions_dir).unwrap();
153
+
154
+ assert!(read_active_sessions(&sessions_dir).unwrap().is_empty());
155
+
156
+ let s2 = make_session("second session", "2026-06-13T14:00:00Z");
157
+ write_open_session(&sessions_dir, &s2).unwrap();
158
+
159
+ let open2 = read_open_sessions(&sessions_dir).unwrap();
160
+ assert_eq!(open2.len(), 1);
161
+ assert_eq!(open2[0].summary, "second session");
114
162
  }
115
163
 
116
164
  #[test]
@@ -140,7 +188,7 @@ fn enforce_history_limit_removes_oldest() {
140
188
  }
141
189
 
142
190
  #[test]
143
- fn enforce_history_limit_ignores_active() {
191
+ fn enforce_history_limit_ignores_open_and_active() {
144
192
  let dir = setup();
145
193
  let sessions_dir = dir.path().join("sessions");
146
194
  fs::create_dir_all(&sessions_dir).unwrap();
@@ -154,13 +202,13 @@ fn enforce_history_limit_ignores_active() {
154
202
  .unwrap();
155
203
  }
156
204
 
157
- let s = make_session("active", "2026-06-13T10:00:00Z");
158
- write_active_session(&sessions_dir, &s).unwrap();
205
+ let s = make_session("open session", "2026-06-13T10:00:00Z");
206
+ write_open_session(&sessions_dir, &s).unwrap();
159
207
 
160
208
  enforce_history_limit(&sessions_dir, 2).unwrap();
161
209
 
162
- let active = read_active_sessions(&sessions_dir).unwrap();
163
- assert_eq!(active.len(), 1);
210
+ let open = read_open_sessions(&sessions_dir).unwrap();
211
+ assert_eq!(open.len(), 1);
164
212
  }
165
213
 
166
214
  #[test]
@@ -187,9 +235,125 @@ fn generate_session_filename_format() {
187
235
  }
188
236
 
189
237
  #[test]
190
- fn read_active_sessions_nonexistent_dir() {
238
+ fn read_open_sessions_nonexistent_dir() {
191
239
  let dir = setup();
192
240
  let sessions_dir = dir.path().join("nonexistent");
193
- let sessions = read_active_sessions(&sessions_dir).unwrap();
241
+ let sessions = read_open_sessions(&sessions_dir).unwrap();
194
242
  assert!(sessions.is_empty());
195
243
  }
244
+
245
+ #[test]
246
+ fn generate_session_id_format() {
247
+ let id = generate_session_id();
248
+ assert!(id.starts_with("s-"), "should start with s-: {id}");
249
+ assert!(id.len() > 20, "should be long enough: {id}");
250
+ let parts: Vec<&str> = id.splitn(2, '-').collect();
251
+ assert_eq!(parts[0], "s");
252
+ }
253
+
254
+ #[test]
255
+ fn write_open_session_assigns_id() {
256
+ let dir = setup();
257
+ let sessions_dir = dir.path().join("sessions");
258
+ fs::create_dir_all(&sessions_dir).unwrap();
259
+
260
+ let s = make_session("test", "2026-06-15T10:00:00Z");
261
+ assert!(s.id.is_none());
262
+
263
+ write_open_session(&sessions_dir, &s).unwrap();
264
+
265
+ let sessions = read_open_sessions(&sessions_dir).unwrap();
266
+ assert_eq!(sessions.len(), 1);
267
+ assert!(
268
+ sessions[0].id.is_some(),
269
+ "written session should have an id"
270
+ );
271
+ assert!(sessions[0].id.as_ref().unwrap().starts_with("s-"));
272
+ }
273
+
274
+ #[test]
275
+ fn read_old_session_without_id_gets_synthesized_id() {
276
+ let dir = setup();
277
+ let sessions_dir = dir.path().join("sessions");
278
+ fs::create_dir_all(&sessions_dir).unwrap();
279
+
280
+ // Write a session file without id field (simulating pre-upgrade file)
281
+ fs::write(
282
+ sessions_dir.join("20260613-143000-old-session.open.json"),
283
+ r#"{"version":2,"summary":"old session"}"#,
284
+ )
285
+ .unwrap();
286
+
287
+ let sessions = read_open_sessions(&sessions_dir).unwrap();
288
+ assert_eq!(sessions.len(), 1);
289
+ assert!(
290
+ sessions[0].id.is_some(),
291
+ "old session should get a synthesized id"
292
+ );
293
+ let id = sessions[0].id.as_ref().unwrap();
294
+ assert!(id.starts_with("s-"), "synthesized id format: {id}");
295
+ assert!(
296
+ id.contains("20260613-143000"),
297
+ "synthesized id should contain original timestamp: {id}"
298
+ );
299
+ }
300
+
301
+ #[test]
302
+ fn close_session_by_id_closes_only_targeted() {
303
+ let dir = setup();
304
+ let sessions_dir = dir.path().join("sessions");
305
+ fs::create_dir_all(&sessions_dir).unwrap();
306
+
307
+ let s1 = make_session("session one", "2026-06-15T10:00:00Z");
308
+ let s2 = make_session("session two", "2026-06-15T11:00:00Z");
309
+ write_open_session(&sessions_dir, &s1).unwrap();
310
+ write_open_session(&sessions_dir, &s2).unwrap();
311
+
312
+ let sessions = read_open_sessions(&sessions_dir).unwrap();
313
+ assert_eq!(sessions.len(), 2);
314
+
315
+ let target_id = sessions[0].id.as_ref().unwrap().clone();
316
+ let other_id = sessions[1].id.as_ref().unwrap().clone();
317
+
318
+ let result = close_session_by_id(&sessions_dir, &target_id).unwrap();
319
+ assert!(result.is_some(), "should close the targeted session");
320
+
321
+ let remaining_open = read_open_sessions(&sessions_dir).unwrap();
322
+ assert_eq!(remaining_open.len(), 1);
323
+ assert_eq!(remaining_open[0].id.as_deref().unwrap(), other_id);
324
+ }
325
+
326
+ #[test]
327
+ fn activate_session_by_id_activates_only_targeted() {
328
+ let dir = setup();
329
+ let sessions_dir = dir.path().join("sessions");
330
+ fs::create_dir_all(&sessions_dir).unwrap();
331
+
332
+ let s1 = make_session("session one", "2026-06-15T10:00:00Z");
333
+ let s2 = make_session("session two", "2026-06-15T11:00:00Z");
334
+ write_open_session(&sessions_dir, &s1).unwrap();
335
+ write_open_session(&sessions_dir, &s2).unwrap();
336
+
337
+ let sessions = read_open_sessions(&sessions_dir).unwrap();
338
+ let target_id = sessions[0].id.as_ref().unwrap().clone();
339
+
340
+ let result = activate_session_by_id(&sessions_dir, &target_id).unwrap();
341
+ assert!(result.is_some(), "should activate the targeted session");
342
+
343
+ let remaining_open = read_open_sessions(&sessions_dir).unwrap();
344
+ assert_eq!(remaining_open.len(), 1, "one session should remain open");
345
+
346
+ let active = read_active_sessions(&sessions_dir).unwrap();
347
+ assert_eq!(active.len(), 1, "one session should be active");
348
+ assert_eq!(active[0].id.as_deref().unwrap(), target_id);
349
+ }
350
+
351
+ #[test]
352
+ fn close_session_by_id_nonexistent_returns_none() {
353
+ let dir = setup();
354
+ let sessions_dir = dir.path().join("sessions");
355
+ fs::create_dir_all(&sessions_dir).unwrap();
356
+
357
+ let result = close_session_by_id(&sessions_dir, "s-nonexistent").unwrap();
358
+ assert!(result.is_none());
359
+ }
@@ -360,7 +360,7 @@ fn import_source_recorded_in_environment() {
360
360
  let entries: Vec<_> = std::fs::read_dir(&sessions_dir)
361
361
  .unwrap()
362
362
  .filter_map(|e| e.ok())
363
- .filter(|e| e.file_name().to_string_lossy().ends_with(".active.json"))
363
+ .filter(|e| e.file_name().to_string_lossy().ends_with(".open.json"))
364
364
  .collect();
365
365
  assert_eq!(entries.len(), 1);
366
366
 
@@ -440,3 +440,165 @@ fn no_referrals_dir_is_graceful() {
440
440
  let parsed: Value = serde_json::from_str(&text).unwrap();
441
441
  assert_eq!(parsed["total"], 0);
442
442
  }
443
+
444
+ #[test]
445
+ fn refer_warns_on_missing_details() {
446
+ let (_base, proj_a, proj_b) = setup_two_projects();
447
+
448
+ let resp = call_tool(
449
+ "handoff_refer",
450
+ json!({
451
+ "project_dir": proj_a.to_string_lossy(),
452
+ "target_project_dir": proj_b.to_string_lossy(),
453
+ "summary": "Minimal referral"
454
+ }),
455
+ );
456
+
457
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
458
+ let text = get_text(&resp);
459
+ assert!(text.contains("Referral sent"));
460
+ let warning_count = text.matches("Warning").count();
461
+ assert!(
462
+ warning_count >= 3,
463
+ "should have at least 3 warnings (details, tasks, context), got {warning_count}: {text}"
464
+ );
465
+ assert!(
466
+ text.contains("details"),
467
+ "should warn about missing details: {text}"
468
+ );
469
+ assert!(
470
+ text.contains("tasks"),
471
+ "should warn about missing tasks: {text}"
472
+ );
473
+ assert!(
474
+ text.contains("context"),
475
+ "should warn about missing context: {text}"
476
+ );
477
+ }
478
+
479
+ #[test]
480
+ fn refer_warns_on_tasks_without_done_criteria() {
481
+ let (_base, proj_a, proj_b) = setup_two_projects();
482
+
483
+ let resp = call_tool(
484
+ "handoff_refer",
485
+ json!({
486
+ "project_dir": proj_a.to_string_lossy(),
487
+ "target_project_dir": proj_b.to_string_lossy(),
488
+ "summary": "Referral with bare tasks",
489
+ "details": "Some details here",
490
+ "priority": "high",
491
+ "context": { "branch": "main" },
492
+ "tasks": [
493
+ { "title": "Task without criteria" },
494
+ { "title": "Task with criteria", "done_criteria": [{"item": "check", "checked": false}] }
495
+ ]
496
+ }),
497
+ );
498
+
499
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
500
+ let text = get_text(&resp);
501
+ assert!(
502
+ text.contains("Task #1 'Task without criteria' has no done_criteria"),
503
+ "should warn about task without criteria: {text}"
504
+ );
505
+ assert!(
506
+ !text.contains("Task #2"),
507
+ "should NOT warn about task with criteria: {text}"
508
+ );
509
+ }
510
+
511
+ #[test]
512
+ fn refer_no_warnings_when_complete() {
513
+ let (_base, proj_a, proj_b) = setup_two_projects();
514
+
515
+ let resp = call_tool(
516
+ "handoff_refer",
517
+ json!({
518
+ "project_dir": proj_a.to_string_lossy(),
519
+ "target_project_dir": proj_b.to_string_lossy(),
520
+ "summary": "Complete referral",
521
+ "referral_type": "request",
522
+ "priority": "high",
523
+ "details": "Full description of what needs to happen",
524
+ "context": {
525
+ "branch": "feat/x",
526
+ "commit": "abc123",
527
+ "spec_docs": [
528
+ format!("{}/.handoff/config.toml", proj_a.to_string_lossy()),
529
+ "https://gitlab.example.com/project/-/merge_requests/1"
530
+ ]
531
+ },
532
+ "tasks": [
533
+ {
534
+ "title": "Do the thing",
535
+ "done_criteria": [{"item": "Thing is done", "checked": false}]
536
+ }
537
+ ]
538
+ }),
539
+ );
540
+
541
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
542
+ let text = get_text(&resp);
543
+ assert!(text.contains("Referral sent"));
544
+ assert!(
545
+ !text.contains("Warning"),
546
+ "should have no warnings when fully specified: {text}"
547
+ );
548
+ }
549
+
550
+ #[test]
551
+ fn refer_warns_on_context_without_spec_docs() {
552
+ let (_base, proj_a, proj_b) = setup_two_projects();
553
+
554
+ let resp = call_tool(
555
+ "handoff_refer",
556
+ json!({
557
+ "project_dir": proj_a.to_string_lossy(),
558
+ "target_project_dir": proj_b.to_string_lossy(),
559
+ "summary": "Has context but no spec refs",
560
+ "details": "Description",
561
+ "priority": "medium",
562
+ "context": { "branch": "main", "commit": "abc123" },
563
+ "tasks": [
564
+ { "title": "Task", "done_criteria": [{"item": "check", "checked": false}] }
565
+ ]
566
+ }),
567
+ );
568
+
569
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
570
+ let text = get_text(&resp);
571
+ assert!(
572
+ text.contains("spec/doc references"),
573
+ "should warn about missing spec references in context: {text}"
574
+ );
575
+ }
576
+
577
+ #[test]
578
+ fn refer_warns_on_nonexistent_spec_path() {
579
+ let (_base, proj_a, proj_b) = setup_two_projects();
580
+
581
+ let resp = call_tool(
582
+ "handoff_refer",
583
+ json!({
584
+ "project_dir": proj_a.to_string_lossy(),
585
+ "target_project_dir": proj_b.to_string_lossy(),
586
+ "summary": "Spec path does not exist",
587
+ "details": "Description",
588
+ "priority": "medium",
589
+ "context": {
590
+ "spec_docs": ["/nonexistent/path/to/spec.md"]
591
+ },
592
+ "tasks": [
593
+ { "title": "Task", "done_criteria": [{"item": "check", "checked": false}] }
594
+ ]
595
+ }),
596
+ );
597
+
598
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
599
+ let text = get_text(&resp);
600
+ assert!(
601
+ text.contains("does not exist"),
602
+ "should warn about nonexistent spec path: {text}"
603
+ );
604
+ }