//! Layer 3: Content controller. //! //! An idle pager only renders a splash screen — not useful for scroll, //! stream, or resize scenarios. [`ContentController`] wraps the shared //! [`MockInferenceServer`] from `kigi-test-support` and provides the //! env vars that point the bundled shell agent at it, so the pager ends //! up rendering real agent output. //! //! The caller controls the response text via [`ContentController::set_response`]. //! The mock server streams the set response to every inference request. use std::path::Path; use anyhow::{Context, Result}; use kigi_test_support::MockInferenceServer; pub use kigi_test_support::mock_server::LogEntry; pub use kigi_test_support::mock_server::MockModelEntry as MockModel; pub use kigi_test_support::mock_server::StorageUpload; // SSE event builders for `enqueue_response` scripts (reasoning turns etc.). pub use kigi_test_support::sse; pub use kigi_test_support::{ScriptedResponse, SseEvent}; /// Drives content into the pager by serving a mock inference endpoint that /// the bundled shell agent hits for `/v1/chat/completions` and `/v1/responses`. /// /// Thin wrapper over the shared [`MockInferenceServer`]: adds the isolated /// `$HOME` sandbox and pager env plumbing, and applies the harness defaults /// the pager depends on (always-200 `/v1/settings`, fixed default response). /// /// Shuts the server down on drop (the inner server's `Drop`). pub struct ContentController { server: MockInferenceServer, home: tempfile::TempDir, } impl ContentController { /// Start the mock inference server on a random local port. /// /// Must be called from within a tokio runtime. pub async fn start() -> Result { Self::start_with_models(vec![MockModel::new("test-model")]).await } /// Start the mock server with a custom set of models returned by /// `GET /v1/models`. Use [`MockModel::with_agent_type`] to configure /// models with different harness types for agent-type-mismatch tests. pub async fn start_with_models(models: Vec) -> Result { let server = MockInferenceServer::start_with_models(models) .await .context("start mock inference server")?; // Pre-delegation parity, both load-bearing for PTY tests: settings // must be 200 `{"allow_access": true}` (the shared 404-until-set // default strands the pager on the upsell screen), and the response // mode must be a fixed text (the shared default is echo). server.preset_allow_access(); server.set_response(default_response_text()); let home = tempfile::tempdir().context("create temp HOME")?; Ok(Self { server, home }) } /// Base URL of the mock server, e.g. `http://127.0.0.1:41823/v1`. pub fn url(&self) -> String { self.server.url() } /// Isolated `$HOME` directory that the pager should use (keeps its ~/.kigi /// cache/state out of the real home during tests). pub fn home(&self) -> &Path { self.home.path() } /// Env vars to pass to the pager process so it hits the mock server /// with telemetry / feedback disabled. /// /// Mirrors `kigi_test_support::env::test_env_cmd_tokio`. pub fn env_for_pager(&self) -> Vec<(String, String)> { let home = self.home.path().to_string_lossy().into_owned(); let kigi_home = self .home .path() .join(".kigi") .to_string_lossy() .into_owned(); vec![ ("HOME".into(), home), // Explicit KIGI_SHARE_DIR prevents leaking the real user's // config.toml when $HOME alone isn't sufficient (e.g. if // KIGI_SHARE_DIR is set in the test runner's env). ("KIGI_SHARE_DIR".into(), kigi_home), ("KIGI_CODE_BASE_URL".into(), self.url()), ("KIGI_API_BASE_URL".into(), self.url()), // Hermeticity: bundled open-platform (moonshot) model entries // carry real platform base URLs; the documented dev/test override // pins them to the mock so no PTY test can reach a live endpoint. ("KIGI_MOONSHOT_CN_BASE_URL".into(), self.url()), ("KIGI_MOONSHOT_AI_BASE_URL".into(), self.url()), ("XAI_API_KEY".into(), "test-key-for-ci".into()), ("KIGI_TELEMETRY_ENABLED".into(), "false".into()), ("KIGI_FEEDBACK_ENABLED".into(), "false".into()), ("KIGI_TRACE_UPLOAD".into(), "false".into()), // Next-prompt autocomplete fires an extra background model call // at every turn end (default ON). Off by default in PTY tests so // the mock's fixed response can't leak in as ghost text and // scripted per-path FIFOs aren't consumed by it. Tests exercising // the feature re-enable it via extra env. ("KIGI_PROMPT_SUGGESTIONS".into(), "false".into()), // No inference retries in tests. The mock always answers 200, so a // retry only ever fires when a turn is deliberately stalled // (`hold_agent_completions` / a long `chunk_delay`). On a slow // runner that stall can exceed the client's first-token budget and // retry the request — and because the mock serves `set_agent_turns` // by popping one response per REQUEST, a retry consumes the next // turn's slot, misaligning every following turn (the promoted queue // prompt then hangs waiting for a response that was already popped). // Pinning retries to 0 keeps one request == one turn. ("KIGI_MAX_RETRIES".into(), "0".into()), ] } /// Replace the mocked assistant response. All subsequent chat completion /// requests will stream this text word-by-word. pub fn set_response(&self, text: impl Into) { self.server.set_response(text); } /// Queue a byte-exact scripted response for the next request on `path` /// (e.g. `"/v1/responses"`). Consumed FIFO per path; falls back to the /// active fixed/echo mode when the queue is empty. pub fn enqueue_response(&self, path: impl Into, response: ScriptedResponse) { self.server.enqueue_response(path, response); } /// Access the underlying mock inference server for advanced scripting. pub fn server(&self) -> &MockInferenceServer { &self.server } /// Pace the mocked SSE streams: each event is emitted after `delay`. /// `None` restores instant streaming. Use to hold a turn visibly /// "streaming" long enough to interact with it (e.g. Esc-cancel tests). pub fn set_chunk_delay(&self, delay: Option) { self.server.set_chunk_delay(delay); } /// Hold every agent turn's completion until [`release_agent_completions`] /// is called. Keeps a turn deterministically "streaming" so a test can /// interact with it (queue edits/removals) without racing turn end. /// /// [`release_agent_completions`]: Self::release_agent_completions pub fn hold_agent_completions(&self) { self.server.hold_agent_completions(); } /// Release a hold set by [`hold_agent_completions`], letting the gated /// turn complete. /// /// [`hold_agent_completions`]: Self::hold_agent_completions pub fn release_agent_completions(&self) { self.server.release_agent_completions(); } /// Queue one response per agent turn (FIFO) so each carries a distinct /// sentinel. See [`MockInferenceServer::set_agent_turns`]. pub fn set_turns(&self, turns: impl IntoIterator) { self.server.set_agent_turns(turns); } /// Number of inference requests the pager has made so far. pub fn request_count(&self) -> u32 { self.server.request_count() } /// Whether the server has seen a chat completion request. pub fn has_chat_completion(&self) -> bool { self.server.has_chat_completion_request() || self.server.has_responses_request() } /// Snapshot of all received requests — useful for test diagnostics. pub fn requests(&self) -> Vec { self.server.requests() } pub fn request_bodies(&self) -> Vec { self.server.request_bodies() } // ── Mock storage controls (park-on-401 e2e) ──────────────────────────── /// Flip the mock `/v1/storage` 401 gate (the auth-outage window). pub fn set_storage_unauthorized(&self, unauthorized: bool) { self.server.set_storage_unauthorized(unauthorized); } /// Total `/v1/storage` upload attempts, including 401-rejected ones. pub fn storage_request_count(&self) -> u32 { self.server.storage_request_count() } /// Snapshot of accepted (HTTP 200) `/v1/storage` uploads. pub fn storage_uploads(&self) -> Vec { self.server.storage_uploads() } } fn default_response_text() -> String { "Hello from the pty_harness mock inference server.".to_owned() } #[cfg(test)] mod tests { use super::*; /// The pre-delegation mock always served 200 `{"allow_access": true}`; /// the shared server defaults to 404-until-set. A 404 strands the pager /// on the subscription upsell screen and breaks every PTY test. #[tokio::test] async fn settings_endpoint_allows_access_by_default() { let content = ContentController::start().await.unwrap(); let resp = reqwest::get(format!("{}/settings", content.url())) .await .unwrap(); assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.unwrap(); assert_eq!(body, serde_json::json!({ "allow_access": true })); } /// The pre-delegation mock streamed a fixed default text to every /// request; the shared server defaults to echo. #[tokio::test] async fn default_response_streams_fixed_text() { let content = ContentController::start().await.unwrap(); let body = reqwest::Client::new() .post(format!("{}/chat/completions", content.url())) .json(&serde_json::json!({ "model": "test-model", "messages": [{ "role": "user", "content": "anything" }] })) .send() .await .unwrap() .text() .await .unwrap(); let streamed: String = body .lines() .filter_map(|l| l.strip_prefix("data:")) .map(str::trim_start) .filter(|d| *d != "[DONE]") .filter_map(|d| serde_json::from_str::(d).ok()) .filter_map(|v| { v.get("choices") .and_then(|c| c.get(0)) .and_then(|c| c.get("delta")) .and_then(|d| d.get("content")) .and_then(serde_json::Value::as_str) .map(String::from) }) .collect(); assert_eq!(streamed, default_response_text()); assert!(content.has_chat_completion()); } /// `env_for_pager` keeps the exact sandbox + endpoint env contract the /// pager spawn path depends on. #[tokio::test] async fn env_for_pager_shape() { let content = ContentController::start().await.unwrap(); let env = content.env_for_pager(); let get = |k: &str| { env.iter() .find(|(key, _)| key.as_str() == k) .map(|(_, v)| v.clone()) }; assert_eq!(get("HOME").as_deref(), content.home().to_str()); assert_eq!( get("KIGI_SHARE_DIR").as_deref(), content.home().join(".kigi").to_str() ); assert_eq!(get("KIGI_CODE_BASE_URL"), Some(content.url())); assert_eq!(get("KIGI_API_BASE_URL"), Some(content.url())); assert_eq!(get("KIGI_MOONSHOT_CN_BASE_URL"), Some(content.url())); assert_eq!(get("KIGI_MOONSHOT_AI_BASE_URL"), Some(content.url())); assert_eq!(get("XAI_API_KEY").as_deref(), Some("test-key-for-ci")); assert_eq!(get("KIGI_TELEMETRY_ENABLED").as_deref(), Some("false")); assert_eq!(get("KIGI_FEEDBACK_ENABLED").as_deref(), Some("false")); assert_eq!(get("KIGI_TRACE_UPLOAD").as_deref(), Some("false")); assert_eq!(get("KIGI_PROMPT_SUGGESTIONS").as_deref(), Some("false")); assert_eq!(get("KIGI_MAX_RETRIES").as_deref(), Some("0")); assert_eq!(env.len(), 12, "env list must not silently grow or shrink"); } }