//! Integration tests for the pod-comm tools (`SendToPod`, //! `ReadPodOutput`, `StopPod`). //! //! The real child Pod binary is not started. Instead each test stands //! up a mock `UnixListener` that speaks the socket protocol directly: //! it emits the connect-time `Event::Snapshot`, accepts methods such as //! `Method::Run` / `Method::Shutdown`, and responds with the relevant //! events when needed. This keeps the tests fast and independent of the //! LLM layer — the tools are exercised for their wire behaviour alone. use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock, Mutex}; use llm_worker::llm_client::types::{ContentPart, Item, Role}; use llm_worker::tool::ToolOutput; use manifest::{Permission, Scope, ScopeRule, SharedScope}; use pod::runtime::dir::{RuntimeDir, SpawnedPodRecord}; use pod::runtime::pod_registry::{self, LockFileGuard}; use pod::spawn::comm_tools::{read_pod_output_tool, send_to_pod_tool, stop_pod_tool}; use pod::spawn::registry::SpawnedPodRegistry; use pod_store::{CombinedStore, FsPodStore, PodMetadataStore}; use protocol::stream::{JsonLineReader, JsonLineWriter}; use protocol::{ErrorCode, Event, Greeting, Method}; use serde_json::json; use session_store::FsStore; use tempfile::TempDir; use tokio::net::UnixListener; use tokio::sync::mpsc; use tokio::task::JoinHandle; /// Serialises env-mutating tests. The test harness runs tasks across /// threads, and `YOI_RUNTIME_DIR` is a process-wide resource. static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); /// Take `ENV_LOCK` and clear any env vars that would outrank /// `YOI_RUNTIME_DIR` in `paths::runtime_dir` resolution; restore /// previous values on drop. struct EnvGuard { prev_home: Option, prev_xdg: Option, _lock: std::sync::MutexGuard<'static, ()>, } impl EnvGuard { fn acquire() -> Self { let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let prev_home = std::env::var("YOI_HOME").ok(); let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok(); unsafe { std::env::remove_var("YOI_HOME"); std::env::remove_var("XDG_RUNTIME_DIR"); } Self { prev_home, prev_xdg, _lock: lock, } } } impl Drop for EnvGuard { fn drop(&mut self) { unsafe { match &self.prev_home { Some(v) => std::env::set_var("YOI_HOME", v), None => std::env::remove_var("YOI_HOME"), } match &self.prev_xdg { Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v), None => std::env::remove_var("XDG_RUNTIME_DIR"), } std::env::remove_var("YOI_RUNTIME_DIR"); } } } /// Create a spawner-owned `RuntimeDir` + `SpawnedPodRegistry` scoped to /// a fresh tempdir. The returned `TempDir` must be kept alive by the /// caller for the duration of the test. async fn setup_registry() -> (TempDir, Arc, Arc) { let tmp = TempDir::new().unwrap(); let rd = RuntimeDir::create(tmp.path(), "spawner").await.unwrap(); let rd = Arc::new(rd); let registry = SpawnedPodRegistry::new(rd.clone()); (tmp, registry, rd) } /// Register a fake spawned-child record pointing at a given socket /// path, with a trivial write-scope for `scope_path`. Does not touch /// pods.json. async fn register_child( registry: &SpawnedPodRegistry, name: &str, socket: &Path, scope_path: &Path, ) { let record = SpawnedPodRecord { pod_name: name.into(), socket_path: socket.to_path_buf(), scope_delegated: vec![ScopeRule { target: scope_path.to_path_buf(), permission: Permission::Write, recursive: true, }], callback_address: "/dev/null".into(), }; registry.add(record).await.unwrap(); } /// Bind a Unix listener at a socket path inside the given directory. async fn bind_mock_socket(dir: &Path, name: &str) -> (PathBuf, UnixListener) { let socket = dir.join(format!("{name}.sock")); let listener = UnixListener::bind(&socket).unwrap(); (socket, listener) } /// Minimal connect-time snapshot used by mock socket servers. fn empty_snapshot() -> Event { Event::Snapshot { entries: Vec::new(), greeting: Greeting { pod_name: "child".into(), cwd: "/tmp".into(), provider: "anthropic".into(), model: "x".into(), scope_summary: String::new(), tools: Vec::new(), context_window: 200_000, context_tokens: 0, }, status: protocol::PodStatus::Idle, } } /// Accept one connection, send the protocol's connect-time snapshot, /// and read exactly one `Method` line from it. /// The reader half is kept open; caller awaits the returned handle. fn accept_one_method(listener: UnixListener) -> JoinHandle> { tokio::spawn(async move { let (stream, _) = listener.accept().await.ok()?; let (r, w) = stream.into_split(); let mut reader = JsonLineReader::new(r); let mut writer = JsonLineWriter::new(w); writer.write(&empty_snapshot()).await.ok()?; reader.next::().await.ok().flatten() }) } /// Accept one connection, send the protocol's connect-time snapshot, /// read one `Method`, then write `response` back. Used by `SendToPod` /// tests to mock the real controller's `TurnStart` acknowledgement (or /// its `AlreadyRunning` rejection). fn accept_method_and_respond( listener: UnixListener, response: Event, ) -> JoinHandle> { tokio::spawn(async move { let (stream, _) = listener.accept().await.ok()?; let (r, w) = stream.into_split(); let mut reader = JsonLineReader::new(r); let mut writer = JsonLineWriter::new(w); writer.write(&empty_snapshot()).await.ok()?; let method = reader.next::().await.ok().flatten(); if method.is_some() { let _ = writer.write(&response).await; } method }) } /// Pretend to be a spawned Pod whose connect-time snapshot carries a /// fixed set of assistant items. Sends `Event::Snapshot` immediately on /// every accept — the real Pod does the same, so `ReadPodOutput`'s /// `fetch_history` just consumes the first non-Alert event. fn serve_history(listener: UnixListener, items: Vec) -> JoinHandle<()> { tokio::spawn(async move { loop { let Ok((stream, _)) = listener.accept().await else { return; }; let (_r, w) = stream.into_split(); let mut writer = JsonLineWriter::new(w); let entries: Vec = items .iter() .map(|item| { let entry = session_store::LogEntry::AssistantItem { ts: 0, item: session_store::LoggedItem::from(item), }; serde_json::to_value(&entry).unwrap() }) .collect(); let event = Event::Snapshot { entries, greeting: Greeting { pod_name: "child".into(), cwd: "/tmp".into(), provider: "anthropic".into(), model: "x".into(), scope_summary: String::new(), tools: Vec::new(), context_window: 200_000, context_tokens: 0, }, status: protocol::PodStatus::Idle, }; let _ = writer.write(&event).await; } }) } fn serve_pod_methods(listener: UnixListener) -> mpsc::Receiver { let (tx, rx) = mpsc::channel(8); tokio::spawn(async move { loop { let Ok((stream, _)) = listener.accept().await else { return; }; let (r, w) = stream.into_split(); let mut reader = JsonLineReader::new(r); let mut writer = JsonLineWriter::new(w); if writer.write(&empty_snapshot()).await.is_err() { continue; } let Some(method) = reader.next::().await.ok().flatten() else { continue; }; let is_shutdown = matches!(method, Method::Shutdown); if matches!(method, Method::Run { .. }) { let _ = writer.write(&Event::TurnStart { turn: 1 }).await; } if tx.send(method).await.is_err() || is_shutdown { return; } } }); rx } fn assistant(text: &str) -> Item { Item::Message { id: None, role: Role::Assistant, content: vec![ContentPart::Text { text: text.into() }], status: None, } } // --------------------------------------------------------------------------- // SendToPod // --------------------------------------------------------------------------- #[tokio::test] async fn send_to_pod_delivers_run_method() { let (tmp, registry, _rd) = setup_registry().await; let (socket, listener) = bind_mock_socket(tmp.path(), "child").await; // Mock the controller's accept path: after reading the method, // ack with `TurnStart` so `SendToPod`'s confirmation loop succeeds. let received = accept_method_and_respond(listener, Event::TurnStart { turn: 1 }); register_child(®istry, "child", &socket, tmp.path()).await; let def = send_to_pod_tool(registry); let (_meta, tool) = def(); let input = json!({ "name": "child", "message": "hello there" }).to_string(); let output: ToolOutput = tool.execute(&input).await.unwrap(); assert!( output.summary.contains("child"), "summary: {}", output.summary ); let method = received.await.unwrap().expect("expected a method"); match method { Method::Run { input } => match input.as_slice() { [protocol::Segment::Text { content }] => assert_eq!(content, "hello there"), other => panic!("expected single Text segment, got {other:?}"), }, other => panic!("expected Run, got {other:?}"), } } #[tokio::test] async fn send_to_pod_errors_on_unknown_pod() { let (_tmp, registry, _rd) = setup_registry().await; let def = send_to_pod_tool(registry); let (_meta, tool) = def(); let input = json!({ "name": "nope", "message": "hi" }).to_string(); let err = tool.execute(&input).await.unwrap_err(); assert!(err.to_string().contains("no spawned pod"), "{err}"); } #[tokio::test] async fn send_to_pod_errors_when_pod_already_running() { let (tmp, registry, _rd) = setup_registry().await; let (socket, listener) = bind_mock_socket(tmp.path(), "child").await; // Respond with the same `Error { AlreadyRunning }` that the real // controller emits when `Method::Run` arrives during RUNNING. let received = accept_method_and_respond( listener, Event::Error { code: ErrorCode::AlreadyRunning, message: "Pod is already executing a turn".into(), }, ); register_child(®istry, "child", &socket, tmp.path()).await; let def = send_to_pod_tool(registry); let (_meta, tool) = def(); let input = json!({ "name": "child", "message": "hi" }).to_string(); let err = tool.execute(&input).await.unwrap_err(); assert!( err.to_string().contains("already running"), "expected AlreadyRunning wording: {err}" ); // Ensure the listener was in fact hit with a Method::Run before the // rejection path fired — otherwise we'd be asserting on an error // that came from a connect failure. let method = received.await.unwrap().expect("expected a method"); assert!(matches!(method, Method::Run { .. })); } // --------------------------------------------------------------------------- // ReadPodOutput // --------------------------------------------------------------------------- #[tokio::test] async fn read_pod_output_returns_new_assistant_text_then_empty_on_second_call() { let (tmp, registry, _rd) = setup_registry().await; let (socket, listener) = bind_mock_socket(tmp.path(), "child").await; register_child(®istry, "child", &socket, tmp.path()).await; let items = vec![ Item::user_message("hello"), assistant("hi back"), assistant("still working"), ]; let _server = serve_history(listener, items); let def = read_pod_output_tool(registry); let (_meta, tool) = def(); let input = json!({ "name": "child" }).to_string(); let first: ToolOutput = tool.execute(&input).await.unwrap(); let body = first.content.expect("first read should have content"); assert!(body.contains("hi back"), "body: {body}"); assert!(body.contains("still working"), "body: {body}"); // Cursor now points past all items — second call returns no new text. let second: ToolOutput = tool.execute(&input).await.unwrap(); assert!( second.content.is_none(), "unexpected content: {:?}", second.content ); assert!( second.summary.contains("no new assistant text"), "summary: {}", second.summary ); } #[tokio::test] async fn read_pod_output_reports_stopped_on_dead_socket() { let (tmp, registry, _rd) = setup_registry().await; // Register a record pointing at a socket that nobody is listening // on. Connect must fail → tool reports "stopped". let dead_socket = tmp.path().join("dead.sock"); register_child(®istry, "child", &dead_socket, tmp.path()).await; let def = read_pod_output_tool(registry); let (_meta, tool) = def(); let input = json!({ "name": "child" }).to_string(); let output: ToolOutput = tool.execute(&input).await.unwrap(); assert!(output.summary.contains("stopped"), "{}", output.summary); } // --------------------------------------------------------------------------- // StopPod // --------------------------------------------------------------------------- #[tokio::test] async fn stop_pod_sends_shutdown_and_releases_scope() { let _env = EnvGuard::acquire(); let tmp = TempDir::new().unwrap(); let store_tmp = TempDir::new().unwrap(); let store = CombinedStore::new( FsStore::new(store_tmp.path()).unwrap(), FsPodStore::new(store_tmp.path().join("pods")).unwrap(), ); let rd = Arc::new(RuntimeDir::create(tmp.path(), "spawner").await.unwrap()); let parent_scope = SharedScope::new( Scope::writable(tmp.path()) .unwrap() .with_added_deny_rules([ScopeRule { target: tmp.path().to_path_buf(), permission: Permission::Write, recursive: true, }]) .unwrap(), ); unsafe { std::env::set_var("YOI_RUNTIME_DIR", tmp.path()); } let lock_path = tmp.path().join("pods.json"); // Seed pods.json with a restored top-level `spawner` allocation whose // scope_deny contains the delegated child path plus the live child // allocation — mimics a parent resumed after SpawnPod. { let mut g = LockFileGuard::open(&lock_path).unwrap(); let rule = ScopeRule { target: tmp.path().to_path_buf(), permission: Permission::Write, recursive: true, }; pod_registry::register_pod_with_deny( &mut g, "spawner".into(), std::process::id(), "/tmp/spawner.sock".into(), vec![rule.clone()], vec![rule.clone()], session_store::new_segment_id(), ) .unwrap(); pod_registry::register_pod( &mut g, "child".into(), std::process::id(), "/tmp/child.sock".into(), vec![rule], session_store::new_segment_id(), ) .unwrap(); } let loaded = SpawnedPodRegistry::load_from_pod_state_with_reclaim( rd.clone(), store.clone(), "spawner".into(), Some(parent_scope.clone()), ) .await .unwrap(); let registry = loaded.registry; let (socket, listener) = bind_mock_socket(tmp.path(), "child").await; let received = accept_one_method(listener); register_child(®istry, "child", &socket, tmp.path()).await; let def = stop_pod_tool(registry.clone()); let (_meta, tool) = def(); let input = json!({ "name": "child" }).to_string(); let output: ToolOutput = tool.execute(&input).await.unwrap(); assert!(output.summary.contains("stopped"), "{}", output.summary); // The child got a Shutdown. let method = received.await.unwrap().expect("expected shutdown"); assert!(matches!(method, Method::Shutdown)); // Allocation for `child` is gone; `spawner` remains and its restored // dynamic deny layer has been reclaimed. { let g = LockFileGuard::open(&lock_path).unwrap(); assert!(g.data().find("child").is_none(), "child still allocated"); let spawner = g.data().find("spawner").expect("spawner missing"); assert!(spawner.scope_deny.is_empty(), "deny not reclaimed"); } assert_eq!( parent_scope .snapshot() .permission_at(&tmp.path().join("file.txt")), Some(Permission::Write) ); // spawned_pods.json now lists zero children. let spawned = rd.path().join("spawned_pods.json"); let contents = std::fs::read_to_string(&spawned).unwrap(); let records: Vec = serde_json::from_str(&contents).unwrap(); assert!(records.is_empty()); } #[tokio::test] async fn stop_pod_succeeds_even_when_child_unreachable() { let _env = EnvGuard::acquire(); let (tmp, registry, _rd) = setup_registry().await; unsafe { std::env::set_var("YOI_RUNTIME_DIR", tmp.path()); } // No live listener — socket never bound. Registered record points // at a dead path. StopPod should still clean up local bookkeeping. let dead_socket = tmp.path().join("dead.sock"); register_child(®istry, "child", &dead_socket, tmp.path()).await; let def = stop_pod_tool(registry.clone()); let (_meta, tool) = def(); let input = json!({ "name": "child" }).to_string(); let output: ToolOutput = tool.execute(&input).await.unwrap(); assert!(output.summary.contains("stopped"), "{}", output.summary); // Registry no longer knows about the child. assert!(registry.get("child").await.is_none()); } // --------------------------------------------------------------------------- // Persistence / restore // --------------------------------------------------------------------------- #[tokio::test] async fn restored_registry_uses_pod_state_without_runtime_file() { let _env = EnvGuard::acquire(); let runtime_tmp = TempDir::new().unwrap(); let store_tmp = TempDir::new().unwrap(); let store = CombinedStore::new( FsStore::new(store_tmp.path()).unwrap(), FsPodStore::new(store_tmp.path().join("pods")).unwrap(), ); unsafe { std::env::set_var("YOI_RUNTIME_DIR", runtime_tmp.path()); } let rd = Arc::new( RuntimeDir::create(runtime_tmp.path(), "spawner") .await .unwrap(), ); let registry = SpawnedPodRegistry::load_from_pod_state(rd.clone(), store.clone(), "spawner".to_string()) .await .unwrap(); let (socket, listener) = bind_mock_socket(runtime_tmp.path(), "child").await; let mut received = serve_pod_methods(listener); register_child(®istry, "child", &socket, runtime_tmp.path()).await; std::fs::remove_file(rd.path().join("spawned_pods.json")).unwrap(); let restored = SpawnedPodRegistry::load_from_pod_state(rd.clone(), store.clone(), "spawner".to_string()) .await .unwrap(); let def = send_to_pod_tool(restored.clone()); let (_meta, tool) = def(); let input = json!({ "name": "child", "message": "after restart" }).to_string(); tool.execute(&input).await.unwrap(); match received.recv().await.expect("expected Run") { Method::Run { input } => match input.as_slice() { [protocol::Segment::Text { content }] => assert_eq!(content, "after restart"), other => panic!("expected single Text segment, got {other:?}"), }, other => panic!("expected Run, got {other:?}"), } let def = stop_pod_tool(restored.clone()); let (_meta, tool) = def(); tool.execute(&json!({ "name": "child" }).to_string()) .await .unwrap(); assert!(matches!( received.recv().await.expect("expected Shutdown"), Method::Shutdown )); assert!(restored.get("child").await.is_none()); let metadata = store .read_by_name("spawner") .unwrap() .expect("spawner metadata should remain"); assert!(metadata.spawned_children.is_empty()); assert_eq!(metadata.reclaimed_children.len(), 1); assert_eq!(metadata.reclaimed_children[0].pod_name, "child"); let runtime_contents = std::fs::read_to_string(rd.path().join("spawned_pods.json")).unwrap(); let runtime_records: Vec = serde_json::from_str(&runtime_contents).unwrap(); assert!(runtime_records.is_empty()); } #[tokio::test] async fn load_from_pod_state_prunes_runtime_children_and_reclaims_durable_delegation() { let runtime_tmp = TempDir::new().unwrap(); let store_tmp = TempDir::new().unwrap(); let store = CombinedStore::new( FsStore::new(store_tmp.path()).unwrap(), FsPodStore::new(store_tmp.path().join("pods")).unwrap(), ); let rd = Arc::new( RuntimeDir::create(runtime_tmp.path(), "spawner") .await .unwrap(), ); let registry = SpawnedPodRegistry::load_from_pod_state(rd.clone(), store.clone(), "spawner".to_string()) .await .unwrap(); let (live_socket, listener) = bind_mock_socket(runtime_tmp.path(), "alive").await; let _server = serve_pod_methods(listener); register_child(®istry, "alive", &live_socket, runtime_tmp.path()).await; register_child( ®istry, "missing", &runtime_tmp.path().join("missing.sock"), runtime_tmp.path(), ) .await; let restored = SpawnedPodRegistry::load_from_pod_state(rd.clone(), store.clone(), "spawner".to_string()) .await .unwrap(); assert!(restored.get("alive").await.is_some()); assert!(restored.get("missing").await.is_none()); let metadata = store .read_by_name("spawner") .unwrap() .expect("spawner metadata should be written"); assert_eq!(metadata.spawned_children.len(), 1); assert_eq!(metadata.spawned_children[0].pod_name, "alive"); assert_eq!(metadata.reclaimed_children.len(), 1); assert_eq!(metadata.reclaimed_children[0].pod_name, "missing"); } #[tokio::test] async fn load_from_pod_state_reclaims_missing_child_scope_and_records_history() { let _env = EnvGuard::acquire(); let runtime_tmp = TempDir::new().unwrap(); let store_tmp = TempDir::new().unwrap(); let store = CombinedStore::new( FsStore::new(store_tmp.path()).unwrap(), FsPodStore::new(store_tmp.path().join("pods")).unwrap(), ); unsafe { std::env::set_var("YOI_RUNTIME_DIR", runtime_tmp.path()); } let rd = Arc::new( RuntimeDir::create(runtime_tmp.path(), "spawner") .await .unwrap(), ); let missing_rule = ScopeRule { target: runtime_tmp.path().to_path_buf(), permission: Permission::Write, recursive: true, }; { let mut g = LockFileGuard::open(&runtime_tmp.path().join("pods.json")).unwrap(); pod_registry::register_pod_with_deny( &mut g, "spawner".into(), std::process::id(), "/tmp/spawner.sock".into(), vec![missing_rule.clone()], vec![missing_rule.clone()], session_store::new_segment_id(), ) .unwrap(); } let parent_scope = SharedScope::new( Scope::writable(runtime_tmp.path()) .unwrap() .with_added_deny_rules([missing_rule.clone()]) .unwrap(), ); let seed = SpawnedPodRegistry::load_from_pod_state(rd.clone(), store.clone(), "spawner".into()) .await .unwrap(); seed.add(SpawnedPodRecord { pod_name: "missing".into(), socket_path: runtime_tmp.path().join("missing.sock"), scope_delegated: vec![missing_rule.clone()], callback_address: "/dev/null".into(), }) .await .unwrap(); let loaded = SpawnedPodRegistry::load_from_pod_state_with_reclaim( rd.clone(), store.clone(), "spawner".into(), Some(parent_scope.clone()), ) .await .unwrap(); assert!(loaded.reclaimed_unreachable); assert!(loaded.registry.get("missing").await.is_none()); assert_eq!( parent_scope .snapshot() .permission_at(&runtime_tmp.path().join("file.txt")), Some(Permission::Write) ); let g = LockFileGuard::open(&runtime_tmp.path().join("pods.json")).unwrap(); assert!(g.data().find("missing").is_none()); assert!(g.data().find("spawner").unwrap().scope_deny.is_empty()); let metadata = store .read_by_name("spawner") .unwrap() .expect("spawner metadata should remain"); assert!(metadata.spawned_children.is_empty()); assert_eq!(metadata.reclaimed_children.len(), 1); assert_eq!(metadata.reclaimed_children[0].pod_name, "missing"); let runtime_contents = std::fs::read_to_string(rd.path().join("spawned_pods.json")).unwrap(); let runtime_records: Vec = serde_json::from_str(&runtime_contents).unwrap(); assert!(runtime_records.is_empty()); }