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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/mcp/handlers/dashboard.rs +4 -2
- package/src/mcp/handlers/get_task.rs +3 -0
- package/src/mcp/handlers/import_context.rs +39 -5
- package/src/mcp/handlers/list_tasks.rs +3 -0
- package/src/mcp/handlers/load_context.rs +50 -21
- package/src/mcp/handlers/save_context.rs +106 -9
- package/src/mcp/handlers/update_task.rs +47 -0
- package/src/mcp/resources.rs +3 -2
- package/src/mcp/router.rs +1 -1
- package/src/mcp/tools.rs +69 -25
- package/src/storage/sessions.rs +161 -33
- package/src/storage/tasks.rs +162 -1
- package/tests/storage_sessions.rs +189 -25
- package/tests/storage_tasks.rs +3 -0
- package/tests/tool_import_context.rs +1 -1
- package/tests/tool_sessions.rs +638 -5
package/tests/tool_sessions.rs
CHANGED
|
@@ -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(".
|
|
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(".
|
|
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(".
|
|
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(".
|
|
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
|
+
}
|