1use std::path::PathBuf;
2use std::time::{Duration, Instant};
3
4use serde_json::{json, Value};
5
6use crate::context::AppContext;
7use crate::protocol::{RawRequest, Response};
8
9pub type DispatchFn<'a> = dyn Fn(RawRequest, &AppContext) -> Response + 'a;
10pub type FinalizeFn<'a> = dyn Fn(&mut Response) + 'a;
11
12#[derive(Debug)]
18pub struct PhaseTrace {
19 frame_decoded: Instant,
20 executor_submitted: Option<Instant>,
21 job_admitted: Option<Instant>,
22 translate_done: Option<Instant>,
23 execute_done: Option<Instant>,
24 format_done: Option<Instant>,
25 finalize_done: Option<Instant>,
26 waiting_on: WaitingOn,
27 waiting_on_build_id: Option<String>,
28 wait_ms: u64,
29}
30
31#[derive(Debug, Clone, Copy)]
32pub struct ToolCallEgressTiming {
33 pub enqueued: Instant,
34 pub dequeued: Instant,
35 pub write_started: Instant,
36 pub write_finished: Instant,
37 pub frame_bytes: usize,
38 pub queue_depth: usize,
39 pub writer_active_at_enqueue: bool,
40 pub writer_queue_was_full: bool,
41 pub reserve_timeouts: u32,
42}
43
44#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
46pub enum WaitingOn {
47 #[default]
48 None,
49 Build,
50 Limiter,
51 ArtifactLoad,
52 Resolver,
53}
54
55impl WaitingOn {
56 pub fn as_str(self) -> &'static str {
57 match self {
58 Self::None => "none",
59 Self::Build => "build",
60 Self::Limiter => "limiter",
61 Self::ArtifactLoad => "artifact_load",
62 Self::Resolver => "resolver",
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
68pub struct ToolCallPhaseDurations {
69 pub queue: Duration,
70 pub translate: Duration,
71 pub execute: Duration,
72 pub format: Duration,
73 pub finalize: Duration,
74 pub egress_enqueue: Duration,
75 pub egress_queue: Duration,
76 pub egress_prepare: Duration,
77 pub egress_write: Duration,
78 pub egress: Duration,
79 pub frame_bytes: usize,
80 pub writer_queue_depth: usize,
81 pub writer_active_at_enqueue: bool,
82 pub writer_queue_was_full: bool,
83 pub writer_reserve_timeouts: u32,
84 pub total: Duration,
85 pub waiting_on: WaitingOn,
86 pub waiting_on_build_id: Option<String>,
87 pub wait_ms: u64,
88}
89
90impl PhaseTrace {
91 pub fn new(frame_decoded: Instant) -> Self {
92 Self {
93 frame_decoded,
94 executor_submitted: None,
95 job_admitted: None,
96 translate_done: None,
97 execute_done: None,
98 format_done: None,
99 finalize_done: None,
100 waiting_on: WaitingOn::None,
101 waiting_on_build_id: None,
102 wait_ms: 0,
103 }
104 }
105
106 pub fn mark_executor_submitted(&mut self) {
107 self.executor_submitted = Some(Instant::now());
108 }
109
110 pub fn mark_job_admitted(&mut self) {
111 crate::logging::reset_tool_call_wait();
112 let now = Instant::now();
113 self.job_admitted = Some(now);
114 if let Some(submitted) = self.executor_submitted {
115 crate::logging::note_tool_call_queue_ms(
116 now.duration_since(submitted)
117 .as_millis()
118 .min(u64::MAX as u128) as u64,
119 );
120 }
121 }
122
123 fn mark_translate_done(&mut self) {
124 self.translate_done = Some(Instant::now());
125 }
126
127 pub(crate) fn mark_execute_done(&mut self) {
128 self.execute_done = Some(Instant::now());
129 let (waiting_on, waiting_on_build_id, wait_ms) = crate::logging::take_tool_call_wait();
130 self.waiting_on = waiting_on;
131 self.waiting_on_build_id = waiting_on_build_id;
132 self.wait_ms = wait_ms;
133 }
134
135 fn mark_format_done(&mut self) {
136 self.format_done = Some(Instant::now());
137 }
138
139 fn mark_finalize_done(&mut self) {
140 self.finalize_done = Some(Instant::now());
141 }
142
143 pub fn finish(self, egress: ToolCallEgressTiming) -> Option<ToolCallPhaseDurations> {
144 let executor_submitted = self.executor_submitted?;
145 let job_admitted = self.job_admitted?;
146 let translate_done = self.translate_done?;
147 let execute_done = self.execute_done?;
148 let format_done = self.format_done?;
149 let finalize_done = self.finalize_done?;
150 Some(ToolCallPhaseDurations {
151 queue: job_admitted.duration_since(executor_submitted),
152 translate: translate_done.duration_since(job_admitted),
153 execute: execute_done.duration_since(translate_done),
154 format: format_done.duration_since(execute_done),
155 finalize: finalize_done.duration_since(format_done),
156 egress_enqueue: egress.enqueued.duration_since(finalize_done),
157 egress_queue: egress.dequeued.duration_since(egress.enqueued),
158 egress_prepare: egress.write_started.duration_since(egress.dequeued),
159 egress_write: egress.write_finished.duration_since(egress.write_started),
160 egress: egress.write_finished.duration_since(finalize_done),
161 frame_bytes: egress.frame_bytes,
162 writer_queue_depth: egress.queue_depth,
163 writer_active_at_enqueue: egress.writer_active_at_enqueue,
164 writer_queue_was_full: egress.writer_queue_was_full,
165 writer_reserve_timeouts: egress.reserve_timeouts,
166 total: egress.write_finished.duration_since(self.frame_decoded),
167 waiting_on: self.waiting_on,
168 waiting_on_build_id: self.waiting_on_build_id,
169 wait_ms: self.wait_ms,
170 })
171 }
172}
173
174#[derive(Debug)]
179pub struct ToolCallResult {
180 pub text: String,
181 pub response: crate::protocol::Response,
182}
183
184#[derive(Debug)]
187pub enum ToolCallOutcome {
188 Unary(ToolCallResult),
189}
190
191#[derive(Debug, Clone)]
194pub struct ToolCallContext {
195 pub project_root: PathBuf,
196 pub session_id: Option<String>,
197 pub request_id: String,
198 pub diagnostics_on_edit: bool,
199 pub preview: bool,
200 pub edit_slot_survives: Option<bool>,
203 pub report_registration_downgrade: bool,
206}
207
208pub(crate) fn ensure_hashline_registration(
209 app_ctx: &AppContext,
210 project_root: &std::path::Path,
211 session: &str,
212 edit_slot_survives: Option<bool>,
213 report_registration_downgrade: bool,
214) -> bool {
215 let binding_root = app_ctx
216 .canonical_cache_root_opt()
217 .unwrap_or_else(|| project_root.to_path_buf());
218 let edit_slot_survives = match edit_slot_survives {
219 Some(value) => value,
220 None if app_ctx.harness_opt().is_some_and(|harness| {
221 matches!(
222 harness,
223 crate::harness::Harness::Opencode | crate::harness::Harness::Pi
224 )
225 }) && app_ctx
226 .hashline_bindings()
227 .peek(&binding_root, session)
228 .is_none() =>
229 {
230 false
231 }
232 None => return false,
233 };
234 let registration = app_ctx.hashline_bindings().register(
235 &binding_root,
236 session.to_string(),
237 crate::hashline::integration::RegistrationRequest {
238 configured_enabled: app_ctx.config().hashline_enabled,
239 edit_slot_survives,
240 },
241 );
242 report_registration_downgrade
243 && registration.downgrade.is_some()
244 && !registration.stores_preserved
245}
246
247fn attach_hashline_downgrade(response: &mut Response) {
248 let warning = crate::commands::configure::hashline_downgrade_warning();
249 if let Some(data) = response.data.as_object_mut() {
250 match data.get_mut("warnings").and_then(Value::as_array_mut) {
251 Some(warnings) => warnings.push(warning),
252 None => {
253 data.insert("warnings".to_string(), json!([warning]));
254 }
255 }
256 }
257}
258
259fn append_hashline_downgrade_text(text: &mut String) {
260 text.push_str("\n\n");
261 text.push_str(crate::commands::configure::HASHLINE_DOWNGRADE_MESSAGE);
262}
263
264pub(crate) struct PreparedToolCall {
265 pub(crate) request: RawRequest,
266 pub(crate) surface_downgraded: bool,
267}
268
269pub(crate) fn prepare_tool_call(
270 bare_name: &str,
271 args: Value,
272 format_context: &crate::subc_format::FormatContext,
273 ctx: &ToolCallContext,
274 app_ctx: &AppContext,
275 mut phase_trace: Option<&mut PhaseTrace>,
276) -> Result<PreparedToolCall, ToolCallResult> {
277 let sanitized_args = strip_agent_preview_arg_owned(args);
278 let binding_root = app_ctx
279 .canonical_cache_root_opt()
280 .unwrap_or_else(|| ctx.project_root.clone());
281 let session = ctx
282 .session_id
283 .as_deref()
284 .unwrap_or(crate::protocol::DEFAULT_SESSION_ID);
285 let surface_downgraded = ensure_hashline_registration(
286 app_ctx,
287 &ctx.project_root,
288 session,
289 ctx.edit_slot_survives,
290 ctx.report_registration_downgrade,
291 );
292 let binding_guard = app_ctx.hashline_bindings().capture(binding_root, session);
293 let translate_context = crate::subc_translate::TranslateContext {
294 diagnostics_on_edit: ctx.diagnostics_on_edit,
295 preview: ctx.preview,
296 effective_hashline: crate::hashline::integration::effective_for_capture(
297 binding_guard.as_ref(),
298 ),
299 };
300 let (command, translated_args) = if crate::subc_translate::supports_tool(bare_name) {
301 match crate::subc_translate::subc_translate_owned_with_context(
302 bare_name,
303 sanitized_args,
304 ctx.project_root.as_path(),
305 translate_context,
306 ) {
307 Ok(translated) => (translated.command, translated.args),
308 Err(err) => {
309 if let Some(trace) = phase_trace.as_mut() {
310 trace.mark_translate_done();
311 trace.mark_execute_done();
312 }
313 let response = Response::error(ctx.request_id.clone(), err.code, err.message);
314 let result = tool_call_result_from_response(
315 bare_name,
316 format_context,
317 response,
318 surface_downgraded,
319 );
320 if let Some(trace) = phase_trace.as_mut() {
321 trace.mark_format_done();
322 trace.mark_finalize_done();
323 }
324 return Err(result);
325 }
326 }
327 } else {
328 let map = match sanitized_args {
329 Value::Object(map) => map,
330 _ => serde_json::Map::new(),
331 };
332 (bare_name.to_string(), map)
333 };
334
335 let request = match raw_request_from_translated(command, translated_args, ctx) {
336 Ok(req) => req,
337 Err(error) => {
338 if let Some(trace) = phase_trace.as_mut() {
339 trace.mark_translate_done();
340 trace.mark_execute_done();
341 }
342 let response = Response::error(
343 ctx.request_id.clone(),
344 "invalid_request",
345 format!("failed to build request from tool call: {error}"),
346 );
347 let result = tool_call_result_from_response(
348 bare_name,
349 format_context,
350 response,
351 surface_downgraded,
352 );
353 if let Some(trace) = phase_trace.as_mut() {
354 trace.mark_format_done();
355 trace.mark_finalize_done();
356 }
357 return Err(result);
358 }
359 };
360 if let Some(trace) = phase_trace.as_mut() {
361 trace.mark_translate_done();
362 }
363
364 Ok(PreparedToolCall {
365 request,
366 surface_downgraded,
367 })
368}
369
370pub(crate) fn finish_tool_call_response(
371 bare_name: &str,
372 format_context: &crate::subc_format::FormatContext,
373 mut response: Response,
374 surface_downgraded: bool,
375 finalizer: Option<&FinalizeFn<'_>>,
376 mut phase_trace: Option<&mut PhaseTrace>,
377) -> ToolCallResult {
378 if surface_downgraded {
379 attach_hashline_downgrade(&mut response);
380 }
381 let mut text =
382 crate::subc_format::format_response_with_context(bare_name, &response, format_context);
383 if surface_downgraded {
384 append_hashline_downgrade_text(&mut text);
385 }
386 if let Some(trace) = phase_trace.as_mut() {
387 trace.mark_format_done();
388 }
389 if let Some(finalizer) = finalizer {
390 finalizer(&mut response);
391 }
392 if let Some(trace) = phase_trace.as_mut() {
393 trace.mark_finalize_done();
394 }
395 ToolCallResult { text, response }
396}
397
398pub fn run_tool_call(
399 bare_name: &str,
400 args: Value,
401 format_context: &crate::subc_format::FormatContext,
402 ctx: &ToolCallContext,
403 app_ctx: &AppContext,
404 dispatch: &DispatchFn<'_>,
405 finalizer: Option<&FinalizeFn<'_>>,
406 mut phase_trace: Option<&mut PhaseTrace>,
407) -> ToolCallOutcome {
408 let prepared = match prepare_tool_call(
409 bare_name,
410 args,
411 format_context,
412 ctx,
413 app_ctx,
414 phase_trace.as_deref_mut(),
415 ) {
416 Ok(prepared) => prepared,
417 Err(result) => return ToolCallOutcome::Unary(result),
418 };
419
420 let response = if prepared.request.command == "inspect" {
421 crate::commands::inspect::handle_inspect_tool_call(&prepared.request, app_ctx)
422 } else {
423 dispatch(prepared.request, app_ctx)
424 };
425 if let Some(trace) = phase_trace.as_mut() {
426 trace.mark_execute_done();
427 }
428 let result = finish_tool_call_response(
429 bare_name,
430 format_context,
431 response,
432 prepared.surface_downgraded,
433 finalizer,
434 phase_trace,
435 );
436 ToolCallOutcome::Unary(result)
437}
438
439fn raw_request_from_translated(
440 command: String,
441 mut params: serde_json::Map<String, Value>,
442 ctx: &ToolCallContext,
443) -> Result<RawRequest, &'static str> {
444 if params.contains_key("method") {
445 return Err("duplicate field `command`");
446 }
447
448 if ctx.preview {
449 params.insert("preview".to_string(), json!(true));
450 }
451
452 params.remove("id");
453 if command != "bash" {
454 params.remove("command");
455 }
456 params.remove("session_id");
457 let lsp_hints = params.remove("lsp_hints").filter(|value| !value.is_null());
458
459 Ok(RawRequest {
460 id: ctx.request_id.clone(),
461 command,
462 lsp_hints,
463 session_id: ctx.session_id.clone(),
464 params: Value::Object(params),
465 })
466}
467
468pub(crate) fn strip_agent_preview_arg_owned(mut args: Value) -> Value {
469 if let Some(map) = args.as_object_mut() {
470 map.remove("preview");
471 }
472 args
473}
474
475fn tool_call_result_from_response(
476 bare_name: &str,
477 format_context: &crate::subc_format::FormatContext,
478 mut response: Response,
479 surface_downgraded: bool,
480) -> ToolCallResult {
481 if surface_downgraded {
482 attach_hashline_downgrade(&mut response);
483 }
484 let mut text =
485 crate::subc_format::format_response_with_context(bare_name, &response, format_context);
486 if surface_downgraded {
487 append_hashline_downgrade_text(&mut text);
488 }
489 ToolCallResult { text, response }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn phase_trace_reports_execution_and_writer_egress_subphases() {
498 let t0 = Instant::now();
499 let trace = PhaseTrace {
500 frame_decoded: t0,
501 executor_submitted: Some(t0 + Duration::from_millis(1)),
502 job_admitted: Some(t0 + Duration::from_millis(3)),
503 translate_done: Some(t0 + Duration::from_millis(6)),
504 execute_done: Some(t0 + Duration::from_millis(10)),
505 format_done: Some(t0 + Duration::from_millis(15)),
506 finalize_done: Some(t0 + Duration::from_millis(21)),
507 waiting_on: WaitingOn::None,
508 waiting_on_build_id: None,
509 wait_ms: 0,
510 };
511
512 let phases = trace
513 .finish(ToolCallEgressTiming {
514 enqueued: t0 + Duration::from_millis(28),
515 dequeued: t0 + Duration::from_millis(35),
516 write_started: t0 + Duration::from_millis(37),
517 write_finished: t0 + Duration::from_millis(48),
518 frame_bytes: 262_144,
519 queue_depth: 17,
520 writer_active_at_enqueue: true,
521 writer_queue_was_full: true,
522 reserve_timeouts: 2,
523 })
524 .unwrap();
525
526 assert_eq!(phases.queue, Duration::from_millis(2));
527 assert_eq!(phases.translate, Duration::from_millis(3));
528 assert_eq!(phases.execute, Duration::from_millis(4));
529 assert_eq!(phases.format, Duration::from_millis(5));
530 assert_eq!(phases.finalize, Duration::from_millis(6));
531 assert_eq!(phases.egress_enqueue, Duration::from_millis(7));
532 assert_eq!(phases.egress_queue, Duration::from_millis(7));
533 assert_eq!(phases.egress_prepare, Duration::from_millis(2));
534 assert_eq!(phases.egress_write, Duration::from_millis(11));
535 assert_eq!(phases.egress, Duration::from_millis(27));
536 assert_eq!(phases.frame_bytes, 262_144);
537 assert_eq!(phases.writer_queue_depth, 17);
538 assert!(phases.writer_active_at_enqueue);
539 assert!(phases.writer_queue_was_full);
540 assert_eq!(phases.writer_reserve_timeouts, 2);
541 assert_eq!(phases.total, Duration::from_millis(48));
542 }
543
544 #[test]
545 fn phase_trace_carries_wait_harvested_on_worker_to_finish_on_other_thread() {
546 let mut trace = PhaseTrace::new(Instant::now());
547 trace.mark_executor_submitted();
548 let worker = std::thread::spawn(move || {
549 trace.mark_job_admitted();
550 crate::logging::note_tool_call_wait(WaitingOn::Limiter, Some("b-1-1"), 42);
551 trace.mark_translate_done();
552 trace.mark_execute_done();
553 trace.mark_format_done();
554 trace.mark_finalize_done();
555 trace
556 });
557 let trace = worker.join().expect("worker");
558 let phases = std::thread::spawn(move || {
559 let now = Instant::now();
560 trace.finish(ToolCallEgressTiming {
561 enqueued: now,
562 dequeued: now,
563 write_started: now,
564 write_finished: now,
565 frame_bytes: 0,
566 queue_depth: 0,
567 writer_active_at_enqueue: false,
568 writer_queue_was_full: false,
569 reserve_timeouts: 0,
570 })
571 })
572 .join()
573 .expect("writer")
574 .expect("complete phase trace");
575 assert_eq!(phases.waiting_on, WaitingOn::Limiter);
576 assert_eq!(phases.waiting_on_build_id.as_deref(), Some("b-1-1"));
577 assert_eq!(phases.wait_ms, 42);
578 }
579
580 mod raw_request_construction {
581 use std::hint::black_box;
582
583 use super::*;
584 use crate::test_allocations::count as count_allocations;
585
586 fn context(preview: bool) -> ToolCallContext {
587 ToolCallContext {
588 project_root: PathBuf::from("/workspace"),
589 session_id: Some("session-realistic".to_string()),
590 request_id: "subc-7-42".to_string(),
591 diagnostics_on_edit: true,
592 preview,
593 edit_slot_survives: None,
594 report_registration_downgrade: false,
595 }
596 }
597
598 fn object(value: Value) -> serde_json::Map<String, Value> {
599 value.as_object().cloned().expect("test input is an object")
600 }
601
602 fn legacy_raw_request(
603 command: String,
604 mut params: serde_json::Map<String, Value>,
605 ctx: &ToolCallContext,
606 ) -> Result<RawRequest, String> {
607 if ctx.preview {
608 params.insert("preview".to_string(), json!(true));
609 }
610 params.insert("id".to_string(), json!(ctx.request_id.clone()));
611 params.insert("command".to_string(), json!(command));
612 params.insert("session_id".to_string(), json!(ctx.session_id.clone()));
613 serde_json::from_value(Value::Object(params)).map_err(|error| error.to_string())
614 }
615
616 fn dispatch_result_bytes(request: RawRequest) -> Vec<u8> {
617 let response = Response::success(
618 request.id.clone(),
619 json!({
620 "received_command": request.command,
621 "received_lsp_hints": request.lsp_hints,
622 "received_session_id": request.session_id,
623 "received_params": request.params,
624 }),
625 );
626 serde_json::to_vec(&response).expect("serialize recording dispatch response")
627 }
628
629 #[test]
630 fn direct_raw_request_construction_avoids_flatten_rematerialization() {
631 let direct_params = object(json!({
632 "file": "/workspace/src/main.rs",
633 "start_line": 150,
634 "end_line": 229,
635 }));
636 let legacy_params = direct_params.clone();
637 let ctx = context(false);
638 let direct_command = "read".to_string();
639 let legacy_command = direct_command.clone();
640
641 let (direct, direct_allocations) = count_allocations(|| {
642 raw_request_from_translated(direct_command, direct_params, &ctx)
643 .expect("direct request")
644 });
645 let (legacy, legacy_allocations) = count_allocations(|| {
646 legacy_raw_request(legacy_command, legacy_params, &ctx).expect("legacy request")
647 });
648 black_box((&direct, &legacy));
649
650 assert_eq!(direct_allocations, 2);
651 assert!(
652 legacy_allocations >= 20,
653 "legacy flatten path unexpectedly used only {legacy_allocations} allocations"
654 );
655 assert!(
656 legacy_allocations >= direct_allocations + 18,
657 "direct={direct_allocations}, legacy={legacy_allocations}"
658 );
659 }
660
661 #[test]
662 fn direct_raw_request_matches_legacy_dispatch_bytes() {
663 let edits = (0..100)
664 .map(|index| {
665 json!({
666 "match": format!("old declaration {index}"),
667 "replacement": format!("new declaration {index}"),
668 "replace_all": false,
669 })
670 })
671 .collect::<Vec<_>>();
672 let cases = [
673 (
674 "read",
675 "read",
676 object(json!({
677 "file": "/workspace/src/main.rs",
678 "start_line": 1,
679 "end_line": 80,
680 })),
681 false,
682 ),
683 (
684 "write",
685 "write",
686 object(json!({
687 "file": "/workspace/src/new.rs",
688 "content": "fn created() {}\n",
689 "create_dirs": true,
690 })),
691 false,
692 ),
693 (
694 "batch-edit-100",
695 "batch",
696 object(json!({
697 "file": "/workspace/src/large.rs",
698 "edits": edits,
699 })),
700 false,
701 ),
702 (
703 "preview",
704 "read",
705 object(json!({"file": "/workspace/src/main.rs"})),
706 true,
707 ),
708 (
709 "lsp-hints",
710 "move_symbol",
711 object(json!({
712 "file": "/workspace/src/main.rs",
713 "symbol": "run",
714 "destination": "/workspace/src/moved.rs",
715 "lsp_hints": {
716 "symbols": [{
717 "name": "run",
718 "file": "/workspace/src/main.rs",
719 "line": 12,
720 "kind": "function",
721 }],
722 },
723 })),
724 false,
725 ),
726 (
727 "null-lsp-hints",
728 "move_symbol",
729 object(json!({
730 "file": "/workspace/src/main.rs",
731 "symbol": "run",
732 "destination": "/workspace/src/moved.rs",
733 "lsp_hints": null,
734 })),
735 false,
736 ),
737 ];
738
739 for (label, command, params, preview) in cases {
740 let ctx = context(preview);
741 let direct = raw_request_from_translated(command.to_string(), params.clone(), &ctx)
742 .expect("direct request");
743 let legacy =
744 legacy_raw_request(command.to_string(), params, &ctx).expect("legacy request");
745
746 assert_eq!(
747 dispatch_result_bytes(direct),
748 dispatch_result_bytes(legacy),
749 "recording dispatch response differed for {label}"
750 );
751 }
752 }
753
754 #[test]
755 fn direct_raw_request_preserves_method_alias_rejection() {
756 let params = object(json!({"method": "agent-supplied-command"}));
757 let ctx = context(false);
758 let direct_error =
759 raw_request_from_translated("read".to_string(), params.clone(), &ctx)
760 .expect_err("method alias must conflict with server-owned command");
761 let legacy_error = legacy_raw_request("read".to_string(), params, &ctx)
762 .expect_err("legacy path rejects the duplicate alias");
763
764 assert_eq!(direct_error, legacy_error);
765 }
766
767 #[test]
768 fn direct_raw_request_preserves_bash_command_param() {
769 let params = object(json!({
770 "command": "echo standalone-tool-call-ok",
771 "background": false,
772 }));
773 let ctx = context(false);
774 let request = raw_request_from_translated("bash".to_string(), params, &ctx)
775 .expect("bash request");
776
777 assert_eq!(request.command, "bash");
778 assert_eq!(
779 request.params.get("command").and_then(Value::as_str),
780 Some("echo standalone-tool-call-ok")
781 );
782 }
783
784 #[test]
785 fn direct_raw_request_still_strips_command_param_for_non_bash_tools() {
786 let params = object(json!({"command": "agent-supplied-command"}));
787 let ctx = context(false);
788 let request = raw_request_from_translated("read".to_string(), params, &ctx)
789 .expect("read request");
790
791 assert_eq!(request.command, "read");
792 assert!(request.params.get("command").is_none());
793 }
794 }
795}