#[cfg(feature = "tool_health")]
pub mod health;
#[cfg(feature = "tool_shield")]
pub mod shield;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::Value;
use std::collections::HashMap;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use crate::message::ToolContent as MessageToolContent;
pub mod permission;
pub mod registry;
pub use permission::PermissionCheck;
pub use registry::{FnTool, ToolRegistry};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSchema {
pub tool: String,
pub description: String,
pub input_schema: Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum DisplayHint {
Text,
Diff,
Json,
Code {
language: String,
},
Suppress,
Markdown,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ToolOutput {
pub payload: MessageToolContent,
pub is_error: bool,
pub display_hint: Option<DisplayHint>,
}
impl ToolOutput {
pub fn success(payload: impl Into<MessageToolContent>) -> Self {
Self {
payload: payload.into(),
is_error: false,
display_hint: None,
}
}
pub fn error(payload: impl Into<MessageToolContent>) -> Self {
Self {
payload: payload.into(),
is_error: true,
display_hint: None,
}
}
pub fn text(text: impl Into<String>) -> Self {
Self::success(text.into())
}
pub fn error_text(text: impl Into<String>) -> Self {
Self::error(text.into())
}
#[must_use]
pub fn with_hint(mut self, hint: DisplayHint) -> Self {
self.display_hint = Some(hint);
self
}
#[must_use]
pub fn text_content(&self) -> String {
match &self.payload {
MessageToolContent::Text(s) => s.clone(),
MessageToolContent::Multipart(parts) => {
use crate::message::ToolContentPart;
parts
.iter()
.filter_map(|p| match p {
ToolContentPart::Text { text } => Some(text.clone()),
ToolContentPart::Image { .. } => None,
})
.collect::<Vec<_>>()
.join("\n")
}
}
}
pub fn structured<T: serde::Serialize>(value: &T) -> Self {
match serde_json::to_string(value) {
Ok(json) => Self::success(json),
Err(e) => Self::error_text(format!("structured serialization failed: {e}")),
}
}
#[must_use]
pub fn structured_value(&self) -> Option<serde_json::Value> {
let MessageToolContent::Text(s) = &self.payload else {
return None;
};
serde_json::from_str(s).ok()
}
#[must_use]
pub fn structured_as<T: crate::structured::StructuredOutput + DeserializeOwned>(
&self,
) -> Option<T> {
self.structured_value().and_then(|v| T::from_value(v).ok())
}
}
impl From<String> for ToolOutput {
fn from(s: String) -> Self {
Self::text(s)
}
}
impl From<&str> for ToolOutput {
fn from(s: &str) -> Self {
Self::text(s)
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ToolDispatchResult {
pub tool_call_id: String,
pub output: crate::message::ToolContent,
pub is_error: bool,
pub duration: Duration,
pub resolved_tool_name: String,
pub display_hint: Option<DisplayHint>,
}
impl ToolDispatchResult {
#[must_use]
pub fn ok(tool_name: &str, output: String, duration: Duration) -> Self {
Self {
tool_call_id: String::new(),
output: crate::message::ToolContent::Text(output),
is_error: false,
duration,
resolved_tool_name: tool_name.to_string(),
display_hint: None,
}
}
#[must_use]
pub fn err(tool_name: &str, message: String, duration: Duration) -> Self {
Self {
tool_call_id: String::new(),
output: crate::message::ToolContent::Text(message),
is_error: true,
duration,
resolved_tool_name: tool_name.to_string(),
display_hint: None,
}
}
#[must_use]
pub fn from_tool_output(tool_name: &str, output: ToolOutput, duration: Duration) -> Self {
Self::from(output)
.with_tool_name(tool_name)
.with_duration(duration)
}
#[must_use]
pub fn with_call_id(mut self, id: impl Into<String>) -> Self {
self.tool_call_id = id.into();
self
}
#[must_use]
pub fn with_tool_name(mut self, name: &str) -> Self {
name.clone_into(&mut self.resolved_tool_name);
self
}
#[must_use]
pub fn with_duration(mut self, dur: Duration) -> Self {
self.duration = dur;
self
}
#[must_use]
pub fn from_tool_error(tool_name: &str, error: &ToolError, duration: Duration) -> Self {
Self {
tool_call_id: String::new(),
output: crate::message::ToolContent::Text(error.to_string()),
is_error: true,
duration,
resolved_tool_name: tool_name.to_string(),
display_hint: None,
}
}
#[must_use]
pub fn from_result(
tool_name: &str,
result: Result<ToolOutput, ToolError>,
duration: Duration,
) -> Self {
match result {
Ok(output) => Self::from_tool_output(tool_name, output, duration),
Err(e) => Self::from_tool_error(tool_name, &e, duration),
}
}
}
impl From<ToolOutput> for ToolDispatchResult {
fn from(output: ToolOutput) -> Self {
Self {
tool_call_id: String::new(),
output: output.payload,
is_error: output.is_error,
duration: Duration::ZERO,
resolved_tool_name: String::new(),
display_hint: output.display_hint,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ToolError {
#[error("Tool not found: {0}. Available: {1}")]
NotFound(String, String),
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Execution error: {0}")]
Execution(String),
#[error("Permission denied: {0}")]
Permission(String),
#[error("File not found: {0}")]
FileNotFound(String),
#[error("Timeout after {0}s")]
Timeout(u64),
#[error("Cancelled")]
Cancelled,
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
impl ToolError {
pub fn not_found(tool: impl Into<String>, available: &[&str]) -> Self {
let tool = tool.into();
let available_str = if available.is_empty() {
"none registered".to_string()
} else if available.len() <= 10 {
available.join(", ")
} else {
let count = available.len().saturating_sub(10);
format!(
"{}... (and {count} more)",
available
.iter()
.take(10)
.copied()
.collect::<Vec<_>>()
.join(", "),
)
};
Self::NotFound(tool, available_str)
}
}
#[derive(Clone)]
pub struct ToolContext {
pub cwd: String,
pub session_id: uuid::Uuid,
pub temp_dir: String,
pub is_non_interactive: bool,
pub user_context: HashMap<String, String>,
pub extensions: HashMap<std::any::TypeId, Arc<dyn std::any::Any + Send + Sync>>,
}
impl ToolContext {
pub fn set_extension<T: 'static + Send + Sync>(&mut self, val: T) {
self.extensions
.insert(std::any::TypeId::of::<T>(), Arc::new(val));
}
#[must_use]
pub fn get_extension<T: 'static>(&self) -> Option<&T> {
self.extensions
.get(&std::any::TypeId::of::<T>())
.and_then(|arc| arc.downcast_ref::<T>())
}
}
impl fmt::Debug for ToolContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ToolContext")
.field("cwd", &self.cwd)
.field("session_id", &self.session_id)
.field("temp_dir", &self.temp_dir)
.field("is_non_interactive", &self.is_non_interactive)
.field("user_context", &self.user_context)
.field("extensions", &format!("{} entries", self.extensions.len()))
.finish()
}
}
impl Default for ToolContext {
fn default() -> Self {
Self {
cwd: ".".to_string(),
session_id: uuid::Uuid::new_v4(),
temp_dir: std::env::temp_dir().to_string_lossy().to_string(),
is_non_interactive: false,
user_context: HashMap::new(),
extensions: HashMap::new(),
}
}
}
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn schema(&self) -> ToolSchema;
fn call(
&self,
input: Value,
context: &ToolContext,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>>;
fn is_concurrency_safe(&self) -> bool {
false
}
fn is_safe_for_concurrent_execution(&self, _input: &Value) -> bool {
self.is_concurrency_safe()
}
fn resource_key(&self, _input: &Value) -> Option<String> {
None
}
fn is_read_only(&self) -> bool {
false
}
fn system_prompt(&self) -> Option<String> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "testing")]
use crate::engine::RunConfig;
#[cfg(feature = "testing")]
use crate::engine::core::Loop;
use serde_json::json;
struct EchoTool;
impl Tool for EchoTool {
fn name(&self) -> &'static str {
"echo"
}
fn description(&self) -> &'static str {
"Echoes back the input"
}
fn schema(&self) -> ToolSchema {
ToolSchema {
tool: "echo".into(),
description: "Echoes back the input".into(),
input_schema: json!({
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"]
}),
}
}
fn call(
&self,
input: Value,
_context: &ToolContext,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
let msg = input
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Box::pin(async move { Ok(ToolOutput::text(msg)) })
}
fn is_concurrency_safe(&self) -> bool {
true
}
fn is_read_only(&self) -> bool {
true
}
}
struct FailTool;
impl Tool for FailTool {
fn name(&self) -> &'static str {
"fail"
}
fn description(&self) -> &'static str {
"Always fails"
}
fn schema(&self) -> ToolSchema {
ToolSchema {
tool: "fail".into(),
description: "Always fails".into(),
input_schema: json!({ "type": "object", "properties": {} }),
}
}
fn call(
&self,
_input: Value,
_context: &ToolContext,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>> {
Box::pin(async { Err(ToolError::Execution("always fails".into())) })
}
}
#[test]
fn test_tool_result_success() {
let result = ToolOutput::text("hello");
assert!(!result.is_error);
assert_eq!(result.text_content(), "hello");
}
#[test]
fn test_tool_result_error() {
let result = ToolOutput::error_text("something went wrong");
assert!(result.is_error);
assert_eq!(result.text_content(), "something went wrong");
}
#[test]
fn test_tool_result_from_string() {
let result: ToolOutput = "hello".into();
assert!(!result.is_error);
}
#[test]
fn test_tool_result_from_str() {
let result: ToolOutput = "hello".into();
assert!(!result.is_error);
}
#[test]
fn test_tool_error_not_found() {
let err = ToolError::not_found("missing", &["tool_a", "tool_b"]);
assert!(err.to_string().contains("missing"));
assert!(err.to_string().contains("tool_a"));
}
#[test]
fn test_tool_error_not_found_empty() {
let err = ToolError::not_found("missing", &[]);
assert!(err.to_string().contains("none registered"));
}
#[test]
fn test_tool_error_not_found_many() {
let tools: Vec<&str> = (0..15)
.map(|i| Box::leak(format!("tool_{i}").into_boxed_str()) as &str)
.collect();
let err = ToolError::not_found("missing", &tools);
assert!(err.to_string().contains("and 5 more"));
}
#[test]
fn test_tool_context_default() {
let ctx = ToolContext::default();
assert_eq!(ctx.cwd, ".");
assert!(!ctx.is_non_interactive);
}
#[test]
fn test_permission_check_allow() {
let check = PermissionCheck::allow();
assert!(check.is_allow());
assert!(!check.is_deny());
}
#[test]
fn test_permission_check_deny() {
let check = PermissionCheck::deny("unsafe");
assert!(check.is_deny());
assert!(!check.is_allow());
}
#[test]
fn test_permission_check_ask() {
let check = PermissionCheck::ask("Run this?");
assert!(check.is_ask());
}
#[test]
fn test_permission_check_modify() {
let check = PermissionCheck::modify(json!({"safe": true}));
assert!(check.is_modify());
}
#[test]
fn test_tool_schema_serialization() {
let schema = ToolSchema {
tool: "test".into(),
description: "A test tool".into(),
input_schema: json!({"type": "object"}),
};
let json = serde_json::to_string(&schema).unwrap();
let back: ToolSchema = serde_json::from_str(&json).unwrap();
assert_eq!(schema.tool, back.tool);
}
#[test]
fn test_registry_register_and_get() {
let mut registry = ToolRegistry::new();
assert!(registry.is_empty());
registry.register(EchoTool);
assert_eq!(registry.len(), 1);
assert!(registry.contains("echo"));
let tool = registry.get("echo").unwrap();
assert_eq!(tool.name(), "echo");
}
#[test]
fn test_registry_get_missing() {
let registry = ToolRegistry::new();
assert!(registry.get("missing").is_none());
}
#[test]
fn test_registry_all_schemas() {
let mut registry = ToolRegistry::new();
registry.register(EchoTool);
let schemas = registry.all_schemas();
assert_eq!(schemas.len(), 1);
assert_eq!(schemas[0].tool, "echo");
}
#[test]
fn test_registry_tool_names() {
let mut registry = ToolRegistry::new();
registry.register(FailTool);
registry.register(EchoTool);
let names = registry.tool_names();
assert_eq!(names, vec!["echo", "fail"]);
}
#[test]
fn test_registry_concurrent_safe_tools() {
let mut registry = ToolRegistry::new();
registry.register(EchoTool);
registry.register(FailTool);
let safe = registry.concurrent_safe_tools();
assert_eq!(safe.len(), 1);
assert_eq!(safe[0].name(), "echo");
}
#[tokio::test]
async fn test_echo_tool_call() {
let tool = EchoTool;
let ctx = ToolContext::default();
let result = tool.call(json!({"message": "hello"}), &ctx).await;
assert!(result.is_ok());
let result = result.unwrap();
assert_eq!(result.text_content(), "hello");
}
#[tokio::test]
async fn test_fail_tool_call() {
let tool = FailTool;
let ctx = ToolContext::default();
let result = tool.call(json!({}), &ctx).await;
assert!(result.is_err());
}
#[test]
fn test_tool_trait_concurrency_default() {
let tool = FailTool;
assert!(!tool.is_concurrency_safe());
assert!(!tool.is_safe_for_concurrent_execution(&json!({})));
}
#[test]
fn test_tool_trait_read_only_default() {
let tool = FailTool;
assert!(!tool.is_read_only());
}
#[test]
fn test_tool_trait_system_prompt_default() {
let tool = EchoTool;
assert!(tool.system_prompt().is_none());
}
#[test]
fn tool_output_structured_round_trip() {
use crate::structured::StructuredOutput;
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
struct Action {
tool: String,
args: serde_json::Value,
}
impl StructuredOutput for Action {
fn name() -> &'static str {
"action"
}
fn schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"tool": { "type": "string" },
"args": {}
},
"required": ["tool", "args"]
})
}
}
let action = Action {
tool: "write".to_string(),
args: serde_json::json!({"path": "/a"}),
};
let out = ToolOutput::structured(&action);
assert!(!out.is_error);
assert!(out.structured_value().is_some());
let back: Action = out.structured_as().expect("should round-trip");
assert_eq!(back, action);
}
#[test]
fn tool_output_structured_value_none_for_non_json() {
let out = ToolOutput::text("not json");
assert!(out.structured_value().is_none());
}
#[test]
fn tool_output_structured_with_plain_serialize() {
#[derive(serde::Serialize)]
struct Count {
n: u32,
}
let out = ToolOutput::structured(&Count { n: 7 });
assert!(!out.is_error);
let v = out.structured_value().expect("should be valid JSON");
assert_eq!(v["n"], 7);
}
#[test]
fn tool_output_structured_primitive() {
let out = ToolOutput::structured(&42u32);
assert!(!out.is_error);
let v = out.structured_value().expect("should parse");
assert_eq!(v, 42);
}
#[test]
fn tool_output_structured_value_for_multipart_is_none() {
use crate::message::{ToolContent, ToolContentPart};
let out = ToolOutput::success(ToolContent::Multipart(vec![ToolContentPart::Text {
text: "a".into(),
}]));
assert!(out.structured_value().is_none());
}
#[test]
fn tool_output_structured_as_returns_none_when_type_mismatches() {
use crate::structured::StructuredOutput;
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq)]
struct Target {
name: String,
}
impl StructuredOutput for Target {
fn name() -> &'static str {
"target"
}
fn schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"]
})
}
}
let out = ToolOutput::text(r#"{"count": 5}"#);
let result: Option<Target> = out.structured_as();
assert!(result.is_none(), "mismatched shape should not deserialize");
}
#[test]
fn display_hint_with_hint_sets_field() {
let out = ToolOutput::text("x").with_hint(DisplayHint::Json);
assert_eq!(out.display_hint, Some(DisplayHint::Json));
}
#[test]
fn display_hint_default_is_none_for_all_constructors() {
assert_eq!(ToolOutput::text("x").display_hint, None);
assert_eq!(ToolOutput::error_text("x").display_hint, None);
assert_eq!(
ToolOutput::success(MessageToolContent::Text("x".to_string())).display_hint,
None
);
assert_eq!(
ToolOutput::error(MessageToolContent::Text("x".to_string())).display_hint,
None
);
let from_string: ToolOutput = String::from("x").into();
assert_eq!(from_string.display_hint, None);
let from_str: ToolOutput = "x".into();
assert_eq!(from_str.display_hint, None);
}
#[test]
fn display_hint_with_hint_does_not_mutate_payload_or_error_flag() {
let out = ToolOutput::error_text("boom").with_hint(DisplayHint::Suppress);
assert!(out.is_error, "is_error untouched");
assert_eq!(out.text_content(), "boom", "payload untouched");
assert_eq!(out.display_hint, Some(DisplayHint::Suppress));
}
#[test]
fn display_hint_code_carries_language() {
let out = ToolOutput::text("fn main() {}").with_hint(DisplayHint::Code {
language: "rust".into(),
});
match &out.display_hint {
Some(DisplayHint::Code { language }) => assert_eq!(language, "rust"),
other => panic!("expected Code{{language}}, got {other:?}"),
}
}
#[test]
fn display_hint_markdown_round_trips() {
let out = ToolOutput::text("# hi").with_hint(DisplayHint::Markdown);
assert_eq!(out.display_hint, Some(DisplayHint::Markdown));
}
#[test]
fn display_hint_is_clone_and_eq() {
let diff = DisplayHint::Diff;
assert_eq!(diff, DisplayHint::Diff);
let code = DisplayHint::Code {
language: "py".into(),
};
assert_eq!(
code,
DisplayHint::Code {
language: "py".into()
}
);
assert_ne!(DisplayHint::Text, DisplayHint::Diff);
}
#[test]
fn display_hint_serde_round_trip_tagged() {
let json = serde_json::to_string(&DisplayHint::Diff).expect("Diff serializes");
assert_eq!(json, "{\"type\":\"diff\"}");
let parsed: DisplayHint = serde_json::from_str(&json).expect("Diff deserializes");
assert_eq!(parsed, DisplayHint::Diff);
let code_json = serde_json::to_string(&DisplayHint::Code {
language: "rust".into(),
})
.expect("");
assert_eq!(code_json, "{\"type\":\"code\",\"language\":\"rust\"}");
let code_parsed: DisplayHint = serde_json::from_str(&code_json).expect("");
assert_eq!(
code_parsed,
DisplayHint::Code {
language: "rust".into()
}
);
}
#[test]
fn from_tool_output_forwards_display_hint() {
let out = ToolOutput::text("diff body").with_hint(DisplayHint::Diff);
let result: ToolDispatchResult = out.into();
assert_eq!(result.display_hint, Some(DisplayHint::Diff));
}
#[test]
fn tool_dispatch_result_ok_err_from_tool_error_carry_none() {
use std::time::Duration;
assert_eq!(
ToolDispatchResult::ok("t", "x".into(), Duration::ZERO).display_hint,
None
);
assert_eq!(
ToolDispatchResult::err("t", "x".into(), Duration::ZERO).display_hint,
None
);
}
#[test]
fn from_tool_output_constructor_forwards_hint() {
use std::time::Duration;
let out = ToolOutput::text("json body").with_hint(DisplayHint::Json);
let result = ToolDispatchResult::from_tool_output("t", out, Duration::ZERO);
assert_eq!(result.display_hint, Some(DisplayHint::Json));
}
#[test]
fn from_result_forwards_hint_on_ok_and_none_on_err() {
use std::time::Duration;
let ok: Result<ToolOutput, ToolError> =
Ok(ToolOutput::text("x").with_hint(DisplayHint::Markdown));
assert_eq!(
ToolDispatchResult::from_result("t", ok, Duration::ZERO).display_hint,
Some(DisplayHint::Markdown)
);
let err: Result<ToolOutput, ToolError> = Err(ToolError::not_found("t", &[]));
assert_eq!(
ToolDispatchResult::from_result("t", err, Duration::ZERO).display_hint,
None
);
}
#[test]
fn soft_error_preserves_display_hint() {
let soft_err = ToolOutput::error_text("conflict in foo.rs").with_hint(DisplayHint::Diff);
assert!(soft_err.is_error, "fixture is a soft error");
let result: ToolDispatchResult = soft_err.into();
assert_eq!(
result.display_hint,
Some(DisplayHint::Diff),
"soft-error outputs preserve their hint (only hard errors / panics drop it)"
);
}
struct HintedTool {
name: &'static str,
output_text: &'static str,
hint: Option<DisplayHint>,
}
impl Tool for HintedTool {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
"stub for display-hint threading tests"
}
fn schema(&self) -> ToolSchema {
ToolSchema {
tool: self.name.into(),
description: "stub for display-hint threading tests".into(),
input_schema: json!({"type": "object", "properties": {}}),
}
}
fn call(
&self,
_input: serde_json::Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, ToolError>> + Send + '_>>
{
let out = match self.hint.clone() {
Some(h) => ToolOutput::text(self.output_text).with_hint(h),
None => ToolOutput::text(self.output_text),
};
Box::pin(async move { Ok(out) })
}
}
#[derive(Default)]
struct PostCapture {
posts: std::sync::Mutex<Vec<crate::observer::ToolPostContext>>,
}
impl crate::observer::LoopObserver for PostCapture {
fn name(&self) -> &'static str {
"post-capture"
}
fn on_tool_post(&self, ctx: &crate::observer::ToolPostContext) {
crate::error::recover_guard(self.posts.lock()).push(ctx.clone());
}
}
#[cfg(feature = "testing")]
fn hinted_dispatch_setup(
tool_name: &'static str,
output_text: &'static str,
hint: Option<DisplayHint>,
) -> (
crate::engine::BareLoop<crate::testing::MockApiClient>,
std::sync::Arc<PostCapture>,
) {
use crate::testing::{MockApiClient, MockResponse, MockToolCall};
let client = MockApiClient::new("test-model").with_responses(vec![
MockResponse {
text: String::new(),
tool_call: Some(MockToolCall {
id: "call_1".into(),
name: tool_name.into(),
input: json!({}),
}),
stop_reason: "tool_use".into(),
},
MockResponse {
text: "done".into(),
tool_call: None,
stop_reason: "end_turn".into(),
},
]);
let mut registry = crate::tool::ToolRegistry::new();
registry.register(HintedTool {
name: tool_name,
output_text,
hint,
});
let mut agent = crate::engine::BareLoop::new(
std::sync::Arc::new(client),
registry,
crate::config::SessionConfig::default(),
);
let capture = std::sync::Arc::new(PostCapture::default());
agent.register_observer(capture.clone());
(agent, capture)
}
#[cfg(feature = "testing")]
#[tokio::test]
async fn hint_reaches_observer_on_normal_path() {
let (mut agent, capture) = hinted_dispatch_setup(
"diff_tool",
"@@ -1 +1 @@\n-old\n+new",
Some(DisplayHint::Diff),
);
agent
.run("edit the file", &RunConfig::default())
.await
.unwrap();
let posts = crate::error::recover_guard(capture.posts.lock()).clone();
assert_eq!(posts.len(), 1, "exactly one tool call this turn");
assert_eq!(
posts[0].display_hint,
Some(DisplayHint::Diff),
"the hint set by the tool must reach the observer"
);
assert_eq!(posts[0].tool, "diff_tool");
}
#[cfg(feature = "testing")]
#[tokio::test]
async fn no_hint_tool_yields_none_at_observer() {
let (mut agent, capture) = hinted_dispatch_setup("plain", "just text", None);
agent.run("go", &RunConfig::default()).await.unwrap();
let posts = crate::error::recover_guard(capture.posts.lock()).clone();
assert_eq!(posts.len(), 1);
assert_eq!(
posts[0].display_hint, None,
"no hint set → None at observer"
);
}
#[cfg(feature = "testing")]
#[tokio::test]
async fn suppress_hint_keeps_full_payload_into_conversation() {
let (mut agent, capture) =
hinted_dispatch_setup("reader", "the quick brown fox", Some(DisplayHint::Suppress));
agent.run("read it", &RunConfig::default()).await.unwrap();
let posts = crate::error::recover_guard(capture.posts.lock()).clone();
assert_eq!(posts[0].display_hint, Some(DisplayHint::Suppress));
let conv = agent.conversation();
let tool_result_text: String = conv
.iter()
.flat_map(|m| {
m.parts.iter().filter_map(|p| match p {
crate::message::MessagePart::ToolResult { output, .. } => {
Some(output.to_string())
}
_ => None,
})
})
.collect();
assert!(
tool_result_text.contains("the quick brown fox"),
"Suppress hint must not strip the payload from the conversation: {tool_result_text}"
);
}
}