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 translated_bare_name = match bare_name {
306        // The public tool is registered as `aft_inspect`, while hoisted plugin
307        // callers normally send the translator's bare `inspect` spelling.
308        "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    // Plumbing calls are the plugin's, not the model's: after every agent tool
477    // call the plugin drains completions (`bash_drain_completions`) under the
478    // same session, so counting them would reset the run on every agent call
479    // and the breaker could never see two agent calls in a row. The first live
480    // probe found exactly that: five identical bash calls over 78 s, no steer.
481    // Hash the rendered tool text before status bars, alerts, and trailers are attached. Those
482    // decorations carry moving counts, so hashing afterward would make identical results appear
483    // different forever and silently prevent the breaker from firing.
484    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}