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