返回 CodeWhale
lifecycle_outbox_exec.rs
根目录 / crates / tui / tests / integration / lifecycle_outbox_exec.rs
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
377 lines RUST