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