test(harness): smart mock LLM provider + fake Composio backend + 21 new tests (#1729)

This commit is contained in:
Steven Enamakel
2026-05-14 05:11:06 -07:00
committed by GitHub
parent 2aea60c208
commit 1c58d478c5
7 changed files with 2329 additions and 10 deletions
+1 -10
View File
@@ -141,16 +141,7 @@ export function handleIntegrations(ctx) {
return true;
}
if (method === "POST" && /^\/openai\/v1\/chat\/completions\/?$/.test(url)) {
json(res, 200, {
choices: [
{
message: { role: "assistant", content: "Hello from e2e mock agent" },
},
],
});
return true;
}
// (chat/completions is handled by routes/llm.mjs ahead of this route)
// ── Composio ───────────────────────────────────────────────
if (
+148
View File
@@ -0,0 +1,148 @@
import { json } from "../http.mjs";
import { behavior, parseBehaviorJson, setMockBehavior } from "../state.mjs";
/**
* Smart mock LLM endpoint.
*
* Drives keyword-based routing so unit/E2E tests can exercise the agent
* harness end-to-end without spinning up a real model. The mock looks
* at the latest user/tool message in the request and either:
*
* 1. Replays a forced response queue (`llmForcedResponses` behavior),
* 2. Matches a configured keyword rule (`llmKeywordRules` behavior),
* 3. Falls through to a sensible default ("Hello from e2e mock agent").
*
* Keyword rules look like:
*
* [
* {
* "keyword": "search", // case-insensitive substring
* "toolCalls": [
* { "name": "search_tool", "arguments": {"q": "rust"} }
* ],
* "content": "Looking it up..."
* },
* {
* "keyword": "search_tool-ok",
* "content": "Here's the answer."
* }
* ]
*
* Configure with:
* POST /__admin/behavior body: {"llmKeywordRules": "<json-string>"}
*
* This mirrors the Rust-side `KeywordScriptedProvider` in
* `src/openhuman/agent/harness/test_support.rs` so the same testing
* mental model applies on both sides of the FFI.
*/
function pickProbeText(parsedBody) {
if (!parsedBody || !Array.isArray(parsedBody.messages)) return "";
for (let i = parsedBody.messages.length - 1; i >= 0; i -= 1) {
const m = parsedBody.messages[i];
if (!m || typeof m !== "object") continue;
if (m.role === "user" || m.role === "tool") {
if (typeof m.content === "string") return m.content;
if (Array.isArray(m.content)) {
return m.content
.filter((c) => c && c.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join(" ");
}
}
}
return "";
}
function makeChoice({ content, toolCalls, callIdSeed }) {
const message = { role: "assistant", content: content ?? "" };
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
message.tool_calls = toolCalls.map((tc, idx) => ({
id: tc.id ?? `call_${callIdSeed}_${idx}`,
type: "function",
function: {
name: String(tc.name ?? ""),
arguments:
typeof tc.arguments === "string"
? tc.arguments
: JSON.stringify(tc.arguments ?? {}),
},
}));
if (!content) message.content = null;
}
return { index: 0, message, finish_reason: toolCalls?.length ? "tool_calls" : "stop" };
}
function buildResponse({ model, content, toolCalls }) {
const seed = Date.now();
return {
id: `chatcmpl-mock-${seed}`,
object: "chat.completion",
created: Math.floor(seed / 1000),
model: model || "e2e-mock-model",
choices: [makeChoice({ content, toolCalls, callIdSeed: seed })],
usage: {
prompt_tokens: 10,
completion_tokens: 10,
total_tokens: 20,
},
};
}
/**
* Drive a mock OpenAI-compatible /v1/chat/completions endpoint with
* keyword-based responses. Returns true if the request was handled.
*/
export function handleLlmCompletions(ctx) {
const { method, url, parsedBody, res } = ctx;
if (
method !== "POST" ||
!/^\/openai\/v1\/chat\/completions\/?$/.test(url)
) {
return false;
}
const mockBehavior = behavior();
const model =
typeof parsedBody?.model === "string" ? parsedBody.model : "e2e-mock-model";
// 1. Forced queue — replay exact ChatResponse objects in order.
const forced = parseBehaviorJson("llmForcedResponses", []);
if (Array.isArray(forced) && forced.length > 0) {
const next = forced.shift();
// Persist the shrunk queue back so subsequent requests advance.
setMockBehavior("llmForcedResponses", JSON.stringify(forced));
json(res, 200, buildResponse({ model, ...next }));
return true;
}
// 2. Keyword rules.
const rules = parseBehaviorJson("llmKeywordRules", []);
const probe = pickProbeText(parsedBody).toLowerCase();
if (Array.isArray(rules)) {
for (const rule of rules) {
if (!rule || typeof rule.keyword !== "string") continue;
if (probe.includes(rule.keyword.toLowerCase())) {
json(
res,
200,
buildResponse({
model,
content: rule.content ?? "",
toolCalls: rule.toolCalls ?? [],
}),
);
return true;
}
}
}
// 3. Default fallback.
const fallback =
typeof mockBehavior.llmFallbackContent === "string" &&
mockBehavior.llmFallbackContent.length > 0
? mockBehavior.llmFallbackContent
: "Hello from e2e mock agent";
json(res, 200, buildResponse({ model, content: fallback }));
return true;
}
+5
View File
@@ -14,6 +14,7 @@ import { handleConversations } from "./routes/conversations.mjs";
import { handleCron } from "./routes/cron.mjs";
import { handleIntegrations } from "./routes/integrations.mjs";
import { handleInvites } from "./routes/invites.mjs";
import { handleLlmCompletions } from "./routes/llm.mjs";
import { handleOAuth } from "./routes/oauth.mjs";
import { handlePayments } from "./routes/payments.mjs";
import { handleUser } from "./routes/user.mjs";
@@ -37,6 +38,10 @@ const ROUTE_HANDLERS = [
handleUser,
handleInvites,
handlePayments,
// LLM completions must run before the catch-all stub in
// `handleIntegrations` so keyword-driven test scripts can override
// the default "Hello from e2e mock agent" reply.
handleLlmCompletions,
handleIntegrations,
handleWebhooks,
handleCron,
@@ -0,0 +1,557 @@
//! Targeted bug-hunt tests for the agent harness + tool dispatch.
//!
//! These pair the [`super::test_support::KeywordScriptedProvider`] with
//! tightly-scoped tools to probe corner cases that aren't covered by
//! the broader behavioural suite. Each test documents the behaviour
//! observed, and any tests prefixed `documents_` describe a quirk
//! worth flagging in code review (silent data loss, surprising
//! precedence rules, etc.) rather than asserting correctness.
use super::test_support::{KeywordRule, KeywordScriptedProvider, ScriptedToolCall};
use super::tool_loop::run_tool_call_loop;
use crate::openhuman::providers::{ChatMessage, ChatResponse, ToolCall};
use crate::openhuman::tools::traits::{Tool, ToolResult};
use async_trait::async_trait;
use parking_lot::Mutex;
use serde_json::json;
use std::sync::Arc;
fn mm() -> crate::openhuman::config::MultimodalConfig {
crate::openhuman::config::MultimodalConfig::default()
}
struct ArgsCapturingTool {
name_str: String,
captured: Arc<Mutex<Vec<serde_json::Value>>>,
output: String,
}
impl ArgsCapturingTool {
fn new(name: &str, output: &str) -> (Self, Arc<Mutex<Vec<serde_json::Value>>>) {
let captured = Arc::new(Mutex::new(Vec::new()));
(
Self {
name_str: name.to_string(),
captured: captured.clone(),
output: output.to_string(),
},
captured,
)
}
}
#[async_trait]
impl Tool for ArgsCapturingTool {
fn name(&self) -> &str {
&self.name_str
}
fn description(&self) -> &str {
"captures args"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({"type":"object","additionalProperties":true})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.captured.lock().push(args);
Ok(ToolResult::success(self.output.clone()))
}
}
// ── 1. Native tool call with a JSON-encoded string of args ────────
//
// Real OpenAI/Anthropic providers send `arguments` as a *string*
// containing JSON. The harness must transparently decode it before
// passing to the tool.
#[tokio::test]
async fn native_tool_call_decodes_json_encoded_arguments_string() {
let provider =
KeywordScriptedProvider::new(vec![KeywordRule::final_reply("captured-ok", "done")])
.with_native_tools(true);
// Forced first turn: native tool_call with arguments as a STRING.
provider.push_forced_response(ChatResponse {
text: None,
tool_calls: vec![ToolCall {
id: "c1".into(),
name: "captured".into(),
arguments: "{\"city\":\"Berlin\",\"n\":3}".to_string(),
}],
usage: None,
});
let (tool, captured) = ArgsCapturingTool::new("captured", "captured-ok");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(tool)];
let mut history = vec![ChatMessage::user("anything")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
None,
"channel",
&mm(),
3,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
assert_eq!(out, "done");
let args = captured.lock();
assert_eq!(args.len(), 1);
assert_eq!(args[0]["city"], "Berlin");
assert_eq!(args[0]["n"], 3);
}
// ── 2. SILENT FAILURE: non-JSON args string is replaced with `{}` ──
//
// `parse_arguments_value` calls `serde_json::from_str` on the string
// payload and silently falls back to `{}` on parse failure. This
// means: if a model emits `arguments: "world"` (not valid JSON), the
// tool sees `{}` — the user's intent is silently dropped and there's
// no signal to the LLM that anything went wrong.
//
// This test documents the behaviour so future refactors don't
// "accidentally" fix it without considering downstream impact, and
// flags the behaviour for follow-up.
#[tokio::test]
async fn documents_silent_drop_of_non_json_arguments_string() {
let provider =
KeywordScriptedProvider::new(vec![KeywordRule::final_reply("captured-ok", "done")])
.with_native_tools(true);
provider.push_forced_response(ChatResponse {
text: None,
tool_calls: vec![ToolCall {
id: "c1".into(),
name: "captured".into(),
// Not valid JSON — the model "meant" a plain string.
arguments: "world".to_string(),
}],
usage: None,
});
let (tool, captured) = ArgsCapturingTool::new("captured", "captured-ok");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(tool)];
let mut history = vec![ChatMessage::user("hi")];
run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
None,
"channel",
&mm(),
3,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
let args = captured.lock();
assert_eq!(args.len(), 1);
// BUG-CANDIDATE: the LLM's intent ("world") is silently dropped.
// The tool receives an empty object with no indication the args
// were unparseable. A more defensive design would surface a
// structured error back to the model instead.
assert_eq!(args[0], json!({}));
}
// ── 3. Parallel tool calls in a single iteration ──────────────────
//
// The model may emit multiple `<tool_call>` blocks at once. They
// should all execute in order, each result threaded into history.
#[tokio::test]
async fn parallel_tool_calls_in_single_iteration_all_execute() {
let provider =
KeywordScriptedProvider::new(vec![KeywordRule::final_reply("tool_b-ok", "all done")]);
// Both tool calls share one assistant turn (XML path).
provider.push_forced_response(ChatResponse {
text: Some(
"<tool_call>{\"name\":\"tool_a\",\"arguments\":{\"k\":1}}</tool_call>\n\
<tool_call>{\"name\":\"tool_b\",\"arguments\":{\"k\":2}}</tool_call>"
.into(),
),
tool_calls: vec![],
usage: None,
});
let (a, a_calls) = ArgsCapturingTool::new("tool_a", "tool_a-ok");
let (b, b_calls) = ArgsCapturingTool::new("tool_b", "tool_b-ok");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(a), Box::new(b)];
let mut history = vec![ChatMessage::user("do both")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
assert_eq!(out, "all done");
assert_eq!(a_calls.lock().len(), 1);
assert_eq!(b_calls.lock().len(), 1);
assert_eq!(a_calls.lock()[0]["k"], 1);
assert_eq!(b_calls.lock()[0]["k"], 2);
}
// ── 4. Same-named tools: first match in registry wins ─────────────
#[tokio::test]
async fn same_named_tool_in_registry_first_match_wins() {
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call("go", ScriptedToolCall::new("dupe", json!({}))),
KeywordRule::final_reply("first-output", "got first"),
]);
let (first, first_calls) = ArgsCapturingTool::new("dupe", "first-output");
let (second, second_calls) = ArgsCapturingTool::new("dupe", "second-output");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(first), Box::new(second)];
let mut history = vec![ChatMessage::user("go ahead")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
assert_eq!(out, "got first");
assert_eq!(first_calls.lock().len(), 1);
assert_eq!(second_calls.lock().len(), 0);
}
// ── 5. Markdown-fenced tool call (```tool_call ... ```) ───────────
//
// Some OpenRouter-mediated models emit fenced markdown blocks
// instead of XML tags. `parse_tool_calls` is supposed to handle this.
#[tokio::test]
async fn markdown_fenced_tool_call_block_is_parsed() {
let provider =
KeywordScriptedProvider::new(vec![KeywordRule::final_reply("tool_a-ok", "ok done")]);
provider.push_forced_response(ChatResponse {
text: Some(
"Here's the call:\n\
```tool_call\n\
{\"name\":\"tool_a\",\"arguments\":{\"x\":42}}\n\
```"
.into(),
),
tool_calls: vec![],
usage: None,
});
let (a, a_calls) = ArgsCapturingTool::new("tool_a", "tool_a-ok");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(a)];
let mut history = vec![ChatMessage::user("anything")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
assert_eq!(out, "ok done");
assert_eq!(a_calls.lock().len(), 1);
assert_eq!(a_calls.lock()[0]["x"], 42);
}
// ── 6. Native vs prompt-guided precedence ─────────────────────────
//
// When a response carries BOTH native `tool_calls` *and* an XML
// `<tool_call>` block in the text, the native calls are authoritative
// and the XML must NOT also fire (else the same logical call could
// execute twice).
#[tokio::test]
async fn native_tool_calls_take_precedence_over_xml_in_text() {
let provider =
KeywordScriptedProvider::new(vec![KeywordRule::final_reply("tool_a-ok", "done")])
.with_native_tools(true);
provider.push_forced_response(ChatResponse {
text: Some("<tool_call>{\"name\":\"tool_a\",\"arguments\":{}}</tool_call>".into()),
tool_calls: vec![ToolCall {
id: "c1".into(),
name: "tool_a".into(),
arguments: "{\"src\":\"native\"}".into(),
}],
usage: None,
});
let (a, a_calls) = ArgsCapturingTool::new("tool_a", "tool_a-ok");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(a)];
let mut history = vec![ChatMessage::user("call it")];
run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
// Tool ran exactly once, using the native args (not the XML block).
assert_eq!(a_calls.lock().len(), 1);
assert_eq!(a_calls.lock()[0]["src"], "native");
}
// ── 7. Big tool output: per-tool cap truncation ───────────────────
struct CappedBigTool;
#[async_trait]
impl Tool for CappedBigTool {
fn name(&self) -> &str {
"cap_big"
}
fn description(&self) -> &str {
"emits a big payload but caps it"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({"type":"object"})
}
fn max_result_size_chars(&self) -> Option<usize> {
Some(50)
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult::success("X".repeat(500)))
}
}
#[tokio::test]
async fn per_tool_max_result_size_caps_history_payload() {
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call("go", ScriptedToolCall::new("cap_big", json!({}))),
KeywordRule::final_reply("truncated by tool cap", "ok"),
]);
let tools: Vec<Box<dyn Tool>> = vec![Box::new(CappedBigTool)];
let mut history = vec![ChatMessage::user("go big")];
run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
let tool_results = history
.iter()
.find(|msg| msg.role == "user" && msg.content.contains("[Tool results]"))
.expect("tool results should land in history");
assert!(
tool_results.content.contains("truncated by tool cap"),
"cap marker missing: {}",
tool_results.content
);
assert!(
tool_results.content.len() < 500,
"raw 500-char body must not flow through (got {} chars)",
tool_results.content.len()
);
}
// ── 8. Empty assistant response with no tool calls terminates loop
#[tokio::test]
async fn empty_response_with_no_tool_calls_terminates_with_empty_text() {
let provider = KeywordScriptedProvider::new(vec![]);
provider.push_forced_response(ChatResponse {
text: Some(String::new()),
tool_calls: vec![],
usage: None,
});
let tools: Vec<Box<dyn Tool>> = vec![];
let mut history = vec![ChatMessage::user("hi")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
assert!(out.is_empty());
// Loop must still record the (empty) assistant turn.
assert!(history.iter().any(|m| m.role == "assistant"));
}
// ── 9. Progress sink receives ordered turn lifecycle events ───────
#[tokio::test]
async fn progress_sink_emits_lifecycle_events_in_order() {
use crate::openhuman::agent::progress::AgentProgress;
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call("go", ScriptedToolCall::new("p_tool", json!({}))),
KeywordRule::final_reply("p_tool-ok", "all done"),
]);
let (tool, _) = ArgsCapturingTool::new("p_tool", "p_tool-ok");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(tool)];
let (tx, mut rx) = tokio::sync::mpsc::channel::<AgentProgress>(32);
let mut history = vec![ChatMessage::user("go go")];
run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"m",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
Some(tx),
None,
)
.await
.unwrap();
let mut events = Vec::new();
while let Ok(e) = rx.try_recv() {
events.push(e);
}
// Must see TurnStarted, at least 2 IterationStarted, ToolCallStarted,
// ToolCallCompleted, and TurnCompleted.
let kinds: Vec<&'static str> = events
.iter()
.map(|e| match e {
AgentProgress::TurnStarted => "TurnStarted",
AgentProgress::IterationStarted { .. } => "IterationStarted",
AgentProgress::ToolCallStarted { .. } => "ToolCallStarted",
AgentProgress::ToolCallCompleted { .. } => "ToolCallCompleted",
AgentProgress::TurnCompleted { .. } => "TurnCompleted",
_ => "Other",
})
.collect();
assert_eq!(kinds.first().copied(), Some("TurnStarted"));
assert_eq!(kinds.last().copied(), Some("TurnCompleted"));
assert!(kinds.contains(&"IterationStarted"));
assert!(kinds.contains(&"ToolCallStarted"));
assert!(kinds.contains(&"ToolCallCompleted"));
// ToolCallStarted must precede its matching ToolCallCompleted.
let started = kinds.iter().position(|k| *k == "ToolCallStarted").unwrap();
let completed = kinds
.iter()
.position(|k| *k == "ToolCallCompleted")
.unwrap();
assert!(started < completed);
}
+7
View File
@@ -51,5 +51,12 @@ pub(crate) use instructions::build_tool_instructions_filtered;
pub(crate) use parse::parse_tool_calls;
pub(crate) use tool_loop::run_tool_call_loop;
#[cfg(test)]
mod bughunt_tests;
#[cfg(test)]
pub(crate) mod test_support;
#[cfg(test)]
mod test_support_tests;
#[cfg(test)]
mod tests;
+633
View File
@@ -0,0 +1,633 @@
//! Smart-mock test support for the agent harness.
//!
//! This module provides a reusable "fake LLM" that drives the real
//! [`run_tool_call_loop`] without needing any network access. Two
//! building blocks are exposed:
//!
//! 1. [`KeywordScriptedProvider`] — a [`Provider`] implementation that
//! inspects the latest `user` message of the conversation and emits
//! canned tool calls (or a final reply) when a configured keyword
//! matches. The first turn that has *no* matching rule returns a
//! plain "done" reply, which terminates the loop deterministically.
//!
//! Compared to the hand-rolled `ScriptedProvider` in
//! [`super::tool_loop_tests`], this provider:
//! * Reacts to the rolling conversation state instead of replaying
//! a fixed queue — so tests can exercise iterative loops where
//! what the LLM does next depends on what tools returned.
//! * Supports both **native** OpenAI-style `tool_calls` and
//! **prompt-guided** `<tool_call>…</tool_call>` text — flipping
//! a single flag toggles which surface the harness exercises.
//! * Records the messages it saw and the responses it returned for
//! post-hoc assertion.
//!
//! 2. [`spawn_fake_composio_backend`] — boots a minimal axum app that
//! responds to the `/agent-integrations/composio/*` routes with
//! realistic-looking fixture data (real-world toolkit/action shapes,
//! not synthetic gibberish). Tests can pair this with a
//! [`crate::openhuman::composio::ComposioClient`] to exercise the
//! full agent → tool → backend → response flow against a hermetic
//! in-process server.
//!
//! Both helpers are `#[cfg(test)]`-only so they never leak into release
//! binaries.
use std::collections::VecDeque;
use std::sync::Arc;
use async_trait::async_trait;
use parking_lot::Mutex;
use serde_json::json;
use crate::openhuman::providers::traits::ProviderCapabilities;
use crate::openhuman::providers::{ChatMessage, ChatRequest, ChatResponse, Provider, ToolCall};
/// One scripted reaction the [`KeywordScriptedProvider`] can emit when
/// it sees its keyword in the latest user/tool turn.
#[derive(Debug, Clone)]
pub struct KeywordRule {
/// Substring matched (case-insensitive) against the latest user
/// or tool message in the conversation.
pub keyword: String,
/// Tool calls to emit. Empty ⇒ no tool calls, only `final_text`.
pub tool_calls: Vec<ScriptedToolCall>,
/// Optional plain-text body to include alongside any tool calls.
/// When `tool_calls` is empty, this becomes the loop-terminating
/// final response.
pub final_text: Option<String>,
/// How many times this rule may fire. `None` ⇒ unlimited.
pub max_fires: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct ScriptedToolCall {
pub name: String,
pub arguments: serde_json::Value,
}
impl ScriptedToolCall {
pub fn new(name: impl Into<String>, arguments: serde_json::Value) -> Self {
Self {
name: name.into(),
arguments,
}
}
}
impl KeywordRule {
pub fn final_reply(keyword: impl Into<String>, text: impl Into<String>) -> Self {
Self {
keyword: keyword.into(),
tool_calls: Vec::new(),
final_text: Some(text.into()),
max_fires: None,
}
}
pub fn tool_call(keyword: impl Into<String>, call: ScriptedToolCall) -> Self {
Self {
keyword: keyword.into(),
tool_calls: vec![call],
final_text: None,
max_fires: Some(1),
}
}
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.final_text = Some(text.into());
self
}
pub fn unlimited(mut self) -> Self {
self.max_fires = None;
self
}
}
/// Snapshot of one turn the provider served — handy for tests that
/// want to assert what the LLM "saw" without coupling to the harness
/// internals.
#[derive(Debug, Clone)]
pub struct ProviderTurn {
pub messages: Vec<ChatMessage>,
pub rule_keyword: Option<String>,
pub emitted_tool_calls: Vec<ToolCall>,
pub emitted_text: Option<String>,
}
struct ProviderState {
rules: Vec<KeywordRule>,
fired: Vec<usize>,
turns: Vec<ProviderTurn>,
fallback_text: String,
next_call_id: usize,
/// Optional queue of scripted responses to consume *before* the
/// keyword rules run — useful when a test wants the first turn to
/// behave deterministically regardless of the user message.
forced: VecDeque<ChatResponse>,
}
/// Smart provider that reacts to conversation state via keyword rules.
pub struct KeywordScriptedProvider {
state: Arc<Mutex<ProviderState>>,
native_tools: bool,
vision: bool,
}
impl KeywordScriptedProvider {
pub fn new(rules: Vec<KeywordRule>) -> Self {
Self {
state: Arc::new(Mutex::new(ProviderState {
rules,
fired: Vec::new(),
turns: Vec::new(),
fallback_text: "done".to_string(),
next_call_id: 0,
forced: VecDeque::new(),
})),
native_tools: false,
vision: false,
}
}
pub fn with_native_tools(mut self, enabled: bool) -> Self {
self.native_tools = enabled;
self
}
pub fn with_vision(mut self, enabled: bool) -> Self {
self.vision = enabled;
self
}
pub fn with_fallback(self, text: impl Into<String>) -> Self {
{
let mut guard = self.state.lock();
guard.fallback_text = text.into();
}
self
}
pub fn push_forced_response(&self, resp: ChatResponse) {
self.state.lock().forced.push_back(resp);
}
pub fn turns(&self) -> Vec<ProviderTurn> {
self.state.lock().turns.clone()
}
pub fn turn_count(&self) -> usize {
self.state.lock().turns.len()
}
}
fn latest_user_or_tool_msg(messages: &[ChatMessage]) -> Option<&ChatMessage> {
messages
.iter()
.rev()
.find(|m| m.role == "user" || m.role == "tool")
}
#[async_trait]
impl Provider for KeywordScriptedProvider {
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
native_tool_calling: self.native_tools,
vision: self.vision,
}
}
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok("fallback".into())
}
async fn chat(
&self,
request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> anyhow::Result<ChatResponse> {
let messages = request.messages.to_vec();
let mut state = self.state.lock();
// Forced queue wins, regardless of keyword matching.
if let Some(resp) = state.forced.pop_front() {
state.turns.push(ProviderTurn {
messages,
rule_keyword: None,
emitted_tool_calls: resp.tool_calls.clone(),
emitted_text: resp.text.clone(),
});
return Ok(resp);
}
let probe = latest_user_or_tool_msg(&messages)
.map(|m| m.content.to_lowercase())
.unwrap_or_default();
let mut chosen: Option<usize> = None;
for (idx, rule) in state.rules.iter().enumerate() {
let fired = *state.fired.get(idx).unwrap_or(&0);
if let Some(cap) = rule.max_fires {
if fired >= cap {
continue;
}
}
if probe.contains(&rule.keyword.to_lowercase()) {
chosen = Some(idx);
break;
}
}
let (rule_keyword, tool_calls, text) = if let Some(idx) = chosen {
while state.fired.len() <= idx {
state.fired.push(0);
}
state.fired[idx] += 1;
let rule = state.rules[idx].clone();
let tool_calls: Vec<ToolCall> = if self.native_tools {
rule.tool_calls
.iter()
.map(|c| {
let id = state.next_call_id;
state.next_call_id += 1;
ToolCall {
id: format!("call_{id}"),
name: c.name.clone(),
arguments: c.arguments.to_string(),
}
})
.collect()
} else {
Vec::new()
};
let text = if self.native_tools {
rule.final_text.clone()
} else if !rule.tool_calls.is_empty() {
// Prompt-guided: emit XML-wrapped tool calls in text.
let mut body = String::new();
if let Some(prefix) = &rule.final_text {
body.push_str(prefix);
if !prefix.ends_with('\n') {
body.push('\n');
}
}
for c in &rule.tool_calls {
body.push_str("<tool_call>");
body.push_str(&json!({"name": c.name, "arguments": c.arguments}).to_string());
body.push_str("</tool_call>\n");
}
Some(body)
} else {
rule.final_text.clone()
};
(Some(rule.keyword.clone()), tool_calls, text)
} else {
// No rule matched — emit the fallback as the final reply
// so the loop terminates rather than hanging.
(None, Vec::new(), Some(state.fallback_text.clone()))
};
let resp = ChatResponse {
text: text.clone(),
tool_calls: tool_calls.clone(),
usage: None,
};
state.turns.push(ProviderTurn {
messages,
rule_keyword,
emitted_tool_calls: tool_calls,
emitted_text: text,
});
Ok(resp)
}
}
// ── Fake Composio backend ──────────────────────────────────────────
use crate::openhuman::composio::ComposioClient;
use crate::openhuman::integrations::IntegrationClient;
use axum::{
extract::{Path, State},
routing::{delete, get, post},
Json, Router,
};
/// Realistic-looking Composio fixture data (toolkit slugs, action
/// names, and parameter shapes lifted from the production catalog —
/// just enough to exercise the schemas without coupling tests to the
/// upstream API).
#[derive(Default, Clone)]
pub struct ComposioFixture {
pub toolkits: Vec<String>,
pub connections: Vec<serde_json::Value>,
pub tools: Vec<serde_json::Value>,
/// Per-action canned execute responses, keyed by action slug.
pub execute_responses: std::collections::HashMap<String, serde_json::Value>,
}
impl ComposioFixture {
/// A reasonable default fixture: GMail/Notion/GitHub connections
/// with two actions each. Use this when a test just needs *some*
/// Composio data and doesn't care about the exact shape.
pub fn realistic() -> Self {
Self {
toolkits: vec![
"gmail".to_string(),
"notion".to_string(),
"github".to_string(),
"slack".to_string(),
],
connections: vec![
json!({
"id": "conn_gmail_1",
"toolkit": "gmail",
"status": "ACTIVE",
"createdAt": "2026-04-01T12:00:00Z",
}),
json!({
"id": "conn_notion_1",
"toolkit": "notion",
"status": "ACTIVE",
"createdAt": "2026-04-02T08:00:00Z",
}),
json!({
"id": "conn_github_1",
"toolkit": "github",
"status": "ACTIVE",
"createdAt": "2026-04-03T15:30:00Z",
}),
],
tools: vec![
json!({
"name": "GMAIL_SEND_EMAIL",
"description": "Send an email via Gmail",
"parameters": {
"type": "object",
"properties": {
"recipient_email": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"},
},
"required": ["recipient_email", "subject", "body"],
},
}),
json!({
"name": "GMAIL_FETCH_EMAILS",
"description": "Fetch emails from Gmail",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"max_results": {"type": "integer"},
},
},
}),
json!({
"name": "NOTION_CREATE_PAGE",
"description": "Create a new Notion page",
"parameters": {
"type": "object",
"properties": {
"parent_id": {"type": "string"},
"title": {"type": "string"},
},
"required": ["parent_id", "title"],
},
}),
json!({
"name": "GITHUB_CREATE_ISSUE",
"description": "Open a GitHub issue",
"parameters": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"title": {"type": "string"},
"body": {"type": "string"},
},
"required": ["owner", "repo", "title"],
},
}),
],
execute_responses: [
(
"GMAIL_SEND_EMAIL".to_string(),
json!({"message_id": "gmail-msg-1234", "thread_id": "gmail-thread-9999"}),
),
(
"GMAIL_FETCH_EMAILS".to_string(),
json!({
"messages": [
{"id": "m1", "subject": "Welcome", "from": "team@openhuman.com"},
{"id": "m2", "subject": "Invoice", "from": "billing@stripe.com"},
]
}),
),
(
"NOTION_CREATE_PAGE".to_string(),
json!({"page_id": "notion-page-abc123", "url": "https://notion.so/abc123"}),
),
(
"GITHUB_CREATE_ISSUE".to_string(),
json!({
"number": 42,
"html_url": "https://github.com/example/repo/issues/42",
}),
),
]
.into_iter()
.collect(),
}
}
}
#[derive(Clone)]
struct FakeComposioState {
fixture: Arc<Mutex<ComposioFixture>>,
requests: Arc<Mutex<Vec<(String, String, serde_json::Value)>>>,
}
/// Handle to a spawned in-process Composio backend.
pub struct FakeComposioBackend {
pub base_url: String,
state: FakeComposioState,
}
impl FakeComposioBackend {
/// All `(method, path, body)` requests received in order.
pub fn requests(&self) -> Vec<(String, String, serde_json::Value)> {
self.state.requests.lock().clone()
}
/// Build a `ComposioClient` pointed at this backend.
pub fn client(&self) -> ComposioClient {
let inner = Arc::new(IntegrationClient::new(
self.base_url.clone(),
"test-token".into(),
));
ComposioClient::new(inner)
}
}
async fn record<B: serde::Serialize + Clone>(
requests: &Arc<Mutex<Vec<(String, String, serde_json::Value)>>>,
method: &str,
path: &str,
body: Option<&B>,
) {
let body_v = match body {
Some(b) => serde_json::to_value(b).unwrap_or(serde_json::Value::Null),
None => serde_json::Value::Null,
};
requests
.lock()
.push((method.to_string(), path.to_string(), body_v));
}
/// Spawn an in-process Composio backend on `127.0.0.1:0`.
pub async fn spawn_fake_composio_backend(fixture: ComposioFixture) -> FakeComposioBackend {
let state = FakeComposioState {
fixture: Arc::new(Mutex::new(fixture)),
requests: Arc::new(Mutex::new(Vec::new())),
};
let app = Router::new()
.route(
"/agent-integrations/composio/toolkits",
get({
let st = state.clone();
move || async move {
record::<()>(&st.requests, "GET", "/toolkits", None).await;
let toolkits = st.fixture.lock().toolkits.clone();
Json(json!({
"success": true,
"data": { "toolkits": toolkits }
}))
}
}),
)
.route(
"/agent-integrations/composio/connections",
get({
let st = state.clone();
move || async move {
record::<()>(&st.requests, "GET", "/connections", None).await;
let connections = st.fixture.lock().connections.clone();
Json(json!({
"success": true,
"data": { "connections": connections }
}))
}
}),
)
.route(
"/agent-integrations/composio/tools",
get({
let st = state.clone();
move || async move {
record::<()>(&st.requests, "GET", "/tools", None).await;
let tools = st.fixture.lock().tools.clone();
Json(json!({
"success": true,
"data": { "tools": tools }
}))
}
}),
)
.route(
"/agent-integrations/composio/authorize",
post({
let st = state.clone();
move |Json(body): Json<serde_json::Value>| async move {
record(&st.requests, "POST", "/authorize", Some(&body)).await;
let toolkit = body
.get("toolkit")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
Json(json!({
"success": true,
"data": {
"connectUrl": format!("https://composio.dev/auth/{toolkit}"),
"connectionId": format!("conn_{toolkit}_pending"),
}
}))
}
}),
)
.route(
"/agent-integrations/composio/execute",
post({
let st = state.clone();
move |Json(body): Json<serde_json::Value>| async move {
record(&st.requests, "POST", "/execute", Some(&body)).await;
// ComposioClient::execute_tool sends `{tool, arguments}`.
let action = body
.get("tool")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let fx = st.fixture.lock();
let response = fx
.execute_responses
.get(&action)
.cloned()
.unwrap_or_else(|| json!({"ok": true, "action": action.clone()}));
// Wrap in the BackendResponse envelope expected by
// IntegrationClient, with the inner shape matching
// ComposioExecuteResponse.
Json(json!({
"success": true,
"data": {
"data": response,
"successful": true,
"costUsd": 0.0,
}
}))
}
}),
)
.route(
"/agent-integrations/composio/connections/{id}",
delete({
let st = state.clone();
move |Path(id): Path<String>| async move {
record::<()>(&st.requests, "DELETE", &format!("/connections/{id}"), None).await;
let mut fx = st.fixture.lock();
fx.connections
.retain(|c| c.get("id").and_then(|v| v.as_str()).unwrap_or("") != id);
Json(json!({
"success": true,
"data": {"deleted": true}
}))
}
}),
)
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
FakeComposioBackend {
base_url: format!("http://127.0.0.1:{}", addr.port()),
state,
}
}
// A handler signature placeholder to silence unused warnings when the
// State extractor isn't reached (e.g. when only sub-routes fire).
#[allow(dead_code)]
async fn _unused(_state: State<FakeComposioState>) {}
@@ -0,0 +1,978 @@
//! Behavioural tests of the agent harness driven by the smart mock
//! provider in [`super::test_support`]. These exercise the real
//! [`run_tool_call_loop`] path end-to-end — no provider stubbing inside
//! the test bodies — and surface regressions in tool dispatch, parsing,
//! and history threading.
use super::test_support::{
spawn_fake_composio_backend, ComposioFixture, KeywordRule, KeywordScriptedProvider,
ScriptedToolCall,
};
use super::tool_loop::run_tool_call_loop;
use crate::openhuman::providers::{ChatMessage, ChatResponse};
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult, ToolScope};
use async_trait::async_trait;
use serde_json::json;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
fn mm() -> crate::openhuman::config::MultimodalConfig {
crate::openhuman::config::MultimodalConfig::default()
}
/// Generic test tool: records the args it was called with and returns
/// whatever was wired at construction.
struct RecordingTool {
name_str: String,
description_str: String,
result: ToolResult,
calls: Arc<parking_lot::Mutex<Vec<serde_json::Value>>>,
permission: PermissionLevel,
scope_v: ToolScope,
category_v: ToolCategory,
}
impl RecordingTool {
fn echo(name: &str) -> (Self, Arc<parking_lot::Mutex<Vec<serde_json::Value>>>) {
let calls = Arc::new(parking_lot::Mutex::new(Vec::new()));
let tool = Self {
name_str: name.to_string(),
description_str: format!("recording tool {name}"),
result: ToolResult::success(format!("{name}-ok")),
calls: calls.clone(),
permission: PermissionLevel::ReadOnly,
scope_v: ToolScope::All,
category_v: ToolCategory::System,
};
(tool, calls)
}
}
#[async_trait]
impl Tool for RecordingTool {
fn name(&self) -> &str {
&self.name_str
}
fn description(&self) -> &str {
&self.description_str
}
fn parameters_schema(&self) -> serde_json::Value {
json!({"type": "object", "additionalProperties": true})
}
fn permission_level(&self) -> PermissionLevel {
self.permission
}
fn scope(&self) -> ToolScope {
self.scope_v
}
fn category(&self) -> ToolCategory {
self.category_v
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.calls.lock().push(args);
Ok(self.result.clone())
}
}
// ── 1. Keyword-driven loop: prompt-guided XML path ────────────────
#[tokio::test]
async fn keyword_provider_drives_prompt_guided_tool_loop_to_completion() {
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call(
"search",
ScriptedToolCall::new("search_tool", json!({"q": "rust"})),
)
.with_text("Looking it up."),
KeywordRule::final_reply("search_tool-ok", "Here is the answer."),
]);
let (search_tool, search_calls) = RecordingTool::echo("search_tool");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(search_tool)];
let mut history = vec![ChatMessage::user("please search the web for rust news")];
let result = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"test-model",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.expect("loop should complete");
assert_eq!(result, "Here is the answer.");
assert_eq!(
search_calls.lock().len(),
1,
"tool should fire exactly once"
);
assert_eq!(search_calls.lock()[0]["q"], "rust");
assert!(provider.turn_count() >= 2);
}
// ── 2. Keyword-driven loop: native tool_calls path ────────────────
#[tokio::test]
async fn keyword_provider_drives_native_tool_calls_path() {
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call(
"weather",
ScriptedToolCall::new("weather_tool", json!({"city": "Berlin"})),
),
KeywordRule::final_reply("weather_tool-ok", "It's sunny."),
])
.with_native_tools(true);
let (weather_tool, weather_calls) = RecordingTool::echo("weather_tool");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(weather_tool)];
let mut history = vec![ChatMessage::user("what's the weather in Berlin?")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock-native",
"test-model",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.expect("loop should complete");
assert_eq!(out, "It's sunny.");
assert_eq!(weather_calls.lock().len(), 1);
// History should contain a tool role message (native path) referencing the call id.
assert!(history.iter().any(|m| m.role == "tool"));
let tool_msg = history.iter().find(|m| m.role == "tool").unwrap();
assert!(
tool_msg.content.contains("weather_tool-ok"),
"tool result should be threaded through history: {}",
tool_msg.content
);
}
// ── 3. Multi-tool chain via successive keyword matches ────────────
#[tokio::test]
async fn keyword_provider_chains_multiple_tools_across_iterations() {
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call(
"draft an email",
ScriptedToolCall::new("draft_tool", json!({"to": "alice@example.com"})),
),
KeywordRule::tool_call(
"draft_tool-ok",
ScriptedToolCall::new("send_tool", json!({"draft_id": "d-1"})),
),
KeywordRule::final_reply("send_tool-ok", "Email sent to alice."),
]);
let (draft_tool, draft_calls) = RecordingTool::echo("draft_tool");
let (send_tool, send_calls) = RecordingTool::echo("send_tool");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(draft_tool), Box::new(send_tool)];
let mut history = vec![ChatMessage::user("draft an email to alice")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"test-model",
0.0,
true,
None,
"channel",
&mm(),
10,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
assert_eq!(out, "Email sent to alice.");
assert_eq!(draft_calls.lock().len(), 1);
assert_eq!(send_calls.lock().len(), 1);
}
// ── 4. Unknown tool name handled gracefully ───────────────────────
#[tokio::test]
async fn keyword_provider_unknown_tool_surfaces_error_and_loop_continues() {
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call("go", ScriptedToolCall::new("nonexistent_tool", json!({}))),
// After we see "Unknown tool" in the role=tool injection, give up.
KeywordRule::final_reply("unknown tool", "Sorry, I can't do that."),
]);
let tools: Vec<Box<dyn Tool>> = vec![];
let mut history = vec![ChatMessage::user("go go go")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"test-model",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
assert_eq!(out, "Sorry, I can't do that.");
// The Tool Results message should record the "Unknown tool: nonexistent_tool".
assert!(history.iter().any(|m| m.content.contains("Unknown tool")));
}
// ── 5. Max iterations guard ───────────────────────────────────────
#[tokio::test]
async fn run_tool_call_loop_returns_max_iterations_error() {
// Configure a rule that keeps firing forever — but cap iterations.
let provider = KeywordScriptedProvider::new(vec![KeywordRule {
keyword: "echo-ok".to_string(), // matches tool result, so it loops
tool_calls: vec![ScriptedToolCall::new("echo", json!({}))],
final_text: None,
max_fires: None,
}])
// First turn: kick it off
.with_fallback("end");
provider.push_forced_response(ChatResponse {
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
tool_calls: vec![],
usage: None,
});
let (echo_tool, _) = RecordingTool::echo("echo");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(echo_tool)];
let mut history = vec![ChatMessage::user("loop forever")];
let err = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"test-model",
0.0,
true,
None,
"channel",
&mm(),
3,
None,
None,
&[],
None,
None,
)
.await
.expect_err("should hit max iterations");
let s = err.to_string();
assert!(
s.contains("3") && s.to_lowercase().contains("iteration"),
"expected MaxIterationsExceeded with 3, got: {s}"
);
}
// ── 6. CliRpcOnly tools are blocked in the agent loop ─────────────
struct CliOnlyTool {
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl Tool for CliOnlyTool {
fn name(&self) -> &str {
"cli_only_tool"
}
fn description(&self) -> &str {
"cli-only"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({"type": "object"})
}
fn scope(&self) -> ToolScope {
ToolScope::CliRpcOnly
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(ToolResult::success("ran"))
}
}
#[tokio::test]
async fn agent_loop_refuses_clirpconly_tools() {
let calls = Arc::new(AtomicUsize::new(0));
let tool = CliOnlyTool {
calls: calls.clone(),
};
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call("use", ScriptedToolCall::new("cli_only_tool", json!({}))),
KeywordRule::final_reply("only available via", "Denied as expected."),
]);
let tools: Vec<Box<dyn Tool>> = vec![Box::new(tool)];
let mut history = vec![ChatMessage::user("use the tool")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"test-model",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
assert_eq!(out, "Denied as expected.");
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"CliRpcOnly tool must never execute in the agent loop"
);
}
// ── 7. Tool error result is threaded back as `Error: …` ───────────
struct FailingTool;
#[async_trait]
impl Tool for FailingTool {
fn name(&self) -> &str {
"fail_tool"
}
fn description(&self) -> &str {
"always fails"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({"type": "object"})
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult::error("boom"))
}
}
#[tokio::test]
async fn tool_error_result_is_surfaced_to_next_iteration() {
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call("try", ScriptedToolCall::new("fail_tool", json!({}))),
KeywordRule::final_reply("boom", "got the error"),
]);
let tools: Vec<Box<dyn Tool>> = vec![Box::new(FailingTool)];
let mut history = vec![ChatMessage::user("try the broken tool")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"test-model",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
assert_eq!(out, "got the error");
assert!(history.iter().any(|m| m.content.contains("Error: boom")));
}
// ── 8. Tool that bails with anyhow::Error ─────────────────────────
struct PanickyTool;
#[async_trait]
impl Tool for PanickyTool {
fn name(&self) -> &str {
"panicky"
}
fn description(&self) -> &str {
"raises anyhow"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({"type": "object"})
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
anyhow::bail!("kaboom")
}
}
#[tokio::test]
async fn tool_anyhow_error_surfaces_in_history() {
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call("run", ScriptedToolCall::new("panicky", json!({}))),
KeywordRule::final_reply("kaboom", "tool blew up"),
]);
let tools: Vec<Box<dyn Tool>> = vec![Box::new(PanickyTool)];
let mut history = vec![ChatMessage::user("run it")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"test-model",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
assert_eq!(out, "tool blew up");
assert!(history.iter().any(|m| m.content.contains("kaboom")));
}
// ── 9. visible_tool_names whitelist hides tools from the model ────
#[tokio::test]
async fn visible_tool_names_whitelist_rejects_filtered_out_tools() {
let provider = KeywordScriptedProvider::new(vec![
// Model asks for a tool that *exists* but is filtered out.
KeywordRule::tool_call("go", ScriptedToolCall::new("hidden", json!({}))),
KeywordRule::final_reply("unknown tool", "Cannot reach hidden tool."),
]);
let (visible_tool, visible_calls) = RecordingTool::echo("visible");
let (hidden_tool, hidden_calls) = RecordingTool::echo("hidden");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(visible_tool), Box::new(hidden_tool)];
let mut visible = std::collections::HashSet::new();
visible.insert("visible".to_string());
let mut history = vec![ChatMessage::user("go please")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"test-model",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
Some(&visible),
&[],
None,
None,
)
.await
.unwrap();
assert_eq!(out, "Cannot reach hidden tool.");
assert_eq!(visible_calls.lock().len(), 0);
assert_eq!(
hidden_calls.lock().len(),
0,
"hidden tool must not execute when filtered out"
);
}
// ── 10. extra_tools are reachable alongside the registry ──────────
#[tokio::test]
async fn extra_tools_are_invokable_alongside_registry() {
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call("delegate", ScriptedToolCall::new("extra", json!({"x": 1}))),
KeywordRule::final_reply("extra-ok", "delegated"),
]);
let (extra_tool, extra_calls) = RecordingTool::echo("extra");
let extras: Vec<Box<dyn Tool>> = vec![Box::new(extra_tool)];
let tools: Vec<Box<dyn Tool>> = vec![];
let mut history = vec![ChatMessage::user("delegate the work")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"test-model",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&extras,
None,
None,
)
.await
.unwrap();
assert_eq!(out, "delegated");
assert_eq!(extra_calls.lock().len(), 1);
}
// ── 11. Composio fixture backend: end-to-end client wiring ────────
#[tokio::test]
async fn fake_composio_backend_serves_realistic_toolkits() {
let backend = spawn_fake_composio_backend(ComposioFixture::realistic()).await;
let client = backend.client();
let toolkits = client.list_toolkits().await.unwrap();
assert!(toolkits.toolkits.contains(&"gmail".to_string()));
assert!(toolkits.toolkits.contains(&"github".to_string()));
let conns = client.list_connections().await.unwrap();
assert!(
conns.connections.iter().any(|c| c.toolkit == "gmail"),
"expected a gmail connection: {:?}",
conns.connections
);
let exec = client
.execute_tool(
"GMAIL_SEND_EMAIL",
Some(json!({"recipient_email": "a@b.com", "subject": "hi", "body": "hello"})),
)
.await
.unwrap();
assert!(exec.successful, "execute should report success");
let resp_json = serde_json::to_value(&exec.data).unwrap();
assert!(
resp_json.to_string().contains("gmail-msg-1234"),
"expected fixture response, got: {resp_json}"
);
let reqs = backend.requests();
// Authorize wasn't called; toolkits + connections + execute were.
assert!(reqs.iter().any(|(_, p, _)| p == "/toolkits"));
assert!(reqs.iter().any(|(_, p, _)| p == "/connections"));
assert!(reqs.iter().any(|(_, p, _)| p == "/execute"));
}
// ── 12. End-to-end: harness drives a Composio tool against fake backend
#[tokio::test]
async fn harness_invokes_composio_action_tool_against_fake_backend() {
use crate::openhuman::composio::ComposioActionTool;
let backend = spawn_fake_composio_backend(ComposioFixture::realistic()).await;
let client = backend.client();
let tool = ComposioActionTool::new(
client,
"GMAIL_SEND_EMAIL".to_string(),
"Send a Gmail email".to_string(),
Some(json!({"type": "object"})),
);
let provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call(
"send email",
ScriptedToolCall::new(
"GMAIL_SEND_EMAIL",
json!({"recipient_email": "alice@example.com", "subject": "hi", "body": "hello"}),
),
),
KeywordRule::final_reply("gmail-msg-1234", "Email sent."),
]);
let tools: Vec<Box<dyn Tool>> = vec![Box::new(tool)];
let mut history = vec![ChatMessage::user("send email to alice")];
let out = run_tool_call_loop(
&provider,
&mut history,
&tools,
"mock",
"test-model",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.unwrap();
assert_eq!(out, "Email sent.");
// Backend should have received the execute POST.
let reqs = backend.requests();
let exec = reqs
.iter()
.find(|(m, p, _)| m == "POST" && p == "/execute")
.expect("execute call must have hit the backend");
assert_eq!(exec.2["tool"], "GMAIL_SEND_EMAIL");
assert_eq!(exec.2["arguments"]["recipient_email"], "alice@example.com");
}
// ── 13. Orchestrator-prompt → delegation → Composio round-trip ────
//
// End-to-end test that ties together the three things the user needs
// confidence in:
//
// 1. The orchestrator's system prompt (built by
// `orchestrator::prompt::build`) advertises a connected toolkit
// via the collapsed `delegate_to_integrations_agent` tool.
// 2. Given that prompt and a user task that mentions the toolkit,
// the LLM (mocked) emits a tool call that satisfies the real
// `SkillDelegationTool` schema.
// 3. Delegation reaches the integrations side, where a
// `ComposioActionTool` is dispatched against a real
// `ComposioClient` pointed at a hermetic fake backend — and the
// backend records the action with the orchestrator-provided args.
//
// To avoid pulling in the full sub-agent runner, the test substitutes
// a `TestDelegationTool` that mirrors `SkillDelegationTool`'s contract
// (same tool name, same schema validation against the connected
// toolkit list) but runs a *nested* `run_tool_call_loop` for the
// integrations side instead of calling `dispatch_subagent`. The
// nested loop is the same code path the real integrations_agent uses,
// so the wiring under test is the orchestrator → delegation arg →
// integrations LLM → ComposioActionTool → backend chain.
struct TestDelegationTool {
connected_toolkits: Vec<(String, String)>,
nested_tools: Arc<parking_lot::Mutex<Option<Vec<Box<dyn Tool>>>>>,
inner_provider: Arc<KeywordScriptedProvider>,
}
impl TestDelegationTool {
fn new(
connected_toolkits: Vec<(String, String)>,
nested_tools: Vec<Box<dyn Tool>>,
inner_provider: Arc<KeywordScriptedProvider>,
) -> Self {
Self {
connected_toolkits,
nested_tools: Arc::new(parking_lot::Mutex::new(Some(nested_tools))),
inner_provider,
}
}
}
#[async_trait]
impl Tool for TestDelegationTool {
fn name(&self) -> &str {
// Mirror SkillDelegationTool's canonical name so the orchestrator
// system prompt's references resolve.
"delegate_to_integrations_agent"
}
fn description(&self) -> &str {
"Delegate to integrations_agent (test stand-in for SkillDelegationTool)."
}
fn parameters_schema(&self) -> serde_json::Value {
// Same shape as the real SkillDelegationTool.
let slugs: Vec<&str> = self
.connected_toolkits
.iter()
.map(|(s, _)| s.as_str())
.collect();
json!({
"type": "object",
"required": ["toolkit", "prompt"],
"properties": {
"toolkit": {"type": "string", "enum": slugs},
"prompt": {"type": "string"}
}
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let toolkit = args
.get("toolkit")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let prompt = args
.get("prompt")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if toolkit.is_empty() {
return Ok(ToolResult::error("`toolkit` is required"));
}
let known = self
.connected_toolkits
.iter()
.any(|(slug, _)| slug == &toolkit);
if !known {
return Ok(ToolResult::error(format!(
"toolkit `{toolkit}` is not connected"
)));
}
if prompt.is_empty() {
return Ok(ToolResult::error("`prompt` is required"));
}
// Take ownership of the nested tool list (one-shot).
let nested_tools = self
.nested_tools
.lock()
.take()
.expect("nested tools already consumed");
// Run a NESTED tool loop — same code path the integrations_agent
// uses inside the real sub-agent runner.
let mut nested_history = vec![ChatMessage::user(format!("[toolkit={toolkit}] {prompt}"))];
let out = run_tool_call_loop(
self.inner_provider.as_ref(),
&mut nested_history,
&nested_tools,
"test-integrations",
"test-model",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await?;
Ok(ToolResult::success(out))
}
}
#[tokio::test]
async fn orchestrator_prompt_drives_composio_call_via_delegation_chain() {
use crate::openhuman::agent::agents::orchestrator::prompt as orch_prompt;
use crate::openhuman::agent::prompts::types::ConnectedIntegration;
use crate::openhuman::composio::ComposioActionTool;
// ── 1. Build the orchestrator's system prompt with gmail wired in.
let integrations = vec![ConnectedIntegration {
toolkit: "gmail".into(),
description: "Email send/fetch via Gmail.".into(),
tools: Vec::new(),
connected: true,
}];
let ctx = {
use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat};
use std::collections::HashSet;
use std::sync::OnceLock;
static EMPTY: OnceLock<HashSet<String>> = OnceLock::new();
crate::openhuman::context::prompt::PromptContext {
workspace_dir: std::path::Path::new("."),
model_name: "test",
agent_id: "orchestrator",
tools: &[],
skills: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: EMPTY.get_or_init(HashSet::new),
tool_call_format: ToolCallFormat::PFormat,
connected_integrations: &integrations,
connected_identities_md: String::new(),
include_profile: false,
include_memory_md: false,
curated_snapshot: None,
user_identity: None,
}
};
let system_prompt = orch_prompt::build(&ctx).expect("build orchestrator prompt");
// The prompt must explicitly route gmail tasks via the collapsed
// delegation tool — otherwise the orchestrator can't know to call it.
assert!(system_prompt.contains("## Connected Integrations"));
assert!(system_prompt.contains("delegate_to_integrations_agent"));
assert!(system_prompt.contains("toolkit: \"gmail\""));
// ── 2. Spawn the fake Composio backend + wire a ComposioActionTool.
let backend = spawn_fake_composio_backend(ComposioFixture::realistic()).await;
let composio_client = backend.client();
let gmail_action_tool: Box<dyn Tool> = Box::new(ComposioActionTool::new(
composio_client,
"GMAIL_SEND_EMAIL".to_string(),
"Send a Gmail email".to_string(),
Some(json!({"type": "object"})),
));
// ── 3. Inner (integrations-side) provider: emit a ComposioActionTool call,
// then a final reply once it sees the action's success marker.
let inner_provider = Arc::new(KeywordScriptedProvider::new(vec![
KeywordRule::tool_call(
"toolkit=gmail",
ScriptedToolCall::new(
"GMAIL_SEND_EMAIL",
json!({
"recipient_email": "alice@example.com",
"subject": "hi",
"body": "hello from orchestrator",
}),
),
),
KeywordRule::final_reply("gmail-msg-1234", "delivered"),
]));
let nested_tools: Vec<Box<dyn Tool>> = vec![gmail_action_tool];
// ── 4. Outer (orchestrator) provider: when the user asks to email Alice
// via gmail, emit the delegation tool call. After the delegation
// returns "delivered", produce the final user-facing reply.
let outer_provider = KeywordScriptedProvider::new(vec![
KeywordRule::tool_call(
"send an email to alice",
ScriptedToolCall::new(
"delegate_to_integrations_agent",
json!({
"toolkit": "gmail",
"prompt": "Send an email to alice@example.com saying hi"
}),
),
),
KeywordRule::final_reply("delivered", "I've sent the email to Alice."),
]);
// ── 5. Wire the test delegation tool that bridges outer -> inner loop.
let delegation_tool: Box<dyn Tool> = Box::new(TestDelegationTool::new(
vec![(
"gmail".to_string(),
"Email send/fetch via Gmail.".to_string(),
)],
nested_tools,
inner_provider.clone(),
));
let outer_tools: Vec<Box<dyn Tool>> = vec![delegation_tool];
let mut history = vec![
ChatMessage::system(system_prompt),
ChatMessage::user("Please send an email to alice@example.com saying hi via gmail."),
];
// ── 6. Drive the outer (orchestrator) loop.
let final_reply = run_tool_call_loop(
&outer_provider,
&mut history,
&outer_tools,
"orchestrator-mock",
"test-model",
0.0,
true,
None,
"channel",
&mm(),
5,
None,
None,
&[],
None,
None,
)
.await
.expect("orchestrator loop should complete");
assert_eq!(final_reply, "I've sent the email to Alice.");
// ── 7. Assert the Composio backend actually received the action with
// the orchestrator-routed arguments.
let backend_reqs = backend.requests();
let exec = backend_reqs
.iter()
.find(|(m, p, _)| m == "POST" && p == "/execute")
.expect("Composio /execute must have been called via the orchestrator chain");
assert_eq!(exec.2["tool"], "GMAIL_SEND_EMAIL");
assert_eq!(exec.2["arguments"]["recipient_email"], "alice@example.com");
assert_eq!(exec.2["arguments"]["subject"], "hi");
// ── 8. Both sides of the chain should have seen exactly one turn that
// emitted the expected call.
let outer_turns = outer_provider.turns();
assert!(
outer_turns
.iter()
.any(|t| t.rule_keyword.as_deref() == Some("send an email to alice")),
"orchestrator must have matched the delegation rule"
);
let inner_turns = inner_provider.turns();
assert!(
inner_turns
.iter()
.any(|t| t.rule_keyword.as_deref() == Some("toolkit=gmail")),
"integrations agent must have matched its tool-call rule"
);
}