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