diff --git a/codex-rs/tool-api/src/bundle.rs b/codex-rs/tool-api/src/bundle.rs index ba24e1e2dd3c..9c03184c6dd4 100644 --- a/codex-rs/tool-api/src/bundle.rs +++ b/codex-rs/tool-api/src/bundle.rs @@ -72,8 +72,56 @@ pub trait ToolExecutor: Send + Sync { /// Returns whether the call may mutate user state. /// /// Hosts can use this conservative signal for serialization or approval - /// policy. Context-free read tools should keep the default. + /// policy. Read-only tools should override this default. fn is_mutating<'a>(&'a self, _call: &'a ToolCall) -> BoolFuture<'a> { - Box::pin(async { false }) + Box::pin(async { true }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::task::Context; + use std::task::Poll; + use std::task::Wake; + use std::task::Waker; + + use super::*; + use crate::JsonToolOutput; + use crate::ToolInput; + + struct DefaultMutatingExecutor; + + impl ToolExecutor<()> for DefaultMutatingExecutor { + fn execute<'a>(&'a self, _call: ToolCall<()>) -> ToolFuture<'a> { + Box::pin(async { + Ok(Box::new(JsonToolOutput::new(serde_json::json!(null))) as Box) + }) + } + } + + struct NoopWaker; + + impl Wake for NoopWaker { + fn wake(self: Arc) {} + } + + #[test] + fn contributed_tools_default_to_mutating() { + let call = ToolCall { + context: (), + call_id: "call-default-mutating".to_string(), + input: ToolInput::Function { + arguments: "{}".to_string(), + }, + }; + let mut future = DefaultMutatingExecutor.is_mutating(&call); + let waker = Waker::from(Arc::new(NoopWaker)); + let mut context = Context::from_waker(&waker); + + assert!(matches!( + future.as_mut().poll(&mut context), + Poll::Ready(true) + )); } }