use async_trait::async_trait;
use saya_agent::{
AgentEvent, AgentEventSink, AgentLimits, AgentRequest, AllowReadOnlyApproval, 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 OkExecutor;
#[async_trait]
impl ToolExecutor for OkExecutor {
async fn execute(&self, _: &str, _: serde_json::Value) -> Result<serde_json::Value, ToolError> {
Ok(serde_json::json!({"ok": true}))
}
}
struct FailExecutor;
#[async_trait]
impl ToolExecutor for FailExecutor {
async fn execute(&self, _: &str, _: serde_json::Value) -> Result<serde_json::Value, ToolError> {
Err(ToolError::QueryFailed)
}
}
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 read_only_tool() -> ToolDefinition {
ToolDefinition {
name: "bounded_sql_query".into(),
description: "read-only query".into(),
read_only: true,
parameters: serde_json::json!({"type":"object"}),
effect: ToolEffect {
database_data: true,
external_side_effect: false,
requires_approval: false,
local_state: LocalStateEffect::None,
},
}
}
fn write_tool() -> ToolDefinition {
ToolDefinition {
name: "contract_propose".into(),
description: "persists a candidate claim".into(),
read_only: false,
parameters: serde_json::json!({"type":"object"}),
effect: ToolEffect {
database_data: true,
external_side_effect: false,
requires_approval: false,
local_state: LocalStateEffect::WriteCandidate,
},
}
}
fn request() -> AgentRequest {
AgentRequest {
prompt: "do something".into(),
profile_names: vec!["analytics".into()],
model: "mock-model".into(),
system_prompt: None,
history: Vec::new(),
context_blocks: Vec::new(),
}
}
async fn run_one(tool: ToolDefinition, executor: &dyn ToolExecutor) -> Vec<AgentEvent> {
let events = Arc::new(Mutex::new(Vec::new()));
let provider = OneCallProvider {
call: ToolCall {
id: "c1".into(),
name: tool.name.clone(),
arguments: serde_json::json!({}),
},
turn: Mutex::new(0),
};
let sink = RecordingSink {
events: events.clone(),
};
let limits = AgentLimits {
permit_candidate_writes: true,
..AgentLimits::default()
};
run_agent_with_sink(
&provider,
executor,
request(),
vec![tool],
limits,
&AllowReadOnlyApproval,
&sink,
saya_agent::CancellationToken::new(),
)
.await
.expect("run completes");
std::mem::take(&mut *events.lock().unwrap())
}
fn completed_summary(events: &[AgentEvent]) -> Option<&str> {
events.iter().find_map(|event| match event {
AgentEvent::ToolCompleted { summary, .. } => Some(summary.as_str()),
_ => None,
})
}
#[tokio::test]
async fn read_only_tool_reports_read_only_completion() {
let events = run_one(read_only_tool(), &OkExecutor).await;
let summary = completed_summary(&events).expect("a ToolCompleted was emitted");
assert!(
summary.contains("read-only"),
"a read-only tool reports read-only: got \"{summary}\""
);
assert!(
!summary.contains("write"),
"a read-only tool must not report a write: got \"{summary}\""
);
}
#[tokio::test]
async fn write_tool_does_not_report_read_only_completion() {
let events = run_one(write_tool(), &OkExecutor).await;
let summary = completed_summary(&events).expect("a ToolCompleted was emitted");
assert!(
summary.contains("write"),
"a write tool reports a write: got \"{summary}\""
);
assert!(
!summary.contains("read-only"),
"a write tool must not report read-only — that would overstate: got \"{summary}\""
);
}
#[tokio::test]
async fn write_tool_failure_summary_keeps_the_failed_substring() {
let events = run_one(write_tool(), &FailExecutor).await;
let summary = completed_summary(&events).expect("a ToolCompleted was emitted");
assert!(
summary.contains("write"),
"a failed write tool still names a write: got \"{summary}\""
);
assert!(
summary.contains("failed"),
"the failure summary keeps \"failed\" so tool_metadata reads failed: got \"{summary}\""
);
}
#[tokio::test]
async fn read_only_tool_failure_reports_read_only_failure() {
let events = run_one(read_only_tool(), &FailExecutor).await;
let summary = completed_summary(&events).expect("a ToolCompleted was emitted");
assert!(
summary.contains("read-only"),
"a failed read-only tool still reports read-only: got \"{summary}\""
);
assert!(
summary.contains("failed"),
"the failure summary keeps \"failed\": got \"{summary}\""
);
}