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