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 require_generation_binding: true,
65 ..ShellActionOptions::default()
66 }
67}
68
69fn 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)] impl 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 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)] 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 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}