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