diff --git a/crates/colibri-daemon/tests/zot_rpc_smoke.rs b/crates/colibri-daemon/tests/zot_rpc_smoke.rs new file mode 100644 index 0000000..cbae4bd --- /dev/null +++ b/crates/colibri-daemon/tests/zot_rpc_smoke.rs @@ -0,0 +1,110 @@ +//! zot-rpc driver smoke — end-to-end proof of the colibri#143 spawn driver. +//! +//! Spawns a real `zot rpc` subprocess through the Colibri `Spawner` (with +//! `rpc_stdin`), sends a prompt over the driver's `RpcSender`, and reads the +//! agent's stdout JSONL back. Validates the full path: stdin piped → framed +//! prompt delivered → zot runs the loop → events stream out. +//! +//! A placeholder DeepSeek key is enough: zot still acks the request, echoes the +//! prompt as `user_message`, runs a turn, and emits `done` (the turn fails 401, +//! which is the deterministic no-key outcome — we assert on the driver wiring, +//! not on a successful completion). +//! +//! Ignored by default — needs a built zot binary. Run with: +//! ZOT_BIN=/path/to/zot cargo test -p colibri-daemon --test zot_rpc_smoke \ +//! -- --ignored --nocapture + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use colibri_daemon::spawner::{AgentSpawnConfig, Provider, Spawner}; +use colibri_daemon::DaemonConfig; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::time::timeout; + +#[tokio::test] +#[ignore = "needs a built zot binary; set ZOT_BIN"] +async fn zot_rpc_driver_delivers_prompt_and_streams_events() { + let Ok(zot_bin) = std::env::var("ZOT_BIN") else { + eprintln!("ZOT_BIN not set; skipping zot rpc driver smoke"); + return; + }; + assert!( + std::path::Path::new(&zot_bin).exists(), + "ZOT_BIN does not exist: {zot_bin}" + ); + + let mut env = HashMap::new(); + // Placeholder key: the turn 401s deterministically; the driver wiring (our + // subject) still runs fully and emits the ack → user_message → done. + env.insert( + "DEEPSEEK_API_KEY".to_string(), + "placeholder-not-real".to_string(), + ); + + let cfg = AgentSpawnConfig { + binary: zot_bin, + args: vec![ + "rpc".to_string(), + "--provider".to_string(), + "deepseek".to_string(), + "--model".to_string(), + "deepseek-v4-pro".to_string(), + ], + env, + // Local provider → no API-key gate / fallback routing in the spawner. + provider: Provider::Local, + model: "deepseek-v4-pro".to_string(), + // The subject under test: pipe stdin and keep the writer. + rpc_stdin: true, + ..Default::default() + }; + + let spawner = Spawner::new(Arc::new(DaemonConfig::from_env())); + let handle = spawner.spawn(cfg).await.expect("spawn zot rpc"); + + // The driver must expose an RpcSender for an rpc_stdin agent. + let sender = handle + .rpc_sender() + .expect("rpc agent must expose an RpcSender"); + + let prompt = "say hi from the colibri rpc driver"; + let req_id = sender.send_prompt(prompt).await.expect("send rpc prompt"); + assert_eq!(req_id, "1", "first request id should be 1"); + + let stdout = handle.take_stdout().await.expect("rpc agent stdout"); + let mut lines = BufReader::new(stdout).lines(); + + let mut saw_response_ack = false; + let mut saw_our_prompt = false; + let mut saw_done = false; + + // Read until `done` (or timeout). The no-key turn is fast. + let read = timeout(Duration::from_secs(30), async { + while let Ok(Some(line)) = lines.next_line().await { + eprintln!("zot> {line}"); + if line.contains(r#""type":"response""#) && line.contains(r#""success":true"#) { + saw_response_ack = true; + } + if line.contains(r#""type":"user_message""#) && line.contains(prompt) { + saw_our_prompt = true; + } + if line.contains(r#""type":"done""#) { + saw_done = true; + break; + } + } + }) + .await; + assert!(read.is_ok(), "timed out waiting for zot rpc `done`"); + + let _ = handle.kill().await; + + assert!(saw_response_ack, "missing the response ack to our request"); + assert!( + saw_our_prompt, + "zot did not echo our prompt — the driver's stdin prompt was not delivered" + ); + assert!(saw_done, "missing the terminal `done` event"); +}