use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use serde_json::{Value, json};
use sim_kernel::{Args, Callable, Cx, Error, Expr, Object, ObjectCompat, Result, Symbol};
use crate::{
DeterministicGatewayClock, GatewayEvent, GatewayRequest, GatewayStore, MemoryGatewayStore,
RESPONSES_PATH, ResponseIdGenerators, execute_response_request, install_openai_gateway_lib,
openai_gateway_tools_capability,
};
#[test]
fn fixture_tool_call_invokes_explicit_test_tool_and_replays_events() {
let calls = Arc::new(AtomicUsize::new(0));
let mut cx = tool_cx();
register_echo_tool(&mut cx, calls.clone());
let mut store = MemoryGatewayStore::new();
let mut ids = ResponseIdGenerators::deterministic(1);
let mut clock = DeterministicGatewayClock::new(1_000, 10);
let request = tool_request(
"fixture/tool-call",
"hello tool",
echo_tool_descriptor(vec![]),
);
let execution = execute_response_request(&mut cx, &mut store, &mut ids, &mut clock, &request);
let json = response_json(execution.response());
assert_eq!(execution.response().status(), 200);
assert!(
json["output_text"]
.as_str()
.unwrap()
.contains("echo: hello tool")
);
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(has_event(execution.events(), "tool-call"));
assert!(has_event(execution.events(), "tool-result"));
assert_eq!(
stored_events(&store, execution.event_content_ids()),
execution.events()
);
}
#[test]
fn tool_loop_invokes_registered_gateway_callable() {
let mut cx = tool_cx();
install_openai_gateway_lib(&mut cx).unwrap();
let mut store = MemoryGatewayStore::new();
let mut ids = ResponseIdGenerators::deterministic(1);
let mut clock = DeterministicGatewayClock::new(1_000, 10);
let request = tool_request(
"fixture/tool-call",
r#"{"source":"fixture/echo"}"#,
json!([{
"type": "function",
"function": {
"name": "plan_parse",
"description": "Parse a model plan.",
"parameters": {
"type": "object",
"properties": { "source": { "type": "string" } },
"required": ["source"]
},
"x-sim-symbol": "openai-gateway/plan-parse"
}
}]),
);
let execution = execute_response_request(&mut cx, &mut store, &mut ids, &mut clock, &request);
let json = response_json(execution.response());
assert_eq!(execution.response().status(), 200);
assert!(json["output_text"].as_str().unwrap().contains("plan/atom"));
assert!(has_event(execution.events(), "tool-result"));
}
#[test]
fn capability_denied_tool_is_recorded_without_running() {
let calls = Arc::new(AtomicUsize::new(0));
let mut cx = tool_cx();
register_echo_tool(&mut cx, calls.clone());
let mut store = MemoryGatewayStore::new();
let mut ids = ResponseIdGenerators::deterministic(1);
let mut clock = DeterministicGatewayClock::new(1_000, 10);
let request = tool_request(
"fixture/tool-call",
"secret",
echo_tool_descriptor(vec!["tool-secret"]),
);
let execution = execute_response_request(&mut cx, &mut store, &mut ids, &mut clock, &request);
let json = response_json(execution.response());
let result = event_payload(execution.events(), "tool-result");
assert_eq!(execution.response().status(), 200);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert!(format!("{result:?}").contains("capability-denied"));
assert!(
json["output_text"]
.as_str()
.unwrap()
.contains("capability-denied")
);
}
#[test]
fn invalid_tool_arguments_are_structured_tool_results() {
let calls = Arc::new(AtomicUsize::new(0));
let mut cx = tool_cx();
register_echo_tool(&mut cx, calls.clone());
let mut store = MemoryGatewayStore::new();
let mut ids = ResponseIdGenerators::deterministic(1);
let mut clock = DeterministicGatewayClock::new(1_000, 10);
let request = tool_request("fixture/tool-call", r#"{}"#, echo_tool_descriptor(vec![]));
let execution = execute_response_request(&mut cx, &mut store, &mut ids, &mut clock, &request);
let json = response_json(execution.response());
let result = event_payload(execution.events(), "tool-result");
assert_eq!(execution.response().status(), 200);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert!(format!("{result:?}").contains("invalid-arguments"));
assert!(
json["output_text"]
.as_str()
.unwrap()
.contains("invalid-arguments")
);
}
#[test]
fn repeated_identical_tool_call_fails_closed() {
let calls = Arc::new(AtomicUsize::new(0));
let mut cx = tool_cx();
register_echo_tool(&mut cx, calls);
let mut store = MemoryGatewayStore::new();
let mut ids = ResponseIdGenerators::deterministic(1);
let mut clock = DeterministicGatewayClock::new(1_000, 10);
let request = tool_request(
"fixture/repeat-tool-call",
"repeat",
echo_tool_descriptor(vec![]),
);
let execution = execute_response_request(&mut cx, &mut store, &mut ids, &mut clock, &request);
let json = response_json(execution.response());
assert_eq!(execution.response().status(), 400);
assert!(
json["error"]["message"]
.as_str()
.unwrap()
.contains("repeated")
);
assert_eq!(json["error"]["code"], "invalid_model");
}
#[test]
fn phase0_openai_tool_loop_still_calls_registered_function_by_symbol() {
let mut cx = tool_cx();
let calls = Arc::new(AtomicUsize::new(0));
register_echo_tool(&mut cx, calls.clone());
let request = tool_request("fixture/tool-call", "phase 0", echo_tool_descriptor(vec![]));
let mut store = MemoryGatewayStore::new();
let mut ids = ResponseIdGenerators::deterministic(1);
let mut clock = DeterministicGatewayClock::new(1_000, 10);
let execution = execute_response_request(&mut cx, &mut store, &mut ids, &mut clock, &request);
assert_eq!(execution.response().status(), 200);
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(has_event(execution.events(), "tool-call"));
assert!(has_event(execution.events(), "tool-result"));
let source = include_str!("../runtime/tool_loop.rs");
assert!(source.contains("cx.call_function(tool.symbol(), Args::new(args))"));
}
fn tool_cx() -> Cx {
let mut cx = super::cx();
cx.grant(openai_gateway_tools_capability());
cx
}
fn register_echo_tool(cx: &mut Cx, calls: Arc<AtomicUsize>) {
let value = cx.factory().opaque(Arc::new(EchoTool { calls })).unwrap();
cx.registry_mut()
.register_function_value(Symbol::qualified("tool", "echo"), value)
.unwrap();
}
fn tool_request(model: &str, input: &str, tools: Value) -> GatewayRequest {
GatewayRequest::new(
"POST",
RESPONSES_PATH,
vec![("Content-Type".to_owned(), "application/json".to_owned())],
serde_json::to_vec(&json!({
"model": model,
"input": input,
"store": true,
"tools": tools
}))
.unwrap(),
)
}
fn echo_tool_descriptor(capabilities: Vec<&str>) -> Value {
json!([{
"type": "function",
"function": {
"name": "tool_echo",
"description": "Echo text.",
"parameters": {
"type": "object",
"properties": { "text": { "type": "string" } },
"required": ["text"]
},
"x-sim-symbol": "tool/echo",
"x-sim-capabilities": capabilities
}
}])
}
fn response_json(response: &crate::GatewayResponse) -> Value {
serde_json::from_slice(response.body()).unwrap()
}
fn has_event(events: &[GatewayEvent], kind: &str) -> bool {
events
.iter()
.any(|event| event.kind().name.as_ref() == kind)
}
fn event_payload<'a>(events: &'a [GatewayEvent], kind: &str) -> &'a Expr {
events
.iter()
.find(|event| event.kind().name.as_ref() == kind)
.map(GatewayEvent::payload)
.unwrap_or_else(|| panic!("missing {kind} event"))
}
fn stored_events(store: &MemoryGatewayStore, ids: &[sim_kernel::ContentId]) -> Vec<GatewayEvent> {
ids.iter().map(|id| store.event(id).unwrap()).collect()
}
struct EchoTool {
calls: Arc<AtomicUsize>,
}
impl Object for EchoTool {
fn display(&self, _cx: &mut Cx) -> Result<String> {
Ok("#<openai-test-echo-tool>".to_owned())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
impl ObjectCompat for EchoTool {
fn as_callable(&self) -> Option<&dyn Callable> {
Some(self)
}
}
impl Callable for EchoTool {
fn call(&self, cx: &mut Cx, args: Args) -> Result<sim_kernel::Value> {
self.calls.fetch_add(1, Ordering::SeqCst);
let mut args = expect_arg_count(args, 1)?.into_iter();
let text = value_string(cx, args.next().expect("arg count checked"))?;
cx.factory().string(format!("echo: {text}"))
}
}
fn expect_arg_count(args: Args, expected: usize) -> Result<Vec<sim_kernel::Value>> {
let args = args.into_vec();
if args.len() == expected {
Ok(args)
} else {
Err(Error::Eval(format!(
"echo tool expects {expected} argument(s), found {}",
args.len()
)))
}
}
fn value_string(cx: &mut Cx, value: sim_kernel::Value) -> Result<String> {
match value.object().as_expr(cx)? {
Expr::String(text) => Ok(text),
_ => Err(Error::TypeMismatch {
expected: "string",
found: "non-string",
}),
}
}