| 1 | //! End-to-end contract for the `[lifecycle_outbox]` feature on headless |
| 2 | //! `codewhale exec`: with a path configured, a run appends one JSONL |
| 3 | //! `RuntimeEventEnvelope` line per turn boundary (`turn_start` at message |
| 4 | //! dispatch, `turn_end` at the terminal receipt), the per-file `seq` recovers |
| 5 | //! across processes, and with no path configured no file is ever created. |
| 6 | //! |
| 7 | //! A `wiremock` OpenAI-compatible endpoint stands in for the provider, so the |
| 8 | //! run is a real `exec` process end to end — same loader, same engine, same |
| 9 | //! outbox writer — with no external network. |
| 10 | |
| 11 | #![cfg(unix)] |
| 12 | |
| 13 | use std::io::Read; |
| 14 | use std::path::{Path, PathBuf}; |
| 15 | use std::process::{Command, Stdio}; |
| 16 | use std::time::Duration; |
| 17 | |
| 18 | use serde_json::{Value, json}; |
| 19 | use tempfile::TempDir; |
| 20 | use wait_timeout::ChildExt; |
| 21 | use wiremock::matchers::{method, path}; |
| 22 | use wiremock::{Mock, MockServer, ResponseTemplate}; |
| 23 | |
| 24 | const TEST_MODEL: &str = "lifecycle-outbox-model"; |
| 25 | const RUN_TIMEOUT: Duration = Duration::from_secs(60); |
| 26 | |
| 27 | /// Placeholder in `outbox_toml` replaced with the isolated home's absolute |
| 28 | /// outbox path (so callers can read the file back after the run). |
| 29 | const OUTBOX_PATH_TOKEN: &str = "__OUTBOX_PATH__"; |
| 30 | |
| 31 | fn sse_chunk(value: Value) -> String { |
| 32 | format!( |
| 33 | "data: {}\n\n", |
| 34 | serde_json::to_string(&value).expect("SSE JSON") |
| 35 | ) |
| 36 | } |
| 37 | |
| 38 | /// Final-answer SSE: one content delta, then a clean stop. |
| 39 | fn answer_sse(answer: &str) -> String { |
| 40 | [ |
| 41 | sse_chunk(json!({ |
| 42 | "id": "chatcmpl-outbox", |
| 43 | "object": "chat.completion.chunk", |
| 44 | "model": TEST_MODEL, |
| 45 | "choices": [{"index": 0, "delta": {"content": answer}, "finish_reason": null}] |
| 46 | })), |
| 47 | sse_chunk(json!({ |
| 48 | "id": "chatcmpl-outbox", |
| 49 | "object": "chat.completion.chunk", |
| 50 | "model": TEST_MODEL, |
| 51 | "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}] |
| 52 | })), |
| 53 | "data: [DONE]\n\n".to_string(), |
| 54 | ] |
| 55 | .join("") |
| 56 | } |
| 57 | |
| 58 | async fn start_mock_llm() -> MockServer { |
| 59 | let server = MockServer::start().await; |
| 60 | |
| 61 | Mock::given(method("GET")) |
| 62 | .and(path("/v1/models")) |
| 63 | .respond_with( |
| 64 | ResponseTemplate::new(200) |
| 65 | .insert_header("content-type", "application/json") |
| 66 | .set_body_json(json!({ |
| 67 | "object": "list", |
| 68 | "data": [{ "id": TEST_MODEL, "object": "model" }] |
| 69 | })), |
| 70 | ) |
| 71 | .mount(&server) |
| 72 | .await; |
| 73 | |
| 74 | Mock::given(method("POST")) |
| 75 | .and(path("/v1/chat/completions")) |
| 76 | .respond_with( |
| 77 | ResponseTemplate::new(200) |
| 78 | .insert_header("content-type", "text/event-stream") |
| 79 | .insert_header("cache-control", "no-cache") |
| 80 | .set_body_string(answer_sse("ok")), |
| 81 | ) |
| 82 | .mount(&server) |
| 83 | .await; |
| 84 | |
| 85 | server |
| 86 | } |
| 87 | |
| 88 | fn preserve_host_env(command: &mut Command) { |
| 89 | command.env_clear(); |
| 90 | for key in [ |
| 91 | "PATH", |
| 92 | "PATHEXT", |
| 93 | "SystemRoot", |
| 94 | "SystemDrive", |
| 95 | "WINDIR", |
| 96 | "COMSPEC", |
| 97 | "TEMP", |
| 98 | "TMP", |
| 99 | "TERM", |
| 100 | "COLORTERM", |
| 101 | "LANG", |
| 102 | "LC_ALL", |
| 103 | ] { |
| 104 | if let Some(value) = std::env::var_os(key) { |
| 105 | command.env(key, value); |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// Run `codewhale exec` against the mock provider with the given |
| 111 | /// `[lifecycle_outbox]` config block (already TOML-formatted, may be empty). |
| 112 | /// Any `__OUTBOX_PATH__` token in it is replaced with the isolated home's |
| 113 | /// absolute outbox path. Returns the isolated home and workspace dirs (the |
| 114 | /// latter so callers can assert the outbox `payload.workspace` exactly). |
| 115 | fn run_exec_with_outbox_config( |
| 116 | server: &MockServer, |
| 117 | outbox_toml: &str, |
| 118 | expected_exit_code: i32, |
| 119 | ) -> (TempDir, TempDir) { |
| 120 | let workspace = TempDir::new().expect("workspace tempdir"); |
| 121 | let home = TempDir::new().expect("home tempdir"); |
| 122 | let outbox_path = home_outbox_path(&home); |
| 123 | let outbox_toml = outbox_toml.replace(OUTBOX_PATH_TOKEN, &outbox_path.display().to_string()); |
| 124 | |
| 125 | std::fs::create_dir_all(home.path().join(".codewhale")).expect("create codewhale config dir"); |
| 126 | std::fs::create_dir_all(home.path().join(".deepseek")).expect("create deepseek config dir"); |
| 127 | std::fs::write( |
| 128 | home.path().join(".codewhale").join("config.toml"), |
| 129 | format!("provider = \"deepseek\"\nmodel = \"{TEST_MODEL}\"\n{outbox_toml}"), |
| 130 | ) |
| 131 | .expect("write exec config"); |
| 132 | |
| 133 | let mut command = Command::new(codewhale_tui_binary()); |
| 134 | preserve_host_env(&mut command); |
| 135 | command |
| 136 | .current_dir(workspace.path()) |
| 137 | .arg("--workspace") |
| 138 | .arg(workspace.path()) |
| 139 | .arg("--no-project-config") |
| 140 | .arg("exec") |
| 141 | .arg("--auto") |
| 142 | .arg("--model") |
| 143 | .arg(TEST_MODEL) |
| 144 | .arg("answer briefly") |
| 145 | .env("HOME", home.path()) |
| 146 | .env("USERPROFILE", home.path()) |
| 147 | .env("XDG_CONFIG_HOME", home.path().join(".config")) |
| 148 | .env("XDG_DATA_HOME", home.path().join(".local").join("share")) |
| 149 | .env("XDG_CACHE_HOME", home.path().join(".cache")) |
| 150 | .env( |
| 151 | "CODEWHALE_CONFIG_PATH", |
| 152 | home.path().join(".codewhale").join("config.toml"), |
| 153 | ) |
| 154 | .env( |
| 155 | "DEEPSEEK_CONFIG_PATH", |
| 156 | home.path().join(".deepseek").join("config.toml"), |
| 157 | ) |
| 158 | .env("DEEPSEEK_API_KEY", "ci-test-key-not-real") |
| 159 | .env("DEEPSEEK_BASE_URL", server.uri()) |
| 160 | .env("CODEWHALE_BASE_URL", server.uri()) |
| 161 | .env("DEEPSEEK_MODEL", TEST_MODEL) |
| 162 | .env("CODEWHALE_MODEL", TEST_MODEL) |
| 163 | .env("RUST_LOG", "warn") |
| 164 | .stdout(Stdio::piped()) |
| 165 | .stderr(Stdio::piped()); |
| 166 | |
| 167 | let mut child = command.spawn().expect("spawn codewhale-tui exec"); |
| 168 | let stdout_reader = read_pipe_in_background(child.stdout.take().expect("stdout pipe")); |
| 169 | let stderr_reader = read_pipe_in_background(child.stderr.take().expect("stderr pipe")); |
| 170 | |
| 171 | let status = match child |
| 172 | .wait_timeout(RUN_TIMEOUT) |
| 173 | .expect("wait for codewhale-tui") |
| 174 | { |
| 175 | Some(status) => status, |
| 176 | None => { |
| 177 | let _ = child.kill(); |
| 178 | let _ = child.wait(); |
| 179 | let stdout = join_pipe_reader(stdout_reader, "stdout"); |
| 180 | let stderr = join_pipe_reader(stderr_reader, "stderr"); |
| 181 | panic!( |
| 182 | "codewhale-tui exec timed out after {RUN_TIMEOUT:?}\nstdout:\n{}\nstderr:\n{}", |
| 183 | String::from_utf8_lossy(&stdout), |
| 184 | String::from_utf8_lossy(&stderr) |
| 185 | ); |
| 186 | } |
| 187 | }; |
| 188 | |
| 189 | let stdout = join_pipe_reader(stdout_reader, "stdout"); |
| 190 | let stderr = join_pipe_reader(stderr_reader, "stderr"); |
| 191 | assert_eq!( |
| 192 | status.code(), |
| 193 | Some(expected_exit_code), |
| 194 | "codewhale-tui exec returned the wrong exit status\nstdout:\n{}\nstderr:\n{}", |
| 195 | String::from_utf8_lossy(&stdout), |
| 196 | String::from_utf8_lossy(&stderr) |
| 197 | ); |
| 198 | |
| 199 | (home, workspace) |
| 200 | } |
| 201 | |
| 202 | fn read_pipe_in_background<R>(mut reader: R) -> std::thread::JoinHandle<std::io::Result<Vec<u8>>> |
| 203 | where |
| 204 | R: Read + Send + 'static, |
| 205 | { |
| 206 | std::thread::spawn(move || { |
| 207 | let mut output = Vec::new(); |
| 208 | reader.read_to_end(&mut output).map(|_| output) |
| 209 | }) |
| 210 | } |
| 211 | |
| 212 | fn join_pipe_reader( |
| 213 | handle: std::thread::JoinHandle<std::io::Result<Vec<u8>>>, |
| 214 | stream_name: &str, |
| 215 | ) -> Vec<u8> { |
| 216 | handle |
| 217 | .join() |
| 218 | .expect("pipe reader join") |
| 219 | .unwrap_or_else(|err| panic!("failed to read {stream_name}: {err}")) |
| 220 | } |
| 221 | |
| 222 | fn read_outbox_lines(path: &Path) -> Vec<Value> { |
| 223 | let text = std::fs::read_to_string(path).expect("read outbox file"); |
| 224 | text.lines() |
| 225 | .map(|line| { |
| 226 | serde_json::from_str(line) |
| 227 | .unwrap_or_else(|err| panic!("outbox line should parse: {err}\nline: {line}")) |
| 228 | }) |
| 229 | .collect() |
| 230 | } |
| 231 | |
| 232 | fn codewhale_tui_binary() -> PathBuf { |
| 233 | if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale-tui") { |
| 234 | return PathBuf::from(path); |
| 235 | } |
| 236 | if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale-tui") { |
| 237 | return PathBuf::from(path); |
| 238 | } |
| 239 | |
| 240 | let mut path = std::env::current_exe().expect("current test executable path"); |
| 241 | path.pop(); |
| 242 | if path.ends_with("deps") { |
| 243 | path.pop(); |
| 244 | } |
| 245 | path.push(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX)); |
| 246 | path |
| 247 | } |
| 248 | |
| 249 | fn home_outbox_path(home: &TempDir) -> PathBuf { |
| 250 | home.path() |
| 251 | .join(".codewhale") |
| 252 | .join("notifications") |
| 253 | .join("outbox.jsonl") |
| 254 | } |
| 255 | |
| 256 | #[tokio::test(flavor = "multi_thread")] |
| 257 | async fn exec_emits_turn_start_and_turn_end_to_the_configured_outbox() { |
| 258 | let server = start_mock_llm().await; |
| 259 | let (home, workspace) = run_exec_with_outbox_config( |
| 260 | &server, |
| 261 | &format!("[lifecycle_outbox]\npath = {}\n", json!(OUTBOX_PATH_TOKEN)), |
| 262 | 0, |
| 263 | ); |
| 264 | |
| 265 | let outbox_path = home_outbox_path(&home); |
| 266 | assert!(outbox_path.exists(), "outbox file must be created"); |
| 267 | let lines = read_outbox_lines(&outbox_path); |
| 268 | assert_eq!( |
| 269 | lines.len(), |
| 270 | 2, |
| 271 | "one turn_start and one turn_end line: {lines:#?}" |
| 272 | ); |
| 273 | |
| 274 | let start = &lines[0]; |
| 275 | assert_eq!(start["event"], "turn_start"); |
| 276 | assert_eq!(start["kind"], "turn.started"); |
| 277 | assert_eq!(start["schema_version"], 1); |
| 278 | assert_eq!(start["seq"], 1); |
| 279 | assert!(start["timestamp"].as_str().is_some()); |
| 280 | // Headless exec has no engine turn id and (for a fresh run) no session |
| 281 | // id yet — both are honest absences, never fabricated. |
| 282 | assert!(start["turn_id"].is_null()); |
| 283 | // The model field is bounded and never the raw prompt. |
| 284 | assert_eq!(start["payload"]["model"], TEST_MODEL); |
| 285 | // Every payload carries the workspace for consumer-side routing; exec |
| 286 | // runs with `--workspace <dir>`, so the emitted path must match it. |
| 287 | assert_eq!( |
| 288 | start["payload"]["workspace"], |
| 289 | json!(workspace.path().to_string_lossy().as_ref()), |
| 290 | "turn_start must carry the workspace" |
| 291 | ); |
| 292 | |
| 293 | let end = &lines[1]; |
| 294 | assert_eq!(end["event"], "turn_end"); |
| 295 | assert_eq!(end["kind"], "turn.completed"); |
| 296 | assert_eq!(end["seq"], 2); |
| 297 | assert_eq!(end["payload"]["status"], "completed"); |
| 298 | assert!(end["payload"]["error"].is_null()); |
| 299 | assert!(end["payload"]["duration_ms"].as_u64().is_some()); |
| 300 | assert_eq!( |
| 301 | end["payload"]["workspace"], |
| 302 | json!(workspace.path().to_string_lossy().as_ref()), |
| 303 | "turn_end must carry the workspace" |
| 304 | ); |
| 305 | } |
| 306 | |
| 307 | #[tokio::test(flavor = "multi_thread")] |
| 308 | async fn exec_without_outbox_config_writes_no_file() { |
| 309 | let server = start_mock_llm().await; |
| 310 | let (home, _workspace) = run_exec_with_outbox_config(&server, "", 0); |
| 311 | |
| 312 | assert!( |
| 313 | !home_outbox_path(&home).exists(), |
| 314 | "no outbox file must be created when [lifecycle_outbox] is unset" |
| 315 | ); |
| 316 | } |
| 317 | |
| 318 | #[tokio::test(flavor = "multi_thread")] |
| 319 | async fn outbox_seq_recovers_across_processes() { |
| 320 | let server = start_mock_llm().await; |
| 321 | |
| 322 | // First run writes seq 1 (turn_start) and 2 (turn_end). |
| 323 | let (home, _workspace) = run_exec_with_outbox_config( |
| 324 | &server, |
| 325 | &format!("[lifecycle_outbox]\npath = {}\n", json!(OUTBOX_PATH_TOKEN)), |
| 326 | 0, |
| 327 | ); |
| 328 | let shared_outbox = home_outbox_path(&home); |
| 329 | |
| 330 | // Second process, pointing at the SAME file: seq must continue at 3. |
| 331 | let (_second_home, _second_workspace) = run_exec_with_outbox_config( |
| 332 | &server, |
| 333 | &format!( |
| 334 | "[lifecycle_outbox]\npath = {}\n", |
| 335 | json!(shared_outbox.display().to_string()) |
| 336 | ), |
| 337 | 0, |
| 338 | ); |
| 339 | |
| 340 | let lines = read_outbox_lines(&shared_outbox); |
| 341 | assert_eq!(lines.len(), 4, "two runs, four lines: {lines:#?}"); |
| 342 | let seqs: Vec<u64> = lines |
| 343 | .iter() |
| 344 | .map(|line| line["seq"].as_u64().expect("seq")) |
| 345 | .collect(); |
| 346 | assert_eq!( |
| 347 | seqs, |
| 348 | vec![1, 2, 3, 4], |
| 349 | "seq must be monotonic across processes" |
| 350 | ); |
| 351 | } |
| 352 | |
| 353 | #[tokio::test(flavor = "multi_thread")] |
| 354 | async fn failed_exec_persists_the_terminal_receipt_without_changing_its_exit() { |
| 355 | let server = start_mock_llm().await; |
| 356 | Mock::given(method("POST")) |
| 357 | .and(path("/v1/chat/completions")) |
| 358 | .respond_with(ResponseTemplate::new(500).set_body_string("upstream model failure")) |
| 359 | .with_priority(1) |
| 360 | .mount(&server) |
| 361 | .await; |
| 362 | let (home, _workspace) = run_exec_with_outbox_config( |
| 363 | &server, |
| 364 | &format!("[lifecycle_outbox]\npath = {}\n", json!(OUTBOX_PATH_TOKEN)), |
| 365 | 1, |
| 366 | ); |
| 367 | let lines = read_outbox_lines(&home_outbox_path(&home)); |
| 368 | assert_eq!(lines.len(), 2, "failed turn retains both boundaries"); |
| 369 | assert_eq!(lines[0]["event"], "turn_start"); |
| 370 | assert_eq!(lines[0]["seq"], 1); |
| 371 | assert_eq!(lines[1]["event"], "turn_end"); |
| 372 | assert_eq!(lines[1]["kind"], "turn.failed"); |
| 373 | assert_eq!(lines[1]["seq"], 2); |
| 374 | assert_eq!(lines[1]["payload"]["status"], "failed"); |
| 375 | assert!(!lines[1]["payload"]["error"].as_str().unwrap().is_empty()); |
| 376 | } |
| 377 |