#[path = "common/mod.rs"]
mod common;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use common::{base_config, run_and_collect, user_message};
use rpi_agent::{AgentContext, AgentEvent, ToolExecutionMode};
use rpi_ai::event_stream::create_assistant_message_event_stream;
use rpi_ai::types::{AssistantMessage, AssistantMessageEvent, DoneReason, StopReason};
use tokio::sync::Notify;
struct Coord {
release: Arc<Notify>,
first_resolved: Arc<AtomicBool>,
parallel_observed: Arc<AtomicBool>,
}
struct EchoTool {
schema: rpi_ai::types::Tool,
coord: Coord,
}
#[async_trait::async_trait]
impl rpi_agent::AgentTool for EchoTool {
fn schema(&self) -> &rpi_ai::types::Tool {
&self.schema
}
fn label(&self) -> &str {
"Echo"
}
fn execution_mode(&self) -> ToolExecutionMode {
ToolExecutionMode::Parallel
}
async fn execute(
&self,
_tool_call_id: &str,
params: serde_json::Value,
_signal: tokio_util::sync::CancellationToken,
_on_update: Arc<dyn Fn(rpi_agent::ToolResultPartial) + Send + Sync>,
) -> Result<rpi_agent::AgentToolResult, rpi_agent::AgentError> {
let value = params
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if value == "first" {
self.coord.release.notified().await;
self.coord.first_resolved.store(true, Ordering::SeqCst);
}
if value == "second" && !self.coord.first_resolved.load(Ordering::SeqCst) {
self.coord.parallel_observed.store(true, Ordering::SeqCst);
}
Ok(rpi_agent::AgentToolResult::text(format!("echoed: {value}")))
}
}
fn echo_schema() -> rpi_ai::types::Tool {
rpi_ai::types::Tool {
name: "echo".to_string(),
description: "Echo tool".to_string(),
parameters: rpi_ai::types::Schema::new(serde_json::json!({
"type": "object",
"properties": { "value": { "type": "string" } },
"required": ["value"],
"additionalProperties": false,
})),
constrained_sampling: None,
}
}
fn two_call_stream_fn(release: Arc<Notify>) -> rpi_agent::StreamFn {
use std::collections::VecDeque;
let script: Arc<std::sync::Mutex<VecDeque<AssistantMessage>>> =
Arc::new(std::sync::Mutex::new(VecDeque::from(vec![
assistant_two_tool_calls(),
AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![rpi_ai::types::Content::text("done")],
api: rpi_ai::types::Api::Other("openai-responses".into()),
provider: "mock".to_string(),
model: "mock".to_string(),
response_model: None,
response_id: None,
usage: rpi_ai::types::Usage::zero(),
stop_reason: StopReason::Stop,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
},
])));
rpi_agent::stream_fn(move |_model, _ctx, _opts| {
let (mut prod, stream) = create_assistant_message_event_stream();
let next = script.lock().expect("script lock").pop_front();
let release = Arc::clone(&release);
tokio::spawn(async move {
let message = match next {
Some(m) => m,
None => {
let err = AssistantMessage::terminal(
rpi_ai::types::Api::Other("mock".into()),
"mock",
"mock",
StopReason::Error,
"No more mock responses queued",
0,
);
prod.push(AssistantMessageEvent::Error {
reason: rpi_ai::types::ErrorReason::Error,
error: err,
});
return;
}
};
let is_tool_turn = matches!(message.stop_reason, StopReason::ToolUse);
let partial = Arc::new(message.clone());
prod.push(AssistantMessageEvent::Start {
partial: partial.clone(),
});
let reason = match message.stop_reason {
StopReason::ToolUse => DoneReason::ToolUse,
StopReason::Stop => DoneReason::Stop,
_ => DoneReason::Stop,
};
prod.push(AssistantMessageEvent::Done { reason, message });
if is_tool_turn {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
release.notify_one();
}
});
stream
})
}
fn assistant_two_tool_calls() -> AssistantMessage {
AssistantMessage {
role: rpi_ai::types::AssistantRole,
content: vec![
rpi_ai::types::Content::tool_call(
"tool-1",
"echo",
serde_json::json!({ "value": "first" }),
),
rpi_ai::types::Content::tool_call(
"tool-2",
"echo",
serde_json::json!({ "value": "second" }),
),
],
api: rpi_ai::types::Api::Other("openai-responses".into()),
provider: "mock".to_string(),
model: "mock".to_string(),
response_model: None,
response_id: None,
usage: rpi_ai::types::Usage::zero(),
stop_reason: StopReason::ToolUse,
deferred: None,
error_message: None,
raw_stop_reason: None,
end_turn: None,
timestamp: 0,
}
}
fn tool_execution_end_ids(events: &[AgentEvent]) -> Vec<String> {
events
.iter()
.filter_map(|e| match e {
AgentEvent::ToolExecutionEnd { tool_call_id, .. } => Some(tool_call_id.clone()),
_ => None,
})
.collect()
}
fn tool_result_message_ids(events: &[AgentEvent]) -> Vec<String> {
events
.iter()
.filter_map(|e| match e {
AgentEvent::MessageEnd { message } => match message {
rpi_agent::AgentMessage::ToolResult(t) => Some(t.tool_call_id.clone()),
_ => None,
},
_ => None,
})
.collect()
}
fn turn_end_tool_result_ids(events: &[AgentEvent]) -> Vec<String> {
events
.iter()
.filter_map(|e| match e {
AgentEvent::TurnEnd { tool_results, .. } => Some(
tool_results
.iter()
.map(|t| t.tool_call_id.clone())
.collect::<Vec<_>>(),
),
_ => None,
})
.flatten()
.collect()
}
#[tokio::test]
async fn tool_execution_end_in_completion_order_results_in_source_order() {
let coord = Coord {
release: Arc::new(Notify::new()),
first_resolved: Arc::new(AtomicBool::new(false)),
parallel_observed: Arc::new(AtomicBool::new(false)),
};
let tool = EchoTool {
schema: echo_schema(),
coord: Coord {
release: Arc::clone(&coord.release),
first_resolved: Arc::clone(&coord.first_resolved),
parallel_observed: Arc::clone(&coord.parallel_observed),
},
};
let context = AgentContext {
system_prompt: String::new(),
messages: Vec::new(),
tools: vec![Arc::new(tool)],
};
let mut config = base_config();
config.tool_execution = ToolExecutionMode::Parallel;
let stream_fn = two_call_stream_fn(Arc::clone(&coord.release));
let (events, _new_messages) =
run_and_collect(vec![user_message("echo both")], context, config, stream_fn).await;
assert!(
coord.parallel_observed.load(Ordering::SeqCst),
"tool-2 should have run while tool-1 was still pending"
);
assert_eq!(
tool_execution_end_ids(&events),
vec!["tool-2".to_string(), "tool-1".to_string()],
"ToolExecutionEnd must be in completion order (tool-2 finishes first)"
);
assert_eq!(
tool_result_message_ids(&events),
vec!["tool-1".to_string(), "tool-2".to_string()],
"tool-result MessageEnd must be in source/ordinal order"
);
assert_eq!(
turn_end_tool_result_ids(&events),
vec!["tool-1".to_string(), "tool-2".to_string()],
"TurnEnd.toolResults must be in source/ordinal order"
);
}