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