Skip to main content

aft/
run_tool_call.rs

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/// Monotonic timestamps for one subc tool call. The recorder stays on the
13/// request path and only takes an `Instant::now()` at each phase boundary.
14///
15/// Causal wait state is harvested on the executor worker at `mark_execute_done`
16/// because `PhaseTrace::new` and `finish` run on different threads than execute.
17#[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/// Causal wait recorded on a tool call so slow-call logs can name the blocker.
45#[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/// The full result of a tool call: the COMPLETE dispatch Response carried VERBATIM,
175/// plus the server-rendered agent-facing text (what the deleted TS formatters used to produce).
176/// Oracle #1: carry the WHOLE Response — promote nothing, drop nothing (preview_diff, attachments,
177/// status_bar, bg_completions, lsp_diagnostics, code, message, candidates, … all ride inside `response`).
178#[derive(Debug)]
179pub struct ToolCallResult {
180    pub text: String,
181    pub response: crate::protocol::Response,
182}
183
184/// Reserve a discriminated seam so bash/PTY/streaming (P3) doesn't force a signature rewrite.
185/// Only `Unary` is constructed today. Do NOT build `Stream`.
186#[derive(Debug)]
187pub enum ToolCallOutcome {
188    Unary(ToolCallResult),
189}
190
191/// Server-owned settings for a single `tool_call` request.
192/// These fields cannot be supplied through the agent's arguments object.
193#[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    /// Plugin-computed registration fact carried by transports whose bind
201    /// protocol cannot include configure-time process state.
202    pub edit_slot_survives: Option<bool>,
203    /// Whether configure's immediate registration-downgrade warning was discarded
204    /// by this transport and must be reported on the first tool call instead.
205    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}