mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
test(harness): smart mock LLM provider + fake Composio backend + 21 new tests (#1729)
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user