Files
Kigi-CLI/crates/codegen/kigi-pager-pty-harness/src/content.rs
T
ZacharyZhang-NY ebf11057f8 feat(providers): add xAI (Grok) + migrate house BYOK env to KIGI_API_KEY
13th provider (16th registry variant). Also reconciles a naming collision
the matrix flagged: this fork is house-branded "xai" (cf. xai.dev metadata,
KIGI_CODE_XAI_API_KEY legacy env), so XAI_API_KEY + method xai.api_key were
the GENERIC house BYOK, not x.ai/Grok. The provider table wants xai/XAI_API_KEY
for Grok.

Resolution (user-approved): XAI_API_KEY now keys the x.ai/Grok provider; the
house BYOK primary env moves to KIGI_API_KEY, keeping XAI_API_KEY and
KIGI_CODE_XAI_API_KEY as back-compat fallbacks (read_xai_api_key_env checks
KIGI_API_KEY first). The xai.api_key method id is unchanged (persisted-session
compat); the platform method id is the bare "xai", distinct from it.

xAI spec: api.x.ai/v1, Bearer, OpenAI listing + ChatCompletions, Passthrough
(docs confirm stream_options.include_usage accepted). /v1/models is minimal
(ids only) and requires auth, so it doubles as the key validator (401 on bad
key, no override) and metadata comes from models.dev enrichment. Live ids
match the models.dev "xai" keys byte-for-byte, so restrict_to_enriched keeps
the 5 tool-calling chat models (grok-4.5/4.3/4.20-0309-*/build-0.1) and drops
the grok-imagine-* generators + the non-tool multi-agent model. Snapshot
regenerated to include the xai provider (was stale; gen script already listed
it in TARGETS).

Env migration is comprehensive to avoid keying the xai platform (which would
trigger a live api.x.ai fetch) or leaving house-key reads stranded: routed
the trace CLI resolver + acp_agent/auth.json bridge + paste-key ext handler
through the new primary; moved all leader/pager/e2e harness setters to
KIGI_API_KEY; made every house-key isolation test unset KIGI_API_KEY too;
updated user-facing hints to name KIGI_API_KEY.

Tests: e2e proves enrichment-supplied context (wire carries none), non-vacuous
tool_call restriction, bare-id round-trip under xai/, Passthrough; validation
tests hit /models (401 reject, 200 accept); house_env_var_takes_precedence_over_xai
pins the new precedence. Registry at 16; picker 17 rows; 4 auth arrays + xai.

Review (16 findings, all fixed): caught a missed else-branch env clear in the
paste-key handler (would leak the house key past a clear) and a non-hermetic
credential-priority test; both fixed.
2026-07-21 16:23:08 -04:00

299 lines
12 KiB
Rust

//! 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> {
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<MockModel>) -> Result<Self> {
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()),
("KIGI_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<String>) {
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<String>, 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<std::time::Duration>) {
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<Item = String>) {
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<LogEntry> {
self.server.requests()
}
pub fn request_bodies(&self) -> Vec<serde_json::Value> {
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<StorageUpload> {
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::<serde_json::Value>(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("KIGI_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");
}
}