Skip to main content

winx_code_agent/server/
handler.rs

1use crate::server::usage::lifecycle::{phase, Phase};
2
3use rmcp::{
4    model::{
5        CacheScope, CallToolRequestParams, CallToolResponse, CancelTaskParams,
6        GetPromptRequestParams, GetPromptResponse, GetPromptResult, GetTaskParams, GetTaskResult,
7        Icon, Implementation, ListPromptsResult, ListResourcesResult, ListToolsResult,
8        PaginatedRequestParams, PromptMessage, ProtocolVersion, ReadResourceRequestParams,
9        ReadResourceResponse, ReadResourceResult, Resource, ResourceContents, Role,
10        ServerCapabilities, ServerConfig, Tool, UpdateTaskParams,
11    },
12    service::{NotificationContext, RequestContext, RoleServer},
13    ErrorData as McpError, ServerHandler,
14};
15
16use super::catalog::{
17    server_icon_data_uri, winx_prompts, winx_tool, winx_tools, winx_tools_for_policy,
18};
19use super::client_profile::ClientProfile;
20use super::outcomes;
21use super::principal::{
22    conversation_identity_from_context, principal_from_context, scope_tool_request,
23    session_affinity_from_context, task_belongs_to_principal,
24};
25use super::result_delivery::ResultTextDelivery;
26use super::usage::{EffectiveWaitMode, UsageEvent};
27use super::wait_budget::WaitBudget;
28use super::WinxService;
29use crate::runtime::ShellActionOptions;
30use crate::tool_policy::ToolPolicy;
31use crate::tool_registry::ToolKind;
32use crate::tools::edit_files::EditSurface;
33use crate::types::{BashCommand, BashWaitPolicy};
34
35pub(crate) const COMPACT_BASH_OUTPUT_EXTENSION: &str = "io.winx/compact-bash-output";
36const ADAPTIVE_TASK_INLINE_SECONDS: f32 = 2.0;
37const RETURN_EARLY_DEFAULT_SECONDS: f32 = 0.25;
38const RETURN_EARLY_MAX_SECONDS: f32 = 5.0;
39const SYNCHRONOUS_MAX_SECONDS: f32 = 60.0;
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42enum BashTaskRoute {
43    Synchronous,
44    Adaptive,
45    Immediate,
46}
47
48fn bash_task_route(
49    client_tasks: bool,
50    generation_bound_runtime: bool,
51    eligible: bool,
52    policy: BashWaitPolicy,
53) -> BashTaskRoute {
54    if !client_tasks || !generation_bound_runtime || !eligible {
55        return BashTaskRoute::Synchronous;
56    }
57    match policy {
58        BashWaitPolicy::Adaptive => BashTaskRoute::Adaptive,
59        BashWaitPolicy::UntilComplete => BashTaskRoute::Immediate,
60        BashWaitPolicy::ReturnEarly => BashTaskRoute::Synchronous,
61    }
62}
63
64fn adaptive_action_options(compact_output: bool) -> ShellActionOptions {
65    ShellActionOptions {
66        compact_output,
67        // Adaptive promotion is already backed by a Task reservation. Require
68        // the effective guardian epoch to be checked live before this first
69        // action so a recreated guardian cannot launch an untracked process
70        // under a stale adapter cache.
71        require_generation_binding: true,
72        ..ShellActionOptions::default()
73    }
74}
75
76/// Emit the cache fields required by MCP 2026-07-28 without changing the wire
77/// shape for clients that negotiated an older protocol revision.
78fn cache_hints_for_protocol(
79    protocol_version: Option<ProtocolVersion>,
80    cache_scope: CacheScope,
81) -> (Option<u64>, Option<CacheScope>) {
82    if protocol_version.is_some_and(|version| version >= ProtocolVersion::V_2026_07_28) {
83        (Some(0), Some(cache_scope))
84    } else {
85        (None, None)
86    }
87}
88
89fn supports_compact_bash_output(context: &RequestContext<RoleServer>) -> bool {
90    context.client_capabilities().is_some_and(|capabilities| {
91        capabilities
92            .extensions
93            .as_ref()
94            .is_some_and(|extensions| extensions.contains_key(COMPACT_BASH_OUTPUT_EXTENSION))
95    })
96}
97
98fn normalized_wait_request(
99    mut request: CallToolRequestParams,
100    policy: BashWaitPolicy,
101    task_inline: bool,
102) -> Result<CallToolRequestParams, McpError> {
103    let mut arguments = request
104        .arguments
105        .clone()
106        .ok_or_else(|| McpError::invalid_request("Missing BashCommand arguments", None))?;
107    let requested = arguments
108        .get("wait_for_seconds")
109        .filter(|value| !value.is_null())
110        .cloned()
111        .map(serde_json::from_value::<f32>)
112        .transpose()
113        .map_err(|error| {
114            McpError::invalid_request(format!("Invalid wait_for_seconds: {error}"), None)
115        })?;
116    if requested.is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0) {
117        return Err(McpError::invalid_request(
118            "wait_for_seconds must be a finite nonnegative number",
119            None,
120        ));
121    }
122    let (default, maximum) = match (policy, task_inline) {
123        (BashWaitPolicy::Adaptive, true) => {
124            (ADAPTIVE_TASK_INLINE_SECONDS, ADAPTIVE_TASK_INLINE_SECONDS)
125        }
126        (BashWaitPolicy::ReturnEarly, _) => {
127            (RETURN_EARLY_DEFAULT_SECONDS, RETURN_EARLY_MAX_SECONDS)
128        }
129        (BashWaitPolicy::Adaptive, false) => (15.0, SYNCHRONOUS_MAX_SECONDS),
130        (BashWaitPolicy::UntilComplete, false) => {
131            (SYNCHRONOUS_MAX_SECONDS, SYNCHRONOUS_MAX_SECONDS)
132        }
133        (BashWaitPolicy::UntilComplete, true) => unreachable!("until_complete starts a Task"),
134    };
135    arguments.insert(
136        "wait_for_seconds".to_string(),
137        serde_json::Value::from(requested.unwrap_or(default).clamp(0.0, maximum)),
138    );
139    request.arguments = Some(arguments);
140    Ok(request)
141}
142
143#[allow(clippy::unused_async_trait_impl)] // rmcp's trait requires these async signatures
144impl ServerHandler for WinxService {
145    fn get_info(&self) -> ServerConfig {
146        let mut capabilities = ServerCapabilities::builder()
147            .enable_tools()
148            .enable_resources()
149            .enable_prompts()
150            .enable_tasks()
151            .build();
152        capabilities
153            .extensions
154            .get_or_insert_default()
155            .insert(COMPACT_BASH_OUTPUT_EXTENSION.to_string(), rmcp::model::JsonObject::new());
156        ServerConfig::new(capabilities)
157            .with_server_info(
158                Implementation::new("winx-mcp-server", self.version.clone())
159                    .with_title("Winx High-Performance MCP")
160                    .with_icons(vec![Icon::new(server_icon_data_uri())
161                        .with_mime_type("image/png")
162                        .with_sizes(vec!["96x96".to_string()])]),
163            )
164            .with_protocol_version(ProtocolVersion::V_2026_07_28)
165            .with_instructions(crate::utils::orchestration::server_instructions())
166    }
167
168    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
169        self.initialize_from_client_roots(context).await;
170    }
171
172    async fn on_roots_list_changed(&self, context: NotificationContext<RoleServer>) {
173        // A changed Roots list is only used as a retry if Winx is still
174        // uninitialized. Never switch an active shell's workspace implicitly.
175        self.initialize_from_client_roots(context).await;
176    }
177
178    fn get_tool(&self, name: &str) -> Option<Tool> {
179        winx_tool(name, ToolPolicy::default()).cloned()
180    }
181
182    async fn get_task(
183        &self,
184        request: GetTaskParams,
185        context: RequestContext<RoleServer>,
186    ) -> Result<GetTaskResult, McpError> {
187        let principal = principal_from_context(&context);
188        if !task_belongs_to_principal(&request.task_id, principal.as_ref()) {
189            return Err(McpError::invalid_request(
190                format!("Unknown or expired task: {}", request.task_id),
191                None,
192            ));
193        }
194        let mut tasks = self.tasks.lock().await;
195        let entry = tasks.get(&request.task_id).ok_or_else(|| {
196            McpError::invalid_request(format!("Unknown or expired task: {}", request.task_id), None)
197        })?;
198        Ok(GetTaskResult::new(entry.detailed()))
199    }
200
201    async fn update_task(
202        &self,
203        request: UpdateTaskParams,
204        context: RequestContext<RoleServer>,
205    ) -> Result<(), McpError> {
206        let principal = principal_from_context(&context);
207        if !task_belongs_to_principal(&request.task_id, principal.as_ref()) {
208            return Err(McpError::invalid_request(
209                format!("Unknown or expired task: {}", request.task_id),
210                None,
211            ));
212        }
213        let mut tasks = self.tasks.lock().await;
214        tasks.get(&request.task_id).ok_or_else(|| {
215            McpError::invalid_request(format!("Unknown or expired task: {}", request.task_id), None)
216        })?;
217        Ok(())
218    }
219
220    async fn cancel_task(
221        &self,
222        request: CancelTaskParams,
223        context: RequestContext<RoleServer>,
224    ) -> Result<(), McpError> {
225        let principal = principal_from_context(&context);
226        if !task_belongs_to_principal(&request.task_id, principal.as_ref()) {
227            return Err(McpError::invalid_request(
228                format!("Unknown or expired task: {}", request.task_id),
229                None,
230            ));
231        }
232        self.cancel_bash_task(&request.task_id).await
233    }
234
235    async fn list_tools(
236        &self,
237        _request: Option<PaginatedRequestParams>,
238        ctx: RequestContext<RoleServer>,
239    ) -> Result<ListToolsResult, McpError> {
240        let principal = principal_from_context(&ctx);
241        let (tools, scope) = principal.as_ref().map_or_else(
242            || (winx_tools(), CacheScope::Public),
243            |principal| {
244                let policy = principal.tool_policy();
245                let scope = if policy == crate::tool_policy::ToolPolicy::default() {
246                    CacheScope::Public
247                } else {
248                    CacheScope::Private
249                };
250                (winx_tools_for_policy(policy), scope)
251            },
252        );
253        let (ttl_ms, cache_scope) = cache_hints_for_protocol(ctx.protocol_version(), scope);
254        let mut result = ListToolsResult::with_all_items(tools);
255        result.ttl_ms = ttl_ms;
256        result.cache_scope = cache_scope;
257        Ok(result)
258    }
259
260    async fn list_resources(
261        &self,
262        _request: Option<PaginatedRequestParams>,
263        ctx: RequestContext<RoleServer>,
264    ) -> Result<ListResourcesResult, McpError> {
265        let (ttl_ms, cache_scope) =
266            cache_hints_for_protocol(ctx.protocol_version(), CacheScope::Public);
267        let mut result =
268            ListResourcesResult::with_all_items(vec![Resource::new("file://readme", "README")
269                .with_description("Project README documentation")
270                .with_mime_type("text/markdown")]);
271        result.ttl_ms = ttl_ms;
272        result.cache_scope = cache_scope;
273        Ok(result)
274    }
275
276    async fn list_prompts(
277        &self,
278        _request: Option<PaginatedRequestParams>,
279        ctx: RequestContext<RoleServer>,
280    ) -> Result<ListPromptsResult, McpError> {
281        let (ttl_ms, cache_scope) =
282            cache_hints_for_protocol(ctx.protocol_version(), CacheScope::Public);
283        let mut result = ListPromptsResult::with_all_items(winx_prompts());
284        result.ttl_ms = ttl_ms;
285        result.cache_scope = cache_scope;
286        Ok(result)
287    }
288
289    async fn get_prompt(
290        &self,
291        request: GetPromptRequestParams,
292        context: RequestContext<RoleServer>,
293    ) -> Result<GetPromptResponse, McpError> {
294        if request.name != "KnowledgeTransfer" {
295            return Err(McpError::invalid_request(
296                format!("Unknown prompt: {}", request.name),
297                None,
298            ));
299        }
300
301        let principal = principal_from_context(&context);
302        let session_prefix = principal.as_ref().map(crate::config::HttpPrincipal::session_prefix);
303        let text = crate::utils::redact::redact(
304            &self.knowledge_transfer_prompt_text(session_prefix.as_deref()).await,
305        )
306        .into_owned();
307        Ok(GetPromptResult::new(vec![PromptMessage::new_text(Role::User, text)])
308            .with_description("Knowledge transfer handoff prompt")
309            .into())
310    }
311
312    async fn read_resource(
313        &self,
314        request: ReadResourceRequestParams,
315        ctx: RequestContext<RoleServer>,
316    ) -> Result<ReadResourceResponse, McpError> {
317        let content = match request.uri.as_ref() {
318            "file://readme" => match tokio::fs::read_to_string("README.md").await {
319                Ok(content) => vec![ResourceContents::text(
320                    crate::utils::redact::redact(&content).into_owned(),
321                    request.uri.clone(),
322                )],
323                Err(_) => vec![ResourceContents::text(
324                    "README.md not found".to_string(),
325                    request.uri.clone(),
326                )],
327            },
328            _ => {
329                return Err(McpError::invalid_request(
330                    format!("Unknown resource URI: {}", request.uri),
331                    None,
332                ));
333            }
334        };
335        let (ttl_ms, cache_scope) =
336            cache_hints_for_protocol(ctx.protocol_version(), CacheScope::Private);
337        let mut result = ReadResourceResult::new(content);
338        result.ttl_ms = ttl_ms;
339        result.cache_scope = cache_scope;
340        Ok(result.into())
341    }
342
343    #[allow(clippy::too_many_lines)] // request scoping, negotiated routing, execution, and telemetry
344    async fn call_tool(
345        &self,
346        request: CallToolRequestParams,
347        context: RequestContext<RoleServer>,
348    ) -> Result<CallToolResponse, McpError> {
349        let tool_kind = ToolKind::parse(request.name.as_ref());
350        if tool_kind.is_none() {
351            return Err(McpError::invalid_request(format!("Unknown tool: {}", request.name), None));
352        }
353        let principal = principal_from_context(&context);
354        let effective_tool_policy = principal
355            .as_ref()
356            .map_or_else(ToolPolicy::default, super::super::config::HttpPrincipal::tool_policy);
357        // Wire shape only: never consulted for authority.
358        let text_delivery = ResultTextDelivery::for_client(ClientProfile::from_context(&context));
359
360        // Parse edit arguments once into the typed domain before any capability
361        // decision. This avoids policy decisions based on loosely typed/raw
362        // JSON fields and makes malformed edit calls recoverable tool results,
363        // even when another gate (workspace or capability) would also reject
364        // the request.
365        let edit_surface = tool_kind.and_then(EditSurface::from_public_tool);
366        let normalized_edit = if let Some(surface) = edit_surface {
367            let arguments = request
368                .arguments
369                .clone()
370                .map_or(serde_json::Value::Null, serde_json::Value::Object);
371            match crate::tools::edit_files::normalize_edit_call(surface, arguments.clone()) {
372                Ok(normalized) => Some(normalized),
373                Err(error) => {
374                    let mut result =
375                        outcomes::tool_failure(request.name.as_ref(), &error, Some(&arguments))?;
376                    outcomes::enforce_next_action_policy(&mut result, effective_tool_policy);
377                    outcomes::finalize_tool_result(&mut result);
378                    text_delivery.apply(&mut result);
379                    return Ok(result.into());
380                }
381            }
382        } else {
383            None
384        };
385        let policy_allows_call =
386            tool_kind.is_some_and(|kind| effective_tool_policy.allows_call_kind(kind));
387        if !policy_allows_call {
388            return Err(McpError::invalid_request(
389                format!("Tool is not available for this principal: {}", request.name),
390                None,
391            ));
392        }
393        if normalized_edit.as_ref().is_some_and(|edit| edit.verification.is_some())
394            && !effective_tool_policy.edit_permissions().allows_verification()
395        {
396            return Err(McpError::invalid_request(
397                format!("{} verification requires BashCommand authority", request.name),
398                None,
399            ));
400        }
401        let affinity = session_affinity_from_context(&context);
402        let conversation_identity = conversation_identity_from_context(&context);
403        let (request, scope) =
404            scope_tool_request(request, principal, affinity, conversation_identity.as_deref())
405                .map_err(|error| {
406                    McpError::invalid_request(format!("Cannot scope remote request: {error}"), None)
407                })?;
408        let mut usage = UsageEvent::start(&request, &scope, &context);
409        let trace = usage.trace();
410        Box::pin(trace.scope(async {
411            let guard = phase(Phase::WorkspaceCheck);
412            let workspace_result = self
413                .validate_workspace_coherence(
414                    &request,
415                    &scope,
416                    affinity,
417                    conversation_identity.as_deref(),
418                )
419                .await;
420            guard.finish(workspace_result.is_ok());
421            match workspace_result {
422                Ok(coherence) => usage.set_workspace_coherence(coherence.as_str()),
423                Err(error) => {
424                    usage.set_workspace_coherence("rejected");
425                    let arguments = request.arguments.clone().map(serde_json::Value::Object);
426                    let guard = phase(Phase::ResponseFinalize);
427                    let mut result =
428                        outcomes::tool_failure(request.name.as_ref(), &error, arguments.as_ref())?;
429                    outcomes::enforce_next_action_policy(&mut result, effective_tool_policy);
430                    outcomes::finalize_tool_result(&mut result);
431                    text_delivery.apply(&mut result);
432                    scope.unscope_result(&mut result);
433                    let status = outcomes::result_status(&result);
434                    let response_bytes = outcomes::result_size_bytes(&result);
435                    guard.finish(true);
436                    usage.emit("tool_error", &status, response_bytes, Some(&result));
437                    return Ok(result.into());
438                }
439            }
440            let compact_bash_output = supports_compact_bash_output(&context);
441            let wait_budget = WaitBudget::from_context(&context);
442            let wait_field = match tool_kind {
443                Some(ToolKind::BashCommand) => request
444                    .arguments
445                    .as_ref()
446                    .and_then(|a| a.get("wait_for_seconds"))
447                    .map(|v| ("wait_for_seconds", v)),
448                Some(ToolKind::Execution) => request
449                    .arguments
450                    .as_ref()
451                    .and_then(|a| a.get("action"))
452                    .filter(|a| a.get("type").and_then(serde_json::Value::as_str) == Some("wait"))
453                    .and_then(|a| a.get("timeout_seconds"))
454                    .map(|v| ("timeout_seconds", v)),
455                _ => None,
456            };
457            if let Some((name, _value)) = wait_field.filter(|(_, value)| {
458                !value.is_null()
459                    && value.as_f64().is_none_or(|n| n < 0.0 || n > f64::from(f32::MAX))
460            }) {
461                let error = crate::errors::WinxError::InvalidInput(format!(
462                    "{name} must be a finite nonnegative number"
463                ));
464                let arguments = request.arguments.clone().map(serde_json::Value::Object);
465                let mut result =
466                    outcomes::tool_failure(request.name.as_ref(), &error, arguments.as_ref())?;
467                outcomes::enforce_next_action_policy(&mut result, effective_tool_policy);
468                outcomes::finalize_tool_result(&mut result);
469                text_delivery.apply(&mut result);
470                scope.unscope_result(&mut result);
471                usage.emit(
472                    "tool_error",
473                    &outcomes::result_status(&result),
474                    outcomes::result_size_bytes(&result),
475                    Some(&result),
476                );
477                return Ok(result.into());
478            }
479            let supports_tasks = context
480                .client_capabilities()
481                .is_some_and(|capabilities| capabilities.supports_tasks());
482            let wait_policy = if tool_kind == Some(ToolKind::BashCommand) {
483                Some(Self::bash_wait_policy(&request)?)
484            } else {
485                None
486            };
487            if wait_policy == Some(BashWaitPolicy::UntilComplete) {
488                let parsed_bash: Option<BashCommand> = Self::parse_ordinary_bash_request(&request);
489                if let Some(bash) = parsed_bash.as_ref() {
490                    let is_foreground_command = matches!(
491                        bash.action_json,
492                        crate::types::BashCommandAction::Command { is_background: false, .. }
493                    );
494                    if !is_foreground_command {
495                        let action = match &bash.action_json {
496                            crate::types::BashCommandAction::Command {
497                                is_background: true,
498                                ..
499                            } => "background_command",
500                            crate::types::BashCommandAction::Command { .. } => "foreground_command",
501                            crate::types::BashCommandAction::StatusCheck { .. } => "status_check",
502                            crate::types::BashCommandAction::SendText { .. } => "send_text",
503                            crate::types::BashCommandAction::SendSpecials { .. } => "send_specials",
504                            crate::types::BashCommandAction::SendAscii { .. } => "send_ascii",
505                            crate::types::BashCommandAction::Screen { .. } => "screen",
506                            crate::types::BashCommandAction::WaitForTurn { .. } => "wait_for_turn",
507                            crate::types::BashCommandAction::Inspect { .. } => "inspect",
508                            crate::types::BashCommandAction::Prompt { .. } => "prompt",
509                        };
510                        let error = crate::errors::WinxError::InvalidWaitPolicyForAction {
511                            wait_policy: "until_complete".to_string(),
512                            action: action.to_string(),
513                        };
514                        let arguments = request.arguments.clone().map(serde_json::Value::Object);
515                        let guard = phase(Phase::ResponseFinalize);
516                        let mut result =
517                            outcomes::tool_failure("BashCommand", &error, arguments.as_ref())?;
518                        outcomes::enforce_next_action_policy(&mut result, effective_tool_policy);
519                        outcomes::finalize_tool_result(&mut result);
520                        text_delivery.apply(&mut result);
521                        scope.unscope_result(&mut result);
522                        let status = outcomes::result_status(&result);
523                        let response_bytes = outcomes::result_size_bytes(&result);
524                        guard.finish(true);
525                        usage.emit("tool_error", &status, response_bytes, Some(&result));
526                        return Ok(result.into());
527                    }
528                }
529            }
530
531            let eligible = Self::bash_task_is_eligible(&request);
532            let generation_bound_runtime = if supports_tasks && eligible {
533                let thread_id = request
534                    .arguments
535                    .as_ref()
536                    .and_then(|arguments| arguments.get("thread_id"))
537                    .and_then(serde_json::Value::as_str)
538                    .unwrap_or_default();
539                let (slot, session_guard) = self.tool_session_for(thread_id).await;
540                let supported = self
541                    .shell_runtime
542                    .supports_generation_bound_actions_for(&slot)
543                    .await
544                    .unwrap_or(false);
545                drop(session_guard);
546                supported
547            } else {
548                false
549            };
550            let route = wait_policy.map_or(BashTaskRoute::Synchronous, |policy| {
551                bash_task_route(supports_tasks, generation_bound_runtime, eligible, policy)
552            });
553
554            match route {
555                BashTaskRoute::Immediate => {
556                    let reservation = self.reserve_bash_task(&request, &scope).await?;
557                    let effective_wait = usage.effective_wait_for(
558                        &request,
559                        BashWaitPolicy::UntilComplete,
560                        EffectiveWaitMode::Task,
561                    );
562                    return match self
563                        .start_reserved_bash_task(
564                            reservation,
565                            request,
566                            scope,
567                            None,
568                            compact_bash_output,
569                        )
570                        .await
571                    {
572                        Ok(task) => {
573                            usage.record_effective_wait(effective_wait);
574                            usage.emit("task", "working", 0, None);
575                            Ok(CallToolResponse::Task(task))
576                        }
577                        Err(error) => {
578                            usage.emit("protocol_error", "failed", 0, None);
579                            Err(error)
580                        }
581                    };
582                }
583                BashTaskRoute::Adaptive => {
584                    let reservation = self.reserve_bash_task(&request, &scope).await?;
585                    let task_request = request.clone();
586                    let inline_request =
587                        normalized_wait_request(request, BashWaitPolicy::Adaptive, true)?;
588                    let effective_wait = usage.effective_wait_for(
589                        &inline_request,
590                        BashWaitPolicy::Adaptive,
591                        EffectiveWaitMode::TaskInline,
592                    );
593                    match self
594                        .execute_tool_call(
595                            inline_request,
596                            adaptive_action_options(compact_bash_output),
597                            effective_tool_policy.edit_permissions(),
598                        )
599                        .await
600                    {
601                        Ok(execution)
602                            if outcomes::result_status(&execution.result) == "running" =>
603                        {
604                            usage.record_effective_wait(effective_wait);
605                            return match self
606                                .start_reserved_bash_task(
607                                    reservation,
608                                    task_request,
609                                    scope,
610                                    Some(execution),
611                                    compact_bash_output,
612                                )
613                                .await
614                            {
615                                Ok(task) => {
616                                    usage.emit("task", "working", 0, None);
617                                    Ok(CallToolResponse::Task(task))
618                                }
619                                Err(error) => {
620                                    usage.emit("protocol_error", "failed", 0, None);
621                                    Err(error)
622                                }
623                            };
624                        }
625                        Ok(mut execution) => {
626                            usage.record_effective_wait(effective_wait);
627                            self.release_bash_task(&reservation).await;
628                            let guard = phase(Phase::ResponseFinalize);
629                            outcomes::attach_effective_wait(&mut execution.result, effective_wait);
630                            outcomes::enforce_next_action_policy(
631                                &mut execution.result,
632                                effective_tool_policy,
633                            );
634                            text_delivery.apply(&mut execution.result);
635                            scope.unscope_result(&mut execution.result);
636                            let status = outcomes::result_status(&execution.result);
637                            let outcome = if execution.result.is_error == Some(true) {
638                                "tool_error"
639                            } else {
640                                "ok"
641                            };
642                            let response_bytes = outcomes::result_size_bytes(&execution.result);
643                            guard.finish(true);
644                            usage.emit(outcome, &status, response_bytes, Some(&execution.result));
645                            return Ok(execution.result.into());
646                        }
647                        Err(mut error) => {
648                            self.release_bash_task(&reservation).await;
649                            let guard = phase(Phase::ResponseFinalize);
650                            scope.unscope_error(&mut error);
651                            guard.finish(true);
652                            usage.emit("protocol_error", "failed", 0, None);
653                            return Err(error);
654                        }
655                    }
656                }
657                BashTaskRoute::Synchronous => {}
658            }
659
660            let workflow_authority = super::workflow_types::WorkflowAuthority {
661                principal_key: scope.principal().map_or_else(
662                    || "local".to_string(),
663                    |principal| format!("http:{}", principal.id()),
664                ),
665                bash_allowed: effective_tool_policy.allows_call_kind(ToolKind::BashCommand),
666            };
667            let budgeted = match (wait_policy, wait_budget) {
668                (Some(policy), Some(budget)) => {
669                    let bash = Self::parse_ordinary_bash_request(&request);
670                    budget.normalize_bash_request(&request, bash.as_ref(), policy)?
671                }
672                _ => None,
673            };
674            let return_when_awaiting_input = budgeted.is_some();
675            let (request, effective_policy) = match (budgeted, wait_policy) {
676                (Some((request, policy)), _) => (request, Some(policy)),
677                (None, Some(policy)) => {
678                    (normalized_wait_request(request, policy, false)?, Some(policy))
679                }
680                (None, None) => (request, None),
681            };
682            let request = match wait_budget {
683                Some(budget) if tool_kind == Some(ToolKind::Execution) => {
684                    budget.normalize_execution_request(request)
685                }
686                _ => request,
687            };
688            let effective_wait = effective_policy
689                .and_then(|policy| {
690                    usage.effective_wait_for(&request, policy, EffectiveWaitMode::Synchronous)
691                })
692                .or_else(|| usage.effective_execution_wait(&request));
693            let follow_up = matches!(tool_kind, Some(ToolKind::BashCommand | ToolKind::Execution))
694                .then(|| request.clone());
695            match Box::pin(WaitBudget::scope_execution_ceiling(
696                wait_budget,
697                self.execute_tool_call_with_authority(
698                    request,
699                    ShellActionOptions {
700                        compact_output: compact_bash_output,
701                        return_when_awaiting_input,
702                        ..ShellActionOptions::default()
703                    },
704                    effective_tool_policy.edit_permissions(),
705                    workflow_authority,
706                ),
707            ))
708            .await
709            {
710                Ok(mut execution) => {
711                    usage.record_effective_wait(effective_wait);
712                    let guard = phase(Phase::ResponseFinalize);
713                    let result = &mut execution.result;
714                    outcomes::attach_effective_wait(result, effective_wait);
715                    if let Some(request) = follow_up.as_ref() {
716                        WaitBudget::guide_follow_up(result, request);
717                    }
718                    outcomes::enforce_next_action_policy(result, effective_tool_policy);
719                    text_delivery.apply(result);
720                    scope.unscope_result(result);
721                    let status = outcomes::result_status(result);
722                    let outcome = if result.is_error == Some(true) { "tool_error" } else { "ok" };
723                    let response_bytes = outcomes::result_size_bytes(result);
724                    guard.finish(true);
725                    usage.emit(outcome, &status, response_bytes, Some(result));
726                    Ok(execution.result.into())
727                }
728                Err(mut error) => {
729                    let guard = phase(Phase::ResponseFinalize);
730                    scope.unscope_error(&mut error);
731                    guard.finish(true);
732                    usage.emit("protocol_error", "failed", 0, None);
733                    Err(error)
734                }
735            }
736        }))
737        .await
738    }
739}
740
741#[cfg(test)]
742mod tests {
743    use super::super::usage::usage_result_metadata;
744    use super::*;
745    use rmcp::model::CallToolResult;
746
747    mod wait_metrics;
748
749    #[test]
750    fn cache_hints_follow_negotiated_protocol_version() {
751        assert_eq!(
752            cache_hints_for_protocol(Some(ProtocolVersion::V_2026_07_28), CacheScope::Private,),
753            (Some(0), Some(CacheScope::Private))
754        );
755        assert_eq!(
756            cache_hints_for_protocol(Some(ProtocolVersion::V_2026_07_28), CacheScope::Public),
757            (Some(0), Some(CacheScope::Public))
758        );
759        assert_eq!(
760            cache_hints_for_protocol(Some(ProtocolVersion::V_2025_11_25), CacheScope::Private,),
761            (None, None)
762        );
763        assert_eq!(cache_hints_for_protocol(None, CacheScope::Private), (None, None));
764    }
765
766    #[test]
767    fn guardian_1_4_uses_synchronous_policy_fallback_even_for_task_clients() {
768        assert_eq!(
769            bash_task_route(true, false, true, BashWaitPolicy::Adaptive),
770            BashTaskRoute::Synchronous
771        );
772        assert_eq!(
773            bash_task_route(true, false, true, BashWaitPolicy::UntilComplete),
774            BashTaskRoute::Synchronous
775        );
776    }
777
778    #[test]
779    fn task_routing_requires_client_runtime_and_foreground_eligibility() {
780        assert_eq!(
781            bash_task_route(true, true, true, BashWaitPolicy::Adaptive),
782            BashTaskRoute::Adaptive
783        );
784        assert_eq!(
785            bash_task_route(true, true, true, BashWaitPolicy::UntilComplete),
786            BashTaskRoute::Immediate
787        );
788        assert_eq!(
789            bash_task_route(true, true, true, BashWaitPolicy::ReturnEarly),
790            BashTaskRoute::Synchronous
791        );
792        assert_eq!(
793            bash_task_route(false, true, true, BashWaitPolicy::UntilComplete),
794            BashTaskRoute::Synchronous
795        );
796        assert_eq!(
797            bash_task_route(true, true, false, BashWaitPolicy::UntilComplete),
798            BashTaskRoute::Synchronous
799        );
800    }
801
802    #[test]
803    #[allow(clippy::expect_used)]
804    fn receipt_bound_verify_routing_does_not_emit_false_errors() {
805        use std::sync::atomic::{AtomicUsize, Ordering};
806        use std::sync::Arc;
807        use tracing_subscriber::{layer::SubscriberExt, Layer};
808
809        struct ErrorCounter(Arc<AtomicUsize>);
810        impl<S: tracing::Subscriber> Layer<S> for ErrorCounter {
811            fn on_event(
812                &self,
813                event: &tracing::Event<'_>,
814                _context: tracing_subscriber::layer::Context<'_, S>,
815            ) {
816                if *event.metadata().level() == tracing::Level::ERROR {
817                    self.0.fetch_add(1, Ordering::SeqCst);
818                }
819            }
820        }
821        let errors = Arc::new(AtomicUsize::new(0));
822        let subscriber = tracing_subscriber::registry().with(ErrorCounter(Arc::clone(&errors)));
823        let arguments = serde_json::json!({
824            "action_json": {
825                "type": "verify",
826                "verification_id": "verify_123",
827                "command": "cargo test"
828            },
829            "thread_id": "thread"
830        });
831        let request = CallToolRequestParams::new("BashCommand")
832            .with_arguments(arguments.as_object().expect("arguments object").clone());
833        tracing::subscriber::with_default(subscriber, || {
834            assert!(WinxService::parse_ordinary_bash_request(&request).is_none());
835            assert!(!WinxService::bash_task_is_eligible(&request));
836            for (action, eligible) in [
837                (serde_json::json!({"type": "status_check", "status_check": true}), false),
838                (
839                    serde_json::json!({"type": "command", "command": "true", "is_background": true}),
840                    false,
841                ),
842                (serde_json::json!({"type": "command", "command": "true"}), true),
843            ] {
844                let arguments = serde_json::json!({"action_json": action, "thread_id": "thread"});
845                let ordinary = CallToolRequestParams::new("BashCommand")
846                    .with_arguments(arguments.as_object().expect("arguments object").clone());
847                assert!(WinxService::parse_ordinary_bash_request(&ordinary).is_some());
848                assert_eq!(WinxService::bash_task_is_eligible(&ordinary), eligible);
849            }
850        });
851        assert_eq!(
852            errors.load(Ordering::SeqCst),
853            0,
854            "a valid verify action must not emit a parser error during task routing"
855        );
856    }
857
858    #[test]
859    fn adaptive_action_requires_live_generation_binding() {
860        let options = adaptive_action_options(true);
861        assert!(options.compact_output);
862        assert!(options.require_generation_binding);
863    }
864
865    #[test]
866    fn usage_metadata_reads_image_code_map_and_temp_envelopes() {
867        let result_with = |structured| {
868            let mut result = CallToolResult::success(Vec::new());
869            result.structured_content = Some(structured);
870            result
871        };
872        let image = result_with(serde_json::json!({
873            "data": {"result": {
874                "source_bytes": 17_000_000,
875                "delivered_bytes": 2_000_000,
876                "transcoded": true,
877                "deduplicated": false
878            }}
879        }));
880        let metadata = usage_result_metadata(Some(&image));
881        assert_eq!(metadata.source_bytes, 17_000_000);
882        assert_eq!(metadata.payload_bytes, 2_000_000);
883        assert!(metadata.image_transcoded);
884        assert!(!metadata.image_deduplicated);
885
886        let code_map = result_with(serde_json::json!({
887            "source_kind": "canonical",
888            "payload_bytes": 12_345
889        }));
890        let metadata = usage_result_metadata(Some(&code_map));
891        assert_eq!(metadata.source_kind, "canonical");
892        assert_eq!(metadata.payload_bytes, 12_345);
893
894        let temporary = result_with(serde_json::json!({
895            "data": {
896                "session_files": 129,
897                "session_bytes": 4096,
898                "stale_pruned_files": 32,
899                "stale_pruned_bytes": 2048,
900                "temporary_artifact_cleanup_required": true
901            }
902        }));
903        let metadata = usage_result_metadata(Some(&temporary));
904        assert_eq!(metadata.temporary_session_files, Some(129));
905        assert_eq!(metadata.temporary_session_bytes, Some(4096));
906        assert_eq!(metadata.temporary_stale_pruned_files, Some(32));
907        assert_eq!(metadata.temporary_stale_pruned_bytes, Some(2048));
908        assert_eq!(metadata.temporary_over_budget, Some(true));
909
910        let edit_follow_up = result_with(serde_json::json!({
911            "errorCode": "verification_failed",
912            "retrySameCall": false,
913            "nextAction": {"tool": "VerifyEdit"},
914            "requiredReads": [],
915            "data": {
916                "edit_applied": true,
917                "verification_status": "failed",
918                "mutation_transition": "committed",
919                "mutation_replayed": false,
920                "mutation_receipt_persisted": true
921            }
922        }));
923        let metadata = usage_result_metadata(Some(&edit_follow_up));
924        assert_eq!(metadata.error_code, "verification_failed");
925        assert_eq!(metadata.recovery.next_action_tool, "VerifyEdit");
926        assert_eq!(metadata.recovery.required_read_count, 0);
927        assert!(!metadata.recovery.retry_same_call);
928        assert_eq!(metadata.recovery.edit_applied, Some(true));
929        assert!(!metadata.recovery.fresh_read_required);
930        assert_eq!(metadata.recovery.verification_status, "failed");
931        assert_eq!(metadata.recovery.mutation_transition, "committed");
932        assert_eq!(metadata.recovery.mutation_receipt_state, "persisted");
933
934        let edit_conflict = result_with(serde_json::json!({
935            "errorCode": "search_block_not_found",
936            "retrySameCall": false,
937            "nextAction": {"tool": "ReadFiles"},
938            "requiredReads": [{"path": "/workspace/lib.rs", "ranges": []}],
939            "data": {
940                "edit_applied": false,
941                "fresh_read_required": true,
942                "recovery_attempt": 2,
943                "recovery_escalated": true
944            }
945        }));
946        let metadata = usage_result_metadata(Some(&edit_conflict));
947        assert_eq!(metadata.error_code, "search_block_not_found");
948        assert_eq!(metadata.recovery.next_action_tool, "ReadFiles");
949        assert_eq!(metadata.recovery.required_read_count, 1);
950        assert!(!metadata.recovery.retry_same_call);
951        assert_eq!(metadata.recovery.edit_applied, Some(false));
952        assert!(metadata.recovery.fresh_read_required);
953        assert_eq!(metadata.recovery.verification_status, "");
954        assert_eq!(metadata.recovery.recovery_attempt, 2);
955        assert_eq!(metadata.recovery.recovery_level, "escalated");
956    }
957
958    #[test]
959    #[allow(clippy::expect_used, clippy::float_cmp)]
960    fn synchronous_wait_policies_have_explicit_upper_bounds() {
961        let request = |wait_for_seconds: Option<f32>| {
962            let mut arguments = serde_json::Map::new();
963            arguments.insert("command".to_string(), serde_json::Value::String("pwd".to_string()));
964            if let Some(wait) = wait_for_seconds {
965                arguments.insert("wait_for_seconds".to_string(), serde_json::json!(wait));
966            }
967            CallToolRequestParams::new("BashCommand").with_arguments(arguments)
968        };
969        let normalized = |policy, wait| {
970            normalized_wait_request(request(wait), policy, false)
971                .expect("valid wait request")
972                .arguments
973                .and_then(|arguments| arguments.get("wait_for_seconds").cloned())
974                .and_then(|value| value.as_f64())
975                .expect("normalized wait")
976        };
977
978        assert_eq!(normalized(BashWaitPolicy::Adaptive, Some(500.0)), 60.0);
979        assert_eq!(normalized(BashWaitPolicy::UntilComplete, None), 60.0);
980        assert_eq!(normalized(BashWaitPolicy::ReturnEarly, Some(500.0)), 5.0);
981        assert_eq!(normalized(BashWaitPolicy::ReturnEarly, None), 0.25);
982    }
983
984    #[test]
985    fn contract_negative_bash_wait_is_rejected() {
986        let request =
987            CallToolRequestParams::new("BashCommand").with_arguments(serde_json::Map::from_iter([
988                ("wait_for_seconds".to_string(), serde_json::json!(-1)),
989            ]));
990        assert!(normalized_wait_request(request, BashWaitPolicy::Adaptive, false).is_err());
991    }
992}