use async_trait::async_trait;
use saya_agent::{
AgentEvent, AgentEventSink, AgentLimits, AgentRequest, AllowReadOnlyApproval, ApprovalDecider,
ChatProvider, ChatRequest, ChatResponse, LocalStateEffect, ToolCall, ToolDefinition,
ToolEffect, ToolError, ToolExecutor, run_agent_with_sink,
};
use std::sync::{Arc, Mutex};
struct OneCallProvider {
call: ToolCall,
turn: Mutex<u32>,
}
#[async_trait]
impl ChatProvider for OneCallProvider {
fn name(&self) -> &str {
"one-call-mock"
}
async fn complete(&self, _: ChatRequest) -> Result<ChatResponse, saya_agent::ProviderError> {
unreachable!("stream path is used")
}
async fn stream(
&self,
_: ChatRequest,
_: saya_agent::CancellationToken,
) -> Result<saya_agent::ProviderStream, saya_agent::ProviderError> {
let first = {
let mut turn = self.turn.lock().unwrap();
let was = *turn;
*turn += 1;
was == 0
};
let events = if first {
vec![
Ok(saya_agent::ProviderEvent::ToolCalls(vec![
self.call.clone(),
])),
Ok(saya_agent::ProviderEvent::Done),
]
} else {
vec![
Ok(saya_agent::ProviderEvent::TextDelta("done".into())),
Ok(saya_agent::ProviderEvent::Done),
]
};
Ok(Box::pin(futures_util::stream::iter(events)))
}
}
struct RecordingExecutor {
calls: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl ToolExecutor for RecordingExecutor {
async fn execute(
&self,
name: &str,
_: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
self.calls.lock().unwrap().push(name.into());
Ok(serde_json::json!({"ok": true}))
}
}
struct RecordingSink {
events: Arc<Mutex<Vec<AgentEvent>>>,
}
#[async_trait]
impl AgentEventSink for RecordingSink {
async fn emit(&self, event: AgentEvent) {
self.events.lock().unwrap().push(event);
}
}
fn candidate_tool() -> ToolDefinition {
ToolDefinition {
name: "remember_candidate".into(),
description: "may persist a candidate claim".into(),
read_only: false,
parameters: serde_json::json!({"type": "object"}),
effect: ToolEffect {
database_data: false,
external_side_effect: false,
requires_approval: false,
local_state: LocalStateEffect::WriteCandidate,
},
}
}
fn request() -> AgentRequest {
AgentRequest {
prompt: "remember something".into(),
profile_names: vec!["analytics".into()],
model: "mock-model".into(),
system_prompt: None,
history: Vec::new(),
context_blocks: Vec::new(),
}
}
#[tokio::test]
async fn write_candidate_tool_is_denied_by_default_and_does_not_end_the_turn() {
let calls = Arc::new(Mutex::new(Vec::new()));
let events = Arc::new(Mutex::new(Vec::new()));
let provider = OneCallProvider {
call: ToolCall {
id: "c1".into(),
name: "remember_candidate".into(),
arguments: serde_json::json!({}),
},
turn: Mutex::new(0),
};
let sink = RecordingSink {
events: events.clone(),
};
let token = saya_agent::CancellationToken::new();
let output = run_agent_with_sink(
&provider,
&RecordingExecutor {
calls: calls.clone(),
},
request(),
vec![candidate_tool()],
AgentLimits::default(),
&AllowReadOnlyApproval,
&sink,
token,
)
.await
.expect("denial is not a turn-ending error");
assert!(
calls.lock().unwrap().is_empty(),
"the tool must not execute when candidate writes are not permitted"
);
let denied = events.lock().unwrap().iter().find_map(|event| match event {
AgentEvent::ToolDenied { name, reason } => Some((name.clone(), reason.clone())),
_ => None,
});
let (name, reason) = denied.expect("a ToolDenied event must be emitted");
assert_eq!(name, "remember_candidate");
assert!(
!reason.is_empty(),
"the denial must carry a clear reason, not be a silent skip"
);
assert!(
output
.events
.iter()
.any(|event| matches!(event, AgentEvent::Complete)),
"the turn must complete, not end on the denial"
);
assert_eq!(output.tool_metadata[0].name, "remember_candidate");
assert_eq!(output.tool_metadata[0].status, "denied");
}
#[tokio::test]
async fn write_candidate_tool_runs_when_candidate_writes_are_permitted() {
let calls = Arc::new(Mutex::new(Vec::new()));
let events = Arc::new(Mutex::new(Vec::new()));
let provider = OneCallProvider {
call: ToolCall {
id: "c1".into(),
name: "remember_candidate".into(),
arguments: serde_json::json!({}),
},
turn: Mutex::new(0),
};
let sink = RecordingSink {
events: events.clone(),
};
let token = saya_agent::CancellationToken::new();
let limits = AgentLimits {
permit_candidate_writes: true,
..AgentLimits::default()
};
let output = run_agent_with_sink(
&provider,
&RecordingExecutor {
calls: calls.clone(),
},
request(),
vec![candidate_tool()],
limits,
&AllowReadOnlyApproval,
&sink,
token,
)
.await
.expect("run completes");
assert_eq!(&*calls.lock().unwrap(), &["remember_candidate"]);
assert!(
!output
.events
.iter()
.any(|event| matches!(event, AgentEvent::ToolDenied { .. })),
"no denial when candidate writes are permitted"
);
assert_eq!(output.tool_metadata[0].status, "completed");
}
#[tokio::test]
async fn read_local_state_tool_is_unaffected_by_the_candidate_permission() {
let calls = Arc::new(Mutex::new(Vec::new()));
let events = Arc::new(Mutex::new(Vec::new()));
let provider = OneCallProvider {
call: ToolCall {
id: "c1".into(),
name: "contract_search".into(),
arguments: serde_json::json!({}),
},
turn: Mutex::new(0),
};
let sink = RecordingSink {
events: events.clone(),
};
let token = saya_agent::CancellationToken::new();
let read_tool = ToolDefinition {
name: "contract_search".into(),
description: "reads local contracts".into(),
read_only: true,
parameters: serde_json::json!({"type": "object"}),
effect: ToolEffect {
database_data: false,
external_side_effect: false,
requires_approval: false,
local_state: LocalStateEffect::Read,
},
};
let _ = run_agent_with_sink(
&provider,
&RecordingExecutor {
calls: calls.clone(),
},
request(),
vec![read_tool.clone()],
AgentLimits::default(),
&AllowReadOnlyApproval,
&sink,
token,
)
.await
.expect("run completes");
assert_eq!(&*calls.lock().unwrap(), &["contract_search"]);
assert!(
!events
.lock()
.unwrap()
.iter()
.any(|event| matches!(event, AgentEvent::ToolDenied { .. })),
"a Read tool must not be denied by the candidate-write guard"
);
}
struct DenyApproval;
#[async_trait]
impl ApprovalDecider for DenyApproval {
async fn approve(&self, _: &ToolDefinition, _: &serde_json::Value) -> bool {
false
}
}
#[tokio::test]
async fn external_side_effect_without_approval_is_refused_not_auto_run() {
let calls = Arc::new(Mutex::new(Vec::new()));
let events = Arc::new(Mutex::new(Vec::new()));
let provider = OneCallProvider {
call: ToolCall {
id: "c1".into(),
name: "open_browser".into(),
arguments: serde_json::json!({}),
},
turn: Mutex::new(0),
};
let sink = RecordingSink {
events: events.clone(),
};
let external_only = ToolDefinition {
name: "open_browser".into(),
description: "opens something outside the agent".into(),
read_only: true,
parameters: serde_json::json!({"type": "object"}),
effect: ToolEffect {
database_data: false,
external_side_effect: true,
requires_approval: false,
local_state: LocalStateEffect::None,
},
};
let _ = run_agent_with_sink(
&provider,
&RecordingExecutor {
calls: calls.clone(),
},
request(),
vec![external_only],
AgentLimits::default(),
&AllowReadOnlyApproval,
&sink,
saya_agent::CancellationToken::new(),
)
.await
.expect("a denial is not a turn-ending error");
assert!(
calls.lock().unwrap().is_empty(),
"the misconfigured tool must not execute"
);
let reason = events
.lock()
.unwrap()
.iter()
.find_map(|event| match event {
AgentEvent::ToolDenied { name, reason } if name == "open_browser" => {
Some(reason.clone())
}
_ => None,
})
.expect("a ToolDenied event must be emitted");
assert!(
reason.contains("side effect"),
"the reason must name the external side effect, got: {reason}"
);
}
#[tokio::test]
async fn external_side_effect_with_approval_runs_when_approved() {
let calls = Arc::new(Mutex::new(Vec::new()));
let events = Arc::new(Mutex::new(Vec::new()));
let provider = OneCallProvider {
call: ToolCall {
id: "c1".into(),
name: "render_chart".into(),
arguments: serde_json::json!({}),
},
turn: Mutex::new(0),
};
let sink = RecordingSink {
events: events.clone(),
};
let render_chart = ToolDefinition {
name: "render_chart".into(),
description: "visualise a query".into(),
read_only: true,
parameters: serde_json::json!({"type": "object"}),
effect: ToolEffect {
database_data: false,
external_side_effect: true,
requires_approval: true,
local_state: LocalStateEffect::None,
},
};
let _ = run_agent_with_sink(
&provider,
&RecordingExecutor {
calls: calls.clone(),
},
request(),
vec![render_chart],
AgentLimits::default(),
&AllowReadOnlyApproval,
&sink,
saya_agent::CancellationToken::new(),
)
.await
.expect("run completes");
assert_eq!(&*calls.lock().unwrap(), &["render_chart"]);
assert!(
!events
.lock()
.unwrap()
.iter()
.any(|event| matches!(event, AgentEvent::ToolDenied { .. })),
"an approved external-side-effect tool must run, not be denied"
);
}
#[tokio::test]
async fn external_side_effect_with_approval_is_denied_when_approval_refused() {
let calls = Arc::new(Mutex::new(Vec::new()));
let events = Arc::new(Mutex::new(Vec::new()));
let provider = OneCallProvider {
call: ToolCall {
id: "c1".into(),
name: "render_chart".into(),
arguments: serde_json::json!({}),
},
turn: Mutex::new(0),
};
let sink = RecordingSink {
events: events.clone(),
};
let render_chart = ToolDefinition {
name: "render_chart".into(),
description: "visualise a query".into(),
read_only: true,
parameters: serde_json::json!({"type": "object"}),
effect: ToolEffect {
database_data: false,
external_side_effect: true,
requires_approval: true,
local_state: LocalStateEffect::None,
},
};
let _ = run_agent_with_sink(
&provider,
&RecordingExecutor {
calls: calls.clone(),
},
request(),
vec![render_chart],
AgentLimits::default(),
&DenyApproval,
&sink,
saya_agent::CancellationToken::new(),
)
.await
.expect("a denial is not a turn-ending error");
assert!(
calls.lock().unwrap().is_empty(),
"the tool must not execute when approval is refused"
);
let reason = events
.lock()
.unwrap()
.iter()
.find_map(|event| match event {
AgentEvent::ToolDenied { name, reason } if name == "render_chart" => {
Some(reason.clone())
}
_ => None,
})
.expect("a ToolDenied event must be emitted");
assert!(
reason.contains("approval"),
"the reason must name approval as the refusing gate, got: {reason}"
);
}