handoff-mcp-server 0.7.0 → 0.7.2

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.
@@ -1,1194 +0,0 @@
1
- use serde_json::{json, Value};
2
- use tempfile::TempDir;
3
-
4
- fn send(input: &str) -> Option<Value> {
5
- let result = handoff_mcp::mcp::protocol::process_line(input)?;
6
- Some(serde_json::from_str(&result).expect("response should be valid JSON"))
7
- }
8
-
9
- fn setup_project() -> TempDir {
10
- let dir = tempfile::tempdir().expect("failed to create temp dir");
11
-
12
- std::process::Command::new("git")
13
- .args(["init"])
14
- .current_dir(dir.path())
15
- .output()
16
- .unwrap();
17
- std::process::Command::new("git")
18
- .args(["commit", "--allow-empty", "-m", "init"])
19
- .current_dir(dir.path())
20
- .output()
21
- .unwrap();
22
-
23
- let req = json!({
24
- "jsonrpc": "2.0", "id": 0,
25
- "method": "tools/call",
26
- "params": {
27
- "name": "handoff_init",
28
- "arguments": {
29
- "project_dir": dir.path().to_string_lossy(),
30
- "project_name": "test"
31
- }
32
- }
33
- });
34
- send(&req.to_string()).unwrap();
35
- dir
36
- }
37
-
38
- fn call_tool(name: &str, arguments: Value) -> Value {
39
- let req = json!({
40
- "jsonrpc": "2.0", "id": 1,
41
- "method": "tools/call",
42
- "params": { "name": name, "arguments": arguments }
43
- });
44
- send(&req.to_string()).unwrap()
45
- }
46
-
47
- fn get_text(resp: &Value) -> String {
48
- resp["result"]["content"][0]["text"]
49
- .as_str()
50
- .unwrap_or("")
51
- .to_string()
52
- }
53
-
54
- fn is_error(resp: &Value) -> bool {
55
- resp["result"]["isError"].as_bool().unwrap_or(false)
56
- }
57
-
58
- #[test]
59
- fn save_context_creates_session_file() {
60
- let dir = setup_project();
61
- let pd = dir.path().to_string_lossy().to_string();
62
-
63
- let resp = call_tool(
64
- "handoff_save_context",
65
- json!({
66
- "project_dir": &pd,
67
- "summary": "Implemented feature X"
68
- }),
69
- );
70
-
71
- assert!(!is_error(&resp), "error: {}", get_text(&resp));
72
- let text = get_text(&resp);
73
- assert!(text.contains("Session saved"));
74
-
75
- let sessions_dir = dir.path().join(".handoff/sessions");
76
- let active_files: Vec<_> = std::fs::read_dir(&sessions_dir)
77
- .unwrap()
78
- .filter_map(|e| e.ok())
79
- .filter(|e| e.file_name().to_string_lossy().ends_with(".open.json"))
80
- .collect();
81
- assert_eq!(active_files.len(), 1);
82
- }
83
-
84
- #[test]
85
- fn save_context_captures_git_state() {
86
- let dir = setup_project();
87
- let pd = dir.path().to_string_lossy().to_string();
88
-
89
- call_tool(
90
- "handoff_save_context",
91
- json!({
92
- "project_dir": &pd,
93
- "summary": "Test session"
94
- }),
95
- );
96
-
97
- let sessions_dir = dir.path().join(".handoff/sessions");
98
- let active_file = std::fs::read_dir(&sessions_dir)
99
- .unwrap()
100
- .filter_map(|e| e.ok())
101
- .find(|e| e.file_name().to_string_lossy().ends_with(".open.json"))
102
- .unwrap();
103
-
104
- let content = std::fs::read_to_string(active_file.path()).unwrap();
105
- let session: Value = serde_json::from_str(&content).unwrap();
106
-
107
- assert!(session["branch"].is_string());
108
- assert!(session["commit"].is_string());
109
- assert!(session["ended_at"].is_string());
110
- }
111
-
112
- #[test]
113
- fn save_context_closes_previous_active() {
114
- let dir = setup_project();
115
- let pd = dir.path().to_string_lossy().to_string();
116
-
117
- call_tool(
118
- "handoff_save_context",
119
- json!({ "project_dir": &pd, "summary": "First session" }),
120
- );
121
-
122
- let resp = call_tool(
123
- "handoff_save_context",
124
- json!({ "project_dir": &pd, "summary": "Second session" }),
125
- );
126
-
127
- let text = get_text(&resp);
128
- assert!(text.contains("Closed 1 previous session(s)"));
129
-
130
- let sessions_dir = dir.path().join(".handoff/sessions");
131
- let active: Vec<_> = std::fs::read_dir(&sessions_dir)
132
- .unwrap()
133
- .filter_map(|e| e.ok())
134
- .filter(|e| e.file_name().to_string_lossy().ends_with(".open.json"))
135
- .collect();
136
- let closed: Vec<_> = std::fs::read_dir(&sessions_dir)
137
- .unwrap()
138
- .filter_map(|e| e.ok())
139
- .filter(|e| e.file_name().to_string_lossy().ends_with(".closed.json"))
140
- .collect();
141
-
142
- assert_eq!(active.len(), 1);
143
- assert_eq!(closed.len(), 1);
144
- }
145
-
146
- #[test]
147
- fn save_context_with_full_data() {
148
- let dir = setup_project();
149
- let pd = dir.path().to_string_lossy().to_string();
150
-
151
- let resp = call_tool(
152
- "handoff_save_context",
153
- json!({
154
- "project_dir": &pd,
155
- "summary": "Full session",
156
- "decisions": [
157
- { "decision": "Use DMA", "reason": "Better throughput", "confidence": "confirmed" }
158
- ],
159
- "blockers": ["Waiting for hardware"],
160
- "checklist": [
161
- { "item": "Run smoke test", "checked": false, "owner": "ai" }
162
- ],
163
- "handoff_notes": [
164
- { "note": "Push after approval", "category": "caution" }
165
- ],
166
- "references": [
167
- { "label": "Design doc", "uri": "docs/design.md", "type": "doc" }
168
- ],
169
- "context_pointers": [
170
- { "path": "src/main.rs", "reason": "Entry point", "lines": "1-20" }
171
- ],
172
- "environment": { "fw_version": "1.0" }
173
- }),
174
- );
175
-
176
- assert!(!is_error(&resp), "error: {}", get_text(&resp));
177
- }
178
-
179
- #[test]
180
- fn save_context_without_summary_fails() {
181
- let dir = setup_project();
182
- let pd = dir.path().to_string_lossy().to_string();
183
-
184
- let resp = call_tool("handoff_save_context", json!({ "project_dir": &pd }));
185
-
186
- assert!(is_error(&resp));
187
- }
188
-
189
- #[test]
190
- fn load_context_uninitialized_returns_prompt() {
191
- let dir = tempfile::tempdir().unwrap();
192
- let pd = dir.path().to_string_lossy().to_string();
193
-
194
- let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
195
-
196
- assert!(!is_error(&resp));
197
- let text = get_text(&resp);
198
- let parsed: Value = serde_json::from_str(&text).unwrap();
199
- assert_eq!(parsed["status"], "not_initialized");
200
- }
201
-
202
- #[test]
203
- fn load_context_empty_project() {
204
- let dir = setup_project();
205
- let pd = dir.path().to_string_lossy().to_string();
206
-
207
- let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
208
-
209
- assert!(!is_error(&resp), "error: {}", get_text(&resp));
210
- let text = get_text(&resp);
211
- let parsed: Value = serde_json::from_str(&text).unwrap();
212
- assert_eq!(parsed["project"], "test");
213
- assert!(parsed["task_tree"].as_array().unwrap().is_empty());
214
- assert_eq!(parsed["task_summary"]["total"], 0);
215
- }
216
-
217
- #[test]
218
- fn load_context_with_session_and_tasks() {
219
- let dir = setup_project();
220
- let pd = dir.path().to_string_lossy().to_string();
221
-
222
- call_tool(
223
- "handoff_update_task",
224
- json!({
225
- "project_dir": &pd,
226
- "task": { "title": "Task 1", "status": "in_progress" }
227
- }),
228
- );
229
-
230
- call_tool(
231
- "handoff_save_context",
232
- json!({
233
- "project_dir": &pd,
234
- "summary": "Did some work",
235
- "decisions": [
236
- { "decision": "Use approach A", "confidence": "confirmed" }
237
- ],
238
- "handoff_notes": [
239
- { "note": "Check tests", "category": "suggestion" }
240
- ]
241
- }),
242
- );
243
-
244
- let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
245
- let text = get_text(&resp);
246
- let parsed: Value = serde_json::from_str(&text).unwrap();
247
-
248
- assert_eq!(parsed["project"], "test");
249
- assert!(parsed["last_session"]["summary"]
250
- .as_str()
251
- .unwrap()
252
- .contains("Did some work"));
253
- assert_eq!(parsed["task_summary"]["total"], 1);
254
- assert!(!parsed["task_tree"].as_array().unwrap().is_empty());
255
- assert!(!parsed["decisions"].as_array().unwrap().is_empty());
256
- assert!(!parsed["handoff_notes"].as_array().unwrap().is_empty());
257
- }
258
-
259
- #[test]
260
- fn full_session_lifecycle() {
261
- let dir = setup_project();
262
- let pd = dir.path().to_string_lossy().to_string();
263
-
264
- call_tool(
265
- "handoff_update_task",
266
- json!({
267
- "project_dir": &pd,
268
- "task": { "title": "Feature X" }
269
- }),
270
- );
271
-
272
- call_tool(
273
- "handoff_save_context",
274
- json!({
275
- "project_dir": &pd,
276
- "summary": "Session A: started feature X"
277
- }),
278
- );
279
-
280
- let resp = call_tool("handoff_load_context", json!({ "project_dir": &pd }));
281
- let text = get_text(&resp);
282
- let ctx: Value = serde_json::from_str(&text).unwrap();
283
- assert!(ctx["last_session"]["summary"]
284
- .as_str()
285
- .unwrap()
286
- .contains("Session A"));
287
-
288
- call_tool(
289
- "handoff_update_task",
290
- json!({
291
- "project_dir": &pd,
292
- "task": { "id": "t1", "title": "Feature X", "status": "done" }
293
- }),
294
- );
295
-
296
- call_tool(
297
- "handoff_save_context",
298
- json!({
299
- "project_dir": &pd,
300
- "summary": "Session B: completed feature X"
301
- }),
302
- );
303
-
304
- let sessions_dir = dir.path().join(".handoff/sessions");
305
- let active: Vec<_> = std::fs::read_dir(&sessions_dir)
306
- .unwrap()
307
- .filter_map(|e| e.ok())
308
- .filter(|e| e.file_name().to_string_lossy().ends_with(".open.json"))
309
- .collect();
310
- let closed: Vec<_> = std::fs::read_dir(&sessions_dir)
311
- .unwrap()
312
- .filter_map(|e| e.ok())
313
- .filter(|e| e.file_name().to_string_lossy().ends_with(".closed.json"))
314
- .collect();
315
-
316
- assert_eq!(active.len(), 1);
317
- assert_eq!(closed.len(), 1);
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": ".handoff/config.toml", "reason": "Entry point" }
587
- ],
588
- "decisions": [
589
- { "decision": "Use approach A", "confidence": "confirmed" }
590
- ],
591
- "references": [
592
- { "label": "Config", "uri": ".handoff/config.toml", "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
- }
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
- }