1#![forbid(unsafe_code)]
4
5use async_trait::async_trait;
6
7use serde_json::{Value, json};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use wm_core::{
11 Context, EffectRow, EpisodicKind, Galaxy, Gana, ProvenanceSource, Resource, Tool, ToolStats,
12};
13use wm_memory::{Memory, MemoryStore};
14
15fn capture_git_state(root: &Path) -> Option<Value> {
20 let run = |git_args: &[&str]| -> Option<String> {
21 let out = std::process::Command::new("git")
22 .args(git_args)
23 .current_dir(root)
24 .env("GIT_OPTIONAL_LOCKS", "0")
29 .output()
30 .ok()?;
31 if out.status.success() {
32 Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
33 } else {
34 None
35 }
36 };
37 run(&["rev-parse", "--is-inside-work-tree"])?;
40 let commit = run(&["rev-parse", "HEAD"]).unwrap_or_default();
41 let branch = run(&["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_default();
42 let dirty_count = run(&["status", "--porcelain"]).map_or(0, |s| s.lines().count());
43 Some(json!({
44 "commit": commit,
45 "branch": branch,
46 "dirty_count": dirty_count,
47 }))
48}
49
50fn resolve_project_root(args: &Value) -> Option<PathBuf> {
53 args.get("root")
54 .and_then(|v| v.as_str())
55 .filter(|s| !s.is_empty())
56 .map(PathBuf::from)
57 .or_else(|| {
58 std::env::var("WM_PROJECT_ROOT")
59 .ok()
60 .filter(|s| !s.is_empty())
61 .map(PathBuf::from)
62 })
63}
64
65fn latest_session_start(store: &MemoryStore) -> Option<String> {
68 store
69 .scan_all(Galaxy::Sessions)
70 .ok()?
71 .iter()
72 .filter(|m| m.metadata.tags.contains(&"start".to_string()))
73 .max_by_key(|m| m.metadata.created_at)
74 .map(|m| m.metadata.id.to_string())
75}
76
77pub struct SessionStartTool {
79 store: Arc<MemoryStore>,
80 stats: ToolStats,
81 effects: EffectRow,
82 search: Option<Arc<wm_memory::SearchEngine>>,
83}
84
85impl SessionStartTool {
86 pub fn new(store: Arc<MemoryStore>) -> Self {
87 Self {
88 store,
89 stats: ToolStats::default(),
90 effects: EffectRow {
91 writes: vec![Resource::Galaxy("sessions".into())],
92 ..Default::default()
93 },
94 search: None,
95 }
96 }
97
98 #[must_use]
102 pub fn with_search(mut self, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
103 self.search = search;
104 self
105 }
106}
107
108#[async_trait]
109impl Tool for SessionStartTool {
110 fn name(&self) -> &str {
111 "session.start"
112 }
113 fn gana(&self) -> Gana {
114 Gana::StraddlingLegs
115 }
116 fn effects(&self) -> &EffectRow {
117 &self.effects
118 }
119 fn input_schema(&self) -> Value {
120 super::common::schema(
121 &json!({
122 "title": super::common::str_prop("Session title"),
123 "user": super::common::str_prop("User identifier (default 'default')"),
124 }),
125 &[],
126 )
127 }
128 fn description(&self) -> &str {
129 "Start a new session — creates a session memory in Sessions galaxy"
130 }
131 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
132 let title = args
133 .get("title")
134 .and_then(|v| v.as_str())
135 .unwrap_or("Untitled Session");
136 let user = args
137 .get("user")
138 .and_then(|v| v.as_str())
139 .unwrap_or("default");
140 let mut mem = Memory::new(
141 Galaxy::Sessions,
142 json!({
143 "type": "session_start",
144 "title": title,
145 "user": user,
146 })
147 .to_string(),
148 );
149 mem.metadata.tags = vec!["session".into(), "start".into()];
150 mem.metadata.importance = 0.7;
151 mem.metadata.source = "system".to_string();
153 mem.metadata.source_trust = 0.7;
154 self.store.put(Galaxy::Sessions, &mem)?;
155 super::common::index_memory(self.search.as_deref(), &mem);
156 let episodic_capture_error = crate::capture_explicit_memory(
157 &self.store,
158 &mem,
159 EpisodicKind::SystemEvent,
160 ProvenanceSource::System,
161 Some(mem.metadata.id),
162 0,
163 );
164 let mut response = json!({
165 "status": "success",
166 "session_id": mem.metadata.id,
167 "title": title,
168 "user": user,
169 });
170 if let Some(error) = episodic_capture_error {
171 response["warnings"] = json!([format!(
172 "episodic capture failed after the session event was stored: {error}"
173 )]);
174 }
175 Ok(response)
176 }
177 fn stats(&self) -> &ToolStats {
178 &self.stats
179 }
180}
181
182pub struct SessionCheckpointTool {
184 store: Arc<MemoryStore>,
185 stats: ToolStats,
186 effects: EffectRow,
187 search: Option<Arc<wm_memory::SearchEngine>>,
188}
189
190impl SessionCheckpointTool {
191 pub fn new(store: Arc<MemoryStore>) -> Self {
192 Self {
193 store,
194 stats: ToolStats::default(),
195 effects: EffectRow {
196 writes: vec![Resource::Galaxy("sessions".into())],
197 ..Default::default()
198 },
199 search: None,
200 }
201 }
202
203 #[must_use]
207 pub fn with_search(mut self, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
208 self.search = search;
209 self
210 }
211}
212
213#[async_trait]
214impl Tool for SessionCheckpointTool {
215 fn name(&self) -> &str {
216 "session.checkpoint"
217 }
218 fn gana(&self) -> Gana {
219 Gana::StraddlingLegs
220 }
221 fn effects(&self) -> &EffectRow {
222 &self.effects
223 }
224 fn input_schema(&self) -> Value {
225 super::common::schema(
226 &json!({
227 "session_id": super::common::str_prop("Target session (default: most recent session)"),
228 "label": super::common::str_prop("Checkpoint label (default 'checkpoint')"),
229 "data": {
230 "type": "object",
231 "description": "Legacy free-form passthrough stored beside the handoff."
232 },
233 "commit": super::common::str_prop("Manual commit hash (auto-captured from git when root/WM_PROJECT_ROOT is set)"),
234 "branch": super::common::str_prop("Manual branch name (auto-captured when git is available)"),
235 "tests_green": {
236 "type": "boolean",
237 "description": "Whether the test suite was green at checkpoint time."
238 },
239 "next_queue": {
240 "type": "array",
241 "description": "Ordered next-step strings for the next session."
242 },
243 "open_flags": {
244 "type": "array",
245 "description": "Open concerns/flags worth surfacing on resume."
246 },
247 "lease_id": super::common::str_prop("Claimed scope (code.claim lease_id) that remains held at this handoff"),
248 "root": super::common::str_prop("Repository root for auto git-capture (default: WM_PROJECT_ROOT env)"),
249 }),
250 &[],
251 )
252 }
253 fn description(&self) -> &str {
254 "Save a session checkpoint with a verifiable structured handoff: commit, branch, dirty count (auto-captured from git via WM_PROJECT_ROOT), tests_green, next_queue, open_flags, lease_id (a code.claim scope that stays held)."
255 }
256 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
257 let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
258 Some(sid) if !sid.is_empty() => sid.to_string(),
259 _ => latest_session_start(&self.store).ok_or_else(|| {
260 wm_core::CoreError::Tool("no session found — run session.start first".into())
261 })?,
262 };
263 let label = args
264 .get("label")
265 .and_then(|v| v.as_str())
266 .unwrap_or("checkpoint");
267 let data = args.get("data").cloned().unwrap_or_else(|| json!({}));
268
269 let git_state = resolve_project_root(&args).and_then(|root| capture_git_state(&root));
272 let mut handoff = json!({});
273 {
274 let h = handoff.as_object_mut().expect("just created");
275 for (key, value) in [
276 ("commit", args.get("commit")),
277 ("branch", args.get("branch")),
278 ("tests_green", args.get("tests_green")),
279 ("next_queue", args.get("next_queue")),
280 ("open_flags", args.get("open_flags")),
281 ("lease_id", args.get("lease_id")),
282 ] {
283 if value.is_some() {
284 h.insert(key.to_string(), value.cloned().expect("checked above"));
285 }
286 }
287 if let Some(git) = git_state {
288 h.insert("git".to_string(), git);
289 }
290 }
291
292 let mut mem = Memory::new(
293 Galaxy::Sessions,
294 json!({
295 "type": "checkpoint",
296 "session_id": session_id,
297 "label": label,
298 "data": data,
299 "handoff": handoff,
300 })
301 .to_string(),
302 );
303 mem.metadata.tags = vec!["session".into(), "checkpoint".into()];
304 mem.metadata.importance = 0.5;
305 mem.metadata.source = "system".to_string();
307 mem.metadata.source_trust = 0.7;
308 self.store.put(Galaxy::Sessions, &mem)?;
309 super::common::index_memory(self.search.as_deref(), &mem);
310 let episodic_capture_error = crate::capture_explicit_memory(
311 &self.store,
312 &mem,
313 EpisodicKind::SystemEvent,
314 ProvenanceSource::System,
315 uuid::Uuid::parse_str(&session_id).ok(),
316 0,
317 );
318 let mut response = json!({
319 "status": "success",
320 "checkpoint_id": mem.metadata.id,
321 "session_id": session_id,
322 "label": label,
323 "handoff": handoff,
324 });
325 if let Some(error) = episodic_capture_error {
326 response["warnings"] = json!([format!(
327 "episodic capture failed after the checkpoint was stored: {error}"
328 )]);
329 }
330 Ok(response)
331 }
332 fn stats(&self) -> &ToolStats {
333 &self.stats
334 }
335}
336
337pub struct SessionVerifyTool {
344 store: Arc<MemoryStore>,
345 stats: ToolStats,
346 effects: EffectRow,
347}
348
349impl SessionVerifyTool {
350 pub fn new(store: Arc<MemoryStore>) -> Self {
351 Self {
352 store,
353 stats: ToolStats::default(),
354 effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
355 }
356 }
357}
358
359#[async_trait]
360impl Tool for SessionVerifyTool {
361 fn name(&self) -> &str {
362 "session.verify"
363 }
364 fn gana(&self) -> Gana {
365 Gana::StraddlingLegs
366 }
367 fn effects(&self) -> &EffectRow {
368 &self.effects
369 }
370 fn input_schema(&self) -> Value {
371 super::common::schema(
372 &json!({
373 "session_id": super::common::str_prop("Session whose latest checkpoint to verify (default: most recent session)"),
374 "root": super::common::str_prop("Repository root to verify against (default: WM_PROJECT_ROOT env)"),
375 }),
376 &[],
377 )
378 }
379 fn description(&self) -> &str {
380 "Verify a session's stored checkpoint against live git state — reports commit drift and dirty-count delta ('your memory says HEAD was X; git says Y')."
381 }
382 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
383 let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
384 Some(sid) if !sid.is_empty() => sid.to_string(),
385 _ => latest_session_start(&self.store).ok_or_else(|| {
386 wm_core::CoreError::Tool("no session found — run session.start first".into())
387 })?,
388 };
389
390 let memories = self.store.scan_all(Galaxy::Sessions)?;
393 let stored = memories
394 .iter()
395 .filter(|m| {
396 m.metadata.tags.contains(&"checkpoint".to_string())
397 && m.content.contains(&session_id)
398 })
399 .filter_map(|m| {
400 let parsed: Value = serde_json::from_str(&m.content).ok()?;
401 let git = parsed.get("handoff")?.get("git")?.clone();
402 if git.get("commit").and_then(Value::as_str).is_some() {
403 Some((m.metadata.id.to_string(), m.metadata.created_at, git))
404 } else {
405 None
406 }
407 })
408 .max_by_key(|(_, created_at, _)| *created_at);
409
410 let Some((checkpoint_id, _, stored_git)) = stored else {
411 return Ok(json!({
412 "status": "success",
413 "verifiable": false,
414 "message": "no checkpoint with captured git state found for this session — checkpoint with WM_PROJECT_ROOT set (or an explicit root) to enable verification"
415 }));
416 };
417
418 let Some(root) = resolve_project_root(&args) else {
419 return Ok(json!({
420 "status": "error",
421 "checkpoint_id": checkpoint_id,
422 "stored_git": stored_git,
423 "message": "no repository root available — pass 'root' or set WM_PROJECT_ROOT to verify against live git"
424 }));
425 };
426 let Some(current_git) = capture_git_state(&root) else {
427 return Ok(json!({
428 "status": "error",
429 "checkpoint_id": checkpoint_id,
430 "stored_git": stored_git,
431 "message": format!("'{}' is not a usable git work tree", root.display())
432 }));
433 };
434
435 let stored_commit = stored_git["commit"].as_str().unwrap_or_default();
436 let current_commit = current_git["commit"].as_str().unwrap_or_default();
437 let commits_ahead = if stored_commit == current_commit {
438 Some(0)
439 } else {
440 std::process::Command::new("git")
441 .args(["rev-list", "--count", &format!("{stored_commit}..HEAD")])
442 .current_dir(&root)
443 .output()
444 .ok()
445 .filter(|o| o.status.success())
446 .and_then(|o| {
447 String::from_utf8_lossy(&o.stdout)
448 .trim()
449 .parse::<u64>()
450 .ok()
451 })
452 };
453 let dirty_delta = current_git["dirty_count"].as_i64().unwrap_or(0)
454 - stored_git["dirty_count"].as_i64().unwrap_or(0);
455
456 let verdict = if stored_commit == current_commit && dirty_delta == 0 {
457 "clean"
458 } else if stored_commit == current_commit {
459 "dirty-drift"
460 } else {
461 "drifted"
462 };
463
464 Ok(json!({
465 "status": "success",
466 "verifiable": true,
467 "session_id": session_id,
468 "checkpoint_id": checkpoint_id,
469 "stored_git": stored_git,
470 "current_git": current_git,
471 "commits_ahead": commits_ahead,
472 "dirty_delta": dirty_delta,
473 "verdict": verdict,
474 }))
475 }
476 fn stats(&self) -> &ToolStats {
477 &self.stats
478 }
479}
480
481pub struct SessionRecallTool {
483 store: Arc<MemoryStore>,
484 stats: ToolStats,
485 effects: EffectRow,
486}
487
488impl SessionRecallTool {
489 pub fn new(store: Arc<MemoryStore>) -> Self {
490 Self {
491 store,
492 stats: ToolStats::default(),
493 effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
494 }
495 }
496}
497
498#[async_trait]
499impl Tool for SessionRecallTool {
500 fn input_schema(&self) -> Value {
501 super::common::schema(
502 &json!({
503 "session_id": super::common::str_prop("Session UUID to recall (defaults to the most recent session)"),
504 "limit": super::common::int_prop("Maximum turns to return (default 50)"),
505 }),
506 &[],
507 )
508 }
509 fn name(&self) -> &str {
510 "session.recall"
511 }
512 fn gana(&self) -> Gana {
513 Gana::StraddlingLegs
514 }
515 fn effects(&self) -> &EffectRow {
516 &self.effects
517 }
518 fn description(&self) -> &str {
519 "Recall session memories by session_id"
520 }
521 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
522 let session_id = args
523 .get("session_id")
524 .and_then(|v| v.as_str())
525 .unwrap_or("");
526 let limit = args
527 .get("limit")
528 .and_then(serde_json::Value::as_u64)
529 .unwrap_or(50) as usize;
530 let memories = self.store.scan_all(Galaxy::Sessions)?;
531 let filtered: Vec<Value> = memories
532 .iter()
533 .filter(|m| m.content.contains(session_id))
534 .take(limit)
535 .map(|m| {
536 json!({
537 "id": m.metadata.id,
538 "content": m.content,
539 "tags": m.metadata.tags,
540 "created_at": m.metadata.created_at.to_rfc3339(),
541 })
542 })
543 .collect();
544 Ok(json!({
545 "status": "success",
546 "session_id": session_id,
547 "count": filtered.len(),
548 "memories": filtered,
549 }))
550 }
551 fn stats(&self) -> &ToolStats {
552 &self.stats
553 }
554}
555
556pub struct SessionEndTool {
558 store: Arc<MemoryStore>,
559 stats: ToolStats,
560 effects: EffectRow,
561 search: Option<Arc<wm_memory::SearchEngine>>,
562}
563
564impl SessionEndTool {
565 pub fn new(store: Arc<MemoryStore>) -> Self {
566 Self {
567 store,
568 stats: ToolStats::default(),
569 effects: EffectRow {
570 writes: vec![Resource::Galaxy("sessions".into())],
571 ..Default::default()
572 },
573 search: None,
574 }
575 }
576
577 #[must_use]
581 pub fn with_search(mut self, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
582 self.search = search;
583 self
584 }
585}
586
587#[async_trait]
588impl Tool for SessionEndTool {
589 fn name(&self) -> &str {
590 "session.end"
591 }
592 fn gana(&self) -> Gana {
593 Gana::StraddlingLegs
594 }
595 fn effects(&self) -> &EffectRow {
596 &self.effects
597 }
598 fn description(&self) -> &str {
599 "End a session — writes a session_end marker"
600 }
601 fn input_schema(&self) -> Value {
602 super::common::schema(
603 &json!({
604 "session_id": super::common::str_prop("Session UUID to end (start-memory id)"),
605 "summary": super::common::str_prop("Optional closing summary"),
606 }),
607 &["session_id"],
608 )
609 }
610 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
611 let session_id = args
612 .get("session_id")
613 .and_then(|v| v.as_str())
614 .unwrap_or("");
615 let summary = args.get("summary").and_then(|v| v.as_str()).unwrap_or("");
616 let mut mem = Memory::new(
617 Galaxy::Sessions,
618 json!({
619 "type": "session_end",
620 "session_id": session_id,
621 "summary": summary,
622 })
623 .to_string(),
624 );
625 mem.metadata.tags = vec!["session".into(), "end".into()];
626 mem.metadata.importance = 0.6;
627 mem.metadata.source = "system".to_string();
629 mem.metadata.source_trust = 0.7;
630 self.store.put(Galaxy::Sessions, &mem)?;
631 super::common::index_memory(self.search.as_deref(), &mem);
632 let episodic_capture_error = crate::capture_explicit_memory(
633 &self.store,
634 &mem,
635 EpisodicKind::SystemEvent,
636 ProvenanceSource::System,
637 uuid::Uuid::parse_str(session_id).ok(),
638 0,
639 );
640 let mut response = json!({
641 "status": "success",
642 "session_id": session_id,
643 "end_id": mem.metadata.id,
644 });
645 if let Some(error) = episodic_capture_error {
646 response["warnings"] = json!([format!(
647 "episodic capture failed after the session event was stored: {error}"
648 )]);
649 }
650 Ok(response)
651 }
652 fn stats(&self) -> &ToolStats {
653 &self.stats
654 }
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660
661 fn test_store() -> Arc<MemoryStore> {
662 let dir = tempfile::tempdir().unwrap();
663 let path = dir.path().join("lmdb");
664 std::fs::create_dir_all(&path).unwrap();
665 Arc::new(MemoryStore::open_default(path).unwrap())
666 }
667
668 fn start_session(store: &MemoryStore) -> String {
669 let mut mem = Memory::new(
670 Galaxy::Sessions,
671 json!({"type": "session_start"}).to_string(),
672 );
673 mem.metadata.tags = vec!["session".into(), "start".into()];
674 store.put(Galaxy::Sessions, &mem).unwrap();
675 mem.metadata.id.to_string()
676 }
677
678 fn git_repo() -> (tempfile::TempDir, PathBuf) {
681 let dir = tempfile::tempdir().unwrap();
682 let root = dir.path().to_path_buf();
683 let run = |args: &[&str]| {
684 std::process::Command::new("git")
685 .args(args)
686 .current_dir(&root)
687 .output()
688 .expect("git must be available")
689 };
690 assert!(run(&["init", "-q"]).status.success());
691 assert!(run(&["config", "user.email", "t@t"]).status.success());
692 assert!(run(&["config", "user.name", "t"]).status.success());
693 assert!(
694 run(&["commit", "--allow-empty", "-m", "c1"])
695 .status
696 .success()
697 );
698 (dir, root)
699 }
700
701 #[tokio::test]
704 async fn start_marker_stamps_system_provenance() {
705 let store = test_store();
706 let tool = SessionStartTool::new(store.clone());
707 let mut ctx = Context::default();
708 let out = tool
709 .call(&mut ctx, json!({"title": "prov test"}))
710 .await
711 .unwrap();
712 let sid = uuid::Uuid::parse_str(out["session_id"].as_str().unwrap()).unwrap();
713 let mem = store
714 .get(Galaxy::Sessions, sid)
715 .expect("start stored")
716 .expect("start present");
717 assert_eq!(mem.metadata.source, "system");
718 assert!((mem.metadata.source_trust - 0.7).abs() < 1e-5);
719 }
720
721 #[tokio::test]
722 async fn checkpoint_auto_captures_git_state() {
723 let store = test_store();
724 let sid = start_session(&store);
725 let (_guard, root) = git_repo();
726
727 let tool = SessionCheckpointTool::new(store.clone());
728 let mut ctx = Context::default();
729 let r = tool
730 .call(
731 &mut ctx,
732 json!({"session_id": sid, "root": root.display().to_string(), "tests_green": true}),
733 )
734 .await
735 .unwrap();
736
737 assert_eq!(r["status"], "success");
738 let git = &r["handoff"]["git"];
739 let expected = String::from_utf8(
740 std::process::Command::new("git")
741 .args(["rev-parse", "HEAD"])
742 .current_dir(&root)
743 .output()
744 .unwrap()
745 .stdout,
746 )
747 .unwrap();
748 assert_eq!(
749 git["commit"].as_str().unwrap().trim(),
750 expected.trim(),
751 "checkpoint must auto-capture the live HEAD"
752 );
753 assert_eq!(git["dirty_count"], 0);
754 assert_eq!(r["handoff"]["tests_green"], true);
755 }
756
757 #[tokio::test]
758 async fn checkpoint_resolves_latest_session_when_absent() {
759 let store = test_store();
760 let _old = start_session(&store);
761 let newest = start_session(&store);
762
763 let tool = SessionCheckpointTool::new(store);
764 let mut ctx = Context::default();
765 let r = tool.call(&mut ctx, json!({"label": "wrap"})).await.unwrap();
766
767 assert_eq!(r["status"], "success");
768 assert_eq!(r["session_id"], newest, "must target the newest session");
769 }
770
771 #[tokio::test]
772 async fn verify_reports_clean_then_drifted() {
773 let store = test_store();
774 let sid = start_session(&store);
775 let (dir_guard, root) = git_repo();
776 let root_str = root.display().to_string();
777
778 let cp = SessionCheckpointTool::new(store.clone());
779 let mut ctx = Context::default();
780 cp.call(&mut ctx, json!({"session_id": sid, "root": root_str}))
781 .await
782 .unwrap();
783
784 let verify = SessionVerifyTool::new(store.clone());
785 let clean = verify
786 .call(&mut ctx, json!({"session_id": sid, "root": root_str}))
787 .await
788 .unwrap();
789 assert_eq!(clean["verifiable"], true);
790 assert_eq!(clean["verdict"], "clean", "got: {clean}");
791 assert_eq!(clean["commits_ahead"], 0);
792
793 assert!(
795 std::process::Command::new("git")
796 .args(["commit", "--allow-empty", "-m", "c2"])
797 .current_dir(&root)
798 .output()
799 .unwrap()
800 .status
801 .success()
802 );
803
804 let drifted = verify
805 .call(&mut ctx, json!({"session_id": sid, "root": root_str}))
806 .await
807 .unwrap();
808 assert_eq!(drifted["verdict"], "drifted", "got: {drifted}");
809 assert_eq!(drifted["commits_ahead"], 1);
810 assert_ne!(
811 drifted["stored_git"]["commit"],
812 drifted["current_git"]["commit"]
813 );
814
815 drop(dir_guard);
816 }
817
818 #[tokio::test]
819 async fn checkpoint_carries_lease_id_in_handoff() {
820 let store = test_store();
821 let sid = start_session(&store);
822
823 let tool = SessionCheckpointTool::new(store);
824 let mut ctx = Context::default();
825 let r = tool
826 .call(
827 &mut ctx,
828 json!({"session_id": sid, "lease_id": "src/expansion/"}),
829 )
830 .await
831 .unwrap();
832
833 assert_eq!(r["status"], "success");
834 assert_eq!(r["handoff"]["lease_id"], "src/expansion/");
835 }
836
837 #[tokio::test]
838 async fn verify_reports_unverifiable_without_git_checkpoint() {
839 let store = test_store();
840 let sid = start_session(&store);
841
842 let cp = SessionCheckpointTool::new(store.clone());
844 let mut ctx = Context::default();
845 let r = cp.call(&mut ctx, json!({"session_id": sid})).await.unwrap();
847 assert!(r["handoff"]["git"].is_null());
848
849 let verify = SessionVerifyTool::new(store);
850 let v = verify
851 .call(&mut ctx, json!({"session_id": sid}))
852 .await
853 .unwrap();
854 assert_eq!(v["verifiable"], false, "got: {v}");
855 assert!(v["message"].as_str().unwrap().contains("no checkpoint"));
856 }
857}