Skip to main content

winx_code_agent/server/
handler.rs

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