Skip to main content

wm_tools/expansion/
session_ops.rs

1//! Session tools — record, replay, continuity, handoff (v26 parity).
2//!
3//! Port of the v26 session recorder / handoff surface (the last Phase-1
4//! gap): turns are recorded chronologically into the Sessions galaxy,
5//! replayed in full/selective/progressive modes, summarized across
6//! sessions for continuity, and packaged for handoff to another device.
7
8#![forbid(unsafe_code)]
9
10use async_trait::async_trait;
11
12use chrono::{DateTime, NaiveDate, TimeZone, Utc};
13use serde_json::{Value, json};
14use std::fmt::Write as _;
15use std::sync::Arc;
16use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
17use wm_memory::{Memory, MemoryStore};
18
19/// Parse a time-bound argument: epoch seconds (number) or RFC 3339 /
20/// `YYYY-MM-DD` (string; date-only means start of day for `since`, end of
21/// day for `until`).
22fn parse_time_bound(v: &Value, end_of_day: bool) -> Option<DateTime<Utc>> {
23    if let Some(secs) = v
24        .as_i64()
25        .or_else(|| v.as_u64().and_then(|u| i64::try_from(u).ok()))
26    {
27        return Utc.timestamp_opt(secs, 0).single();
28    }
29    let s = v.as_str()?;
30    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
31        return Some(dt.with_timezone(&Utc));
32    }
33    let day = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?;
34    let naive = if end_of_day {
35        day.and_hms_opt(23, 59, 59)?
36    } else {
37        day.and_hms_opt(0, 0, 0)?
38    };
39    Some(naive.and_utc())
40}
41
42/// Apply `since`/`until` time-range filters over loaded turns by their
43/// memory creation time. Invalid bounds are a caller error, not silence.
44fn filter_by_time(
45    turns: Vec<(Memory, Value)>,
46    args: &Value,
47) -> wm_core::Result<Vec<(Memory, Value)>> {
48    let since = match args.get("since") {
49        Some(v) if !v.is_null() => Some(parse_time_bound(v, false).ok_or_else(|| {
50            wm_core::CoreError::InvalidArgs(
51                "invalid 'since' — use epoch seconds, RFC 3339, or YYYY-MM-DD".into(),
52            )
53        })?),
54        _ => None,
55    };
56    let until = match args.get("until") {
57        Some(v) if !v.is_null() => Some(parse_time_bound(v, true).ok_or_else(|| {
58            wm_core::CoreError::InvalidArgs(
59                "invalid 'until' — use epoch seconds, RFC 3339, or YYYY-MM-DD".into(),
60            )
61        })?),
62        _ => None,
63    };
64    Ok(turns
65        .into_iter()
66        .filter(|(m, _)| {
67            since.is_none_or(|t| m.metadata.created_at >= t)
68                && until.is_none_or(|t| m.metadata.created_at <= t)
69        })
70        .collect())
71}
72
73fn turn_json(mem: &Memory) -> Option<Value> {
74    let v: Value = serde_json::from_str(&mem.content).ok()?;
75    if v.get("type").and_then(Value::as_str) == Some("session_turn") {
76        Some(v)
77    } else {
78        None
79    }
80}
81
82/// Load turns for a session (or all sessions).
83///
84/// Turns tagged `superseded-by:<id>` are excluded unless
85/// `include_superseded` — supersession is the amend mechanism for evolving
86/// stories, and consumers want the current story by default.
87fn load_turns(
88    store: &MemoryStore,
89    session_id: Option<&str>,
90    limit: usize,
91    include_superseded: bool,
92) -> wm_core::Result<Vec<(Memory, Value)>> {
93    let memories = store.scan_all(Galaxy::Sessions)?;
94    let mut turns: Vec<(Memory, Value)> = memories
95        .iter()
96        .filter(|m| {
97            include_superseded
98                || !m
99                    .metadata
100                    .tags
101                    .iter()
102                    .any(|t| t.starts_with("superseded-by:"))
103        })
104        .filter_map(|m| turn_json(m).map(|v| (m.clone(), v)))
105        .filter(|(_, v)| {
106            session_id.is_none_or(|sid| v.get("session_id").and_then(Value::as_str) == Some(sid))
107        })
108        .collect();
109    turns.sort_by_key(|(_, v)| {
110        (
111            v.get("sequence").and_then(Value::as_u64).unwrap_or(0),
112            v.get("timestamp").and_then(Value::as_i64).unwrap_or(0),
113        )
114    });
115    turns.truncate(limit);
116    Ok(turns)
117}
118
119fn format_turn(v: &Value, full: bool) -> Value {
120    let role = v.get("role").and_then(Value::as_str).unwrap_or("?");
121    let content = v.get("content").and_then(Value::as_str).unwrap_or("");
122    if full {
123        json!({
124            "session_id": v.get("session_id"),
125            "sequence": v.get("sequence"),
126            "role": role,
127            "turn_type": v.get("turn_type"),
128            "importance": v.get("importance"),
129            "content": content,
130        })
131    } else {
132        json!({
133            "sequence": v.get("sequence"),
134            "role": role,
135            "turn_type": v.get("turn_type"),
136            "preview": content.chars().take(120).collect::<String>(),
137        })
138    }
139}
140
141/// `session.record` — record a conversation turn as persistent session memory.
142pub struct SessionRecordTool {
143    store: Arc<MemoryStore>,
144    stats: ToolStats,
145    effects: EffectRow,
146}
147
148impl SessionRecordTool {
149    #[must_use]
150    pub fn new(store: Arc<MemoryStore>) -> Self {
151        Self {
152            store,
153            stats: ToolStats::default(),
154            effects: EffectRow {
155                writes: vec![Resource::Galaxy("sessions".into())],
156                ..Default::default()
157            },
158        }
159    }
160}
161
162#[async_trait]
163impl Tool for SessionRecordTool {
164    fn name(&self) -> &str {
165        "session.record"
166    }
167    fn gana(&self) -> Gana {
168        Gana::StraddlingLegs
169    }
170    fn effects(&self) -> &EffectRow {
171        &self.effects
172    }
173    fn input_schema(&self) -> Value {
174        super::common::schema(
175            &json!({
176                "content": super::common::str_prop("Turn content"),
177                "role": super::common::str_prop("user | ai (default user)"),
178                "turn_type": super::common::str_prop("message, decision, breakthrough, question, answer, code_change, error, summary, context"),
179                "importance": super::common::num_prop("0-1 importance (default 0.5)"),
180                "session_id": super::common::str_prop("Target session (default: most recent session)"),
181                "supersedes": super::common::str_prop("Memory id of an earlier turn this record corrects/replaces (amend-with-supersede)"),
182            }),
183            &["content"],
184        )
185    }
186    fn description(&self) -> &str {
187        "Record a conversation turn as persistent session memory. Args: content (required), role (user|ai, default user), turn_type (default message), importance (0-1, default 0.5), session_id (optional — defaults to the most recent session), supersedes (optional turn memory-id — marks the old turn superseded so replay/continuity/digest use the new record)."
188    }
189    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
190        let role = args.get("role").and_then(Value::as_str).unwrap_or("user");
191        if !matches!(role, "user" | "ai") {
192            return Err(wm_core::CoreError::InvalidArgs(
193                "role must be 'user' or 'ai'".into(),
194            ));
195        }
196        let content = args
197            .get("content")
198            .and_then(Value::as_str)
199            .ok_or_else(|| wm_core::CoreError::InvalidArgs("content is required".into()))?;
200        let turn_type = args
201            .get("turn_type")
202            .and_then(Value::as_str)
203            .unwrap_or("message");
204        let importance = args
205            .get("importance")
206            .and_then(Value::as_f64)
207            .unwrap_or(0.5)
208            .clamp(0.0, 1.0);
209        let session_id = args.get("session_id").and_then(Value::as_str);
210
211        // Resolve the session: explicit id, or the most recent session_start.
212        // Resolution MUST use `created_at`, not iteration position: LMDB scan
213        // order is key (UUID) order, which is random for v4 UUIDs — picking
214        // positionally (`next_back`) silently misfiles turns into an
215        // arbitrary session once more than one start exists.
216        let session_id: String = if let Some(sid) = session_id {
217            sid.to_string()
218        } else {
219            self.store
220                .scan_all(Galaxy::Sessions)?
221                .iter()
222                .filter(|m| m.metadata.tags.contains(&"start".to_string()))
223                .max_by_key(|m| m.metadata.created_at)
224                .map(|m| m.metadata.id.to_string())
225                .ok_or_else(|| {
226                    wm_core::CoreError::Tool("no session found — run session.start first".into())
227                })?
228        };
229
230        // Sequence = existing turns for this session + 1 (superseded turns
231        // still count — the log position is history, visibility is separate).
232        let sequence = load_turns(&self.store, Some(&session_id), 10_000, true)?.len() as u64 + 1;
233
234        let mut mem = Memory::new(
235            Galaxy::Sessions,
236            json!({
237                "type": "session_turn",
238                "session_id": session_id,
239                "sequence": sequence,
240                "role": role,
241                "turn_type": turn_type,
242                "importance": importance,
243                "content": content,
244                "timestamp": wm_core::time::now_unix_millis(),
245            })
246            .to_string(),
247        );
248        mem.metadata.tags = vec![
249            "session".into(),
250            "turn".into(),
251            role.into(),
252            turn_type.into(),
253            format!("session:{session_id}"),
254        ];
255        // Provenance: the turn's role IS the authorship claim. An ai-role
256        // turn is agent-written and must not claim user provenance — the
257        // sessions-galaxy archaeology finding (2026-08-29) was that every
258        // turn stamped user/1.0 because Memory::new defaulted there. Trust
259        // classes per the retrieval-trust semantics: user 1.0, agent 0.7
260        // (tool-ingested neutral).
261        let (source, trust): (&str, f32) = if role == "user" {
262            ("user", 1.0)
263        } else {
264            ("agent", 0.7)
265        };
266        mem.metadata.source = source.to_string();
267        mem.metadata.source_trust = trust;
268
269        // Amend-with-supersede (P2): mark the corrected turn so default
270        // retrieval uses the new record. History stays intact — the old turn
271        // remains queryable via include_superseded.
272        if let Some(old_id_str) = args.get("supersedes").and_then(Value::as_str) {
273            let old_id = uuid::Uuid::parse_str(old_id_str).map_err(|e| {
274                wm_core::CoreError::InvalidArgs(format!("invalid 'supersedes' id: {e}"))
275            })?;
276            let mut old = self.store.get(Galaxy::Sessions, old_id)?.ok_or_else(|| {
277                wm_core::CoreError::NotFound(format!("superseded turn {old_id} not found"))
278            })?;
279            old.metadata
280                .tags
281                .push(format!("superseded-by:{}", mem.metadata.id));
282            self.store.put(Galaxy::Sessions, &old)?;
283            mem.metadata.tags.push(format!("supersedes:{old_id}"));
284        }
285
286        mem.metadata.importance = importance as f32;
287        self.store.put(Galaxy::Sessions, &mem)?;
288        Ok(json!({
289            "status": "success",
290            "session_id": session_id,
291            "sequence": sequence,
292            "memory_id": mem.metadata.id.to_string(),
293        }))
294    }
295    fn stats(&self) -> &ToolStats {
296        &self.stats
297    }
298}
299
300/// `session.replay` — replay session turns (full, selective, progressive).
301pub struct SessionReplayTool {
302    store: Arc<MemoryStore>,
303    stats: ToolStats,
304    effects: EffectRow,
305}
306
307impl SessionReplayTool {
308    #[must_use]
309    pub fn new(store: Arc<MemoryStore>) -> Self {
310        Self {
311            store,
312            stats: ToolStats::default(),
313            effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
314        }
315    }
316}
317
318#[async_trait]
319impl Tool for SessionReplayTool {
320    fn name(&self) -> &str {
321        "session.replay"
322    }
323    fn gana(&self) -> Gana {
324        Gana::StraddlingLegs
325    }
326    fn effects(&self) -> &EffectRow {
327        &self.effects
328    }
329    fn input_schema(&self) -> Value {
330        super::common::schema(
331            &json!({
332                "mode": super::common::str_prop("full | selective | progressive (default full)"),
333                "session_id": super::common::str_prop("Target session (default: most recent)"),
334                "n": super::common::int_prop("Maximum turns (default 50)"),
335                "since": super::common::str_prop("Time-range floor: epoch seconds, RFC 3339, or YYYY-MM-DD"),
336                "until": super::common::str_prop("Time-range ceiling: epoch seconds, RFC 3339, or YYYY-MM-DD"),
337                "include_superseded": {
338                    "type": "boolean",
339                    "description": "Also return turns replaced via supersedes (default false)."
340                },
341                "turn_types": super::common::str_array_prop("Selective mode: turn types to keep"),
342                "min_importance": super::common::num_prop("Selective mode floor (default 0.7)"),
343                "token_budget": super::common::int_prop("Progressive mode token budget (default 2000)"),
344            }),
345            &[],
346        )
347    }
348    fn description(&self) -> &str {
349        "Replay session turns. Args: mode (full|selective|progressive, default full), session_id (optional), n (default 50), since/until (epoch seconds | RFC 3339 | YYYY-MM-DD — applies to all modes), include_superseded (default false), turn_types (list, for selective), min_importance (0-1, default 0.7, for selective), token_budget (default 2000, for progressive)."
350    }
351    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
352        let mode = args.get("mode").and_then(Value::as_str).unwrap_or("full");
353        let session_id = args.get("session_id").and_then(Value::as_str);
354        let n = args.get("n").and_then(Value::as_u64).unwrap_or(50) as usize;
355        let include_superseded = args
356            .get("include_superseded")
357            .and_then(Value::as_bool)
358            .unwrap_or(false);
359        let turns = filter_by_time(
360            load_turns(&self.store, session_id, 10_000, include_superseded)?,
361            &args,
362        )?;
363
364        // An explicitly requested session that has no turns is an error, not
365        // an empty success — silent emptiness hides typos and stale IDs.
366        if session_id.is_some_and(|sid| !sid.is_empty()) && turns.is_empty() {
367            return Err(wm_core::CoreError::InvalidArgs(format!(
368                "no session found with id {session_id:?}"
369            )));
370        }
371
372        let selected: Vec<(Memory, Value)> = match mode {
373            "selective" => {
374                let min_importance = args
375                    .get("min_importance")
376                    .and_then(Value::as_f64)
377                    .unwrap_or(0.7);
378                let turn_types: Vec<String> = args
379                    .get("turn_types")
380                    .and_then(Value::as_array)
381                    .map_or_else(
382                        || vec!["decision".into(), "breakthrough".into(), "answer".into()],
383                        |a| {
384                            a.iter()
385                                .filter_map(Value::as_str)
386                                .map(str::to_string)
387                                .collect()
388                        },
389                    );
390                turns
391                    .into_iter()
392                    .filter(|(_, v)| {
393                        v.get("importance").and_then(Value::as_f64).unwrap_or(0.0) >= min_importance
394                            && v.get("turn_type")
395                                .and_then(Value::as_str)
396                                .is_some_and(|t| turn_types.contains(&t.to_string()))
397                    })
398                    .collect()
399            }
400            "progressive" => {
401                let budget = args
402                    .get("token_budget")
403                    .and_then(Value::as_u64)
404                    .unwrap_or(2000) as usize;
405                let mut used = 0usize;
406                let mut out = Vec::new();
407                for (m, v) in turns.into_iter().rev() {
408                    let approx = v
409                        .get("content")
410                        .and_then(Value::as_str)
411                        .map_or(0, |c| c.len() / 4);
412                    if used + approx > budget {
413                        break;
414                    }
415                    used += approx;
416                    out.push((m, v));
417                }
418                out.reverse();
419                out
420            }
421            _ => turns
422                .into_iter()
423                .rev()
424                .take(n)
425                .collect::<Vec<_>>()
426                .into_iter()
427                .rev()
428                .collect(),
429        };
430
431        let full = mode != "progressive";
432        let formatted: Vec<Value> = selected.iter().map(|(_, v)| format_turn(v, full)).collect();
433        Ok(json!({
434            "status": "success",
435            "mode": mode,
436            "count": formatted.len(),
437            "session_id": session_id,
438            "turns": formatted,
439        }))
440    }
441    fn stats(&self) -> &ToolStats {
442        &self.stats
443    }
444}
445
446/// `session.continuity` — pull recent turns from the previous session.
447pub struct SessionContinuityTool {
448    store: Arc<MemoryStore>,
449    stats: ToolStats,
450    effects: EffectRow,
451}
452
453impl SessionContinuityTool {
454    #[must_use]
455    pub fn new(store: Arc<MemoryStore>) -> Self {
456        Self {
457            store,
458            stats: ToolStats::default(),
459            effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
460        }
461    }
462}
463
464#[async_trait]
465impl Tool for SessionContinuityTool {
466    fn name(&self) -> &str {
467        "session.continuity"
468    }
469    fn gana(&self) -> Gana {
470        Gana::StraddlingLegs
471    }
472    fn effects(&self) -> &EffectRow {
473        &self.effects
474    }
475    fn input_schema(&self) -> Value {
476        super::common::schema(
477            &json!({
478                "current_session_id": super::common::str_prop("Session to exclude (optional)"),
479                "n": super::common::int_prop("Number of prior turns (default 10)"),
480                "since": super::common::str_prop("Time-range floor: epoch seconds, RFC 3339, or YYYY-MM-DD"),
481                "until": super::common::str_prop("Time-range ceiling: epoch seconds, RFC 3339, or YYYY-MM-DD"),
482            }),
483            &[],
484        )
485    }
486    fn description(&self) -> &str {
487        "Get cross-session continuity — the last N turns of the most recent prior session ('where we left off'). Args: current_session_id (optional, excluded), n (default 10), since/until (epoch seconds | RFC 3339 | YYYY-MM-DD)."
488    }
489    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
490        let current = args.get("current_session_id").and_then(Value::as_str);
491        let n = args.get("n").and_then(Value::as_u64).unwrap_or(10) as usize;
492
493        // Find the most recent session_start that is not the current session.
494        // `rfind` on scan order is a UUID lottery (LMDB iterates by key, and
495        // v4 keys are random) — resolve by `created_at` instead.
496        let memories = self.store.scan_all(Galaxy::Sessions)?;
497        let previous = memories
498            .iter()
499            .filter(|m| {
500                m.metadata.tags.contains(&"start".to_string())
501                    && current.is_none_or(|c| m.metadata.id.to_string() != c)
502            })
503            .max_by_key(|m| m.metadata.created_at);
504
505        let Some(prev) = previous else {
506            // Empty-continuity false-negative guard (2026-08-28 cold-start
507            // friction): "no previous session found" is truthful per-store
508            // but reads as amnesia when the client is wired to the wrong
509            // project store. The server knows its own scope — disclose it
510            // with the same actionable-hint treatment the read-only write
511            // refusal got (2026-08-22).
512            let project = std::env::var("WM_PROJECT").ok().filter(|s| !s.is_empty());
513            let store = self.store.path().display().to_string();
514            let scope = project.map_or_else(
515                || format!("store {store}"),
516                |p| format!("store {store}, project '{p}'"),
517            );
518            return Ok(json!({
519                "status": "success",
520                "previous_session": null,
521                "turns": [],
522                "count": 0,
523                "message": "no previous session found",
524                "hint": format!(
525                    "memory is project-scoped and this server's scope ({scope}) has no sessions. If you expected continuity for your project, your client may be wired to a different store: check the mcp block in your opencode config and compare with GET /status on the fleet (store_path, project). Per-project layout: docs/MULTI_PROJECT_MEMORY.md."
526                ),
527            }));
528        };
529
530        let prev_id = prev.metadata.id.to_string();
531        let mut turns = filter_by_time(
532            load_turns(&self.store, Some(&prev_id), 10_000, false)?,
533            &args,
534        )?;
535        let total = turns.len();
536        let tail: Vec<Value> = turns
537            .split_off(total.saturating_sub(n))
538            .iter()
539            .map(|(_, v)| format_turn(v, true))
540            .collect();
541        Ok(json!({
542            "status": "success",
543            "previous_session": prev_id,
544            "count": tail.len(),
545            "turns": tail,
546        }))
547    }
548    fn stats(&self) -> &ToolStats {
549        &self.stats
550    }
551}
552
553/// `session.digest` — compile a session's records into one handoff block.
554///
555/// The wrap-up writes itself: typed turns grouped by category,
556/// importance-ordered, plus the latest structured checkpoint state if one
557/// exists — so the manual wrap-up that duplicates records by hand becomes a
558/// single deterministic call (experience-report item #4).
559pub struct SessionDigestTool {
560    store: Arc<MemoryStore>,
561    stats: ToolStats,
562    effects: EffectRow,
563}
564
565impl SessionDigestTool {
566    #[must_use]
567    pub fn new(store: Arc<MemoryStore>) -> Self {
568        Self {
569            store,
570            stats: ToolStats::default(),
571            effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
572        }
573    }
574}
575
576/// Display order for turn-type sections; unknown types follow, alphabetical.
577const DIGEST_SECTION_ORDER: &[&str] = &["decision", "breakthrough", "error", "summary"];
578
579#[async_trait]
580impl Tool for SessionDigestTool {
581    fn name(&self) -> &str {
582        "session.digest"
583    }
584    fn gana(&self) -> Gana {
585        Gana::StraddlingLegs
586    }
587    fn effects(&self) -> &EffectRow {
588        &self.effects
589    }
590    fn input_schema(&self) -> Value {
591        super::common::schema(
592            &json!({
593                "session_id": super::common::str_prop("Session to digest (default: most recent)"),
594                "min_importance": super::common::num_prop("Importance floor (default 0.5)"),
595                "include_checkpoint": {
596                    "type": "boolean",
597                    "description": "Append the latest checkpoint's git/handoff state (default true)."
598                },
599                "since": super::common::str_prop("Time-range floor: epoch seconds, RFC 3339, or YYYY-MM-DD"),
600                "until": super::common::str_prop("Time-range ceiling: epoch seconds, RFC 3339, or YYYY-MM-DD"),
601            }),
602            &[],
603        )
604    }
605    fn description(&self) -> &str {
606        "Compile a session into a markdown handoff digest — turns grouped by type and importance-ordered, latest checkpoint state appended. Args: session_id (optional), min_importance (default 0.5), include_checkpoint (default true), since/until."
607    }
608    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
609        let session_id = match args.get("session_id").and_then(Value::as_str) {
610            Some(sid) if !sid.is_empty() => sid.to_string(),
611            _ => self
612                .store
613                .scan_all(Galaxy::Sessions)?
614                .iter()
615                .filter(|m| m.metadata.tags.contains(&"start".to_string()))
616                .max_by_key(|m| m.metadata.created_at)
617                .map(|m| m.metadata.id.to_string())
618                .ok_or_else(|| {
619                    wm_core::CoreError::Tool("no session found — run session.start first".into())
620                })?,
621        };
622        let min_importance = args
623            .get("min_importance")
624            .and_then(Value::as_f64)
625            .unwrap_or(0.5);
626        let include_checkpoint = args
627            .get("include_checkpoint")
628            .and_then(Value::as_bool)
629            .unwrap_or(true);
630
631        let mut turns: Vec<_> = filter_by_time(
632            load_turns(&self.store, Some(&session_id), 10_000, false)?,
633            &args,
634        )?
635        .into_iter()
636        .filter(|(_, v)| {
637            v.get("importance").and_then(Value::as_f64).unwrap_or(0.0) >= min_importance
638        })
639        .collect();
640        turns.sort_by(|a, b| {
641            b.1.get("importance")
642                .and_then(Value::as_f64)
643                .unwrap_or(0.0)
644                .total_cmp(&a.1.get("importance").and_then(Value::as_f64).unwrap_or(0.0))
645        });
646
647        // Group by turn_type; known sections first, others after (alphabetical).
648        let mut groups: Vec<(String, Vec<&Value>)> = Vec::new();
649        for (_, v) in &turns {
650            let t = v
651                .get("turn_type")
652                .and_then(Value::as_str)
653                .unwrap_or("message")
654                .to_string();
655            match groups.iter_mut().find(|(name, _)| *name == t) {
656                Some((_, list)) => list.push(v),
657                None => groups.push((t, vec![v])),
658            }
659        }
660        groups.sort_by_key(|(name, _)| {
661            (
662                DIGEST_SECTION_ORDER
663                    .iter()
664                    .position(|k| k == name)
665                    .unwrap_or(DIGEST_SECTION_ORDER.len()),
666                name.clone(),
667            )
668        });
669
670        let mut digest = format!("# Session handoff — {session_id}\n");
671        let mut included = 0usize;
672        for (turn_type, items) in &groups {
673            writeln!(
674                digest,
675                "\n## {} ({})",
676                capitalize(&pluralize(turn_type)),
677                items.len()
678            )
679            .expect("write to String cannot fail");
680            for v in items {
681                let importance = v.get("importance").and_then(Value::as_f64).unwrap_or(0.0);
682                let content = v.get("content").and_then(Value::as_str).unwrap_or("");
683                writeln!(digest, "- ({importance:.2}) {content}")
684                    .expect("write to String cannot fail");
685                included += 1;
686            }
687        }
688
689        // Latest verifiable checkpoint state, if any.
690        let mut checkpoint_state = Value::Null;
691        if include_checkpoint {
692            if let Some(cp) = self
693                .store
694                .scan_all(Galaxy::Sessions)?
695                .iter()
696                .filter(|m| {
697                    m.metadata.tags.contains(&"checkpoint".to_string())
698                        && m.content.contains(&session_id)
699                })
700                .filter_map(|m| {
701                    let parsed: Value = serde_json::from_str(&m.content).ok()?;
702                    parsed
703                        .get("handoff")
704                        .filter(|h| !h.is_null())
705                        .cloned()
706                        .map(|h| (m.metadata.created_at, h))
707                })
708                .max_by_key(|(created_at, _)| *created_at)
709                .map(|(_, handoff)| handoff)
710            {
711                digest.push_str("\n## Checkpoint state\n");
712                if let Some(git) = cp.get("git") {
713                    writeln!(
714                        digest,
715                        "- commit `{}` on `{}` ({} dirty files)",
716                        git.get("commit").and_then(Value::as_str).unwrap_or("?"),
717                        git.get("branch").and_then(Value::as_str).unwrap_or("?"),
718                        git.get("dirty_count").and_then(Value::as_i64).unwrap_or(0)
719                    )
720                    .expect("write to String cannot fail");
721                }
722                if let Some(q) = cp.get("next_queue").and_then(Value::as_array) {
723                    if !q.is_empty() {
724                        writeln!(
725                            digest,
726                            "- next queue: {}",
727                            q.iter()
728                                .filter_map(Value::as_str)
729                                .collect::<Vec<_>>()
730                                .join(" → ")
731                        )
732                        .expect("write to String cannot fail");
733                    }
734                }
735                if let Some(f) = cp.get("open_flags").and_then(Value::as_array) {
736                    if !f.is_empty() {
737                        writeln!(
738                            digest,
739                            "- open flags: {}",
740                            f.iter()
741                                .filter_map(Value::as_str)
742                                .collect::<Vec<_>>()
743                                .join("; ")
744                        )
745                        .expect("write to String cannot fail");
746                    }
747                }
748                if let Some(tg) = cp.get("tests_green") {
749                    writeln!(digest, "- tests green: {tg}").expect("write to String cannot fail");
750                }
751                checkpoint_state = cp;
752            }
753        }
754
755        Ok(json!({
756            "status": "success",
757            "session_id": session_id,
758            "digest": digest,
759            "turns_included": included,
760            "turns_total_scanned": turns.len(),
761            "sections": groups.iter().map(|(t, items)| json!({"type": t, "count": items.len()})).collect::<Vec<_>>(),
762            "checkpoint": checkpoint_state,
763        }))
764    }
765    fn stats(&self) -> &ToolStats {
766        &self.stats
767    }
768}
769
770/// Capitalize the first letter of a label for section headings.
771fn capitalize(s: &str) -> String {
772    let mut chars = s.chars();
773    match chars.next() {
774        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
775        None => String::new(),
776    }
777}
778
779/// Naive English plural for turn-type labels (decision→Decisions,
780/// summary→Summaries).
781fn pluralize(s: &str) -> String {
782    if let Some(stem) = s.strip_suffix('y') {
783        format!("{stem}ies")
784    } else {
785        format!("{s}s")
786    }
787}
788
789/// `session.handoff` — package a session for transfer to another device.
790pub struct SessionHandoffTool {
791    store: Arc<MemoryStore>,
792    stats: ToolStats,
793    effects: EffectRow,
794}
795
796impl SessionHandoffTool {
797    #[must_use]
798    pub fn new(store: Arc<MemoryStore>) -> Self {
799        Self {
800            store,
801            stats: ToolStats::default(),
802            effects: EffectRow {
803                writes: vec![Resource::Galaxy("sessions".into())],
804                ..Default::default()
805            },
806        }
807    }
808}
809
810#[async_trait]
811impl Tool for SessionHandoffTool {
812    fn name(&self) -> &str {
813        "session.handoff"
814    }
815    fn gana(&self) -> Gana {
816        Gana::StraddlingLegs
817    }
818    fn effects(&self) -> &EffectRow {
819        &self.effects
820    }
821    fn input_schema(&self) -> Value {
822        super::common::schema(
823            &json!({
824                "action": super::common::str_prop("transfer | accept | list"),
825                "session_id": super::common::str_prop("transfer: session to hand off"),
826                "message": super::common::str_prop("transfer: handoff note"),
827                "handoff_id": super::common::str_prop("accept: handoff to accept"),
828            }),
829            &["action"],
830        )
831    }
832    fn description(&self) -> &str {
833        "Transfer or resume a session across devices (actions: transfer, accept, list). transfer: session_id (required) + message; accept: handoff_id; list: pending handoffs."
834    }
835    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
836        let action = args.get("action").and_then(Value::as_str).unwrap_or("list");
837        match action {
838            "transfer" => {
839                let session_id =
840                    args.get("session_id")
841                        .and_then(Value::as_str)
842                        .ok_or_else(|| {
843                            wm_core::CoreError::InvalidArgs(
844                                "session_id required for transfer".into(),
845                            )
846                        })?;
847                let message = args.get("message").and_then(Value::as_str).unwrap_or("");
848                let turns = load_turns(&self.store, Some(session_id), 10_000, false)?;
849                if turns.is_empty() {
850                    return Err(wm_core::CoreError::Tool(format!(
851                        "session {session_id} has no recorded turns"
852                    )));
853                }
854                let summary: Vec<Value> =
855                    turns.iter().map(|(_, v)| format_turn(v, false)).collect();
856                let handoff_id = format!("handoff-{}", uuid::Uuid::new_v4());
857                let mut mem = Memory::new(
858                    Galaxy::Sessions,
859                    json!({
860                        "type": "session_handoff",
861                        "handoff_id": handoff_id,
862                        "session_id": session_id,
863                        "message": message,
864                        "status": "pending",
865                        "turn_count": summary.len(),
866                        "summary": summary,
867                        "created_at": wm_core::time::now_unix_millis(),
868                    })
869                    .to_string(),
870                );
871                mem.metadata.tags = vec![
872                    "session".into(),
873                    "handoff".into(),
874                    format!("session:{session_id}"),
875                ];
876                mem.metadata.importance = 0.8;
877                self.store.put(Galaxy::Sessions, &mem)?;
878                Ok(json!({
879                    "status": "success",
880                    "action": "transfer",
881                    "handoff_id": handoff_id,
882                    "session_id": session_id,
883                    "turn_count": summary.len(),
884                }))
885            }
886            "accept" => {
887                let handoff_id =
888                    args.get("handoff_id")
889                        .and_then(Value::as_str)
890                        .ok_or_else(|| {
891                            wm_core::CoreError::InvalidArgs("handoff_id required for accept".into())
892                        })?;
893                let memories = self.store.scan_all(Galaxy::Sessions)?;
894                let found = memories.iter().find(|m| {
895                    m.metadata.tags.contains(&"handoff".to_string())
896                        && m.content.contains(handoff_id)
897                });
898                let Some(mem) = found else {
899                    return Err(wm_core::CoreError::Tool(format!(
900                        "handoff {handoff_id} not found"
901                    )));
902                };
903                let mut updated = mem.clone();
904                if let Ok(mut v) = serde_json::from_str::<Value>(&updated.content) {
905                    v["status"] = json!("accepted");
906                    updated.content = v.to_string();
907                }
908                self.store.put(Galaxy::Sessions, &updated)?;
909                Ok(json!({
910                    "status": "success",
911                    "action": "accept",
912                    "handoff_id": handoff_id,
913                }))
914            }
915            "list" => {
916                let memories = self.store.scan_all(Galaxy::Sessions)?;
917                let handoffs: Vec<Value> = memories
918                    .iter()
919                    .filter(|m| m.metadata.tags.contains(&"handoff".to_string()))
920                    .filter_map(|m| serde_json::from_str::<Value>(&m.content).ok())
921                    .filter(|v| v.get("status").and_then(Value::as_str) == Some("pending"))
922                    .map(|v| {
923                        json!({
924                            "handoff_id": v.get("handoff_id"),
925                            "session_id": v.get("session_id"),
926                            "message": v.get("message"),
927                            "turn_count": v.get("turn_count"),
928                        })
929                    })
930                    .collect();
931                Ok(json!({
932                    "status": "success",
933                    "action": "list",
934                    "pending_count": handoffs.len(),
935                    "handoffs": handoffs,
936                }))
937            }
938            other => Err(wm_core::CoreError::InvalidArgs(format!(
939                "unknown session.handoff action: {other}"
940            ))),
941        }
942    }
943    fn stats(&self) -> &ToolStats {
944        &self.stats
945    }
946}
947
948/// Register the session-ops tools (4).
949#[must_use]
950/// `session.export` — serialize a session to JSONL for store-to-store moves.
951///
952/// Exports the start marker, every turn (including superseded — history
953/// travels with the session), and checkpoints, preserving ids, timestamps,
954/// and tags so an import reconstructs the session faithfully.
955pub struct SessionExportTool {
956    store: Arc<MemoryStore>,
957    stats: ToolStats,
958    effects: EffectRow,
959}
960
961impl SessionExportTool {
962    pub fn new(store: Arc<MemoryStore>) -> Self {
963        Self {
964            store,
965            stats: ToolStats::default(),
966            effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
967        }
968    }
969}
970
971#[async_trait]
972impl Tool for SessionExportTool {
973    fn name(&self) -> &str {
974        "session.export"
975    }
976    fn gana(&self) -> Gana {
977        Gana::StraddlingLegs
978    }
979    fn effects(&self) -> &EffectRow {
980        &self.effects
981    }
982    fn input_schema(&self) -> Value {
983        super::common::schema(
984            &json!({
985                "session_id": super::common::str_prop("Session to export (default: most recent)"),
986                "path": super::common::str_prop("Write JSONL to this file instead of returning inline"),
987            }),
988            &[],
989        )
990    }
991    fn description(&self) -> &str {
992        "Export a session as JSONL (start marker + turns + checkpoints, preserving ids/timestamps/tags). Args: session_id (optional), path (optional — writes to file; otherwise returns jsonl inline)."
993    }
994    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
995        let session_id = match args.get("session_id").and_then(Value::as_str) {
996            Some(sid) if !sid.is_empty() => sid.to_string(),
997            _ => self
998                .store
999                .scan_all(Galaxy::Sessions)?
1000                .iter()
1001                .filter(|m| m.metadata.tags.contains(&"start".to_string()))
1002                .max_by_key(|m| m.metadata.created_at)
1003                .map(|m| m.metadata.id.to_string())
1004                .ok_or_else(|| {
1005                    wm_core::CoreError::Tool("no session found — run session.start first".into())
1006                })?,
1007        };
1008
1009        // Start marker matches by id; turns/checkpoints carry the id in
1010        // content. Superseded turns are included deliberately: history is
1011        // part of the story being re-homed.
1012        let mut members: Vec<Memory> = self
1013            .store
1014            .scan_all(Galaxy::Sessions)?
1015            .into_iter()
1016            .filter(|m| m.metadata.id.to_string() == session_id || m.content.contains(&session_id))
1017            .collect();
1018        members.sort_by_key(|m| m.metadata.created_at);
1019
1020        let mut jsonl = String::new();
1021        // Envelope v2 (S4): header line first, records after. Importers of
1022        // any version accept the stream; v1 readers that split on lines
1023        // would see the header as an unparseable record — acceptable for a
1024        // forward-only addition, and `wm` builds from this point on all
1025        // read envelopes.
1026        let header = wm_memory::envelope::EnvelopeHeader::new("session_export", members.len());
1027        jsonl.push_str(&header.header_line());
1028        jsonl.push('\n');
1029        for m in &members {
1030            let line = serde_json::to_string(m)
1031                .map_err(|e| wm_core::CoreError::Tool(format!("export serialize: {e}")))?;
1032            jsonl.push_str(&line);
1033            jsonl.push('\n');
1034        }
1035
1036        let path_arg = args
1037            .get("path")
1038            .and_then(Value::as_str)
1039            .filter(|s| !s.is_empty());
1040        if let Some(dest) = path_arg {
1041            std::fs::write(dest, &jsonl)
1042                .map_err(|e| wm_core::CoreError::Tool(format!("export write {dest}: {e}")))?;
1043            Ok(json!({
1044                "status": "success",
1045                "session_id": session_id,
1046                "records": members.len(),
1047                "path": dest,
1048            }))
1049        } else {
1050            Ok(json!({
1051                "status": "success",
1052                "session_id": session_id,
1053                "records": members.len(),
1054                "jsonl": jsonl,
1055            }))
1056        }
1057    }
1058    fn stats(&self) -> &ToolStats {
1059        &self.stats
1060    }
1061}
1062
1063/// `session.import` — restore exported sessions into this store.
1064///
1065/// Counterpart to [`SessionExportTool`]: reads JSONL lines (file path or
1066/// inline), validates the envelope header (`wm_memory::envelope`, S4 —
1067/// bare v1 payloads accepted), deserializes each full Memory record, and
1068/// puts it into the Sessions galaxy with its original id, timestamps, and
1069/// tags — so continuity/replay behave identically in the new store. When a
1070/// writable `SearchEngine` is available, records are indexed in the same
1071/// pass (one commit per import), so imported sessions are searchable
1072/// immediately and leave no index drift for `heal_index_drift` to sweep.
1073pub struct SessionImportTool {
1074    store: Arc<MemoryStore>,
1075    search: Option<Arc<wm_memory::SearchEngine>>,
1076    stats: ToolStats,
1077    effects: EffectRow,
1078}
1079
1080impl SessionImportTool {
1081    pub fn new(store: Arc<MemoryStore>, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
1082        Self {
1083            store,
1084            search,
1085            stats: ToolStats::default(),
1086            effects: EffectRow {
1087                writes: vec![Resource::Galaxy("sessions".into())],
1088                ..Default::default()
1089            },
1090        }
1091    }
1092}
1093
1094#[async_trait]
1095impl Tool for SessionImportTool {
1096    fn name(&self) -> &str {
1097        "session.import"
1098    }
1099    fn gana(&self) -> Gana {
1100        Gana::StraddlingLegs
1101    }
1102    fn effects(&self) -> &EffectRow {
1103        &self.effects
1104    }
1105    fn input_schema(&self) -> Value {
1106        super::common::schema(
1107            &json!({
1108                "path": super::common::str_prop("Read JSONL from this file"),
1109                "jsonl": super::common::str_prop("Or pass the export payload inline"),
1110            }),
1111            &[],
1112        )
1113    }
1114    fn description(&self) -> &str {
1115        "Import sessions from session.export JSONL (path or inline jsonl) — envelope-v2 header validated when present, bare v1 accepted; preserves ids, timestamps, and tags; indexes into the search index; overwrites on id collision."
1116    }
1117    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1118        let payload = match args
1119            .get("path")
1120            .and_then(Value::as_str)
1121            .filter(|s| !s.is_empty())
1122        {
1123            Some(path) => std::fs::read_to_string(path)
1124                .map_err(|e| wm_core::CoreError::Tool(format!("import read {path}: {e}")))?,
1125            None => args
1126                .get("jsonl")
1127                .and_then(Value::as_str)
1128                .filter(|s| !s.is_empty())
1129                .ok_or_else(|| {
1130                    wm_core::CoreError::InvalidArgs(
1131                        "provide either 'path' or inline 'jsonl'".into(),
1132                    )
1133                })?
1134                .to_string(),
1135        };
1136
1137        // Envelope v2 (S4): the first non-empty line may be a header. A
1138        // refused header (newer format) aborts the import — partial
1139        // imports of a forward stream are the failure mode the envelope
1140        // exists to prevent.
1141        let mut envelope: Option<wm_memory::envelope::EnvelopeHeader> = None;
1142        let mut record_lines: Vec<&str> = Vec::new();
1143        let mut header_consumed = false;
1144        for line in payload.lines() {
1145            if line.trim().is_empty() {
1146                continue;
1147            }
1148            if !header_consumed {
1149                header_consumed = true;
1150                match wm_memory::envelope::read_header_line(line) {
1151                    wm_memory::envelope::HeaderRead::Header(h) => {
1152                        envelope = Some(h);
1153                        continue;
1154                    }
1155                    wm_memory::envelope::HeaderRead::Refused(msg) => {
1156                        return Err(wm_core::CoreError::Tool(msg));
1157                    }
1158                    wm_memory::envelope::HeaderRead::NotAHeader => {}
1159                }
1160            }
1161            record_lines.push(line);
1162        }
1163
1164        // Index in the same pass when a writable engine is available.
1165        // Read-only engines cannot take the writer; the import still
1166        // lands in LMDB and `heal_index_drift` sweeps the gap at the next
1167        // writable startup — disclosed honestly below.
1168        let readonly_engine = self.search.as_ref().is_some_and(|s| s.is_readonly());
1169        if readonly_engine {
1170            tracing::warn!(
1171                "session.import running against a read-only search engine — records land \
1172                 in LMDB unindexed; they become searchable at the next writable startup \
1173                 (heal_index_drift)"
1174            );
1175        }
1176        let mut writer_slot = match (&self.search, readonly_engine) {
1177            (Some(s), false) => s.writer().ok(),
1178            _ => None,
1179        };
1180
1181        let mut imported = 0usize;
1182        let mut indexed = 0usize;
1183        let mut skipped = 0usize;
1184        let mut session_ids: Vec<String> = Vec::new();
1185        for (lineno, line) in record_lines.iter().enumerate() {
1186            let mem: Memory = match serde_json::from_str(line) {
1187                Ok(m) => m,
1188                Err(e) => {
1189                    skipped += 1;
1190                    tracing::warn!(line = lineno + 1, error = %e, "skipping unparseable export line");
1191                    continue;
1192                }
1193            };
1194            if let Ok(parsed) = serde_json::from_str::<Value>(&mem.content) {
1195                if let Some(sid) = parsed.get("session_id").and_then(Value::as_str) {
1196                    if !session_ids.iter().any(|s| s == sid) {
1197                        session_ids.push(sid.to_string());
1198                    }
1199                }
1200            }
1201            // Tantivy documents are not keyed — delete-then-add keeps the
1202            // index honest on id collisions (re-imports), mirroring the
1203            // ingest ledger's re-ingest pattern.
1204            if let (Some(search), Some(writer)) = (&self.search, writer_slot.as_mut()) {
1205                let id_str = mem.metadata.id.to_string();
1206                let _ = search.delete_document(writer, &id_str);
1207                match search.add_document(
1208                    writer,
1209                    &id_str,
1210                    mem.metadata.galaxy.db_name(),
1211                    &mem.content,
1212                    &mem.metadata.tags,
1213                    mem.metadata.created_at.timestamp(),
1214                ) {
1215                    Ok(()) => indexed += 1,
1216                    Err(e) => {
1217                        tracing::warn!(id = %id_str, error = %e, "import index add failed (LMDB record kept)");
1218                    }
1219                }
1220            }
1221            self.store.put(Galaxy::Sessions, &mem)?;
1222            imported += 1;
1223        }
1224
1225        if let Some(search) = &self.search {
1226            if let Some(mut writer) = writer_slot {
1227                search
1228                    .commit(&mut writer)
1229                    .map_err(|e| wm_core::CoreError::Tool(format!("import index commit: {e}")))?;
1230            }
1231        }
1232
1233        let mut warnings: Vec<String> = Vec::new();
1234        if let Some(h) = &envelope {
1235            if h.count != imported {
1236                let msg = format!(
1237                    "envelope declares count {} but {} records imported",
1238                    h.count, imported
1239                );
1240                tracing::warn!("{msg}");
1241                warnings.push(msg);
1242            }
1243        }
1244
1245        let envelope_info = envelope.as_ref().map(|h| {
1246            json!({
1247                "format_version": h.format_version,
1248                "kind": h.kind,
1249                "generator": h.generator,
1250                "created_at": h.created_at,
1251                "declared_count": h.count,
1252            })
1253        });
1254
1255        Ok(json!({
1256            "status": "success",
1257            "imported": imported,
1258            "skipped": skipped,
1259            "session_ids": session_ids,
1260            "indexed": indexed,
1261            "envelope": envelope_info,
1262            "warnings": warnings,
1263        }))
1264    }
1265    fn stats(&self) -> &ToolStats {
1266        &self.stats
1267    }
1268}
1269
1270pub fn register_session_ops(
1271    registry: &wm_dispatch::ToolRegistry,
1272    store: &Arc<MemoryStore>,
1273    search: Option<Arc<wm_memory::SearchEngine>>,
1274) -> wm_dispatch::ToolRegistry {
1275    registry
1276        .register(Arc::new(SessionRecordTool::new(store.clone())))
1277        .register(Arc::new(SessionReplayTool::new(store.clone())))
1278        .register(Arc::new(SessionContinuityTool::new(store.clone())))
1279        .register(Arc::new(SessionHandoffTool::new(store.clone())))
1280        .register(Arc::new(SessionExportTool::new(store.clone())))
1281        .register(Arc::new(SessionImportTool::new(store.clone(), search)))
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286    use super::*;
1287
1288    fn test_store() -> Arc<MemoryStore> {
1289        let dir = tempfile::tempdir().unwrap();
1290        let path = dir.path().join("lmdb");
1291        std::fs::create_dir_all(&path).unwrap();
1292        Arc::new(MemoryStore::open_default(path).unwrap())
1293    }
1294
1295    fn start_session(store: &MemoryStore) -> String {
1296        let mut mem = Memory::new(
1297            Galaxy::Sessions,
1298            json!({"type": "session_start"}).to_string(),
1299        );
1300        mem.metadata.tags = vec!["session".into(), "start".into()];
1301        store.put(Galaxy::Sessions, &mem).unwrap();
1302        mem.metadata.id.to_string()
1303    }
1304
1305    /// Start a session with an explicit `created_at` age so tests can pin
1306    /// recency independent of UUID sort order.
1307    fn start_session_aged(store: &MemoryStore, age_secs: i64) -> String {
1308        let mut mem = Memory::new(
1309            Galaxy::Sessions,
1310            json!({"type": "session_start"}).to_string(),
1311        );
1312        mem.metadata.tags = vec!["session".into(), "start".into()];
1313        mem.metadata.created_at = chrono::Utc::now() - chrono::Duration::seconds(age_secs);
1314        store.put(Galaxy::Sessions, &mem).unwrap();
1315        mem.metadata.id.to_string()
1316    }
1317
1318    /// Record a turn with a backdated creation time (for time-filter tests).
1319    fn record_aged_turn(store: &MemoryStore, sid: &str, age_days: u32, content: &str) {
1320        let mut mem = Memory::new(
1321            Galaxy::Sessions,
1322            json!({
1323                "type": "session_turn",
1324                "session_id": sid,
1325                "role": "ai",
1326                "turn_type": "decision",
1327                "importance": 0.9,
1328                "content": content,
1329            })
1330            .to_string(),
1331        );
1332        mem.metadata.tags = vec!["session".into(), "turn".into(), format!("session:{sid}")];
1333        mem.metadata.created_at = Utc::now() - chrono::Duration::days(i64::from(age_days));
1334        store.put(Galaxy::Sessions, &mem).unwrap();
1335    }
1336
1337    #[tokio::test]
1338    async fn replay_time_filters_since_and_until() {
1339        let store = test_store();
1340        let sid = start_session(&store);
1341        record_aged_turn(&store, &sid, 3, "three days ago");
1342        record_aged_turn(&store, &sid, 2, "two days ago");
1343        record_aged_turn(&store, &sid, 1, "yesterday");
1344        record_aged_turn(&store, &sid, 0, "today");
1345
1346        let replay = SessionReplayTool::new(store);
1347        let mut ctx = Context::default();
1348
1349        // "What changed since Tuesday?" — date-only floor.
1350        let two_days_ago = (Utc::now() - chrono::Duration::days(2)).format("%Y-%m-%d");
1351        let v = replay
1352            .call(
1353                &mut ctx,
1354                json!({"session_id": sid, "since": two_days_ago.to_string()}),
1355            )
1356            .await
1357            .unwrap();
1358        assert_eq!(v["count"], 3, "since=date keeps day-of + later: {v}");
1359
1360        // Epoch-seconds ceiling between the yesterday-turn and today.
1361        let until_epoch = (Utc::now() - chrono::Duration::hours(23)).timestamp();
1362        let v = replay
1363            .call(&mut ctx, json!({"session_id": sid, "until": until_epoch}))
1364            .await
1365            .unwrap();
1366        assert_eq!(
1367            v["count"], 3,
1368            "until=epoch(23h ago) keeps the three older turns: {v}"
1369        );
1370
1371        // Combined window (half-day margins absorb sub-second record skew).
1372        let since = (Utc::now() - chrono::Duration::hours(60)).to_rfc3339();
1373        let until = (Utc::now() - chrono::Duration::hours(12)).to_rfc3339();
1374        let v = replay
1375            .call(
1376                &mut ctx,
1377                json!({"session_id": sid, "since": since, "until": until}),
1378            )
1379            .await
1380            .unwrap();
1381        assert_eq!(v["count"], 2, "window keeps two/two-days-ago turns: {v}");
1382        for turn in v["turns"].as_array().unwrap() {
1383            assert_ne!(
1384                turn["content"], "today",
1385                "time filters must exclude out-of-window turns"
1386            );
1387        }
1388
1389        // Invalid bound is an error, not silence.
1390        assert!(
1391            replay
1392                .call(&mut ctx, json!({"session_id": sid, "since": "not-a-date"}))
1393                .await
1394                .is_err()
1395        );
1396    }
1397
1398    #[tokio::test]
1399    async fn continuity_respects_since_filter() {
1400        let store = test_store();
1401        let sid1 = start_session(&store);
1402        record_aged_turn(&store, &sid1, 5, "ancient decision");
1403        record_aged_turn(&store, &sid1, 0, "fresh decision");
1404        let sid2 = start_session(&store);
1405
1406        let continuity = SessionContinuityTool::new(store);
1407        let mut ctx = Context::default();
1408        let cutoff = (Utc::now() - chrono::Duration::days(1))
1409            .format("%Y-%m-%d")
1410            .to_string();
1411        let v = continuity
1412            .call(
1413                &mut ctx,
1414                json!({"current_session_id": sid2, "since": cutoff, "n": 10}),
1415            )
1416            .await
1417            .unwrap();
1418        assert_eq!(v["count"], 1, "only the fresh turn is in range: {v}");
1419        assert_eq!(v["turns"][0]["content"], "fresh decision");
1420
1421        // Unfiltered still sees both.
1422        let all = continuity
1423            .call(&mut ctx, json!({"current_session_id": sid2, "n": 10}))
1424            .await
1425            .unwrap();
1426        assert_eq!(all["count"], 2);
1427    }
1428
1429    #[tokio::test]
1430    async fn digest_groups_by_type_and_respects_importance_floor() {
1431        let store = test_store();
1432        let sid = start_session(&store);
1433        // Mixed types/importances; the 0.3 turn must not appear.
1434        for (turn_type, importance, content) in [
1435            ("summary", 0.6, "wrapped up"),
1436            ("decision", 0.9, "picked architecture CO over alternatives"),
1437            ("error", 0.95, "startForce root cause found"),
1438            ("breakthrough", 0.85, "watch resolution insight"),
1439            ("message", 0.3, "low-value chatter"),
1440        ] {
1441            let mut mem = Memory::new(
1442                Galaxy::Sessions,
1443                json!({
1444                    "type": "session_turn",
1445                    "session_id": sid,
1446                    "role": "ai",
1447                    "turn_type": turn_type,
1448                    "importance": importance,
1449                    "content": content,
1450                })
1451                .to_string(),
1452            );
1453            mem.metadata.tags = vec!["session".into(), "turn".into()];
1454            store.put(Galaxy::Sessions, &mem).unwrap();
1455        }
1456
1457        let tool = SessionDigestTool::new(store);
1458        let mut ctx = Context::default();
1459        let v = tool
1460            .call(&mut ctx, json!({"session_id": sid, "min_importance": 0.5}))
1461            .await
1462            .unwrap();
1463
1464        assert_eq!(v["status"], "success");
1465        let digest = v["digest"].as_str().unwrap();
1466        // Every ≥0.85 turn present verbatim.
1467        for expected in [
1468            "startForce root cause found",
1469            "picked architecture CO over alternatives",
1470            "watch resolution insight",
1471        ] {
1472            assert!(
1473                digest.contains(expected),
1474                "digest must contain '{expected}': {digest}"
1475            );
1476        }
1477        // Nothing below the floor.
1478        assert!(!digest.contains("low-value chatter"), "got: {digest}");
1479        // Section order: decisions before breakthroughs before errors... per DIGEST_SECTION_ORDER decision<breakthrough<error; summary last of knowns.
1480        let d = digest.find("## Decisions").unwrap();
1481        let b = digest.find("## Breakthroughs").unwrap();
1482        let e = digest.find("## Errors").unwrap();
1483        let s = digest.find("## Summaries").unwrap();
1484        assert!(
1485            d < b && b < e && e < s,
1486            "sections must follow canonical order: {digest}"
1487        );
1488        assert_eq!(v["turns_included"], 4);
1489
1490        // Raising the floor to 0.9 drops the breakthrough + summary.
1491        let strict = tool
1492            .call(&mut ctx, json!({"session_id": sid, "min_importance": 0.9}))
1493            .await
1494            .unwrap();
1495        let strict_digest = strict["digest"].as_str().unwrap();
1496        assert!(strict_digest.contains("startForce"));
1497        assert!(!strict_digest.contains("watch resolution insight"));
1498    }
1499
1500    #[tokio::test]
1501    async fn digest_appends_checkpoint_state() {
1502        let store = test_store();
1503        let sid = start_session(&store);
1504
1505        // Seed a checkpoint with handoff state directly (avoids git fixture).
1506        let mut cp = Memory::new(
1507            Galaxy::Sessions,
1508            json!({
1509                "type": "checkpoint",
1510                "session_id": sid,
1511                "label": "wrap",
1512                "data": {},
1513                "handoff": {
1514                    "git": {
1515                        "commit": "abc1234",
1516                        "branch": "main",
1517                        "dirty_count": 2
1518                    },
1519                    "tests_green": true,
1520                    "next_queue": ["first task", "second task"],
1521                    "open_flags": ["flaky probe"]
1522                }
1523            })
1524            .to_string(),
1525        );
1526        cp.metadata.tags = vec!["session".into(), "checkpoint".into()];
1527        store.put(Galaxy::Sessions, &cp).unwrap();
1528
1529        let tool = SessionDigestTool::new(store);
1530        let mut ctx = Context::default();
1531        let v = tool
1532            .call(&mut ctx, json!({"session_id": sid}))
1533            .await
1534            .unwrap();
1535
1536        let digest = v["digest"].as_str().unwrap();
1537        assert!(digest.contains("## Checkpoint state"), "got: {digest}");
1538        assert!(digest.contains("abc1234"));
1539        assert!(digest.contains("first task → second task"));
1540        assert!(digest.contains("flaky probe"));
1541        assert_eq!(v["checkpoint"]["git"]["branch"], "main");
1542    }
1543
1544    #[tokio::test]
1545    async fn supersedes_hides_old_turn_until_requested() {
1546        // P2: evolving stories amend instead of accumulating contradictory
1547        // blobs. The superseded turn leaves default retrieval but stays in
1548        // history behind include_superseded.
1549        let store = test_store();
1550        let sid = start_session(&store);
1551        let record = SessionRecordTool::new(store.clone());
1552        let mut ctx = Context::default();
1553
1554        let first = record
1555            .call(
1556                &mut ctx,
1557                json!({"role": "ai", "turn_type": "decision", "importance": 0.9,
1558                        "content": "perf: 240ms", "session_id": sid}),
1559            )
1560            .await
1561            .unwrap();
1562        let old_id = first["memory_id"].as_str().unwrap().to_string();
1563
1564        let second = record
1565            .call(
1566                &mut ctx,
1567                json!({"role": "ai", "turn_type": "decision", "importance": 0.9,
1568                        "content": "perf revised: 180ms after warm cache",
1569                        "session_id": sid, "supersedes": old_id}),
1570            )
1571            .await
1572            .unwrap();
1573        assert_eq!(second["status"], "success");
1574        let _new_id = second["memory_id"].as_str().unwrap().to_string();
1575
1576        // Default replay shows only the correction.
1577        let replay = SessionReplayTool::new(store.clone());
1578        let v = replay
1579            .call(&mut ctx, json!({"session_id": sid}))
1580            .await
1581            .unwrap();
1582        assert_eq!(
1583            v["count"], 1,
1584            "superseded turn must be hidden by default: {v}"
1585        );
1586        assert_eq!(
1587            v["turns"][0]["content"],
1588            "perf revised: 180ms after warm cache"
1589        );
1590
1591        // History stays intact behind the flag.
1592        let with_history = replay
1593            .call(
1594                &mut ctx,
1595                json!({"session_id": sid, "include_superseded": true}),
1596            )
1597            .await
1598            .unwrap();
1599        assert_eq!(with_history["count"], 2, "got: {with_history}");
1600
1601        // Continuity and digest also use the current story.
1602        let sid2 = start_session(&store);
1603        let continuity = SessionContinuityTool::new(store.clone());
1604        let c = continuity
1605            .call(&mut ctx, json!({"current_session_id": sid2}))
1606            .await
1607            .unwrap();
1608        assert_eq!(c["count"], 1, "continuity must skip superseded turns: {c}");
1609        assert_eq!(
1610            c["turns"][0]["content"],
1611            "perf revised: 180ms after warm cache"
1612        );
1613
1614        let digest = SessionDigestTool::new(store);
1615        let d = digest
1616            .call(&mut ctx, json!({"session_id": sid, "min_importance": 0.5}))
1617            .await
1618            .unwrap();
1619        let digest_text = d["digest"].as_str().unwrap();
1620        assert!(digest_text.contains("180ms"), "got: {digest_text}");
1621        assert!(
1622            !digest_text.contains("240ms"),
1623            "superseded claim must not leak: {digest_text}"
1624        );
1625    }
1626
1627    #[tokio::test]
1628    async fn export_import_roundtrip_preserves_history() {
1629        // B3 acceptance: export → import into a FRESH store → continuity and
1630        // replay return identical turns (ids, timestamps, tags intact).
1631        let store_a = test_store();
1632        let sid = start_session(&store_a);
1633        let record = SessionRecordTool::new(store_a.clone());
1634        let mut ctx = Context::default();
1635        record_aged_turn(&store_a, &sid, 2, "day-one decision");
1636        record_aged_turn(&store_a, &sid, 1, "day-two decision");
1637        let first = record
1638            .call(
1639                &mut ctx,
1640                json!({"role": "ai", "turn_type": "decision", "importance": 0.9,
1641                        "content": "original claim", "session_id": sid}),
1642            )
1643            .await
1644            .unwrap();
1645        record
1646            .call(
1647                &mut ctx,
1648                json!({"role": "ai", "turn_type": "decision", "importance": 0.9,
1649                        "content": "corrected claim",
1650                        "session_id": sid,
1651                        "supersedes": first["memory_id"].as_str().unwrap()}),
1652            )
1653            .await
1654            .unwrap();
1655
1656        // Export inline.
1657        let export = SessionExportTool::new(store_a.clone());
1658        // Title the start marker (S4: envelope roundtrip must preserve it).
1659        {
1660            let mut marker: Memory = store_a
1661                .scan_all(Galaxy::Sessions)
1662                .unwrap()
1663                .into_iter()
1664                .find(|m| m.metadata.tags.contains(&"start".to_string()))
1665                .unwrap();
1666            marker.metadata.title = Some("The Big Decision".to_string());
1667            marker.metadata.topic = Some("v8-slices".to_string());
1668            store_a.put(Galaxy::Sessions, &marker).unwrap();
1669        }
1670        let exported = export
1671            .call(&mut ctx, json!({"session_id": sid}))
1672            .await
1673            .unwrap();
1674        assert_eq!(exported["status"], "success");
1675        let jsonl = exported["jsonl"].as_str().unwrap();
1676        // start + 4 turns (incl. superseded) = 6 records minimum.
1677        assert_eq!(
1678            exported["records"], 5,
1679            "start + 2 aged + 2 claims: {exported}"
1680        );
1681        // Envelope v2: header line + 5 records.
1682        assert_eq!(jsonl.lines().count(), 6);
1683        let header_line = jsonl.lines().next().unwrap();
1684        match wm_memory::envelope::read_header_line(header_line) {
1685            wm_memory::envelope::HeaderRead::Header(h) => {
1686                assert_eq!(h.kind, "session_export");
1687                assert_eq!(h.count, 5);
1688            }
1689            other => panic!("first line must be the envelope header, got {other:?}"),
1690        }
1691
1692        // Import into a completely fresh store.
1693        let store_b = test_store();
1694        let import = SessionImportTool::new(store_b.clone(), None);
1695        let imported = import
1696            .call(&mut ctx, json!({"jsonl": jsonl}))
1697            .await
1698            .unwrap();
1699        assert_eq!(imported["imported"], 5, "got: {imported}");
1700        assert_eq!(imported["skipped"], 0);
1701        assert_eq!(imported["session_ids"], json!([sid]));
1702        // Envelope disclosed; count matched.
1703        assert_eq!(imported["envelope"]["format_version"], 2);
1704        assert_eq!(imported["envelope"]["declared_count"], 5);
1705        assert_eq!(imported["warnings"], json!([]));
1706        // Title/topic survive the roundtrip (start marker carries them).
1707        let marker_b = store_b
1708            .scan_all(Galaxy::Sessions)
1709            .unwrap()
1710            .into_iter()
1711            .find(|m| m.metadata.tags.contains(&"start".to_string()))
1712            .unwrap();
1713        assert_eq!(marker_b.metadata.title.as_deref(), Some("The Big Decision"));
1714        assert_eq!(marker_b.metadata.topic.as_deref(), Some("v8-slices"));
1715
1716        // Replay in store B matches the current story exactly...
1717        let replay_b = SessionReplayTool::new(store_b.clone());
1718        let v = replay_b
1719            .call(&mut ctx, json!({"session_id": sid}))
1720            .await
1721            .unwrap();
1722        assert_eq!(v["count"], 3, "two aged turns + correction: {v}");
1723        let contents: Vec<&str> = v["turns"]
1724            .as_array()
1725            .unwrap()
1726            .iter()
1727            .filter_map(|t| t["content"].as_str())
1728            .collect();
1729        assert!(contents.contains(&"day-one decision"));
1730        assert!(contents.contains(&"corrected claim"));
1731        assert!(!contents.contains(&"original claim"));
1732
1733        // ...and with history the superseded turn is still there.
1734        let full = replay_b
1735            .call(
1736                &mut ctx,
1737                json!({"session_id": sid, "include_superseded": true}),
1738            )
1739            .await
1740            .unwrap();
1741        assert_eq!(full["count"], 4);
1742
1743        // Continuity in store B sees an imported prior session by recency.
1744        let new_sid = start_session(&store_b);
1745        let continuity = SessionContinuityTool::new(store_b);
1746        let c = continuity
1747            .call(
1748                &mut ctx,
1749                json!({"current_session_id": new_sid, "since":
1750                    (Utc::now() - chrono::Duration::days(3)).format("%Y-%m-%d").to_string()}),
1751            )
1752            .await
1753            .unwrap();
1754        assert_eq!(
1755            c["previous_session"], sid,
1756            "import must preserve created_at so recency resolution works"
1757        );
1758        assert_eq!(c["count"], 3);
1759    }
1760
1761    #[tokio::test]
1762    async fn import_rejects_missing_payload() {
1763        let store = test_store();
1764        let tool = SessionImportTool::new(store, None);
1765        let mut ctx = Context::default();
1766        assert!(tool.call(&mut ctx, json!({})).await.is_err());
1767    }
1768
1769    #[tokio::test]
1770    async fn import_refuses_newer_envelope_format() {
1771        let store = test_store();
1772        let mut ctx = Context::default();
1773        let header = wm_memory::envelope::EnvelopeHeader {
1774            format_version: wm_memory::envelope::ENVELOPE_FORMAT_VERSION + 1,
1775            kind: "session_export".into(),
1776            created_at: chrono::Utc::now().to_rfc3339(),
1777            count: 1,
1778            generator: "wm 99.0.0".into(),
1779        };
1780        let record =
1781            serde_json::to_string(&Memory::new(Galaxy::Sessions, "future".into())).unwrap();
1782        let payload = format!("{}\n{record}\n", header.header_line());
1783        let tool = SessionImportTool::new(store, None);
1784        let result = tool.call(&mut ctx, json!({"jsonl": payload})).await;
1785        let err = format!("{:?}", result.unwrap_err());
1786        assert!(err.contains("newer than this build supports"), "{err}");
1787    }
1788
1789    /// S4 acceptance: import through a real writable engine leaves zero
1790    /// index drift — imported sessions are searchable immediately, and a
1791    /// re-import (id collisions) does not duplicate index documents.
1792    #[tokio::test]
1793    async fn import_indexes_tantivy_no_drift_even_on_reimport() {
1794        let dir = tempfile::tempdir().unwrap();
1795        let lmdb = dir.path().join("lmdb");
1796        std::fs::create_dir_all(&lmdb).unwrap();
1797        let store = Arc::new(MemoryStore::open_default(&lmdb).unwrap());
1798        let tantivy = dir.path().join("tantivy");
1799        std::fs::create_dir_all(&tantivy).unwrap();
1800        let search = Arc::new(wm_memory::SearchEngine::open(&tantivy).unwrap());
1801
1802        // Export from a source store.
1803        let store_a = test_store();
1804        let sid = start_session(&store_a);
1805        let mut ctx = Context::default();
1806        SessionRecordTool::new(store_a.clone())
1807            .call(
1808                &mut ctx,
1809                json!({"role": "ai", "turn_type": "decision", "importance": 0.9,
1810                        "content": "kumquat governance ratchet engaged", "session_id": sid}),
1811            )
1812            .await
1813            .unwrap();
1814        let exported = SessionExportTool::new(store_a.clone())
1815            .call(&mut ctx, json!({"session_id": sid}))
1816            .await
1817            .unwrap();
1818        let jsonl = exported["jsonl"].as_str().unwrap().to_string();
1819
1820        // Import into a fresh store WITH a writable engine — twice.
1821        let import = SessionImportTool::new(store.clone(), Some(search.clone()));
1822        for round in 1..=2 {
1823            let r = import
1824                .call(&mut ctx, json!({"jsonl": jsonl}))
1825                .await
1826                .unwrap();
1827            assert_eq!(r["status"], "success", "round {round}: {r}");
1828            assert_eq!(r["skipped"], 0);
1829            assert_eq!(
1830                r["indexed"], r["imported"],
1831                "round {round}: every record indexed: {r}"
1832            );
1833        }
1834
1835        // The acceptance: no drift — LMDB and Tantivy agree after two
1836        // imports (delete-then-add kept the index honest on collision).
1837        let report = wm_memory::reindex::check_consistency(&store, &search);
1838        let drifted: Vec<_> = report
1839            .galaxies
1840            .iter()
1841            .filter(|g| g.drift)
1842            .map(|g| g.galaxy.clone())
1843            .collect();
1844        assert!(
1845            drifted.is_empty(),
1846            "import must leave zero index drift, drifted: {drifted:?}"
1847        );
1848
1849        // And the imported content is actually findable through the index.
1850        let needle_id = store
1851            .scan_all(Galaxy::Sessions)
1852            .unwrap()
1853            .iter()
1854            .find(|m| m.content.contains("kumquat"))
1855            .unwrap()
1856            .metadata
1857            .id
1858            .to_string();
1859        let hits = search.search("kumquat governance ratchet", 10).unwrap();
1860        assert!(
1861            hits.iter().any(|h| h.memory_id == needle_id),
1862            "imported record must be searchable via the index: {hits:?}"
1863        );
1864    }
1865
1866    #[tokio::test]
1867    async fn record_then_replay_full() {
1868        let store = test_store();
1869        let sid = start_session(&store);
1870        let record = SessionRecordTool::new(store.clone());
1871        let mut ctx = Context::default();
1872        for (i, role) in [("user", "hello"), ("ai", "hi there")].iter().enumerate() {
1873            let r = record
1874                .call(
1875                    &mut ctx,
1876                    json!({"role": role.0, "content": role.1, "session_id": sid}),
1877                )
1878                .await
1879                .unwrap();
1880            assert_eq!(r["sequence"], i as u64 + 1);
1881        }
1882
1883        let replay = SessionReplayTool::new(store.clone());
1884        let v = replay
1885            .call(&mut ctx, json!({"mode": "full", "session_id": sid}))
1886            .await
1887            .unwrap();
1888        assert_eq!(v["count"], 2);
1889        assert_eq!(v["turns"][0]["content"], "hello");
1890        assert_eq!(v["turns"][1]["role"], "ai");
1891    }
1892
1893    #[tokio::test]
1894    async fn record_requires_content_and_valid_role() {
1895        let store = test_store();
1896        let tool = SessionRecordTool::new(store);
1897        let mut ctx = Context::default();
1898        assert!(tool.call(&mut ctx, json!({})).await.is_err());
1899        assert!(
1900            tool.call(&mut ctx, json!({"role": "system", "content": "x"}))
1901                .await
1902                .is_err()
1903        );
1904    }
1905
1906    /// Provenance contract (sessions-galaxy archaeology fix, 2026-08-29):
1907    /// the turn's role is its authorship claim — ai turns stamp agent/0.7,
1908    /// user turns stamp user/1.0, and nothing defaults to a user claim.
1909    #[tokio::test]
1910    async fn record_stamps_provenance_from_role() {
1911        let store = test_store();
1912        let sid = start_session(&store);
1913        let record = SessionRecordTool::new(store.clone());
1914        let mut ctx = Context::default();
1915        let ai = record
1916            .call(
1917                &mut ctx,
1918                json!({"role": "ai", "content": "agent turn", "session_id": sid}),
1919            )
1920            .await
1921            .unwrap();
1922        let user = record
1923            .call(
1924                &mut ctx,
1925                json!({"role": "user", "content": "human turn", "session_id": sid}),
1926            )
1927            .await
1928            .unwrap();
1929
1930        let ai_mem = store
1931            .get(
1932                Galaxy::Sessions,
1933                uuid::Uuid::parse_str(ai["memory_id"].as_str().unwrap()).unwrap(),
1934            )
1935            .expect("ai turn stored")
1936            .expect("ai turn present");
1937        assert_eq!(ai_mem.metadata.source, "agent");
1938        assert!((ai_mem.metadata.source_trust - 0.7).abs() < 1e-5);
1939
1940        let user_mem = store
1941            .get(
1942                Galaxy::Sessions,
1943                uuid::Uuid::parse_str(user["memory_id"].as_str().unwrap()).unwrap(),
1944            )
1945            .expect("user turn stored")
1946            .expect("user turn present");
1947        assert_eq!(user_mem.metadata.source, "user");
1948        assert!((user_mem.metadata.source_trust - 1.0).abs() < f32::EPSILON);
1949    }
1950
1951    #[tokio::test]
1952    async fn continuity_returns_previous_session_tail() {
1953        let store = test_store();
1954        let sid1 = start_session(&store);
1955        let record = SessionRecordTool::new(store.clone());
1956        let mut ctx = Context::default();
1957        for i in 0..5 {
1958            record
1959                .call(
1960                    &mut ctx,
1961                    json!({"role": "user", "content": format!("turn {i}"), "session_id": sid1}),
1962                )
1963                .await
1964                .unwrap();
1965        }
1966        let sid2 = start_session(&store);
1967        let continuity = SessionContinuityTool::new(store);
1968        let v = continuity
1969            .call(&mut ctx, json!({"current_session_id": sid2, "n": 2}))
1970            .await
1971            .unwrap();
1972        assert_eq!(v["previous_session"], sid1);
1973        assert_eq!(v["count"], 2);
1974        assert_eq!(v["turns"][1]["content"], "turn 4");
1975        assert!(
1976            v.get("hint").is_none(),
1977            "non-empty continuity must not carry the scoping hint"
1978        );
1979    }
1980
1981    #[tokio::test]
1982    async fn continuity_empty_store_discloses_project_scoping() {
1983        // Cold-start friction (2026-08-28): a client wired to the wrong
1984        // project store saw "no previous session found" — truthful but
1985        // indistinguishable from amnesia. Empty results must disclose the
1986        // server's scope and how to check the wiring.
1987        let store = test_store();
1988        let continuity = SessionContinuityTool::new(store);
1989        let mut ctx = Context::default();
1990        let v = continuity.call(&mut ctx, json!({})).await.unwrap();
1991
1992        assert_eq!(v["status"], "success");
1993        assert_eq!(v["count"], 0);
1994        let hint = v["hint"].as_str().expect("hint present on empty store");
1995        assert!(hint.contains("project-scoped"), "got: {hint}");
1996        assert!(hint.contains("opencode config"), "got: {hint}");
1997        assert!(hint.contains("GET /status"), "got: {hint}");
1998        // The hint names the store this server actually serves.
1999        assert!(hint.contains("store "), "got: {hint}");
2000    }
2001
2002    #[tokio::test]
2003    async fn record_defaults_to_newest_start_by_time_not_key_order() {
2004        // Regression: LMDB iterates by UUID key, which is random for v4 —
2005        // positional "last" silently misfiled turns into arbitrary sessions
2006        // once more than one start existed (observed live 2026-08-22 when a
2007        // fresh session's record landed in an older NEON session).
2008        let store = test_store();
2009        for age in [50_000, 40_000, 30_000, 20_000, 10_000] {
2010            start_session_aged(&store, age);
2011        }
2012        let newest = start_session_aged(&store, 0);
2013
2014        let record = SessionRecordTool::new(store);
2015        let mut ctx = Context::default();
2016        let r = record
2017            .call(&mut ctx, json!({"role": "ai", "content": "latest turn"}))
2018            .await
2019            .unwrap();
2020        assert_eq!(
2021            r["session_id"], newest,
2022            "record without explicit session_id must target the newest start by created_at"
2023        );
2024    }
2025
2026    #[tokio::test]
2027    async fn continuity_picks_newest_prior_by_time_not_key_order() {
2028        // Regression companion: continuity must exclude the current session
2029        // and pick the most recent PRIOR start by created_at, not by scan
2030        // position.
2031        let store = test_store();
2032        for age in [40_000, 30_000, 20_000] {
2033            start_session_aged(&store, age);
2034        }
2035        let newest_prior = start_session_aged(&store, 10);
2036        let current = start_session_aged(&store, 0);
2037
2038        let continuity = SessionContinuityTool::new(store);
2039        let mut ctx = Context::default();
2040        let v = continuity
2041            .call(&mut ctx, json!({"current_session_id": current, "n": 1}))
2042            .await
2043            .unwrap();
2044        assert_eq!(
2045            v["previous_session"], newest_prior,
2046            "continuity must select the newest prior session by created_at"
2047        );
2048    }
2049
2050    #[tokio::test]
2051    async fn handoff_transfer_accept_list() {
2052        let store = test_store();
2053        let sid = start_session(&store);
2054        let record = SessionRecordTool::new(store.clone());
2055        let mut ctx = Context::default();
2056        record
2057            .call(
2058                &mut ctx,
2059                json!({"role": "ai", "content": "context", "session_id": sid}),
2060            )
2061            .await
2062            .unwrap();
2063
2064        let handoff = SessionHandoffTool::new(store.clone());
2065        let t = handoff
2066            .call(
2067                &mut ctx,
2068                json!({"action": "transfer", "session_id": sid, "message": "take over"}),
2069            )
2070            .await
2071            .unwrap();
2072        assert_eq!(t["status"], "success");
2073        let hid = t["handoff_id"].as_str().unwrap().to_string();
2074
2075        let list = handoff
2076            .call(&mut ctx, json!({"action": "list"}))
2077            .await
2078            .unwrap();
2079        assert_eq!(list["pending_count"], 1);
2080
2081        let a = handoff
2082            .call(&mut ctx, json!({"action": "accept", "handoff_id": hid}))
2083            .await
2084            .unwrap();
2085        assert_eq!(a["status"], "success");
2086
2087        let list2 = handoff
2088            .call(&mut ctx, json!({"action": "list"}))
2089            .await
2090            .unwrap();
2091        assert_eq!(list2["pending_count"], 0);
2092    }
2093}