返回 CodeWhale
tool_lifecycle_acceptance.rs
根目录 / crates / tui / tests / tool_lifecycle_acceptance.rs
1 //! Cucumber acceptance test for the public LLM/tool lifecycle.
2
3 use std::io::Read;
4 use std::path::PathBuf;
5 use std::process::{Command, Stdio};
6 use std::time::Duration;
7
8 use cucumber::{World as _, gherkin::Step, given, then, when, writer::Stats as _};
9 use serde_json::{Value, json};
10 use tempfile::TempDir;
11 use wait_timeout::ChildExt;
12 use wiremock::matchers::{method, path};
13 use wiremock::{Mock, MockServer, Request, ResponseTemplate};
14
15 const FEATURE_NAME: &str = "Tool call lifecycle";
16 const FEATURE_PATH: &str = concat!(
17 env!("CARGO_MANIFEST_DIR"),
18 "/tests/features/tool_lifecycle.feature"
19 );
20 const HAPPY_PATH_SCENARIO: &str = "Happy path lists the current directory through a tool";
21 const UNKNOWN_TOOL_SCENARIO: &str = "Unknown tool returns an error result";
22 const MALFORMED_ARGUMENTS_SCENARIO: &str = "Malformed tool arguments return an error result";
23 const REAL_TOOL_ERROR_SCENARIO: &str = "A real tool error is returned to the follow-up request";
24 const EMPTY_TOOL_RESULT_SCENARIO: &str =
25 "An empty tool result is returned to the follow-up request";
26 const MISSING_SUMMARY_SCENARIO: &str =
27 "A follow-up answer missing the expected summary is detected";
28 const TOOL_CALL_ID: &str = "call_tool";
29 const TEST_MODEL: &str = "acceptance-model";
30
31 #[derive(Debug, Default, cucumber::World)]
32 struct ToolLifecycleWorld {
33 workspace: Option<TempDir>,
34 home: Option<TempDir>,
35 llm_server: Option<MockServer>,
36 tool_name: Option<String>,
37 tool_arguments: Option<String>,
38 final_answer: Option<String>,
39 prompt: Option<String>,
40 stdout: String,
41 stderr: String,
42 events: Vec<Value>,
43 requests: Vec<Value>,
44 }
45
46 #[given("an offline CodeWhale workspace containing:")]
47 fn offline_codewhale_workspace_containing(world: &mut ToolLifecycleWorld, step: &Step) {
48 let workspace = TempDir::new().expect("workspace tempdir");
49 let home = TempDir::new().expect("home tempdir");
50
51 for row in data_table_rows(step) {
52 let relative_path = row_value(&row, "path");
53 let kind = row_value(&row, "kind");
54 let path = workspace.path().join(relative_path);
55 match kind.as_str() {
56 "file" => {
57 if let Some(parent) = path.parent() {
58 std::fs::create_dir_all(parent).expect("create workspace file parent");
59 }
60 std::fs::write(&path, "").expect("write workspace file");
61 }
62 "folder" => std::fs::create_dir_all(&path).expect("create workspace folder"),
63 other => panic!("unsupported workspace entry kind: {other}"),
64 }
65 }
66
67 world.workspace = Some(workspace);
68 world.home = Some(home);
69 }
70
71 #[given(regex = r#"^the mocked LLM will request the "([^"]+)" tool with:$"#)]
72 fn mocked_llm_will_request_tool(world: &mut ToolLifecycleWorld, tool_name: String, step: &Step) {
73 let rows = data_table_rows(step);
74 assert_eq!(rows.len(), 1, "tool input table should contain one row");
75 let input = Value::Object(
76 rows[0]
77 .iter()
78 .map(|(key, value)| (key.clone(), Value::String(value.clone())))
79 .collect(),
80 );
81
82 world.tool_name = Some(tool_name);
83 world.tool_arguments = Some(serde_json::to_string(&input).expect("tool input arguments"));
84 }
85
86 #[given(
87 regex = r#"^the mocked LLM will request the "([^"]+)" tool with malformed arguments "([^"]+)"$"#
88 )]
89 fn mocked_llm_will_request_tool_with_malformed_arguments(
90 world: &mut ToolLifecycleWorld,
91 tool_name: String,
92 arguments: String,
93 ) {
94 world.tool_name = Some(tool_name);
95 world.tool_arguments = Some(arguments);
96 }
97
98 #[given("the mocked LLM will answer after the tool result:")]
99 fn mocked_llm_will_answer_after_tool_result(world: &mut ToolLifecycleWorld, step: &Step) {
100 let rows = data_table_rows(step);
101 assert_eq!(rows.len(), 1, "final answer table should contain one row");
102 world.final_answer = Some(row_value(&rows[0], "content"));
103 }
104
105 #[when(regex = r#"^the user asks "([^"]+)"$"#)]
106 async fn user_asks(world: &mut ToolLifecycleWorld, prompt: String) {
107 let server = start_mock_llm(world).await;
108 let output = run_codewhale_exec(world, &server, &prompt);
109
110 world.prompt = Some(prompt);
111 world.stdout = String::from_utf8_lossy(&output.stdout).into_owned();
112 world.stderr = String::from_utf8_lossy(&output.stderr).into_owned();
113 assert!(
114 output.status.success(),
115 "codewhale-tui exec failed\nstdout:\n{}\nstderr:\n{}",
116 world.stdout,
117 world.stderr
118 );
119
120 world.events = parse_stream_events(&world.stdout);
121 world.requests = server
122 .received_requests()
123 .await
124 .expect("mock server should record requests")
125 .into_iter()
126 .filter(|request| request.url.path().ends_with("/chat/completions"))
127 .map(|request| {
128 request
129 .body_json()
130 .expect("chat request body should be JSON")
131 })
132 .collect();
133 world.llm_server = Some(server);
134 }
135
136 #[then("CodeWhale should send the user request to the mocked LLM")]
137 fn codewhale_should_send_user_request_to_mocked_llm(world: &mut ToolLifecycleWorld) {
138 let first_request = world
139 .requests
140 .first()
141 .expect("expected an initial chat request");
142
143 assert!(
144 request_contains_user_text(
145 first_request,
146 world
147 .prompt
148 .as_deref()
149 .expect("scenario prompt should be set")
150 ),
151 "initial request should include the user prompt:\n{first_request:#}"
152 );
153 assert!(
154 !request_contains_tool_result(first_request),
155 "initial request should not include a tool result:\n{first_request:#}"
156 );
157 }
158
159 #[then("the public tool lifecycle should show a running tool:")]
160 fn public_tool_lifecycle_should_show_running_tool(world: &mut ToolLifecycleWorld, step: &Step) {
161 let expected = one_table_row(step);
162 assert_eq!(row_value(&expected, "status"), "running");
163 assert_eq!(row_value(&expected, "marker"), "[~]");
164
165 let event = tool_use_event(world, &row_value(&expected, "tool"));
166 assert_eq!(
167 event.get("input").and_then(|input| input.get("path")),
168 Some(&json!(row_value(&expected, "input")))
169 );
170 assert_expected_action(event, &expected);
171 }
172
173 #[then("the public tool result should return directory entries:")]
174 fn public_tool_result_should_return_directory_entries(world: &mut ToolLifecycleWorld, step: &Step) {
175 let output = tool_result_output(world);
176 let entries: Vec<Value> =
177 serde_json::from_str(output).expect("File.list result should be JSON entries");
178
179 for row in data_table_rows(step) {
180 let expected_name = row_value(&row, "entry");
181 let expected_is_dir = match row_value(&row, "kind").as_str() {
182 "file" => false,
183 "folder" => true,
184 other => panic!("unsupported expected entry kind: {other}"),
185 };
186 assert!(
187 entries.iter().any(|entry| {
188 entry.get("name").and_then(Value::as_str) == Some(expected_name.as_str())
189 && entry.get("is_dir").and_then(Value::as_bool) == Some(expected_is_dir)
190 }),
191 "missing {expected_name} in File.list result:\n{output}"
192 );
193 }
194 }
195
196 #[then("CodeWhale should send the tool result back to the mocked LLM")]
197 fn codewhale_should_send_tool_result_back_to_mocked_llm(world: &mut ToolLifecycleWorld) {
198 let request = world
199 .requests
200 .iter()
201 .find(|request| request_contains_tool_result(request))
202 .expect("expected a follow-up chat request containing the tool result");
203 let tool_result = tool_result_message(request).expect("tool result message");
204 assert_eq!(
205 tool_result
206 .get("tool_call_id")
207 .and_then(serde_json::Value::as_str),
208 Some(TOOL_CALL_ID)
209 );
210
211 let content = tool_result
212 .get("content")
213 .and_then(serde_json::Value::as_str)
214 .expect("tool result content");
215 assert_eq!(
216 content,
217 tool_result_output(world),
218 "follow-up request should preserve the exact public tool result"
219 );
220 }
221
222 #[then(regex = r#"^the public tool result should report an error for "([^"]+)"$"#)]
223 fn public_tool_result_should_report_error_for(world: &mut ToolLifecycleWorld, tool_name: String) {
224 let _ = tool_use_event(world, &tool_name);
225 let event = tool_result_event(world);
226
227 assert_eq!(event.get("status").and_then(Value::as_str), Some("error"));
228 let output = event
229 .get("output")
230 .and_then(Value::as_str)
231 .expect("tool_result error output");
232 assert!(
233 output.contains(&tool_name) && output.contains("not available"),
234 "tool_result error should name the unavailable tool:\n{output}"
235 );
236 }
237
238 #[then("CodeWhale should send the tool error back to the mocked LLM")]
239 fn codewhale_should_send_tool_error_back_to_mocked_llm(world: &mut ToolLifecycleWorld) {
240 let request = world
241 .requests
242 .iter()
243 .find(|request| request_contains_tool_result(request))
244 .expect("expected a follow-up chat request containing the tool error");
245 let tool_result = tool_result_message(request).expect("tool result message");
246 assert_eq!(
247 tool_result
248 .get("tool_call_id")
249 .and_then(serde_json::Value::as_str),
250 Some(TOOL_CALL_ID)
251 );
252
253 let content = tool_result
254 .get("content")
255 .and_then(serde_json::Value::as_str)
256 .expect("tool result content");
257 let tool_name = world.tool_name.as_deref().expect("tool name");
258 assert!(
259 content.contains(tool_name) && content.contains("not available"),
260 "tool error sent to LLM should describe the unavailable tool:\n{content}"
261 );
262 }
263
264 #[then(
265 regex = r#"^the public tool lifecycle should show a running tool with raw input for "([^"]+)"$"#
266 )]
267 fn public_tool_lifecycle_should_show_running_tool_with_raw_input(
268 world: &mut ToolLifecycleWorld,
269 tool_name: String,
270 ) {
271 let event = tool_use_event(world, &tool_name);
272 assert!(
273 value_contains_text(event.get("input").expect("tool_use input"), "{not-json"),
274 "tool_use input should preserve malformed raw arguments:\n{event:#}"
275 );
276 }
277
278 #[then(regex = r#"^the public tool result should report malformed arguments for "([^"]+)"$"#)]
279 fn public_tool_result_should_report_malformed_arguments_for(
280 world: &mut ToolLifecycleWorld,
281 tool_name: String,
282 ) {
283 let _ = tool_use_event(world, &tool_name);
284 let event = tool_result_event(world);
285
286 assert_eq!(event.get("status").and_then(Value::as_str), Some("error"));
287 let output = event
288 .get("output")
289 .and_then(Value::as_str)
290 .expect("tool_result error output");
291 assert_malformed_arguments_text(output);
292 }
293
294 #[then("CodeWhale should send the malformed argument error back to the mocked LLM")]
295 fn codewhale_should_send_malformed_argument_error_back_to_mocked_llm(
296 world: &mut ToolLifecycleWorld,
297 ) {
298 let request = world
299 .requests
300 .iter()
301 .find(|request| request_contains_tool_result(request))
302 .expect("expected a follow-up chat request containing the malformed argument error");
303 let tool_result = tool_result_message(request).expect("tool result message");
304 assert_eq!(
305 tool_result
306 .get("tool_call_id")
307 .and_then(serde_json::Value::as_str),
308 Some(TOOL_CALL_ID)
309 );
310
311 let content = tool_result
312 .get("content")
313 .and_then(serde_json::Value::as_str)
314 .expect("tool result content");
315 assert_malformed_arguments_text(content);
316 }
317
318 #[then(
319 regex = r#"^the public tool result should report a real error for "([^"]+)" containing "([^"]+)"$"#
320 )]
321 fn public_tool_result_should_report_real_error(
322 world: &mut ToolLifecycleWorld,
323 tool_name: String,
324 expected: String,
325 ) {
326 let _ = tool_use_event(world, &tool_name);
327 let event = tool_result_event(world);
328 assert_eq!(event.get("status").and_then(Value::as_str), Some("error"));
329
330 let output = event
331 .get("output")
332 .and_then(Value::as_str)
333 .expect("real tool error output");
334 assert!(
335 output.contains(&expected) && output.contains("Failed to read"),
336 "real {tool_name} failure should preserve the path and execution error:\n{output}"
337 );
338 }
339
340 #[then("CodeWhale should send the real tool error back to the mocked LLM")]
341 fn codewhale_should_send_real_tool_error_back_to_mocked_llm(world: &mut ToolLifecycleWorld) {
342 let request = world
343 .requests
344 .iter()
345 .find(|request| request_contains_tool_result(request))
346 .expect("expected a follow-up chat request containing the real tool error");
347 let content = tool_result_message(request)
348 .and_then(|message| message.get("content"))
349 .and_then(Value::as_str)
350 .expect("real tool error content");
351 assert!(
352 content.contains("missing.txt") && content.contains("Failed to read"),
353 "real tool error sent to the LLM should preserve the execution failure:\n{content}"
354 );
355 }
356
357 #[then("the public tool result should be an empty list")]
358 fn public_tool_result_should_be_an_empty_list(world: &mut ToolLifecycleWorld) {
359 let output = tool_result_output(world);
360 let value: Value = serde_json::from_str(output).expect("empty File.list result should be JSON");
361 assert_eq!(value, json!([]), "empty workspace should return []");
362 assert_eq!(
363 tool_result_event(world)
364 .get("status")
365 .and_then(Value::as_str),
366 Some("success")
367 );
368 }
369
370 #[then("CodeWhale should send the empty tool result back to the mocked LLM")]
371 fn codewhale_should_send_empty_tool_result_back_to_mocked_llm(world: &mut ToolLifecycleWorld) {
372 let request = world
373 .requests
374 .iter()
375 .find(|request| request_contains_tool_result(request))
376 .expect("expected a follow-up chat request containing the empty tool result");
377 let content = tool_result_message(request)
378 .and_then(|message| message.get("content"))
379 .and_then(Value::as_str)
380 .expect("empty tool result content");
381 let value: Value =
382 serde_json::from_str(content).expect("forwarded empty result should be JSON");
383 assert_eq!(value, json!([]), "follow-up request should preserve []");
384 }
385
386 #[then(
387 regex = r#"^the public tool lifecycle should show a failed tool with raw input for "([^"]+)"$"#
388 )]
389 fn public_tool_lifecycle_should_show_failed_tool_with_raw_input(
390 world: &mut ToolLifecycleWorld,
391 tool_name: String,
392 ) {
393 let event = tool_result_event(world);
394 assert_eq!(event.get("status").and_then(Value::as_str), Some("error"));
395
396 let tool_use = tool_use_event(world, &tool_name);
397 assert!(
398 value_contains_text(tool_use.get("input").expect("tool_use input"), "{not-json"),
399 "failed tool_use input should preserve malformed raw arguments:\n{tool_use:#}"
400 );
401 }
402
403 #[then("the public tool lifecycle should show a completed tool:")]
404 fn public_tool_lifecycle_should_show_completed_tool(world: &mut ToolLifecycleWorld, step: &Step) {
405 let expected = one_table_row(step);
406 assert_eq!(row_value(&expected, "status"), "completed");
407 assert_eq!(row_value(&expected, "marker"), "✓");
408
409 let event = tool_result_event(world);
410 assert_eq!(event.get("status").and_then(Value::as_str), Some("success"));
411
412 let tool_use = tool_use_event(world, &row_value(&expected, "tool"));
413 assert_eq!(
414 tool_use.get("input").and_then(|input| input.get("path")),
415 Some(&json!(row_value(&expected, "input")))
416 );
417 assert_expected_action(tool_use, &expected);
418 }
419
420 #[then("the public tool lifecycle should show a failed tool:")]
421 fn public_tool_lifecycle_should_show_failed_tool(world: &mut ToolLifecycleWorld, step: &Step) {
422 let expected = one_table_row(step);
423 assert_eq!(row_value(&expected, "status"), "error");
424 assert_eq!(row_value(&expected, "marker"), "[!]");
425
426 let event = tool_result_event(world);
427 assert_eq!(event.get("status").and_then(Value::as_str), Some("error"));
428
429 let tool_use = tool_use_event(world, &row_value(&expected, "tool"));
430 assert_eq!(
431 tool_use.get("input").and_then(|input| input.get("path")),
432 Some(&json!(row_value(&expected, "input")))
433 );
434 assert_expected_action(tool_use, &expected);
435 }
436
437 #[then(regex = r#"^the public output should include "([^"]+)"$"#)]
438 fn public_output_should_include(world: &mut ToolLifecycleWorld, expected: String) {
439 let content = public_content_output(world);
440 assert!(
441 content.contains(&expected),
442 "public content output should include {expected:?}:\nstdout:\n{}\nstderr:\n{}",
443 world.stdout,
444 world.stderr
445 );
446 }
447
448 #[then(regex = r#"^acceptance should report the missing expected summary "([^"]+)"$"#)]
449 fn acceptance_should_report_missing_expected_summary(
450 world: &mut ToolLifecycleWorld,
451 expected: String,
452 ) {
453 let report = require_follow_up_summary(world, &expected)
454 .expect_err("fixture answer intentionally omits the expected summary");
455 assert!(
456 report.contains(&expected) && report.contains("missing expected summary"),
457 "missing-summary oracle should name the absent contract:\n{report}"
458 );
459 }
460
461 #[tokio::test(flavor = "current_thread")]
462 async fn happy_path_lists_current_directory_through_tool() {
463 run_scenario(HAPPY_PATH_SCENARIO, 10).await;
464 }
465
466 #[tokio::test(flavor = "current_thread")]
467 async fn unknown_tool_returns_error_result() {
468 run_scenario(UNKNOWN_TOOL_SCENARIO, 10).await;
469 }
470
471 #[tokio::test(flavor = "current_thread")]
472 async fn malformed_tool_arguments_return_error_result() {
473 run_scenario(MALFORMED_ARGUMENTS_SCENARIO, 10).await;
474 }
475
476 #[tokio::test(flavor = "current_thread")]
477 async fn real_tool_error_is_returned_to_follow_up_request() {
478 run_scenario(REAL_TOOL_ERROR_SCENARIO, 10).await;
479 }
480
481 #[tokio::test(flavor = "current_thread")]
482 async fn empty_tool_result_is_returned_to_follow_up_request() {
483 run_scenario(EMPTY_TOOL_RESULT_SCENARIO, 10).await;
484 }
485
486 #[tokio::test(flavor = "current_thread")]
487 async fn missing_follow_up_summary_is_detected() {
488 run_scenario(MISSING_SUMMARY_SCENARIO, 11).await;
489 }
490
491 async fn run_scenario(name: &'static str, expected_steps: usize) {
492 let writer = ToolLifecycleWorld::cucumber()
493 .fail_on_skipped()
494 .with_default_cli()
495 .filter_run(FEATURE_PATH, move |feature, _, scenario| {
496 feature.name == FEATURE_NAME && scenario.name == name
497 })
498 .await;
499 assert_eq!(writer.failed_steps(), 0, "scenario failed: {name}");
500 assert_eq!(writer.skipped_steps(), 0, "scenario skipped steps: {name}");
501 assert_eq!(
502 writer.passed_steps(),
503 expected_steps,
504 "scenario did not run: {name}"
505 );
506 }
507
508 async fn start_mock_llm(world: &ToolLifecycleWorld) -> MockServer {
509 let server = MockServer::start().await;
510
511 Mock::given(method("GET"))
512 .and(path("/v1/models"))
513 .respond_with(json_response(json!({
514 "object": "list",
515 "data": [{ "id": TEST_MODEL, "object": "model" }]
516 })))
517 .mount(&server)
518 .await;
519
520 Mock::given(method("POST"))
521 .and(path("/v1/chat/completions"))
522 .and(request_has_tool_result)
523 .respond_with(sse_response(&final_answer_sse(
524 world.final_answer.as_ref().expect("final LLM answer"),
525 )))
526 .mount(&server)
527 .await;
528
529 Mock::given(method("POST"))
530 .and(path("/v1/chat/completions"))
531 .and(request_has_no_tool_result)
532 .respond_with(sse_response(&tool_call_sse(
533 world.tool_name.as_ref().expect("tool name"),
534 world.tool_arguments.as_ref().expect("tool arguments"),
535 )))
536 .mount(&server)
537 .await;
538
539 server
540 }
541
542 fn run_codewhale_exec(
543 world: &ToolLifecycleWorld,
544 server: &MockServer,
545 prompt: &str,
546 ) -> std::process::Output {
547 let workspace = world
548 .workspace
549 .as_ref()
550 .expect("workspace")
551 .path()
552 .to_path_buf();
553 let home = world.home.as_ref().expect("home").path().to_path_buf();
554
555 let mut command = Command::new(codewhale_tui_binary());
556 preserve_host_env(&mut command);
557 command
558 .current_dir(&workspace)
559 .arg("--workspace")
560 .arg(&workspace)
561 .arg("--no-project-config")
562 .arg("exec")
563 .arg("--auto")
564 .arg("--model")
565 .arg(TEST_MODEL)
566 .arg("--output-format")
567 .arg("stream-json")
568 .arg(prompt)
569 .env("HOME", &home)
570 .env("USERPROFILE", &home)
571 .env("XDG_CONFIG_HOME", home.join(".config"))
572 .env("XDG_DATA_HOME", home.join(".local").join("share"))
573 .env("XDG_CACHE_HOME", home.join(".cache"))
574 .env(
575 "CODEWHALE_CONFIG_PATH",
576 home.join(".codewhale").join("config.toml"),
577 )
578 .env(
579 "DEEPSEEK_CONFIG_PATH",
580 home.join(".deepseek").join("config.toml"),
581 )
582 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
583 .env("DEEPSEEK_BASE_URL", server.uri())
584 .env("CODEWHALE_BASE_URL", server.uri())
585 .env("DEEPSEEK_MODEL", TEST_MODEL)
586 .env("CODEWHALE_MODEL", TEST_MODEL)
587 .env("RUST_LOG", "warn")
588 .stdout(Stdio::piped())
589 .stderr(Stdio::piped());
590
591 std::fs::create_dir_all(home.join(".codewhale")).expect("create codewhale home config dir");
592 std::fs::create_dir_all(home.join(".deepseek")).expect("create deepseek home config dir");
593
594 run_with_timeout(command, Duration::from_secs(45))
595 }
596
597 fn run_with_timeout(mut command: Command, timeout: Duration) -> std::process::Output {
598 let mut child = command.spawn().expect("spawn codewhale-tui exec");
599 let stdout_reader = read_pipe_in_background(child.stdout.take().expect("stdout pipe"));
600 let stderr_reader = read_pipe_in_background(child.stderr.take().expect("stderr pipe"));
601
602 let status = match child.wait_timeout(timeout).expect("wait for codewhale-tui") {
603 Some(status) => status,
604 None => {
605 let _ = child.kill();
606 let _ = child.wait();
607 let stdout = join_pipe_reader(stdout_reader, "stdout");
608 let stderr = join_pipe_reader(stderr_reader, "stderr");
609 panic!(
610 "codewhale-tui exec timed out after {timeout:?}\nstdout:\n{}\nstderr:\n{}",
611 String::from_utf8_lossy(&stdout),
612 String::from_utf8_lossy(&stderr)
613 );
614 }
615 };
616
617 let stdout = join_pipe_reader(stdout_reader, "stdout");
618 let stderr = join_pipe_reader(stderr_reader, "stderr");
619
620 std::process::Output {
621 status,
622 stdout,
623 stderr,
624 }
625 }
626
627 fn read_pipe_in_background<R>(mut reader: R) -> std::thread::JoinHandle<std::io::Result<Vec<u8>>>
628 where
629 R: Read + Send + 'static,
630 {
631 std::thread::spawn(move || {
632 let mut output = Vec::new();
633 reader.read_to_end(&mut output).map(|_| output)
634 })
635 }
636
637 fn join_pipe_reader(
638 handle: std::thread::JoinHandle<std::io::Result<Vec<u8>>>,
639 stream_name: &str,
640 ) -> Vec<u8> {
641 handle
642 .join()
643 .unwrap_or_else(|_| panic!("{stream_name} reader thread panicked"))
644 .unwrap_or_else(|err| panic!("read {stream_name}: {err}"))
645 }
646
647 fn preserve_host_env(command: &mut Command) {
648 command.env_clear();
649 for key in [
650 "PATH",
651 "PATHEXT",
652 "SystemRoot",
653 "SystemDrive",
654 "WINDIR",
655 "COMSPEC",
656 "TEMP",
657 "TMP",
658 "TERM",
659 "COLORTERM",
660 "LANG",
661 "LC_ALL",
662 ] {
663 if let Some(value) = std::env::var_os(key) {
664 command.env(key, value);
665 }
666 }
667 }
668
669 fn tool_call_sse(tool_name: &str, arguments: &str) -> String {
670 [
671 sse_chunk(json!({
672 "id": "chatcmpl-tool",
673 "object": "chat.completion.chunk",
674 "model": TEST_MODEL,
675 "choices": [{
676 "index": 0,
677 "delta": {
678 "tool_calls": [{
679 "index": 0,
680 "id": TOOL_CALL_ID,
681 "type": "function",
682 "function": {
683 "name": tool_name,
684 "arguments": arguments
685 }
686 }]
687 },
688 "finish_reason": null
689 }]
690 })),
691 sse_chunk(json!({
692 "id": "chatcmpl-tool",
693 "object": "chat.completion.chunk",
694 "model": TEST_MODEL,
695 "choices": [{
696 "index": 0,
697 "delta": {},
698 "finish_reason": "tool_calls"
699 }],
700 "usage": {
701 "prompt_tokens": 10,
702 "completion_tokens": 2,
703 "total_tokens": 12
704 }
705 })),
706 "data: [DONE]\n\n".to_string(),
707 ]
708 .join("")
709 }
710
711 fn final_answer_sse(answer: &str) -> String {
712 [
713 sse_chunk(json!({
714 "id": "chatcmpl-final",
715 "object": "chat.completion.chunk",
716 "model": TEST_MODEL,
717 "choices": [{
718 "index": 0,
719 "delta": { "content": answer },
720 "finish_reason": null
721 }]
722 })),
723 sse_chunk(json!({
724 "id": "chatcmpl-final",
725 "object": "chat.completion.chunk",
726 "model": TEST_MODEL,
727 "choices": [{
728 "index": 0,
729 "delta": {},
730 "finish_reason": "stop"
731 }],
732 "usage": {
733 "prompt_tokens": 20,
734 "completion_tokens": 8,
735 "total_tokens": 28
736 }
737 })),
738 "data: [DONE]\n\n".to_string(),
739 ]
740 .join("")
741 }
742
743 fn assert_malformed_arguments_text(text: &str) {
744 let lower = text.to_ascii_lowercase();
745 assert!(
746 lower.contains("argument")
747 && (lower.contains("malformed")
748 || lower.contains("parse")
749 || lower.contains("json")
750 || lower.contains("invalid")),
751 "expected malformed argument error text:\n{text}"
752 );
753 }
754
755 fn sse_chunk(value: Value) -> String {
756 format!(
757 "data: {}\n\n",
758 serde_json::to_string(&value).expect("SSE JSON")
759 )
760 }
761
762 fn sse_response(body: &str) -> ResponseTemplate {
763 ResponseTemplate::new(200)
764 .insert_header("content-type", "text/event-stream")
765 .insert_header("cache-control", "no-cache")
766 .set_body_string(body.to_string())
767 }
768
769 fn json_response(value: Value) -> ResponseTemplate {
770 ResponseTemplate::new(200)
771 .insert_header("content-type", "application/json")
772 .set_body_json(value)
773 }
774
775 fn request_has_tool_result(request: &Request) -> bool {
776 request
777 .body_json::<Value>()
778 .is_ok_and(|body| request_contains_tool_result(&body))
779 }
780
781 fn request_has_no_tool_result(request: &Request) -> bool {
782 !request_has_tool_result(request)
783 }
784
785 fn request_contains_tool_result(request: &Value) -> bool {
786 tool_result_message(request).is_some()
787 }
788
789 fn tool_result_message(request: &Value) -> Option<&Value> {
790 request
791 .get("messages")
792 .and_then(Value::as_array)?
793 .iter()
794 .find(|message| message.get("role").and_then(Value::as_str) == Some("tool"))
795 }
796
797 fn request_contains_user_text(request: &Value, expected: &str) -> bool {
798 request
799 .get("messages")
800 .and_then(Value::as_array)
801 .into_iter()
802 .flatten()
803 .any(|message| {
804 message.get("role").and_then(Value::as_str) == Some("user")
805 && message
806 .get("content")
807 .is_some_and(|content| value_contains_text(content, expected))
808 })
809 }
810
811 fn value_contains_text(value: &Value, expected: &str) -> bool {
812 match value {
813 Value::String(text) => text.contains(expected),
814 Value::Array(values) => values
815 .iter()
816 .any(|value| value_contains_text(value, expected)),
817 Value::Object(values) => values
818 .values()
819 .any(|value| value_contains_text(value, expected)),
820 _ => false,
821 }
822 }
823
824 fn public_content_output(world: &ToolLifecycleWorld) -> String {
825 world
826 .events
827 .iter()
828 .filter(|event| event.get("type").and_then(Value::as_str) == Some("content"))
829 .filter_map(|event| event.get("content").and_then(Value::as_str))
830 .collect()
831 }
832
833 fn require_follow_up_summary(world: &ToolLifecycleWorld, expected: &str) -> Result<(), String> {
834 let content = public_content_output(world);
835 if content.contains(expected) {
836 Ok(())
837 } else {
838 Err(format!(
839 "missing expected summary {expected:?} in follow-up answer {content:?}"
840 ))
841 }
842 }
843
844 fn parse_stream_events(stdout: &str) -> Vec<Value> {
845 stdout
846 .lines()
847 .filter(|line| !line.trim().is_empty())
848 .filter_map(|line| {
849 let json_start = line.find('{')?;
850 let json_line = &line[json_start..];
851 Some(serde_json::from_str(json_line).unwrap_or_else(|err| {
852 panic!(
853 "stream-json line should parse: {err}\nline: {line}\njson: {json_line}\nstdout:\n{stdout}"
854 )
855 }))
856 })
857 .collect()
858 }
859
860 fn tool_use_event<'a>(world: &'a ToolLifecycleWorld, expected_tool: &str) -> &'a Value {
861 world
862 .events
863 .iter()
864 .find(|event| {
865 event.get("type").and_then(Value::as_str) == Some("tool_use")
866 && event.get("name").and_then(Value::as_str) == Some(expected_tool)
867 })
868 .unwrap_or_else(|| {
869 panic!(
870 "expected tool_use event for {expected_tool}\nstdout:\n{}\nstderr:\n{}",
871 world.stdout, world.stderr
872 )
873 })
874 }
875
876 fn tool_result_event(world: &ToolLifecycleWorld) -> &Value {
877 world
878 .events
879 .iter()
880 .find(|event| event.get("type").and_then(Value::as_str) == Some("tool_result"))
881 .unwrap_or_else(|| {
882 panic!(
883 "expected tool_result event\nstdout:\n{}\nstderr:\n{}",
884 world.stdout, world.stderr
885 )
886 })
887 }
888
889 fn tool_result_output(world: &ToolLifecycleWorld) -> &str {
890 tool_result_event(world)
891 .get("output")
892 .and_then(Value::as_str)
893 .expect("tool_result output")
894 }
895
896 fn one_table_row(step: &Step) -> Vec<(String, String)> {
897 let rows = data_table_rows(step);
898 assert_eq!(rows.len(), 1, "expected exactly one data table row");
899 rows.into_iter().next().expect("one row")
900 }
901
902 fn data_table_rows(step: &Step) -> Vec<Vec<(String, String)>> {
903 let table = step
904 .table
905 .as_ref()
906 .expect("step should include a data table");
907 let mut rows = table.rows.iter();
908 let headers = rows
909 .next()
910 .expect("data table should include a header")
911 .clone();
912
913 let values: Vec<Vec<(String, String)>> = rows
914 .map(|row| {
915 headers
916 .iter()
917 .zip(row.iter())
918 .map(|(header, value)| (header.clone(), value.clone()))
919 .collect()
920 })
921 .collect();
922 assert!(
923 !values.is_empty(),
924 "data table should include at least one row"
925 );
926 values
927 }
928
929 fn row_value(row: &[(String, String)], header: &str) -> String {
930 row.iter()
931 .find_map(|(key, value)| (key == header).then(|| value.clone()))
932 .unwrap_or_else(|| panic!("data table row missing {header} value"))
933 }
934
935 fn assert_expected_action(event: &Value, expected: &[(String, String)]) {
936 let Some(action) = expected
937 .iter()
938 .find_map(|(key, value)| (key == "action").then_some(value))
939 else {
940 return;
941 };
942 assert_eq!(
943 event.get("input").and_then(|input| input.get("action")),
944 Some(&json!(action)),
945 "canonical tool action should be visible in the lifecycle event"
946 );
947 }
948
949 fn codewhale_tui_binary() -> PathBuf {
950 if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale-tui") {
951 return PathBuf::from(path);
952 }
953 if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale-tui") {
954 return PathBuf::from(path);
955 }
956
957 let mut path = std::env::current_exe().expect("current test executable path");
958 path.pop();
959 if path.ends_with("deps") {
960 path.pop();
961 }
962 path.push(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
963 path
964 }
965
965 lines RUST