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    // The read slot is resolved from config on this side of the boundary rather
235    // than carried, so the plugin and the core answer "can this session mint a
236    // tag?" from the same rule instead of two drifting ones.
237    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}