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