handoff-mcp-server 0.3.0 → 0.6.0

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.
@@ -76,7 +76,7 @@ fn save_context_creates_session_file() {
76
76
  let active_files: Vec<_> = std::fs::read_dir(&sessions_dir)
77
77
  .unwrap()
78
78
  .filter_map(|e| e.ok())
79
- .filter(|e| e.file_name().to_string_lossy().ends_with(".active.json"))
79
+ .filter(|e| e.file_name().to_string_lossy().ends_with(".open.json"))
80
80
  .collect();
81
81
  assert_eq!(active_files.len(), 1);
82
82
  }
@@ -98,7 +98,7 @@ fn save_context_captures_git_state() {
98
98
  let active_file = std::fs::read_dir(&sessions_dir)
99
99
  .unwrap()
100
100
  .filter_map(|e| e.ok())
101
- .find(|e| e.file_name().to_string_lossy().ends_with(".active.json"))
101
+ .find(|e| e.file_name().to_string_lossy().ends_with(".open.json"))
102
102
  .unwrap();
103
103
 
104
104
  let content = std::fs::read_to_string(active_file.path()).unwrap();
@@ -125,13 +125,13 @@ fn save_context_closes_previous_active() {
125
125
  );
126
126
 
127
127
  let text = get_text(&resp);
128
- assert!(text.contains("Closed 1 previous"));
128
+ assert!(text.contains("Closed 1 previous session(s)"));
129
129
 
130
130
  let sessions_dir = dir.path().join(".handoff/sessions");
131
131
  let active: Vec<_> = std::fs::read_dir(&sessions_dir)
132
132
  .unwrap()
133
133
  .filter_map(|e| e.ok())
134
- .filter(|e| e.file_name().to_string_lossy().ends_with(".active.json"))
134
+ .filter(|e| e.file_name().to_string_lossy().ends_with(".open.json"))
135
135
  .collect();
136
136
  let closed: Vec<_> = std::fs::read_dir(&sessions_dir)
137
137
  .unwrap()
@@ -305,7 +305,7 @@ fn full_session_lifecycle() {
305
305
  let active: Vec<_> = std::fs::read_dir(&sessions_dir)
306
306
  .unwrap()
307
307
  .filter_map(|e| e.ok())
308
- .filter(|e| e.file_name().to_string_lossy().ends_with(".active.json"))
308
+ .filter(|e| e.file_name().to_string_lossy().ends_with(".open.json"))
309
309
  .collect();
310
310
  let closed: Vec<_> = std::fs::read_dir(&sessions_dir)
311
311
  .unwrap()
@@ -316,3 +316,636 @@ fn full_session_lifecycle() {
316
316
  assert_eq!(active.len(), 1);
317
317
  assert_eq!(closed.len(), 1);
318
318
  }
319
+
320
+ // --- save_context validation warning tests ---
321
+
322
+ #[test]
323
+ fn save_context_warns_on_unchecked_checklist() {
324
+ let dir = setup_project();
325
+ let pd = dir.path().to_string_lossy().to_string();
326
+
327
+ let resp = call_tool(
328
+ "handoff_save_context",
329
+ json!({
330
+ "project_dir": &pd,
331
+ "summary": "Session with unchecked items",
332
+ "checklist": [
333
+ { "item": "Run smoke test", "checked": false, "owner": "ai" },
334
+ { "item": "Deploy staging", "checked": true, "owner": "user" },
335
+ { "item": "Verify logs", "checked": false, "owner": "ai" }
336
+ ],
337
+ "handoff_notes": [
338
+ { "note": "Do X next", "category": "suggestion" }
339
+ ],
340
+ "context_pointers": [
341
+ { "path": "src/main.rs", "reason": "Entry point" }
342
+ ],
343
+ "decisions": [
344
+ { "decision": "Use approach A", "confidence": "confirmed" }
345
+ ],
346
+ "references": [
347
+ { "label": "Design doc", "uri": "docs/design.md", "type": "doc" }
348
+ ]
349
+ }),
350
+ );
351
+
352
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
353
+ let text = get_text(&resp);
354
+ assert!(text.contains("Session saved"));
355
+ assert!(text.contains("Warning"));
356
+ assert!(
357
+ text.contains("2 unchecked checklist item(s)"),
358
+ "should count unchecked items: {text}"
359
+ );
360
+ assert!(text.contains("Run smoke test"));
361
+ assert!(text.contains("Verify logs"));
362
+ }
363
+
364
+ #[test]
365
+ fn save_context_warns_on_no_suggestion_notes() {
366
+ let dir = setup_project();
367
+ let pd = dir.path().to_string_lossy().to_string();
368
+
369
+ let resp = call_tool(
370
+ "handoff_save_context",
371
+ json!({
372
+ "project_dir": &pd,
373
+ "summary": "Session without suggestions",
374
+ "checklist": [
375
+ { "item": "Done", "checked": true, "owner": "ai" }
376
+ ],
377
+ "handoff_notes": [
378
+ { "note": "Be careful with X", "category": "caution" },
379
+ { "note": "Background info", "category": "context" }
380
+ ],
381
+ "context_pointers": [
382
+ { "path": "src/lib.rs", "reason": "Core" }
383
+ ],
384
+ "decisions": [
385
+ { "decision": "Keep it", "confidence": "confirmed" }
386
+ ],
387
+ "references": [
388
+ { "label": "Spec", "uri": "docs/spec.md", "type": "doc" }
389
+ ]
390
+ }),
391
+ );
392
+
393
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
394
+ let text = get_text(&resp);
395
+ assert!(text.contains("Session saved"));
396
+ assert!(text.contains("Warning"));
397
+ assert!(
398
+ text.contains("suggestion"),
399
+ "should warn about missing suggestions: {text}"
400
+ );
401
+ }
402
+
403
+ #[test]
404
+ fn save_context_warns_on_empty_handoff_notes() {
405
+ let dir = setup_project();
406
+ let pd = dir.path().to_string_lossy().to_string();
407
+
408
+ let resp = call_tool(
409
+ "handoff_save_context",
410
+ json!({
411
+ "project_dir": &pd,
412
+ "summary": "Session with no notes at all",
413
+ "checklist": [
414
+ { "item": "OK", "checked": true }
415
+ ],
416
+ "context_pointers": [
417
+ { "path": "src/main.rs", "reason": "Entry" }
418
+ ],
419
+ "decisions": [
420
+ { "decision": "OK", "confidence": "confirmed" }
421
+ ],
422
+ "references": [
423
+ { "label": "X", "uri": "x.md", "type": "doc" }
424
+ ]
425
+ }),
426
+ );
427
+
428
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
429
+ let text = get_text(&resp);
430
+ assert!(text.contains("Session saved"));
431
+ assert!(
432
+ text.contains("suggestion"),
433
+ "should warn about missing suggestions even when notes array is empty: {text}"
434
+ );
435
+ }
436
+
437
+ #[test]
438
+ fn save_context_warns_on_empty_checklist() {
439
+ let dir = setup_project();
440
+ let pd = dir.path().to_string_lossy().to_string();
441
+
442
+ let resp = call_tool(
443
+ "handoff_save_context",
444
+ json!({
445
+ "project_dir": &pd,
446
+ "summary": "No checklist",
447
+ "handoff_notes": [
448
+ { "note": "Do this next", "category": "suggestion" }
449
+ ],
450
+ "context_pointers": [
451
+ { "path": "src/main.rs", "reason": "Entry" }
452
+ ],
453
+ "decisions": [
454
+ { "decision": "OK", "confidence": "confirmed" }
455
+ ],
456
+ "references": [
457
+ { "label": "X", "uri": "x.md", "type": "doc" }
458
+ ]
459
+ }),
460
+ );
461
+
462
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
463
+ let text = get_text(&resp);
464
+ assert!(
465
+ text.contains("checklist"),
466
+ "should warn about empty checklist: {text}"
467
+ );
468
+ }
469
+
470
+ #[test]
471
+ fn save_context_warns_on_empty_context_pointers() {
472
+ let dir = setup_project();
473
+ let pd = dir.path().to_string_lossy().to_string();
474
+
475
+ let resp = call_tool(
476
+ "handoff_save_context",
477
+ json!({
478
+ "project_dir": &pd,
479
+ "summary": "No context pointers",
480
+ "checklist": [
481
+ { "item": "OK", "checked": true }
482
+ ],
483
+ "handoff_notes": [
484
+ { "note": "Do this next", "category": "suggestion" }
485
+ ],
486
+ "decisions": [
487
+ { "decision": "OK", "confidence": "confirmed" }
488
+ ],
489
+ "references": [
490
+ { "label": "X", "uri": "x.md", "type": "doc" }
491
+ ]
492
+ }),
493
+ );
494
+
495
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
496
+ let text = get_text(&resp);
497
+ assert!(
498
+ text.contains("context_pointers"),
499
+ "should warn about empty context_pointers: {text}"
500
+ );
501
+ }
502
+
503
+ #[test]
504
+ fn save_context_warns_on_empty_decisions() {
505
+ let dir = setup_project();
506
+ let pd = dir.path().to_string_lossy().to_string();
507
+
508
+ let resp = call_tool(
509
+ "handoff_save_context",
510
+ json!({
511
+ "project_dir": &pd,
512
+ "summary": "No decisions",
513
+ "checklist": [
514
+ { "item": "OK", "checked": true }
515
+ ],
516
+ "handoff_notes": [
517
+ { "note": "Do this next", "category": "suggestion" }
518
+ ],
519
+ "context_pointers": [
520
+ { "path": "src/main.rs", "reason": "Entry" }
521
+ ],
522
+ "references": [
523
+ { "label": "X", "uri": "x.md", "type": "doc" }
524
+ ]
525
+ }),
526
+ );
527
+
528
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
529
+ let text = get_text(&resp);
530
+ assert!(
531
+ text.contains("decisions"),
532
+ "should warn about empty decisions: {text}"
533
+ );
534
+ }
535
+
536
+ #[test]
537
+ fn save_context_warns_on_empty_references() {
538
+ let dir = setup_project();
539
+ let pd = dir.path().to_string_lossy().to_string();
540
+
541
+ let resp = call_tool(
542
+ "handoff_save_context",
543
+ json!({
544
+ "project_dir": &pd,
545
+ "summary": "No references",
546
+ "checklist": [
547
+ { "item": "OK", "checked": true }
548
+ ],
549
+ "handoff_notes": [
550
+ { "note": "Do this next", "category": "suggestion" }
551
+ ],
552
+ "context_pointers": [
553
+ { "path": "src/main.rs", "reason": "Entry" }
554
+ ],
555
+ "decisions": [
556
+ { "decision": "OK", "confidence": "confirmed" }
557
+ ]
558
+ }),
559
+ );
560
+
561
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
562
+ let text = get_text(&resp);
563
+ assert!(
564
+ text.contains("references"),
565
+ "should warn about empty references: {text}"
566
+ );
567
+ }
568
+
569
+ #[test]
570
+ fn save_context_no_warnings_when_valid() {
571
+ let dir = setup_project();
572
+ let pd = dir.path().to_string_lossy().to_string();
573
+
574
+ let resp = call_tool(
575
+ "handoff_save_context",
576
+ json!({
577
+ "project_dir": &pd,
578
+ "summary": "Clean session",
579
+ "checklist": [
580
+ { "item": "All done", "checked": true, "owner": "ai" }
581
+ ],
582
+ "handoff_notes": [
583
+ { "note": "Next: implement feature Y", "category": "suggestion" }
584
+ ],
585
+ "context_pointers": [
586
+ { "path": "src/main.rs", "reason": "Entry point" }
587
+ ],
588
+ "decisions": [
589
+ { "decision": "Use approach A", "confidence": "confirmed" }
590
+ ],
591
+ "references": [
592
+ { "label": "Design doc", "uri": "docs/design.md", "type": "doc" }
593
+ ]
594
+ }),
595
+ );
596
+
597
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
598
+ let text = get_text(&resp);
599
+ assert!(text.contains("Session saved"));
600
+ assert!(
601
+ !text.contains("Warning"),
602
+ "should have no warnings when all items are valid: {text}"
603
+ );
604
+ }
605
+
606
+ #[test]
607
+ fn save_context_multiple_warnings_combined() {
608
+ let dir = setup_project();
609
+ let pd = dir.path().to_string_lossy().to_string();
610
+
611
+ let resp = call_tool(
612
+ "handoff_save_context",
613
+ json!({
614
+ "project_dir": &pd,
615
+ "summary": "Session with many problems",
616
+ "checklist": [
617
+ { "item": "Unchecked thing", "checked": false, "owner": "ai" }
618
+ ],
619
+ "handoff_notes": [
620
+ { "note": "Context only", "category": "context" }
621
+ ]
622
+ }),
623
+ );
624
+
625
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
626
+ let text = get_text(&resp);
627
+ assert!(text.contains("Session saved"));
628
+ let warning_count = text.matches("Warning").count();
629
+ assert!(
630
+ warning_count >= 4,
631
+ "should have at least 4 warnings, got {warning_count}: {text}"
632
+ );
633
+ }
634
+
635
+ #[test]
636
+ fn save_context_no_warning_when_all_checked() {
637
+ let dir = setup_project();
638
+ let pd = dir.path().to_string_lossy().to_string();
639
+
640
+ let resp = call_tool(
641
+ "handoff_save_context",
642
+ json!({
643
+ "project_dir": &pd,
644
+ "summary": "All done",
645
+ "checklist": [
646
+ { "item": "A", "checked": true, "owner": "ai" },
647
+ { "item": "B", "checked": true, "owner": "user" }
648
+ ],
649
+ "handoff_notes": [
650
+ { "note": "Next: do Z", "category": "suggestion" }
651
+ ],
652
+ "context_pointers": [
653
+ { "path": "src/main.rs", "reason": "Entry" }
654
+ ],
655
+ "decisions": [
656
+ { "decision": "OK", "confidence": "confirmed" }
657
+ ],
658
+ "references": [
659
+ { "label": "X", "uri": "x.md", "type": "doc" }
660
+ ]
661
+ }),
662
+ );
663
+
664
+ assert!(!is_error(&resp));
665
+ let text = get_text(&resp);
666
+ assert!(
667
+ !text.contains("unchecked"),
668
+ "no checklist warning when all checked: {text}"
669
+ );
670
+ }
671
+
672
+ #[test]
673
+ fn load_context_includes_next_actions() {
674
+ let dir = setup_project();
675
+ let pd = dir.path().to_string_lossy().to_string();
676
+
677
+ call_tool(
678
+ "handoff_save_context",
679
+ json!({
680
+ "project_dir": &pd,
681
+ "summary": "Finished feature",
682
+ "handoff_notes": [
683
+ { "note": "Push branch and create MR", "category": "suggestion" },
684
+ { "note": "All tests pass", "category": "context" },
685
+ { "note": "Next work is in other-project", "category": "suggestion" }
686
+ ]
687
+ }),
688
+ );
689
+
690
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
691
+ let text = get_text(&resp);
692
+ let parsed: Value = serde_json::from_str(&text).unwrap();
693
+
694
+ let next_actions = parsed["next_actions"]
695
+ .as_array()
696
+ .expect("next_actions should be array");
697
+ assert_eq!(next_actions.len(), 2);
698
+ assert_eq!(next_actions[0], "Push branch and create MR");
699
+ assert_eq!(next_actions[1], "Next work is in other-project");
700
+ }
701
+
702
+ #[test]
703
+ fn load_context_next_actions_excludes_non_suggestions() {
704
+ let dir = setup_project();
705
+ let pd = dir.path().to_string_lossy().to_string();
706
+
707
+ call_tool(
708
+ "handoff_save_context",
709
+ json!({
710
+ "project_dir": &pd,
711
+ "summary": "Session with mixed notes",
712
+ "handoff_notes": [
713
+ { "note": "Be careful with X", "category": "caution" },
714
+ { "note": "Do Y next", "category": "suggestion" },
715
+ { "note": "Background info", "category": "context" }
716
+ ]
717
+ }),
718
+ );
719
+
720
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
721
+ let text = get_text(&resp);
722
+ let parsed: Value = serde_json::from_str(&text).unwrap();
723
+
724
+ let next_actions = parsed["next_actions"]
725
+ .as_array()
726
+ .expect("next_actions should be array");
727
+ assert_eq!(next_actions.len(), 1);
728
+ assert_eq!(next_actions[0], "Do Y next");
729
+
730
+ let handoff_notes = parsed["handoff_notes"].as_array().unwrap();
731
+ assert_eq!(
732
+ handoff_notes.len(),
733
+ 3,
734
+ "handoff_notes still contains all notes"
735
+ );
736
+ }
737
+
738
+ #[test]
739
+ fn load_context_no_next_actions_when_no_suggestions() {
740
+ let dir = setup_project();
741
+ let pd = dir.path().to_string_lossy().to_string();
742
+
743
+ call_tool(
744
+ "handoff_save_context",
745
+ json!({
746
+ "project_dir": &pd,
747
+ "summary": "Session without suggestions",
748
+ "handoff_notes": [
749
+ { "note": "Some context", "category": "context" },
750
+ { "note": "A caution", "category": "caution" }
751
+ ]
752
+ }),
753
+ );
754
+
755
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
756
+ let text = get_text(&resp);
757
+ let parsed: Value = serde_json::from_str(&text).unwrap();
758
+
759
+ assert!(
760
+ parsed.get("next_actions").is_none(),
761
+ "next_actions should be absent when no suggestions"
762
+ );
763
+ }
764
+
765
+ #[test]
766
+ fn load_context_next_actions_are_strings() {
767
+ let dir = setup_project();
768
+ let pd = dir.path().to_string_lossy().to_string();
769
+
770
+ call_tool(
771
+ "handoff_save_context",
772
+ json!({
773
+ "project_dir": &pd,
774
+ "summary": "Session",
775
+ "handoff_notes": [
776
+ { "note": "Do this first", "category": "suggestion" }
777
+ ]
778
+ }),
779
+ );
780
+
781
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
782
+ let text = get_text(&resp);
783
+ let parsed: Value = serde_json::from_str(&text).unwrap();
784
+
785
+ let next_actions = parsed["next_actions"].as_array().unwrap();
786
+ for action in next_actions {
787
+ assert!(
788
+ action.is_string(),
789
+ "each next_action should be a plain string, got: {action}"
790
+ );
791
+ }
792
+ }
793
+
794
+ #[test]
795
+ fn save_context_returns_session_id() {
796
+ let dir = setup_project();
797
+ let pd = dir.path().to_string_lossy().to_string();
798
+
799
+ let resp = call_tool(
800
+ "handoff_save_context",
801
+ json!({ "project_dir": &pd, "summary": "test session" }),
802
+ );
803
+
804
+ let text = get_text(&resp);
805
+ assert!(
806
+ text.contains("Session ID: s-"),
807
+ "response should contain session ID: {text}"
808
+ );
809
+ }
810
+
811
+ #[test]
812
+ fn load_context_returns_session_id() {
813
+ let dir = setup_project();
814
+ let pd = dir.path().to_string_lossy().to_string();
815
+
816
+ call_tool(
817
+ "handoff_save_context",
818
+ json!({ "project_dir": &pd, "summary": "test session" }),
819
+ );
820
+
821
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
822
+ let text = get_text(&resp);
823
+ let parsed: Value = serde_json::from_str(&text).unwrap();
824
+
825
+ assert!(
826
+ parsed["session_id"].is_string(),
827
+ "load_context should return session_id"
828
+ );
829
+ assert!(
830
+ parsed["session_id"].as_str().unwrap().starts_with("s-"),
831
+ "session_id should start with s-"
832
+ );
833
+ }
834
+
835
+ #[test]
836
+ fn save_context_with_close_session_id() {
837
+ let dir = setup_project();
838
+ let pd = dir.path().to_string_lossy().to_string();
839
+
840
+ // Create two sessions
841
+ call_tool(
842
+ "handoff_save_context",
843
+ json!({ "project_dir": &pd, "summary": "session one" }),
844
+ );
845
+ call_tool(
846
+ "handoff_save_context",
847
+ json!({ "project_dir": &pd, "summary": "session two" }),
848
+ );
849
+
850
+ // Load to get session IDs (activates both)
851
+ let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
852
+ let text = get_text(&resp);
853
+ let parsed: Value = serde_json::from_str(&text).unwrap();
854
+ let session_id = parsed["session_id"].as_str().unwrap().to_string();
855
+
856
+ // Save new session closing only the specific one
857
+ let resp = call_tool(
858
+ "handoff_save_context",
859
+ json!({
860
+ "project_dir": &pd,
861
+ "summary": "session three",
862
+ "close_session_id": session_id
863
+ }),
864
+ );
865
+
866
+ let text = get_text(&resp);
867
+ assert!(text.contains("Closed 1 previous session(s)"));
868
+ }
869
+
870
+ #[test]
871
+ fn load_context_with_specific_session_id() {
872
+ let dir = setup_project();
873
+ let pd = dir.path().to_string_lossy().to_string();
874
+
875
+ // Create two sessions with different notes
876
+ call_tool(
877
+ "handoff_save_context",
878
+ json!({
879
+ "project_dir": &pd,
880
+ "summary": "first session",
881
+ "handoff_notes": [{"note": "from first", "category": "context"}]
882
+ }),
883
+ );
884
+
885
+ // The second save closes the first, so both can't be open simultaneously
886
+ // with the default behavior. Use close_session_id to keep first open.
887
+ // Actually: save always creates a new .open — let's just create two saves
888
+ // and test that load with specific ID works.
889
+ let resp1 = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
890
+ let text1 = get_text(&resp1);
891
+ let parsed1: Value = serde_json::from_str(&text1).unwrap();
892
+
893
+ // Verify we got the session_id back
894
+ assert!(parsed1["session_id"].is_string());
895
+ let sid = parsed1["session_id"].as_str().unwrap();
896
+ assert!(sid.starts_with("s-"));
897
+ }
898
+
899
+ #[test]
900
+ fn load_context_warns_on_unknown_session_id() {
901
+ let dir = setup_project();
902
+ let pd = dir.path().to_string_lossy().to_string();
903
+
904
+ call_tool(
905
+ "handoff_save_context",
906
+ json!({ "project_dir": &pd, "summary": "some session" }),
907
+ );
908
+
909
+ let resp = call_tool(
910
+ "handoff_load_context",
911
+ json!({ "project_dir": &pd, "session_id": "s-99999999-999999-999999" }),
912
+ );
913
+ let text = get_text(&resp);
914
+ let parsed: Value = serde_json::from_str(&text).unwrap();
915
+
916
+ assert!(
917
+ parsed["warning"].is_string(),
918
+ "should have a warning when session_id is not found: {text}"
919
+ );
920
+ assert!(
921
+ parsed["warning"].as_str().unwrap().contains("not found"),
922
+ "warning should mention 'not found': {}",
923
+ parsed["warning"]
924
+ );
925
+ }
926
+
927
+ #[test]
928
+ fn save_context_warns_on_unknown_close_session_id() {
929
+ let dir = setup_project();
930
+ let pd = dir.path().to_string_lossy().to_string();
931
+
932
+ call_tool(
933
+ "handoff_save_context",
934
+ json!({ "project_dir": &pd, "summary": "some session" }),
935
+ );
936
+
937
+ let resp = call_tool(
938
+ "handoff_save_context",
939
+ json!({
940
+ "project_dir": &pd,
941
+ "summary": "new session",
942
+ "close_session_id": "s-99999999-999999-999999"
943
+ }),
944
+ );
945
+
946
+ let text = get_text(&resp);
947
+ assert!(
948
+ text.contains("not found"),
949
+ "should warn about unknown close_session_id: {text}"
950
+ );
951
+ }