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 config = app_ctx.config();
238 let registration = app_ctx.hashline_bindings().register(
239 &binding_root,
240 session.to_string(),
241 crate::hashline::integration::RegistrationRequest {
242 configured_enabled: config.hashline_enabled,
243 edit_slot_survives,
244 read_slot_survives: config.read_slot_survives(),
245 },
246 );
247 report_registration_downgrade
248 && registration.downgrade.is_some()
249 && !registration.stores_preserved
250}
251
252fn attach_hashline_downgrade(response: &mut Response) {
253 let warning = crate::commands::configure::hashline_downgrade_warning();
254 if let Some(data) = response.data.as_object_mut() {
255 match data.get_mut("warnings").and_then(Value::as_array_mut) {
256 Some(warnings) => warnings.push(warning),
257 None => {
258 data.insert("warnings".to_string(), json!([warning]));
259 }
260 }
261 }
262}
263
264fn append_hashline_downgrade_text(text: &mut String) {
265 text.push_str("\n\n");
266 text.push_str(crate::commands::configure::HASHLINE_DOWNGRADE_MESSAGE);
267}
268
269pub(crate) struct PreparedToolCall {
270 pub(crate) request: RawRequest,
271 pub(crate) surface_downgraded: bool,
272}
273
274pub(crate) fn prepare_tool_call(
275 bare_name: &str,
276 args: Value,
277 format_context: &crate::subc_format::FormatContext,
278 ctx: &ToolCallContext,
279 app_ctx: &AppContext,
280 mut phase_trace: Option<&mut PhaseTrace>,
281) -> Result<PreparedToolCall, ToolCallResult> {
282 let sanitized_args = strip_agent_preview_arg_owned(args);
283 let binding_root = app_ctx
284 .canonical_cache_root_opt()
285 .unwrap_or_else(|| ctx.project_root.clone());
286 let session = ctx
287 .session_id
288 .as_deref()
289 .unwrap_or(crate::protocol::DEFAULT_SESSION_ID);
290 let surface_downgraded = ensure_hashline_registration(
291 app_ctx,
292 &ctx.project_root,
293 session,
294 ctx.edit_slot_survives,
295 ctx.report_registration_downgrade,
296 );
297 let binding_guard = app_ctx.hashline_bindings().capture(binding_root, session);
298 let translate_context = crate::subc_translate::TranslateContext {
299 diagnostics_on_edit: ctx.diagnostics_on_edit,
300 preview: ctx.preview,
301 effective_hashline: crate::hashline::integration::effective_for_capture(
302 binding_guard.as_ref(),
303 ),
304 };
305 let (command, translated_args) = if crate::subc_translate::supports_tool(bare_name) {
306 match crate::subc_translate::subc_translate_owned_with_context(
307 bare_name,
308 sanitized_args,
309 ctx.project_root.as_path(),
310 translate_context,
311 ) {
312 Ok(translated) => (translated.command, translated.args),
313 Err(err) => {
314 if let Some(trace) = phase_trace.as_mut() {
315 trace.mark_translate_done();
316 trace.mark_execute_done();
317 }
318 let response = Response::error(ctx.request_id.clone(), err.code, err.message);
319 let result = tool_call_result_from_response(
320 bare_name,
321 format_context,
322 response,
323 surface_downgraded,
324 );
325 if let Some(trace) = phase_trace.as_mut() {
326 trace.mark_format_done();
327 trace.mark_finalize_done();
328 }
329 return Err(result);
330 }
331 }
332 } else {
333 let map = match sanitized_args {
334 Value::Object(map) => map,
335 _ => serde_json::Map::new(),
336 };
337 (bare_name.to_string(), map)
338 };
339
340 let request = match raw_request_from_translated(command, translated_args, ctx) {
341 Ok(req) => req,
342 Err(error) => {
343 if let Some(trace) = phase_trace.as_mut() {
344 trace.mark_translate_done();
345 trace.mark_execute_done();
346 }
347 let response = Response::error(
348 ctx.request_id.clone(),
349 "invalid_request",
350 format!("failed to build request from tool call: {error}"),
351 );
352 let result = tool_call_result_from_response(
353 bare_name,
354 format_context,
355 response,
356 surface_downgraded,
357 );
358 if let Some(trace) = phase_trace.as_mut() {
359 trace.mark_format_done();
360 trace.mark_finalize_done();
361 }
362 return Err(result);
363 }
364 };
365 if let Some(trace) = phase_trace.as_mut() {
366 trace.mark_translate_done();
367 }
368
369 Ok(PreparedToolCall {
370 request,
371 surface_downgraded,
372 })
373}
374
375pub(crate) fn finish_tool_call_response(
376 bare_name: &str,
377 format_context: &crate::subc_format::FormatContext,
378 mut response: Response,
379 surface_downgraded: bool,
380 finalizer: Option<&FinalizeFn<'_>>,
381 mut phase_trace: Option<&mut PhaseTrace>,
382) -> ToolCallResult {
383 if surface_downgraded {
384 attach_hashline_downgrade(&mut response);
385 }
386 let mut text =
387 crate::subc_format::format_response_with_context(bare_name, &response, format_context);
388 if surface_downgraded {
389 append_hashline_downgrade_text(&mut text);
390 }
391 if let Some(trace) = phase_trace.as_mut() {
392 trace.mark_format_done();
393 }
394 if let Some(finalizer) = finalizer {
395 finalizer(&mut response);
396 }
397 if let Some(trace) = phase_trace.as_mut() {
398 trace.mark_finalize_done();
399 }
400 ToolCallResult { text, response }
401}
402
403pub fn run_tool_call(
404 bare_name: &str,
405 args: Value,
406 format_context: &crate::subc_format::FormatContext,
407 ctx: &ToolCallContext,
408 app_ctx: &AppContext,
409 dispatch: &DispatchFn<'_>,
410 finalizer: Option<&FinalizeFn<'_>>,
411 mut phase_trace: Option<&mut PhaseTrace>,
412) -> ToolCallOutcome {
413 let prepared = match prepare_tool_call(
414 bare_name,
415 args,
416 format_context,
417 ctx,
418 app_ctx,
419 phase_trace.as_deref_mut(),
420 ) {
421 Ok(prepared) => prepared,
422 Err(result) => return ToolCallOutcome::Unary(result),
423 };
424
425 let skipped_before = app_ctx.backup().lock().latest_skipped_order(
426 ctx.session_id
427 .as_deref()
428 .unwrap_or(crate::protocol::DEFAULT_SESSION_ID),
429 );
430 let mut response = if prepared.request.command == "inspect" {
431 crate::commands::inspect::handle_inspect_tool_call(&prepared.request, app_ctx)
432 } else {
433 dispatch(prepared.request, app_ctx)
434 };
435 if response.success && response.data.get("backup_skipped_reason").is_none() {
436 let session = ctx
437 .session_id
438 .as_deref()
439 .unwrap_or(crate::protocol::DEFAULT_SESSION_ID);
440 if let Some(reason) = app_ctx
441 .backup()
442 .lock()
443 .skipped_reason_after(session, skipped_before)
444 {
445 if let Some(object) = response.data.as_object_mut() {
446 object.insert(
447 "backup_skipped_reason".to_string(),
448 Value::String(reason.as_str().to_string()),
449 );
450 }
451 }
452 }
453 if let Some(trace) = phase_trace.as_mut() {
454 trace.mark_execute_done();
455 }
456 let result = finish_tool_call_response(
457 bare_name,
458 format_context,
459 response,
460 prepared.surface_downgraded,
461 finalizer,
462 phase_trace,
463 );
464 ToolCallOutcome::Unary(result)
465}
466
467fn raw_request_from_translated(
468 command: String,
469 mut params: serde_json::Map<String, Value>,
470 ctx: &ToolCallContext,
471) -> Result<RawRequest, &'static str> {
472 if params.contains_key("method") {
473 return Err("duplicate field `command`");
474 }
475
476 if ctx.preview {
477 params.insert("preview".to_string(), json!(true));
478 }
479
480 params.remove("id");
481 if command != "bash" {
482 params.remove("command");
483 }
484 params.remove("session_id");
485 let lsp_hints = params.remove("lsp_hints").filter(|value| !value.is_null());
486
487 Ok(RawRequest {
488 id: ctx.request_id.clone(),
489 command,
490 lsp_hints,
491 session_id: ctx.session_id.clone(),
492 params: Value::Object(params),
493 })
494}
495
496pub(crate) fn strip_agent_preview_arg_owned(mut args: Value) -> Value {
497 if let Some(map) = args.as_object_mut() {
498 map.remove("preview");
499 }
500 args
501}
502
503fn tool_call_result_from_response(
504 bare_name: &str,
505 format_context: &crate::subc_format::FormatContext,
506 mut response: Response,
507 surface_downgraded: bool,
508) -> ToolCallResult {
509 if surface_downgraded {
510 attach_hashline_downgrade(&mut response);
511 }
512 let mut text =
513 crate::subc_format::format_response_with_context(bare_name, &response, format_context);
514 if surface_downgraded {
515 append_hashline_downgrade_text(&mut text);
516 }
517 ToolCallResult { text, response }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[test]
525 fn phase_trace_reports_execution_and_writer_egress_subphases() {
526 let t0 = Instant::now();
527 let trace = PhaseTrace {
528 frame_decoded: t0,
529 executor_submitted: Some(t0 + Duration::from_millis(1)),
530 job_admitted: Some(t0 + Duration::from_millis(3)),
531 translate_done: Some(t0 + Duration::from_millis(6)),
532 execute_done: Some(t0 + Duration::from_millis(10)),
533 format_done: Some(t0 + Duration::from_millis(15)),
534 finalize_done: Some(t0 + Duration::from_millis(21)),
535 waiting_on: WaitingOn::None,
536 waiting_on_build_id: None,
537 wait_ms: 0,
538 };
539
540 let phases = trace
541 .finish(ToolCallEgressTiming {
542 enqueued: t0 + Duration::from_millis(28),
543 dequeued: t0 + Duration::from_millis(35),
544 write_started: t0 + Duration::from_millis(37),
545 write_finished: t0 + Duration::from_millis(48),
546 frame_bytes: 262_144,
547 queue_depth: 17,
548 writer_active_at_enqueue: true,
549 writer_queue_was_full: true,
550 reserve_timeouts: 2,
551 })
552 .unwrap();
553
554 assert_eq!(phases.queue, Duration::from_millis(2));
555 assert_eq!(phases.translate, Duration::from_millis(3));
556 assert_eq!(phases.execute, Duration::from_millis(4));
557 assert_eq!(phases.format, Duration::from_millis(5));
558 assert_eq!(phases.finalize, Duration::from_millis(6));
559 assert_eq!(phases.egress_enqueue, Duration::from_millis(7));
560 assert_eq!(phases.egress_queue, Duration::from_millis(7));
561 assert_eq!(phases.egress_prepare, Duration::from_millis(2));
562 assert_eq!(phases.egress_write, Duration::from_millis(11));
563 assert_eq!(phases.egress, Duration::from_millis(27));
564 assert_eq!(phases.frame_bytes, 262_144);
565 assert_eq!(phases.writer_queue_depth, 17);
566 assert!(phases.writer_active_at_enqueue);
567 assert!(phases.writer_queue_was_full);
568 assert_eq!(phases.writer_reserve_timeouts, 2);
569 assert_eq!(phases.total, Duration::from_millis(48));
570 }
571
572 #[test]
573 fn phase_trace_carries_wait_harvested_on_worker_to_finish_on_other_thread() {
574 let mut trace = PhaseTrace::new(Instant::now());
575 trace.mark_executor_submitted();
576 let worker = std::thread::spawn(move || {
577 trace.mark_job_admitted();
578 crate::logging::note_tool_call_wait(WaitingOn::Limiter, Some("b-1-1"), 42);
579 trace.mark_translate_done();
580 trace.mark_execute_done();
581 trace.mark_format_done();
582 trace.mark_finalize_done();
583 trace
584 });
585 let trace = worker.join().expect("worker");
586 let phases = std::thread::spawn(move || {
587 let now = Instant::now();
588 trace.finish(ToolCallEgressTiming {
589 enqueued: now,
590 dequeued: now,
591 write_started: now,
592 write_finished: now,
593 frame_bytes: 0,
594 queue_depth: 0,
595 writer_active_at_enqueue: false,
596 writer_queue_was_full: false,
597 reserve_timeouts: 0,
598 })
599 })
600 .join()
601 .expect("writer")
602 .expect("complete phase trace");
603 assert_eq!(phases.waiting_on, WaitingOn::Limiter);
604 assert_eq!(phases.waiting_on_build_id.as_deref(), Some("b-1-1"));
605 assert_eq!(phases.wait_ms, 42);
606 }
607
608 mod raw_request_construction {
609 use std::hint::black_box;
610
611 use super::*;
612 use crate::test_allocations::count as count_allocations;
613
614 fn context(preview: bool) -> ToolCallContext {
615 ToolCallContext {
616 project_root: PathBuf::from("/workspace"),
617 session_id: Some("session-realistic".to_string()),
618 request_id: "subc-7-42".to_string(),
619 diagnostics_on_edit: true,
620 preview,
621 edit_slot_survives: None,
622 report_registration_downgrade: false,
623 }
624 }
625
626 fn object(value: Value) -> serde_json::Map<String, Value> {
627 value.as_object().cloned().expect("test input is an object")
628 }
629
630 fn legacy_raw_request(
631 command: String,
632 mut params: serde_json::Map<String, Value>,
633 ctx: &ToolCallContext,
634 ) -> Result<RawRequest, String> {
635 if ctx.preview {
636 params.insert("preview".to_string(), json!(true));
637 }
638 params.insert("id".to_string(), json!(ctx.request_id.clone()));
639 params.insert("command".to_string(), json!(command));
640 params.insert("session_id".to_string(), json!(ctx.session_id.clone()));
641 serde_json::from_value(Value::Object(params)).map_err(|error| error.to_string())
642 }
643
644 fn dispatch_result_bytes(request: RawRequest) -> Vec<u8> {
645 let response = Response::success(
646 request.id.clone(),
647 json!({
648 "received_command": request.command,
649 "received_lsp_hints": request.lsp_hints,
650 "received_session_id": request.session_id,
651 "received_params": request.params,
652 }),
653 );
654 serde_json::to_vec(&response).expect("serialize recording dispatch response")
655 }
656
657 #[test]
658 fn direct_raw_request_construction_avoids_flatten_rematerialization() {
659 let direct_params = object(json!({
660 "file": "/workspace/src/main.rs",
661 "start_line": 150,
662 "end_line": 229,
663 }));
664 let legacy_params = direct_params.clone();
665 let ctx = context(false);
666 let direct_command = "read".to_string();
667 let legacy_command = direct_command.clone();
668
669 let (direct, direct_allocations) = count_allocations(|| {
670 raw_request_from_translated(direct_command, direct_params, &ctx)
671 .expect("direct request")
672 });
673 let (legacy, legacy_allocations) = count_allocations(|| {
674 legacy_raw_request(legacy_command, legacy_params, &ctx).expect("legacy request")
675 });
676 black_box((&direct, &legacy));
677
678 assert_eq!(direct_allocations, 2);
679 assert!(
680 legacy_allocations >= 20,
681 "legacy flatten path unexpectedly used only {legacy_allocations} allocations"
682 );
683 assert!(
684 legacy_allocations >= direct_allocations + 18,
685 "direct={direct_allocations}, legacy={legacy_allocations}"
686 );
687 }
688
689 #[test]
690 fn direct_raw_request_matches_legacy_dispatch_bytes() {
691 let edits = (0..100)
692 .map(|index| {
693 json!({
694 "match": format!("old declaration {index}"),
695 "replacement": format!("new declaration {index}"),
696 "replace_all": false,
697 })
698 })
699 .collect::<Vec<_>>();
700 let cases = [
701 (
702 "read",
703 "read",
704 object(json!({
705 "file": "/workspace/src/main.rs",
706 "start_line": 1,
707 "end_line": 80,
708 })),
709 false,
710 ),
711 (
712 "write",
713 "write",
714 object(json!({
715 "file": "/workspace/src/new.rs",
716 "content": "fn created() {}\n",
717 "create_dirs": true,
718 })),
719 false,
720 ),
721 (
722 "batch-edit-100",
723 "batch",
724 object(json!({
725 "file": "/workspace/src/large.rs",
726 "edits": edits,
727 })),
728 false,
729 ),
730 (
731 "preview",
732 "read",
733 object(json!({"file": "/workspace/src/main.rs"})),
734 true,
735 ),
736 (
737 "lsp-hints",
738 "move_symbol",
739 object(json!({
740 "file": "/workspace/src/main.rs",
741 "symbol": "run",
742 "destination": "/workspace/src/moved.rs",
743 "lsp_hints": {
744 "symbols": [{
745 "name": "run",
746 "file": "/workspace/src/main.rs",
747 "line": 12,
748 "kind": "function",
749 }],
750 },
751 })),
752 false,
753 ),
754 (
755 "null-lsp-hints",
756 "move_symbol",
757 object(json!({
758 "file": "/workspace/src/main.rs",
759 "symbol": "run",
760 "destination": "/workspace/src/moved.rs",
761 "lsp_hints": null,
762 })),
763 false,
764 ),
765 ];
766
767 for (label, command, params, preview) in cases {
768 let ctx = context(preview);
769 let direct = raw_request_from_translated(command.to_string(), params.clone(), &ctx)
770 .expect("direct request");
771 let legacy =
772 legacy_raw_request(command.to_string(), params, &ctx).expect("legacy request");
773
774 assert_eq!(
775 dispatch_result_bytes(direct),
776 dispatch_result_bytes(legacy),
777 "recording dispatch response differed for {label}"
778 );
779 }
780 }
781
782 #[test]
783 fn direct_raw_request_preserves_method_alias_rejection() {
784 let params = object(json!({"method": "agent-supplied-command"}));
785 let ctx = context(false);
786 let direct_error =
787 raw_request_from_translated("read".to_string(), params.clone(), &ctx)
788 .expect_err("method alias must conflict with server-owned command");
789 let legacy_error = legacy_raw_request("read".to_string(), params, &ctx)
790 .expect_err("legacy path rejects the duplicate alias");
791
792 assert_eq!(direct_error, legacy_error);
793 }
794
795 #[test]
796 fn direct_raw_request_preserves_bash_command_param() {
797 let params = object(json!({
798 "command": "echo standalone-tool-call-ok",
799 "background": false,
800 }));
801 let ctx = context(false);
802 let request = raw_request_from_translated("bash".to_string(), params, &ctx)
803 .expect("bash request");
804
805 assert_eq!(request.command, "bash");
806 assert_eq!(
807 request.params.get("command").and_then(Value::as_str),
808 Some("echo standalone-tool-call-ok")
809 );
810 }
811
812 #[test]
813 fn direct_raw_request_still_strips_command_param_for_non_bash_tools() {
814 let params = object(json!({"command": "agent-supplied-command"}));
815 let ctx = context(false);
816 let request = raw_request_from_translated("read".to_string(), params, &ctx)
817 .expect("read request");
818
819 assert_eq!(request.command, "read");
820 assert!(request.params.get("command").is_none());
821 }
822 }
823}