Files
openhuman/tests/raw_coverage/agent_round26_raw_coverage_e2e.rs
T

511 lines
16 KiB
Rust

use anyhow::Result;
use async_trait::async_trait;
use chrono::{TimeZone, Utc};
use openhuman_core::openhuman::agent::debug::{dump_agent_prompt, DumpPromptOptions};
use openhuman_core::openhuman::agent::dispatcher::NativeToolDispatcher;
use openhuman_core::openhuman::agent::Agent;
use openhuman_core::openhuman::config::AgentConfig;
use openhuman_core::openhuman::context::prompt::{
render_ambient_environment, render_safety, render_subagent_system_prompt_with_format,
render_tools, ConnectedIntegration, CuratedMemoryPromptSnapshot, LearnedContextData,
NamespaceSummary as PromptNamespaceSummary, PersonalityRosterEntry, PersonalityRosterSection,
PromptContext, PromptTool, SubagentRenderOptions, SystemPromptBuilder, ToolCallFormat,
UserIdentity,
};
use openhuman_core::openhuman::inference::provider::traits::ProviderCapabilities;
use openhuman_core::openhuman::inference::provider::{
ChatMessage, ChatRequest, ChatResponse, Provider, UsageInfo,
};
use openhuman_core::openhuman::memory::{
Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts,
};
use openhuman_core::openhuman::skills::ops_types::Workflow;
use openhuman_core::openhuman::subconscious::SourceChunk;
use openhuman_core::openhuman::tools::{PermissionLevel, Tool, ToolResult};
use parking_lot::Mutex;
use serde_json::json;
use std::collections::{HashSet, VecDeque};
use std::path::PathBuf;
use std::sync::Arc;
struct EnvGuard {
key: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn set_path(key: &'static str, value: &std::path::Path) -> Self {
let previous = std::env::var_os(key);
unsafe { std::env::set_var(key, value) };
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.previous.take() {
Some(value) => unsafe { std::env::set_var(self.key, value) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: &std::sync::OnceLock<std::sync::Mutex<()>> = &crate::SHARED_ENV_LOCK;
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
#[derive(Clone, Debug)]
struct CapturedRequest {
messages: Vec<ChatMessage>,
tool_names: Vec<String>,
}
struct ScriptedProvider {
responses: Mutex<VecDeque<ChatResponse>>,
requests: Mutex<Vec<CapturedRequest>>,
native_tools: bool,
}
impl ScriptedProvider {
fn new(responses: Vec<ChatResponse>) -> Arc<Self> {
Arc::new(Self {
responses: Mutex::new(VecDeque::from(responses)),
requests: Mutex::new(Vec::new()),
native_tools: true,
})
}
fn requests(&self) -> Vec<CapturedRequest> {
self.requests.lock().clone()
}
}
#[async_trait]
impl Provider for ScriptedProvider {
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
native_tool_calling: self.native_tools,
vision: false,
}
}
async fn chat(
&self,
request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> Result<ChatResponse> {
self.requests.lock().push(CapturedRequest {
messages: request.messages.to_vec(),
tool_names: request
.tools
.map(|tools| tools.iter().map(|tool| tool.name.clone()).collect())
.unwrap_or_default(),
});
Ok(self
.responses
.lock()
.pop_front()
.unwrap_or_else(|| text_response("round26 fallback")))
}
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
message: &str,
_model: &str,
_temperature: f64,
) -> Result<String> {
Ok(format!("summary: {message}"))
}
}
struct StubMemory;
#[async_trait]
impl Memory for StubMemory {
fn name(&self) -> &str {
"round26-memory"
}
async fn store(
&self,
_namespace: &str,
_key: &str,
_content: &str,
_category: MemoryCategory,
_session_id: Option<&str>,
) -> Result<()> {
Ok(())
}
async fn recall(
&self,
_query: &str,
_limit: usize,
_opts: RecallOpts<'_>,
) -> Result<Vec<MemoryEntry>> {
Ok(Vec::new())
}
async fn get(&self, _namespace: &str, _key: &str) -> Result<Option<MemoryEntry>> {
Ok(None)
}
async fn list(
&self,
_namespace: Option<&str>,
_category: Option<&MemoryCategory>,
_session_id: Option<&str>,
) -> Result<Vec<MemoryEntry>> {
Ok(Vec::new())
}
async fn forget(&self, _namespace: &str, _key: &str) -> Result<bool> {
Ok(false)
}
async fn namespace_summaries(&self) -> Result<Vec<NamespaceSummary>> {
Ok(Vec::new())
}
async fn count(&self) -> Result<usize> {
Ok(0)
}
async fn health_check(&self) -> bool {
true
}
}
struct Round26Tool {
name: &'static str,
}
#[async_trait]
impl Tool for Round26Tool {
fn name(&self) -> &str {
self.name
}
fn description(&self) -> &str {
"round26 deterministic test tool"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"alpha": { "type": "string" },
"zeta": { "type": "integer" }
}
})
}
async fn execute(&self, _args: serde_json::Value) -> Result<ToolResult> {
Ok(ToolResult::success("round26 tool output"))
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::ReadOnly
}
}
fn text_response(text: &str) -> ChatResponse {
ChatResponse {
text: Some(text.to_string()),
tool_calls: Vec::new(),
usage: Some(UsageInfo {
input_tokens: 3,
output_tokens: 2,
context_window: 16_000,
cached_input_tokens: 1,
cache_creation_tokens: 0,
reasoning_tokens: 0,
charged_amount_usd: 0.0001,
}),
reasoning_content: None,
}
}
fn prompt_context<'a>(
workspace: &'a std::path::Path,
tools: &'a [PromptTool<'a>],
visible: &'a HashSet<String>,
format: ToolCallFormat,
) -> PromptContext<'a> {
PromptContext {
workspace_dir: workspace,
model_name: "round26-model",
agent_id: "round26-agent",
tools,
workflows: &[] as &[Workflow],
dispatcher_instructions: "round26 dispatcher instructions",
learned: LearnedContextData {
reflections: vec![
" prefer concise status updates ".to_string(),
String::new(),
],
tree_root_summaries: vec![
PromptNamespaceSummary {
namespace: "projects".to_string(),
body: "Root memory summary for round26.".to_string(),
updated_at: Utc.with_ymd_and_hms(2026, 5, 28, 12, 0, 0).unwrap(),
},
PromptNamespaceSummary {
namespace: "empty".to_string(),
body: " ".to_string(),
updated_at: Utc.with_ymd_and_hms(2026, 5, 29, 12, 0, 0).unwrap(),
},
],
..LearnedContextData::default()
},
visible_tool_names: visible,
tool_call_format: format,
connected_integrations: &[] as &[ConnectedIntegration],
connected_identities_md: String::new(),
include_profile: true,
include_memory_md: true,
curated_snapshot: Some(Arc::new(CuratedMemoryPromptSnapshot {
memory: "curated memory snapshot round26".to_string(),
user: "curated user snapshot round26".to_string(),
})),
user_identity: Some(UserIdentity {
id: Some(" user-26 ".to_string()),
name: Some("Round\nTwenty Six".to_string()),
email: Some(" round26@example.test ".to_string()),
}),
personality_soul_md: Some("round26 personality soul override".to_string()),
personality_memory_md: Some("round26 personality memory override".to_string()),
personality_roster: vec![PersonalityRosterEntry {
id: "analyst".to_string(),
name: "Analyst".to_string(),
description: "Checks cold prompt paths".to_string(),
memory_summary: Some("x".repeat(240)),
}],
}
}
#[test]
fn prompt_renderers_cover_user_memory_identity_tools_and_subagent_variants() -> Result<()> {
let workspace = tempfile::tempdir()?;
std::fs::write(workspace.path().join("PROFILE.md"), "profile file round26")?;
std::fs::write(
workspace.path().join("MEMORY.md"),
"workspace memory round26",
)?;
let schema = json!({
"type": "object",
"properties": {
"zeta": { "type": "integer" },
"alpha": { "type": "string" }
}
})
.to_string();
let tools = [PromptTool::with_schema(
"round26_tool",
"Prompt-rendered tool",
schema,
)];
let mut visible = HashSet::new();
visible.insert("round26_tool".to_string());
let ctx = prompt_context(workspace.path(), &tools, &visible, ToolCallFormat::PFormat);
let built = SystemPromptBuilder::with_defaults()
.add_section(Box::new(PersonalityRosterSection))
.with_reflection_context(vec![
SourceChunk {
ref_id: "entity:alpha".to_string(),
kind: "entity".to_string(),
content: "Resolved source chunk\nwith newline.".to_string(),
metadata: json!({"hotness": 7}),
},
SourceChunk {
ref_id: "missing:beta".to_string(),
kind: "missing".to_string(),
content: String::new(),
metadata: json!({}),
},
])
.build(&ctx)?;
assert!(built.contains("round26 personality soul override"));
assert!(built.contains("### PROFILE.md"));
assert!(built.contains("profile file round26"));
assert!(built.contains("round26 personality memory override"));
assert!(built.contains("## User Memory"));
assert!(built.contains("projects (last updated 2026-05-28)"));
assert!(built.contains("round26_tool[alpha|zeta]"));
assert!(built.contains("## Memory context"));
assert!(built.contains("Resolved source chunk with newline."));
assert!(!built.contains("missing:beta"));
assert!(built.contains("## Available Personalities"));
assert!(built.contains("Recent context: "));
let ambient = render_ambient_environment(&ctx)?;
assert!(ambient.contains("## Runtime"));
assert!(ambient.contains("- name: Round Twenty Six"));
assert!(ambient.contains("- email: round26@example.test"));
assert!(ambient.contains("## Current Date & Time"));
let native_ctx = prompt_context(workspace.path(), &tools, &visible, ToolCallFormat::Native);
let native_tools = render_tools(&native_ctx)?;
assert_eq!(native_tools.trim(), "round26 dispatcher instructions");
assert!(render_safety().contains("Prefer `trash` over `rm`"));
let parent_tools: Vec<Box<dyn Tool>> = vec![Box::new(Round26Tool {
name: "parent_tool",
})];
let extra_tools: Vec<Box<dyn Tool>> = vec![Box::new(Round26Tool { name: "extra_tool" })];
let subagent_json = render_subagent_system_prompt_with_format(
workspace.path(),
"round26-model",
&[0, 99],
&parent_tools,
&extra_tools,
"Round26 archetype",
SubagentRenderOptions {
include_safety_preamble: true,
include_identity: true,
include_skills_catalog: false,
include_profile: true,
include_memory_md: true,
},
ToolCallFormat::Json,
&[],
);
assert!(subagent_json.contains("Round26 archetype"));
assert!(subagent_json.contains("### PROFILE.md"));
assert!(subagent_json.contains("- **parent_tool**"));
assert!(subagent_json.contains("- **extra_tool**"));
assert!(subagent_json.contains("Parameters:"));
assert!(subagent_json.contains("## Safety"));
let subagent_native = render_subagent_system_prompt_with_format(
workspace.path(),
"round26-model",
&[0],
&parent_tools,
&extra_tools,
"Round26 archetype",
SubagentRenderOptions::narrow(),
ToolCallFormat::Native,
&[],
);
assert!(!subagent_native.contains("## Tools"));
assert!(subagent_native.contains("native tool-calling output"));
Ok(())
}
#[tokio::test]
async fn builder_dedupes_visible_native_tools_and_seed_resume_bounds_history() -> Result<()> {
let workspace = tempfile::tempdir()?;
let provider = ScriptedProvider::new(vec![text_response("round26 resumed final")]);
let tools: Vec<Box<dyn Tool>> = vec![
Box::new(Round26Tool {
name: "round26_duplicate",
}),
Box::new(Round26Tool {
name: "round26_duplicate",
}),
Box::new(Round26Tool {
name: "round26_hidden",
}),
];
let mut visible = HashSet::new();
visible.insert("round26_duplicate".to_string());
let mut agent = Agent::builder()
.provider_arc(provider.clone())
.tools(tools)
.visible_tool_names(visible)
.memory(Arc::new(StubMemory))
.tool_dispatcher(Box::new(NativeToolDispatcher))
.workspace_dir(workspace.path().to_path_buf())
.event_context("round26-session", "round26-channel")
.agent_definition_name("round26/orchestrator")
.config(AgentConfig {
max_history_messages: 4,
..AgentConfig::default()
})
.explicit_preferences_enabled(false)
.build()?;
let original_key = agent.session_key().to_string();
agent.set_agent_definition_name("round26 renamed/agent");
assert_ne!(original_key, agent.session_key());
assert!(agent.session_key().ends_with("_round26_renamed_agent"));
agent.seed_resume_from_messages(
vec![
("user".to_string(), "old user one".to_string()),
("agent".to_string(), "old assistant one".to_string()),
("systemish".to_string(), "falls back to user".to_string()),
("assistant".to_string(), "old assistant two".to_string()),
("user".to_string(), "current message".to_string()),
],
" current message ",
)?;
let answer = agent.run_single("current message").await?;
assert_eq!(answer, "round26 resumed final");
let requests = provider.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].tool_names, vec!["round26_duplicate"]);
assert!(requests[0]
.messages
.iter()
.any(|msg| msg.role == "assistant" && msg.content == "old assistant two"));
assert!(requests[0]
.messages
.iter()
.any(|msg| msg.role == "user" && msg.content == "falls back to user"));
// The live turn's user message is stamped with the per-turn
// `Current Date & Time:` line (#3602), so match by suffix rather than
// exact equality — the dedup contract (exactly one "current message"
// user turn after resume) still holds.
assert_eq!(
requests[0]
.messages
.iter()
.filter(|msg| msg.role == "user" && msg.content.ends_with("current message"))
.count(),
1
);
Ok(())
}
#[tokio::test]
async fn debug_dump_integrations_agent_reports_missing_toolkit_without_network() -> Result<()> {
let _env = env_lock();
let workspace = tempfile::tempdir()?;
let _workspace_guard = EnvGuard::set_path("OPENHUMAN_WORKSPACE", workspace.path());
let err = dump_agent_prompt(DumpPromptOptions::new("integrations_agent"))
.await
.expect_err("integrations_agent needs an explicit toolkit");
let message = err.to_string();
assert!(message.contains("integrations_agent requires a `toolkit` argument"));
assert!(message.contains("composio list_connection"));
let mut options = DumpPromptOptions::new("integrations_agent");
options.workspace_dir_override = Some(PathBuf::from(workspace.path()));
options.model_override = Some("round26-debug-model".to_string());
let err = dump_agent_prompt(options)
.await
.expect_err("missing toolkit should fail before any remote client call");
assert!(err
.to_string()
.contains("integrations_agent requires a `toolkit` argument"));
Ok(())
}