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