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 semantic_key = crate::response_finalize::repeat_breaker::semantic_key(bare_name, &args);
414 let mut result = match prepare_tool_call(
415 bare_name,
416 args,
417 format_context,
418 ctx,
419 app_ctx,
420 phase_trace.as_deref_mut(),
421 ) {
422 Err(result) => result,
423 Ok(prepared) => {
424 let skipped_before = app_ctx.backup().lock().latest_skipped_order(
425 ctx.session_id
426 .as_deref()
427 .unwrap_or(crate::protocol::DEFAULT_SESSION_ID),
428 );
429 let mut response = if prepared.request.command == "inspect" {
430 crate::commands::inspect::handle_inspect_tool_call(&prepared.request, app_ctx)
431 } else {
432 dispatch(prepared.request, app_ctx)
433 };
434 if response.success && response.data.get("backup_skipped_reason").is_none() {
435 let session = ctx
436 .session_id
437 .as_deref()
438 .unwrap_or(crate::protocol::DEFAULT_SESSION_ID);
439 if let Some(reason) = app_ctx
440 .backup()
441 .lock()
442 .skipped_reason_after(session, skipped_before)
443 {
444 if let Some(object) = response.data.as_object_mut() {
445 object.insert(
446 "backup_skipped_reason".to_string(),
447 Value::String(reason.as_str().to_string()),
448 );
449 }
450 }
451 }
452 if let Some(trace) = phase_trace.as_mut() {
453 trace.mark_execute_done();
454 }
455 finish_tool_call_response(
456 bare_name,
457 format_context,
458 response,
459 prepared.surface_downgraded,
460 finalizer,
461 phase_trace,
462 )
463 }
464 };
465
466 let session_id = ctx
467 .session_id
468 .as_deref()
469 .unwrap_or(crate::protocol::DEFAULT_SESSION_ID);
470 let output_hash = crate::response_finalize::repeat_breaker::output_hash(&result.text);
479 let intervention = if crate::subc::is_subc_native_plumbing_tool(bare_name) {
480 None
481 } else {
482 app_ctx
483 .repeat_breaker()
484 .observe(session_id, bare_name, semantic_key, output_hash)
485 };
486 if let Some(intervention) = intervention {
487 crate::response_finalize::append_repeat_breaker_reminder(
488 &mut result.text,
489 session_id,
490 &intervention,
491 );
492 }
493
494 ToolCallOutcome::Unary(result)
495}
496
497fn raw_request_from_translated(
498 command: String,
499 mut params: serde_json::Map<String, Value>,
500 ctx: &ToolCallContext,
501) -> Result<RawRequest, &'static str> {
502 if params.contains_key("method") {
503 return Err("duplicate field `command`");
504 }
505
506 if ctx.preview {
507 params.insert("preview".to_string(), json!(true));
508 }
509
510 params.remove("id");
511 if command != "bash" {
512 params.remove("command");
513 }
514 params.remove("session_id");
515 let lsp_hints = params.remove("lsp_hints").filter(|value| !value.is_null());
516
517 Ok(RawRequest {
518 id: ctx.request_id.clone(),
519 command,
520 lsp_hints,
521 session_id: ctx.session_id.clone(),
522 params: Value::Object(params),
523 })
524}
525
526pub(crate) fn strip_agent_preview_arg_owned(mut args: Value) -> Value {
527 if let Some(map) = args.as_object_mut() {
528 map.remove("preview");
529 }
530 args
531}
532
533fn tool_call_result_from_response(
534 bare_name: &str,
535 format_context: &crate::subc_format::FormatContext,
536 mut response: Response,
537 surface_downgraded: bool,
538) -> ToolCallResult {
539 if surface_downgraded {
540 attach_hashline_downgrade(&mut response);
541 }
542 let mut text =
543 crate::subc_format::format_response_with_context(bare_name, &response, format_context);
544 if surface_downgraded {
545 append_hashline_downgrade_text(&mut text);
546 }
547 ToolCallResult { text, response }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn phase_trace_reports_execution_and_writer_egress_subphases() {
556 let t0 = Instant::now();
557 let trace = PhaseTrace {
558 frame_decoded: t0,
559 executor_submitted: Some(t0 + Duration::from_millis(1)),
560 job_admitted: Some(t0 + Duration::from_millis(3)),
561 translate_done: Some(t0 + Duration::from_millis(6)),
562 execute_done: Some(t0 + Duration::from_millis(10)),
563 format_done: Some(t0 + Duration::from_millis(15)),
564 finalize_done: Some(t0 + Duration::from_millis(21)),
565 waiting_on: WaitingOn::None,
566 waiting_on_build_id: None,
567 wait_ms: 0,
568 };
569
570 let phases = trace
571 .finish(ToolCallEgressTiming {
572 enqueued: t0 + Duration::from_millis(28),
573 dequeued: t0 + Duration::from_millis(35),
574 write_started: t0 + Duration::from_millis(37),
575 write_finished: t0 + Duration::from_millis(48),
576 frame_bytes: 262_144,
577 queue_depth: 17,
578 writer_active_at_enqueue: true,
579 writer_queue_was_full: true,
580 reserve_timeouts: 2,
581 })
582 .unwrap();
583
584 assert_eq!(phases.queue, Duration::from_millis(2));
585 assert_eq!(phases.translate, Duration::from_millis(3));
586 assert_eq!(phases.execute, Duration::from_millis(4));
587 assert_eq!(phases.format, Duration::from_millis(5));
588 assert_eq!(phases.finalize, Duration::from_millis(6));
589 assert_eq!(phases.egress_enqueue, Duration::from_millis(7));
590 assert_eq!(phases.egress_queue, Duration::from_millis(7));
591 assert_eq!(phases.egress_prepare, Duration::from_millis(2));
592 assert_eq!(phases.egress_write, Duration::from_millis(11));
593 assert_eq!(phases.egress, Duration::from_millis(27));
594 assert_eq!(phases.frame_bytes, 262_144);
595 assert_eq!(phases.writer_queue_depth, 17);
596 assert!(phases.writer_active_at_enqueue);
597 assert!(phases.writer_queue_was_full);
598 assert_eq!(phases.writer_reserve_timeouts, 2);
599 assert_eq!(phases.total, Duration::from_millis(48));
600 }
601
602 #[test]
603 fn phase_trace_carries_wait_harvested_on_worker_to_finish_on_other_thread() {
604 let mut trace = PhaseTrace::new(Instant::now());
605 trace.mark_executor_submitted();
606 let worker = std::thread::spawn(move || {
607 trace.mark_job_admitted();
608 crate::logging::note_tool_call_wait(WaitingOn::Limiter, Some("b-1-1"), 42);
609 trace.mark_translate_done();
610 trace.mark_execute_done();
611 trace.mark_format_done();
612 trace.mark_finalize_done();
613 trace
614 });
615 let trace = worker.join().expect("worker");
616 let phases = std::thread::spawn(move || {
617 let now = Instant::now();
618 trace.finish(ToolCallEgressTiming {
619 enqueued: now,
620 dequeued: now,
621 write_started: now,
622 write_finished: now,
623 frame_bytes: 0,
624 queue_depth: 0,
625 writer_active_at_enqueue: false,
626 writer_queue_was_full: false,
627 reserve_timeouts: 0,
628 })
629 })
630 .join()
631 .expect("writer")
632 .expect("complete phase trace");
633 assert_eq!(phases.waiting_on, WaitingOn::Limiter);
634 assert_eq!(phases.waiting_on_build_id.as_deref(), Some("b-1-1"));
635 assert_eq!(phases.wait_ms, 42);
636 }
637
638 mod raw_request_construction {
639 use std::hint::black_box;
640
641 use super::*;
642 use crate::test_allocations::count as count_allocations;
643
644 fn context(preview: bool) -> ToolCallContext {
645 ToolCallContext {
646 project_root: PathBuf::from("/workspace"),
647 session_id: Some("session-realistic".to_string()),
648 request_id: "subc-7-42".to_string(),
649 diagnostics_on_edit: true,
650 preview,
651 edit_slot_survives: None,
652 report_registration_downgrade: false,
653 }
654 }
655
656 fn object(value: Value) -> serde_json::Map<String, Value> {
657 value.as_object().cloned().expect("test input is an object")
658 }
659
660 fn legacy_raw_request(
661 command: String,
662 mut params: serde_json::Map<String, Value>,
663 ctx: &ToolCallContext,
664 ) -> Result<RawRequest, String> {
665 if ctx.preview {
666 params.insert("preview".to_string(), json!(true));
667 }
668 params.insert("id".to_string(), json!(ctx.request_id.clone()));
669 params.insert("command".to_string(), json!(command));
670 params.insert("session_id".to_string(), json!(ctx.session_id.clone()));
671 serde_json::from_value(Value::Object(params)).map_err(|error| error.to_string())
672 }
673
674 fn dispatch_result_bytes(request: RawRequest) -> Vec<u8> {
675 let response = Response::success(
676 request.id.clone(),
677 json!({
678 "received_command": request.command,
679 "received_lsp_hints": request.lsp_hints,
680 "received_session_id": request.session_id,
681 "received_params": request.params,
682 }),
683 );
684 serde_json::to_vec(&response).expect("serialize recording dispatch response")
685 }
686
687 #[test]
688 fn direct_raw_request_construction_avoids_flatten_rematerialization() {
689 let direct_params = object(json!({
690 "file": "/workspace/src/main.rs",
691 "start_line": 150,
692 "end_line": 229,
693 }));
694 let legacy_params = direct_params.clone();
695 let ctx = context(false);
696 let direct_command = "read".to_string();
697 let legacy_command = direct_command.clone();
698
699 let (direct, direct_allocations) = count_allocations(|| {
700 raw_request_from_translated(direct_command, direct_params, &ctx)
701 .expect("direct request")
702 });
703 let (legacy, legacy_allocations) = count_allocations(|| {
704 legacy_raw_request(legacy_command, legacy_params, &ctx).expect("legacy request")
705 });
706 black_box((&direct, &legacy));
707
708 assert_eq!(direct_allocations, 2);
709 assert!(
710 legacy_allocations >= 20,
711 "legacy flatten path unexpectedly used only {legacy_allocations} allocations"
712 );
713 assert!(
714 legacy_allocations >= direct_allocations + 18,
715 "direct={direct_allocations}, legacy={legacy_allocations}"
716 );
717 }
718
719 #[test]
720 fn direct_raw_request_matches_legacy_dispatch_bytes() {
721 let edits = (0..100)
722 .map(|index| {
723 json!({
724 "match": format!("old declaration {index}"),
725 "replacement": format!("new declaration {index}"),
726 "replace_all": false,
727 })
728 })
729 .collect::<Vec<_>>();
730 let cases = [
731 (
732 "read",
733 "read",
734 object(json!({
735 "file": "/workspace/src/main.rs",
736 "start_line": 1,
737 "end_line": 80,
738 })),
739 false,
740 ),
741 (
742 "write",
743 "write",
744 object(json!({
745 "file": "/workspace/src/new.rs",
746 "content": "fn created() {}\n",
747 "create_dirs": true,
748 })),
749 false,
750 ),
751 (
752 "batch-edit-100",
753 "batch",
754 object(json!({
755 "file": "/workspace/src/large.rs",
756 "edits": edits,
757 })),
758 false,
759 ),
760 (
761 "preview",
762 "read",
763 object(json!({"file": "/workspace/src/main.rs"})),
764 true,
765 ),
766 (
767 "lsp-hints",
768 "move_symbol",
769 object(json!({
770 "file": "/workspace/src/main.rs",
771 "symbol": "run",
772 "destination": "/workspace/src/moved.rs",
773 "lsp_hints": {
774 "symbols": [{
775 "name": "run",
776 "file": "/workspace/src/main.rs",
777 "line": 12,
778 "kind": "function",
779 }],
780 },
781 })),
782 false,
783 ),
784 (
785 "null-lsp-hints",
786 "move_symbol",
787 object(json!({
788 "file": "/workspace/src/main.rs",
789 "symbol": "run",
790 "destination": "/workspace/src/moved.rs",
791 "lsp_hints": null,
792 })),
793 false,
794 ),
795 ];
796
797 for (label, command, params, preview) in cases {
798 let ctx = context(preview);
799 let direct = raw_request_from_translated(command.to_string(), params.clone(), &ctx)
800 .expect("direct request");
801 let legacy =
802 legacy_raw_request(command.to_string(), params, &ctx).expect("legacy request");
803
804 assert_eq!(
805 dispatch_result_bytes(direct),
806 dispatch_result_bytes(legacy),
807 "recording dispatch response differed for {label}"
808 );
809 }
810 }
811
812 #[test]
813 fn direct_raw_request_preserves_method_alias_rejection() {
814 let params = object(json!({"method": "agent-supplied-command"}));
815 let ctx = context(false);
816 let direct_error =
817 raw_request_from_translated("read".to_string(), params.clone(), &ctx)
818 .expect_err("method alias must conflict with server-owned command");
819 let legacy_error = legacy_raw_request("read".to_string(), params, &ctx)
820 .expect_err("legacy path rejects the duplicate alias");
821
822 assert_eq!(direct_error, legacy_error);
823 }
824
825 #[test]
826 fn direct_raw_request_preserves_bash_command_param() {
827 let params = object(json!({
828 "command": "echo standalone-tool-call-ok",
829 "background": false,
830 }));
831 let ctx = context(false);
832 let request = raw_request_from_translated("bash".to_string(), params, &ctx)
833 .expect("bash request");
834
835 assert_eq!(request.command, "bash");
836 assert_eq!(
837 request.params.get("command").and_then(Value::as_str),
838 Some("echo standalone-tool-call-ok")
839 );
840 }
841
842 #[test]
843 fn direct_raw_request_still_strips_command_param_for_non_bash_tools() {
844 let params = object(json!({"command": "agent-supplied-command"}));
845 let ctx = context(false);
846 let request = raw_request_from_translated("read".to_string(), params, &ctx)
847 .expect("read request");
848
849 assert_eq!(request.command, "read");
850 assert!(request.params.get("command").is_none());
851 }
852 }
853}