winx-code-agent 0.2.358

Remote-first Rust MCP runtime with Streamable HTTP, durable PTYs, and guarded agent tools
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Instant;

use rmcp::service::{NotificationContext, RoleServer};
use tokio::sync::Mutex;
use tracing::{info, warn};

use super::{SharedBashState, WinxService};
use crate::runtime::{EmbeddedShellRuntime, ShellRuntime};
use crate::state::bash_state::generate_thread_id;
use crate::state::task_state::TaskRegistry;
use crate::types::{Initialize, InitializeType, ModeName};

/// Upper bound on concurrently-live adapter sessions. Each embedded session owns
/// a PTY; daemon-backed sessions also release their guardian when evicted.
pub(super) const MAX_SESSIONS: usize = 32;

/// Per-`thread_id` shell sessions. Each `thread_id` gets its own
/// `BashState`/PTY, so concurrent threads (or HTTP clients sharing the service)
/// never execute in each other's shell. Tools that don't carry a `thread_id`
/// (legacy clients) fall back to the most recently active session.
#[derive(Default)]
pub(super) struct SessionRegistry {
    pub(super) slots: HashMap<String, SharedBashState>,
    /// Last-use timestamps for LRU eviction.
    pub(super) last_used: HashMap<String, Instant>,
    /// In-flight operation count per session. A session whose count is > 0 (it
    /// has a live [`SessionGuard`]) is never chosen as an LRU eviction victim.
    pub(super) in_flight: HashMap<String, SessionPin>,
    /// Most recently addressed session, used as the fallback for tool calls
    /// that omit a `thread_id`.
    pub(super) last_active: Option<String>,
}

/// Lock-free pin counter for one session. A live [`SessionGuard`] keeps the
/// count `> 0`, which marks the session as in-flight so LRU eviction skips it.
/// Clones share the same counter — the registry hands a clone to each
/// concurrent call. Keep this discipline in lockstep with the loom model.
#[derive(Clone)]
pub(super) struct SessionPin {
    count: Arc<AtomicUsize>,
}

impl Default for SessionPin {
    fn default() -> Self {
        Self { count: Arc::new(AtomicUsize::new(0)) }
    }
}

impl SessionPin {
    fn acquire(&self) -> SessionGuard {
        self.count.fetch_add(1, Ordering::SeqCst);
        SessionGuard { count: self.count.clone() }
    }

    fn is_pinned(&self) -> bool {
        self.count.load(Ordering::SeqCst) > 0
    }
}

/// RAII marker that a session has an operation in flight. Bumps the session's
/// in-flight counter on creation and releases it from synchronous `Drop`.
pub(super) struct SessionGuard {
    count: Arc<AtomicUsize>,
}

impl Drop for SessionGuard {
    fn drop(&mut self) {
        self.count.fetch_sub(1, Ordering::SeqCst);
    }
}

/// How an empty `thread_id` is resolved by the session registry.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SessionIsolation {
    /// Local stdio transport — a single client. An empty `thread_id` falls back
    /// to the last-active session (legacy single-client convenience).
    Lenient,
    /// Remote HTTP transport — multiple clients behind one shared bearer token.
    /// The last-active fallback is disabled, so an empty `thread_id` from one
    /// client can never resolve to another client's shell; such calls get a
    /// dedicated anonymous slot instead.
    ///
    /// Residual: two clients that deliberately send the same explicit
    /// `thread_id` still share a shell. Closing that needs per-client identities.
    Strict,
}

impl Default for WinxService {
    fn default() -> Self {
        Self::new()
    }
}

impl WinxService {
    /// Create a new `WinxService` for local stdio transport.
    pub fn new() -> Self {
        Self::with_isolation(SessionIsolation::Lenient)
    }

    /// Create a service with an explicit session-isolation policy. The HTTP
    /// transport uses [`SessionIsolation::Strict`].
    pub fn with_isolation(isolation: SessionIsolation) -> Self {
        Self::with_runtime(isolation, Arc::new(EmbeddedShellRuntime))
    }

    /// Create a service with an explicit shell-runtime boundary.
    pub fn with_runtime(isolation: SessionIsolation, shell_runtime: Arc<dyn ShellRuntime>) -> Self {
        info!(?isolation, "Creating new WinxService instance");
        Self {
            sessions: Arc::new(Mutex::new(SessionRegistry::default())),
            tasks: Arc::new(Mutex::new(TaskRegistry::default())),
            root_bootstrap: Arc::new(Mutex::new(())),
            shell_runtime,
            mutations: super::mutations::MutationCoordinator::default(),
            execution_history: Arc::new(super::executions::history::WorkflowHistory::default()),
            version: crate::build_info::display_version().to_string(),
            isolation,
        }
    }

    /// Resolve the session slot for a `thread_id`, creating it if absent.
    /// Marks the slot as most-recently-used and evicts the LRU idle session when
    /// adding a new key would exceed [`MAX_SESSIONS`].
    pub(super) async fn session_for(&self, thread_id: &str) -> (SharedBashState, SessionGuard) {
        let thread_id = crate::types::normalize_thread_id(thread_id);
        let mut registry = self.sessions.lock().await;
        let key = if thread_id.is_empty() {
            match self.isolation {
                SessionIsolation::Lenient => {
                    registry.last_active.clone().unwrap_or_else(|| "default".to_string())
                }
                SessionIsolation::Strict => "anonymous".to_string(),
            }
        } else {
            thread_id.clone()
        };

        if !registry.slots.contains_key(&key) && registry.slots.len() >= MAX_SESSIONS {
            let victim = registry
                .last_used
                .iter()
                .filter(|(candidate, _)| **candidate != key)
                .filter(|(candidate, _)| {
                    registry.in_flight.get(candidate.as_str()).is_none_or(|pin| !pin.is_pinned())
                })
                .min_by_key(|(_, last_used)| **last_used)
                .map(|(candidate, _)| candidate.clone());

            if let Some(victim) = victim {
                // The daemon runtime owns a process outside this registry. Release
                // it while the registry lock prevents the same thread_id from being
                // recreated underneath the termination request.
                if let Err(error) = self.shell_runtime.terminate_session(&victim).await {
                    warn!(%error, "failed to terminate LRU shell session '{victim}'");
                }
                registry.slots.remove(&victim);
                registry.last_used.remove(&victim);
                registry.in_flight.remove(&victim);
                if registry.last_active.as_deref() == Some(victim.as_str()) {
                    registry.last_active = None;
                }
                warn!("Evicted LRU shell session '{victim}' (session cap {MAX_SESSIONS})");
            } else {
                warn!(
                    "All {MAX_SESSIONS} sessions busy; exceeding the cap rather than evicting an in-flight session"
                );
            }
        }

        let slot =
            registry.slots.entry(key.clone()).or_insert_with(|| Arc::new(Mutex::new(None))).clone();
        let pin = registry.in_flight.entry(key.clone()).or_default().clone();
        registry.last_used.insert(key.clone(), Instant::now());
        if !thread_id.is_empty() {
            registry.last_active = Some(key);
        }
        (slot, pin.acquire())
    }

    /// [`Self::session_for`] plus transparent rehydration: the entry point for
    /// every tool EXCEPT `Initialize`. After an adapter restart the registry is
    /// empty, but the per-thread snapshot survives on disk and (daemon runtime)
    /// the guardian may still own the live PTY — so a tool call carrying a
    /// known `thread_id` self-heals here instead of failing with
    /// `BashStateNotInitialized` and burning a model round-trip on re-Initialize.
    ///
    /// `Initialize` transitions deliberately keep the raw [`Self::session_for`]:
    /// their recovery semantics (downgrading `reset_shell`/`user_asked_mode_change`
    /// to `first_call`) depend on observing the missing live state. Only the
    /// read-only `status` probe rehydrates like an ordinary tool.
    pub(super) async fn tool_session_for(
        &self,
        thread_id: &str,
    ) -> (SharedBashState, SessionGuard) {
        let (slot, guard) = self.session_for(thread_id).await;
        self.rehydrate_slot_if_persisted(&slot, thread_id).await;
        (slot, guard)
    }

    /// Rebuild a missing adapter-side `BashState` from its persisted snapshot
    /// and attach-or-create the runtime session behind it. No-op when the slot
    /// is live, the `thread_id` is empty/anonymous, or nothing was persisted.
    /// Failure leaves the slot empty so callers fail exactly as before.
    async fn rehydrate_slot_if_persisted(&self, slot: &SharedBashState, thread_id: &str) {
        use crate::runtime::ShellSessionTransition;

        let thread_id = crate::types::normalize_thread_id(thread_id);
        if thread_id.is_empty() {
            return;
        }
        let mut state_guard = slot.lock().await;
        if state_guard.is_some() {
            return;
        }
        let snapshot = match crate::state::persistence::load_bash_state(&thread_id) {
            Ok(Some(snapshot)) => snapshot,
            Ok(None) => return,
            Err(error) => {
                warn!(%error, thread_id, "failed to read persisted session snapshot");
                return;
            }
        };

        let mut state = crate::state::bash_state::BashState::new();
        state.apply_snapshot(&snapshot);
        // The registry key is authoritative for which session this is.
        state.current_thread_id.clone_from(&thread_id);
        if !state.workspace_root.exists() {
            warn!(
                thread_id,
                workspace_root = %state.workspace_root.display(),
                "not rehydrating session: persisted workspace no longer exists"
            );
            return;
        }

        // FirstCall is attach-or-create in both runtimes: the daemon guardian
        // that survived the adapter restart hands back its authoritative
        // snapshot with the live PTY untouched; a dead guardian (or the
        // embedded runtime) gets a fresh PTY at the persisted cwd/mode.
        match self
            .shell_runtime
            .configure_session(&mut state, ShellSessionTransition::FirstCall)
            .await
        {
            Ok(configured) => {
                info!(
                    thread_id,
                    attached_existing = configured.attached_existing,
                    "rehydrated session from persisted state after adapter restart"
                );
                if !configured.attached_existing {
                    state.recovery_note = Some(lost_shell_recovery_note(&thread_id, &state));
                }
                *state_guard = Some(state);
            }
            Err(error) => {
                warn!(%error, thread_id, "failed to rehydrate persisted session");
            }
        }
    }

    /// The most recently active session slot, optionally confined to one HTTP
    /// principal's internal thread-id prefix.
    pub(super) async fn active_slot(
        &self,
        session_prefix: Option<&str>,
    ) -> Option<SharedBashState> {
        let registry = self.sessions.lock().await;
        if let Some(prefix) = session_prefix {
            return registry
                .last_used
                .iter()
                .filter(|(key, _)| key.starts_with(prefix))
                .max_by_key(|(_, last_used)| **last_used)
                .and_then(|(key, _)| registry.slots.get(key).cloned());
        }
        registry.last_active.as_ref().and_then(|key| registry.slots.get(key).cloned())
    }

    /// Read the immutable project identity of an already initialized adapter
    /// session without creating a slot or changing LRU state. Remote coherence
    /// checks use this before any tool can touch the shell or filesystem.
    pub(super) async fn bound_workspace(&self, thread_id: &str) -> Option<std::path::PathBuf> {
        let thread_id = crate::types::normalize_thread_id(thread_id);
        if thread_id.is_empty() {
            return None;
        }
        let slot = {
            let registry = self.sessions.lock().await;
            registry.slots.get(&thread_id).cloned()
        }?;
        let state = slot.lock().await;
        state.as_ref().filter(|state| state.initialized).map(|state| state.workspace_root.clone())
    }

    pub(super) async fn has_initialized_session(&self) -> bool {
        let slots = {
            let registry = self.sessions.lock().await;
            registry.slots.values().cloned().collect::<Vec<_>>()
        };
        for slot in slots {
            if slot.lock().await.as_ref().is_some_and(|state| state.initialized) {
                return true;
            }
        }
        false
    }

    /// Bootstrap a local single-client session from the client's first MCP Root.
    /// The negotiated MCP protocol still exposes Roots, although `rmcp` marks this
    /// compatibility API as deprecated in anticipation of the later SEP-2577 draft.
    #[allow(deprecated)]
    pub(super) async fn initialize_from_client_roots(
        &self,
        context: NotificationContext<RoleServer>,
    ) {
        if self.isolation != SessionIsolation::Lenient {
            return;
        }
        let _bootstrap_guard = self.root_bootstrap.lock().await;
        if self.has_initialized_session().await {
            return;
        }
        let supports_roots =
            context.peer.peer_info().is_some_and(|info| info.capabilities.roots.is_some());
        if !supports_roots {
            return;
        }
        let roots = match context.peer.list_roots().await {
            Ok(result) => result.roots,
            Err(error) => {
                warn!(%error, "client advertised Roots but roots/list failed");
                return;
            }
        };
        let cwd = std::env::current_dir().ok();
        let mut paths =
            roots.iter().filter_map(|root| root_uri_to_path(&root.uri)).collect::<Vec<_>>();
        let selected = cwd
            .as_ref()
            .and_then(|cwd| paths.iter().position(|path| cwd.starts_with(path)))
            .map(|index| paths.remove(index))
            .or_else(|| paths.into_iter().next());
        let Some(workspace) = selected else {
            warn!("client returned no usable local file Roots; Initialize remains required");
            return;
        };

        let initialize = Initialize {
            init_type: InitializeType::FirstCall,
            any_workspace_path: workspace.to_string_lossy().into_owned(),
            initial_files_to_read: Vec::new(),
            task_id_to_resume: String::new(),
            mode_name: ModeName::Wcgw,
            thread_id: generate_thread_id(),
            code_writer_config: None,
        };
        let arguments = match serde_json::to_value(initialize) {
            Ok(arguments) => arguments,
            Err(error) => {
                warn!(%error, "failed to serialize Roots bootstrap request");
                return;
            }
        };
        match self.handle_initialize(Some(arguments)).await {
            Ok(_) => info!(workspace = %workspace.display(), "initialized Winx from MCP Roots"),
            Err(error) => warn!(%error.message, "failed to initialize Winx from MCP Roots"),
        }
    }
}

/// The show-once note for a session whose live shell did NOT survive the
/// restart (a fresh PTY was created). When an interactive agent was running
/// in the lost PTY, include how to resume its conversation.
fn lost_shell_recovery_note(
    thread_id: &str,
    state: &crate::state::bash_state::BashState,
) -> String {
    let base = format!(
        "Recovered session {thread_id} from its persisted snapshot after a restart: mode, \
         permissions, and edit receipts were restored, and a fresh shell was started at {}.",
        state.cwd.display()
    );
    match crate::state::agent_resume::load_agent_session(thread_id) {
        Ok(Some(record)) => {
            let advice = record.recovery_advice_for(thread_id, &state.workspace_root);
            let classification = advice.classification.as_str();
            let detail = match advice.resume_argv {
                Some(argv) => format!(
                    "The recorded `{}` launch explicitly selected a conversation target. \
                     Recovery classification={classification}, source=launch_argument, \
                     current_conversation_verified=false. To target that recorded ID, use `{}` \
                     (recorded cwd {}). Winx has not verified backend acceptance or the \
                     conversation currently selected after an in-TUI switch; no recovery \
                     command was executed.",
                    record.agent,
                    argv.join(" "),
                    record.cwd
                ),
                None => format!(
                    "Recorded agent: `{}`. Recovery classification={classification}, \
                     current_conversation_verified=false. No supported, scope-bound \
                     conversation selector is available. Directory-most-recent continuation \
                     may select a different conversation; no recovery command was executed.",
                    record.agent
                ),
            };
            format!("{base} {detail}")
        }
        Ok(None) => base,
        Err(error) => {
            warn!(%error, thread_id, "failed to read agent-session record");
            base
        }
    }
}

pub(super) fn root_uri_to_path(uri: &str) -> Option<std::path::PathBuf> {
    let encoded = uri.strip_prefix("file://")?;
    let encoded = if let Some(path) = encoded.strip_prefix("localhost/") {
        format!("/{path}")
    } else if encoded.starts_with('/') {
        encoded.to_string()
    } else {
        // Refuse non-local file authorities (file://host/path).
        return None;
    };
    let decoded = percent_encoding::percent_decode_str(&encoded).decode_utf8().ok()?;
    // `file:///C:/dir` carries the drive after the authority slash on Windows.
    let decoded: &str = if cfg!(windows) {
        decoded.strip_prefix('/').filter(|rest| is_drive_prefixed(rest)).unwrap_or(&decoded)
    } else {
        &decoded
    };
    let path = std::path::PathBuf::from(decoded);
    path.is_absolute().then_some(path)
}

fn is_drive_prefixed(path: &str) -> bool {
    let mut chars = path.chars();
    matches!((chars.next(), chars.next()), (Some(drive), Some(':')) if drive.is_ascii_alphabetic())
}

#[cfg(test)]
#[allow(clippy::expect_used)]
mod rehydration_tests {
    use std::sync::Arc;

    use super::SessionIsolation;
    use crate::errors::WinxError;
    use crate::runtime::{
        ShellRuntime, ShellRuntimeConfigureFuture, ShellRuntimeFuture, ShellRuntimeUnitFuture,
        ShellSessionConfiguration, ShellSessionTransition,
    };
    use crate::server::WinxService;
    use crate::state::agent_resume::{save_agent_session, AgentSessionRecord};
    use crate::state::bash_state::BashState;
    use crate::state::persistence::{delete_bash_state, save_bash_state};

    /// Runtime double: attach-or-create succeeds without a real PTY and
    /// reports whether the "live" session survived.
    struct StubRuntime {
        attached_existing: bool,
    }

    impl ShellRuntime for StubRuntime {
        fn configure_session<'a>(
            &'a self,
            _bash_state: &'a mut BashState,
            _transition: ShellSessionTransition,
        ) -> ShellRuntimeConfigureFuture<'a> {
            let attached_existing = self.attached_existing;
            Box::pin(async move {
                Ok(ShellSessionConfiguration { attach_hint: None, attached_existing })
            })
        }

        fn run_action<'a>(
            &'a self,
            _bash_state: &'a Arc<tokio::sync::Mutex<Option<BashState>>>,
            _command: crate::types::BashCommand,
        ) -> ShellRuntimeFuture<'a> {
            Box::pin(async { Err(WinxError::CommandExecutionError("stub".to_string())) })
        }

        fn interrupt<'a>(
            &'a self,
            _bash_state: &'a Arc<tokio::sync::Mutex<Option<BashState>>>,
        ) -> ShellRuntimeUnitFuture<'a> {
            Box::pin(async { Ok(()) })
        }

        fn terminate_session<'a>(&'a self, _thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
            Box::pin(async { Ok(()) })
        }
    }

    fn service(attached_existing: bool) -> WinxService {
        WinxService::with_runtime(
            SessionIsolation::Strict,
            Arc::new(StubRuntime { attached_existing }),
        )
    }

    fn unique_thread_id(tag: &str) -> String {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("system time after epoch")
            .as_nanos();
        // Alphanumeric + underscore only, so `normalize_thread_id` keeps the
        // id verbatim and the on-disk snapshot file matches.
        format!("winxtest_rehydrate_{tag}_{}_{nanos}", std::process::id())
    }

    fn persist_snapshot(thread_id: &str, workspace: &std::path::Path) {
        let mut state = BashState::new();
        state.current_thread_id = thread_id.to_string();
        state.workspace_root = workspace.to_path_buf();
        state.cwd = workspace.to_path_buf();
        state.initialized = true;
        save_bash_state(thread_id, &state.snapshot()).expect("persist snapshot");
    }

    #[tokio::test]
    async fn tool_call_rehydrates_a_persisted_session_transparently() {
        let workspace = tempfile::tempdir().expect("workspace");
        let thread_id = unique_thread_id("attach");
        persist_snapshot(&thread_id, workspace.path());

        // Fresh service = adapter restart: empty registry, snapshot on disk.
        let (slot, _guard) = service(true).tool_session_for(&thread_id).await;
        {
            let state = slot.lock().await;
            let state = state.as_ref().expect("session should rehydrate from disk");
            assert!(state.initialized);
            assert_eq!(state.current_thread_id, thread_id);
            assert_eq!(state.workspace_root, workspace.path());
            // The live session survived (guardian attach) — nothing to report.
            assert!(state.recovery_note.is_none());
        }
        delete_bash_state(&thread_id).ok();
    }

    #[tokio::test]
    async fn lost_shell_rehydration_surfaces_the_agent_resume_hint() {
        let workspace = tempfile::tempdir().expect("workspace");
        let thread_id = unique_thread_id("resume");
        persist_snapshot(&thread_id, workspace.path());
        save_agent_session(
            &thread_id,
            &AgentSessionRecord {
                agent: "claude".to_string(),
                command: "claude".to_string(),
                cwd: workspace.path().to_string_lossy().into_owned(),
                launched_at_unix_ms: 1,
                workspace_root: None,
                scoped_thread_id: None,
                execution_token: None,
                conversation_selector: None,
            },
        )
        .expect("persist agent record");

        // attached_existing=false = the PTY did not survive (reboot / dead
        // guardian): the note must carry the resume command.
        let (slot, _guard) = service(false).tool_session_for(&thread_id).await;
        {
            let state = slot.lock().await;
            let note = state
                .as_ref()
                .expect("session should rehydrate")
                .recovery_note
                .clone()
                .expect("lost shell must set a recovery note");
            assert!(note.contains("best_effort"), "missing recovery classification: {note}");
            assert!(!note.contains("claude --continue"), "unbound hint selected latest: {note}");
            assert!(!note.contains("resume its conversation"), "unverified identity claim: {note}");
        }
        delete_bash_state(&thread_id).ok();
        crate::state::agent_resume::clear_agent_session(&thread_id).ok();
    }

    #[tokio::test]
    async fn without_a_persisted_snapshot_the_slot_stays_empty() {
        let thread_id = unique_thread_id("missing");
        let (slot, _guard) = service(true).tool_session_for(&thread_id).await;
        assert!(slot.lock().await.is_none(), "no snapshot on disk must not fabricate state");
    }

    #[tokio::test]
    async fn empty_thread_id_never_rehydrates() {
        let (slot, _guard) = service(true).tool_session_for("").await;
        assert!(slot.lock().await.is_none());
    }

    fn status_probe(thread_id: &str, workspace: &std::path::Path) -> serde_json::Value {
        serde_json::json!({
            "type": "status",
            "any_workspace_path": workspace,
            "mode_name": "wcgw",
            "thread_id": thread_id
        })
    }

    fn tool_request(arguments: &serde_json::Value) -> rmcp::model::CallToolRequestParams {
        rmcp::model::CallToolRequestParams::new("Initialize")
            .with_arguments(arguments.as_object().expect("request object").clone())
    }

    // Regression: after an adapter restart a stateless client's first call was
    // a status probe of its known pair, which failed `not_initialized` and
    // forced a full first_call although the snapshot survived on disk.
    #[tokio::test]
    async fn status_probe_rehydrates_a_persisted_session_after_restart() {
        let workspace = tempfile::tempdir().expect("workspace");
        let thread_id = unique_thread_id("status");
        persist_snapshot(&thread_id, workspace.path());
        let arguments = status_probe(&thread_id, workspace.path());

        let service = service(false);
        let coherence = service
            .validate_workspace_coherence(
                &tool_request(&arguments),
                &crate::server::principal::RequestScope::default(),
                crate::config::HttpSessionAffinity::Thread,
                None,
            )
            .await
            .expect("known pair must validate after restart");
        assert_eq!(coherence, crate::server::coherence::WorkspaceCoherence::Validated);

        let result = service.handle_initialize(Some(arguments)).await.expect("status result");
        assert_ne!(result.is_error, Some(true), "{result:?}");
        let structured = result.structured_content.clone().expect("structured status");
        assert_eq!(structured["initialize_transition"], "status", "{structured}");
        assert_eq!(structured["initialize_recovered_missing_session"], true, "{structured}");
        let text = &result.content[0].as_text().expect("status text").text;
        assert!(text.contains("[session recovery]"), "lost shell must be reported: {text}");
        let state = service.bound_workspace(&thread_id).await.expect("rehydrated binding");
        assert_eq!(state, workspace.path());
        delete_bash_state(&thread_id).ok();
    }

    #[tokio::test]
    async fn non_strict_status_probe_rehydrates_a_persisted_session() {
        let workspace = tempfile::tempdir().expect("workspace");
        let thread_id = unique_thread_id("status_local");
        persist_snapshot(&thread_id, workspace.path());
        let service = WinxService::with_runtime(
            SessionIsolation::Lenient,
            Arc::new(StubRuntime { attached_existing: true }),
        );

        let result = service
            .handle_initialize(Some(status_probe(&thread_id, workspace.path())))
            .await
            .expect("status result");
        assert_ne!(result.is_error, Some(true), "{result:?}");
        let structured = result.structured_content.clone().expect("structured status");
        assert_eq!(structured["initialize_transition"], "status", "{structured}");
        // The live shell survived, so there is nothing to recover or report.
        assert_eq!(structured["initialize_recovered_missing_session"], false, "{structured}");
        delete_bash_state(&thread_id).ok();
    }

    #[tokio::test]
    async fn status_probe_keeps_cross_workspace_pairs_fail_closed_after_restart() {
        let bound = tempfile::tempdir().expect("bound workspace");
        let other = tempfile::tempdir().expect("other workspace");
        let thread_id = unique_thread_id("status_mismatch");
        persist_snapshot(&thread_id, bound.path());
        let arguments = status_probe(&thread_id, other.path());
        let scope = crate::server::principal::RequestScope::default();

        let service = service(true);
        let error = service
            .validate_workspace_coherence(
                &tool_request(&arguments),
                &scope,
                crate::config::HttpSessionAffinity::Thread,
                None,
            )
            .await
            .expect_err("persisted pair belongs to another workspace");
        assert!(matches!(error, WinxError::WorkspaceBindingMismatch { .. }), "{error:?}");

        // A workspace-derived id that does not match the path never loads a
        // snapshot; it stays an unknown session.
        let fresh = WinxService::with_runtime(
            SessionIsolation::Strict,
            Arc::new(StubRuntime { attached_existing: true }),
        );
        let error = fresh
            .validate_workspace_coherence(
                &tool_request(&arguments),
                &scope,
                crate::config::HttpSessionAffinity::Workspace,
                None,
            )
            .await
            .expect_err("invented workspace id must fail");
        assert!(matches!(error, WinxError::BashStateNotInitialized), "{error:?}");
        assert!(fresh.bound_workspace(&thread_id).await.is_none(), "must not rehydrate");
        delete_bash_state(&thread_id).ok();
    }
}