mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
380 lines
13 KiB
Rust
380 lines
13 KiB
Rust
//! Round25 raw integration coverage for large channel misses.
|
|
//!
|
|
//! Only loopback services and parser fixtures are used.
|
|
|
|
use axum::{
|
|
extract::State,
|
|
http::{HeaderMap, StatusCode},
|
|
routing::{get, post},
|
|
Json, Router,
|
|
};
|
|
use openhuman_core::core::socketio::WebChannelEvent;
|
|
use openhuman_core::openhuman::channels::providers::email_channel::{
|
|
test_support as email_support, EmailChannel, EmailConfig,
|
|
};
|
|
use openhuman_core::openhuman::channels::providers::lark::test_support as lark_support;
|
|
use openhuman_core::openhuman::channels::providers::mattermost::{
|
|
test_support as mattermost_support, MattermostChannel,
|
|
};
|
|
use openhuman_core::openhuman::channels::providers::telegram::test_support as telegram_support;
|
|
use openhuman_core::openhuman::channels::providers::web::{self, test_support as web_support};
|
|
use openhuman_core::openhuman::channels::test_support::{
|
|
build_channel_context_block_for_test, run_dispatch_harness,
|
|
select_acknowledgment_reaction_for_test, DispatchHarnessOptions, TestMemoryEntry,
|
|
};
|
|
use openhuman_core::openhuman::channels::traits::{Channel, ChannelMessage, SendMessage};
|
|
use serde_json::{json, Value};
|
|
use std::sync::{Arc, Mutex};
|
|
use tokio::sync::broadcast::error::RecvError;
|
|
|
|
#[derive(Default)]
|
|
struct MattermostMockState {
|
|
post_bodies: Mutex<Vec<Value>>,
|
|
typing_bodies: Mutex<Vec<Value>>,
|
|
auth_headers: Mutex<Vec<String>>,
|
|
}
|
|
|
|
async fn spawn_mattermost_mock() -> (String, Arc<MattermostMockState>) {
|
|
let state = Arc::new(MattermostMockState::default());
|
|
let app = Router::new()
|
|
.route(
|
|
"/api/v4/users/me",
|
|
get(|| async { Json(json!({"id": "bot-id", "username": "openhuman"})) }),
|
|
)
|
|
.route(
|
|
"/api/v4/posts",
|
|
post(
|
|
|State(state): State<Arc<MattermostMockState>>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<Value>| async move {
|
|
if let Some(auth) = headers
|
|
.get("authorization")
|
|
.and_then(|value| value.to_str().ok())
|
|
{
|
|
state.auth_headers.lock().expect("auth headers").push(auth.to_string());
|
|
}
|
|
state.post_bodies.lock().expect("post bodies").push(body);
|
|
(StatusCode::OK, Json(json!({"id": "post-created"})))
|
|
},
|
|
),
|
|
)
|
|
.route(
|
|
"/api/v4/users/me/typing",
|
|
post(
|
|
|State(state): State<Arc<MattermostMockState>>,
|
|
Json(body): Json<Value>| async move {
|
|
state.typing_bodies.lock().expect("typing bodies").push(body);
|
|
(StatusCode::OK, Json(json!({"ok": true})))
|
|
},
|
|
),
|
|
)
|
|
.with_state(Arc::clone(&state));
|
|
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind mattermost mock");
|
|
let addr = listener.local_addr().expect("mattermost mock addr");
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app)
|
|
.await
|
|
.expect("serve mattermost mock");
|
|
});
|
|
(format!("http://127.0.0.1:{}", addr.port()), state)
|
|
}
|
|
|
|
async fn recv_event(
|
|
rx: &mut tokio::sync::broadcast::Receiver<WebChannelEvent>,
|
|
expected: &str,
|
|
) -> WebChannelEvent {
|
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3);
|
|
loop {
|
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
let event = tokio::time::timeout(remaining, rx.recv())
|
|
.await
|
|
.expect("web event timeout")
|
|
.unwrap_or_else(|err| match err {
|
|
RecvError::Lagged(_) => panic!("web event receiver lagged"),
|
|
RecvError::Closed => panic!("web event receiver closed"),
|
|
});
|
|
if event.event == expected {
|
|
return event;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn web_channel_validation_cancellation_and_error_events_are_observable() {
|
|
assert_eq!(web_support::key_for_test("thread-1"), "thread-1");
|
|
assert_eq!(
|
|
serde_json::from_str::<Value>(&web_support::event_session_id_for_test(
|
|
"client-1", "thread-1"
|
|
))
|
|
.expect("session json"),
|
|
json!({"client_id": "client-1", "thread_id": "thread-1"})
|
|
);
|
|
|
|
assert!(web::start_chat(
|
|
" ",
|
|
"thread",
|
|
"hello",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
web::ChatRequestMetadata::default()
|
|
)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("client_id is required"));
|
|
assert!(web::cancel_chat("client", " ")
|
|
.await
|
|
.unwrap_err()
|
|
.contains("thread_id"));
|
|
|
|
let mut rx = web::subscribe_web_channel_events();
|
|
web_support::set_forced_run_chat_task_error_for_test(Some(
|
|
"provider OpenAI returned 429 rate limit; retry after 2 seconds",
|
|
))
|
|
.await;
|
|
let request_id = web::start_chat(
|
|
"client-round25",
|
|
"thread-round25",
|
|
"hello from round25",
|
|
Some("gpt-5".to_string()),
|
|
Some(0.2),
|
|
None,
|
|
Some("en-US".to_string()),
|
|
None,
|
|
web::ChatRequestMetadata::default(),
|
|
)
|
|
.await
|
|
.expect("start forced-error chat");
|
|
|
|
let event = recv_event(&mut rx, "chat_error").await;
|
|
assert_eq!(event.client_id, "client-round25");
|
|
assert_eq!(event.thread_id, "thread-round25");
|
|
assert_eq!(event.request_id, request_id);
|
|
assert_eq!(event.error_type.as_deref(), Some("rate_limited"));
|
|
assert_eq!(event.error_retryable, Some(true));
|
|
|
|
let missing = web::cancel_chat("client-round25", "thread-round25")
|
|
.await
|
|
.expect("cancel non-inflight");
|
|
assert!(missing.is_none());
|
|
|
|
// Clear the forced error so subsequent tests in this binary are not affected.
|
|
web_support::set_forced_run_chat_task_error_for_test(None).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mattermost_loopback_send_typing_health_and_parser_paths() {
|
|
let (base, state) = spawn_mattermost_mock().await;
|
|
let channel = MattermostChannel::new(
|
|
format!("{base}/"),
|
|
"mm-token".to_string(),
|
|
Some("chan-1".to_string()),
|
|
vec!["alice".to_string()],
|
|
true,
|
|
true,
|
|
);
|
|
|
|
assert!(channel.health_check().await);
|
|
channel
|
|
.send(&SendMessage::new("hello mattermost", "chan-1:root-7"))
|
|
.await
|
|
.expect("send mattermost");
|
|
channel
|
|
.start_typing("chan-1:root-7")
|
|
.await
|
|
.expect("start typing");
|
|
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
|
channel
|
|
.stop_typing("chan-1:root-7")
|
|
.await
|
|
.expect("stop typing");
|
|
|
|
let post_bodies = state.post_bodies.lock().expect("post bodies");
|
|
assert_eq!(post_bodies[0]["channel_id"], "chan-1");
|
|
assert_eq!(post_bodies[0]["root_id"], "root-7");
|
|
assert_eq!(post_bodies[0]["message"], "hello mattermost");
|
|
drop(post_bodies);
|
|
|
|
let typing_bodies = state.typing_bodies.lock().expect("typing bodies");
|
|
assert_eq!(typing_bodies[0]["channel_id"], "chan-1");
|
|
assert_eq!(typing_bodies[0]["parent_id"], "root-7");
|
|
drop(typing_bodies);
|
|
assert_eq!(
|
|
state.auth_headers.lock().expect("auth headers")[0],
|
|
"Bearer mm-token"
|
|
);
|
|
|
|
let post = json!({
|
|
"id": "post-1",
|
|
"user_id": "alice",
|
|
"message": "@OpenHuman please triage",
|
|
"create_at": 1_700_000_005_000_i64,
|
|
"metadata": {"mentions": ["bot-id"]}
|
|
});
|
|
assert!(mattermost_support::contains_bot_mention_for_test(
|
|
"hello @openhuman",
|
|
"bot-id",
|
|
"OpenHuman",
|
|
&post,
|
|
));
|
|
assert_eq!(
|
|
mattermost_support::normalize_mattermost_content_for_test(
|
|
"@OpenHuman please triage",
|
|
"bot-id",
|
|
"OpenHuman",
|
|
&post,
|
|
)
|
|
.as_deref(),
|
|
Some("please triage")
|
|
);
|
|
let parsed = mattermost_support::parse_mattermost_post_for_test(
|
|
&channel,
|
|
&post,
|
|
"bot-id",
|
|
"OpenHuman",
|
|
1_700_000_000_000_i64,
|
|
"chan-1",
|
|
)
|
|
.expect("mattermost parsed");
|
|
assert_eq!(parsed.channel, "mattermost");
|
|
assert_eq!(parsed.reply_target, "chan-1:post-1");
|
|
assert_eq!(parsed.content, "please triage");
|
|
|
|
let denied = mattermost_support::parse_mattermost_post_for_test(
|
|
&MattermostChannel::new(
|
|
base,
|
|
"mm-token".to_string(),
|
|
None,
|
|
vec!["bob".to_string()],
|
|
true,
|
|
false,
|
|
),
|
|
&post,
|
|
"bot-id",
|
|
"OpenHuman",
|
|
1_700_000_000_000_i64,
|
|
"chan-1",
|
|
);
|
|
assert!(denied.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn lark_email_telegram_and_dispatch_pure_paths_cover_large_helpers() {
|
|
let (tenant_url, send_url) = lark_support::endpoint_urls_for_test(false);
|
|
assert!(tenant_url.contains("open.larksuite.com/open-apis/auth"));
|
|
assert!(send_url.ends_with("/im/v1/messages?receive_id_type=chat_id"));
|
|
|
|
let (ws_url, ping) = lark_support::endpoint_response_for_test(
|
|
r#"{"code":0,"data":{"URL":"wss://lark.example/ws?service_id=42","ClientConfig":{"PingInterval":11}}}"#,
|
|
)
|
|
.expect("endpoint parse");
|
|
assert_eq!(ws_url, "wss://lark.example/ws?service_id=42");
|
|
assert_eq!(ping, Some(11));
|
|
assert!(
|
|
lark_support::endpoint_response_for_test(r#"{"code":1901,"msg":"bad app"}"#)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("1901")
|
|
);
|
|
|
|
let encoded =
|
|
lark_support::encode_frame_for_test(7, 0, "pong", Some(br#"{"ok":true}"#.to_vec()));
|
|
let decoded = lark_support::decode_frame_for_test(&encoded).expect("decode frame");
|
|
assert_eq!(decoded.0, 7);
|
|
assert_eq!(decoded.1, 0);
|
|
assert_eq!(decoded.2, "pong");
|
|
assert_eq!(decoded.3.as_deref(), Some(br#"{"ok":true}"#.as_slice()));
|
|
|
|
let (remaining, reaction) =
|
|
telegram_support::parse_reaction_marker_for_test(" [REACTION:👍|123] continuing ");
|
|
assert_eq!(remaining, "continuing");
|
|
assert_eq!(reaction.as_deref(), Some("👍|123"));
|
|
assert_eq!(
|
|
telegram_support::parse_reaction_marker_for_test("[REACTION:]").1,
|
|
None
|
|
);
|
|
|
|
let raw = b"From: Nobody <nobody@example.com>\r\nSubject: Empty\r\n\r\n";
|
|
let parsed = email_support::parse_email_fixture(raw).expect("empty email parse");
|
|
assert_eq!(parsed.sender, "nobody@example.com");
|
|
assert!(parsed.text.is_empty());
|
|
let message = EmailChannel::new(EmailConfig {
|
|
from_address: "bot@example.com".to_string(),
|
|
..Default::default()
|
|
})
|
|
.build_message_with_attachment(
|
|
"ops@example.com",
|
|
"Artifact",
|
|
"body",
|
|
"artifact.txt",
|
|
lettre::message::header::ContentType::TEXT_PLAIN,
|
|
b"artifact bytes".to_vec(),
|
|
)
|
|
.expect("message with attachment");
|
|
let formatted = String::from_utf8_lossy(&message.formatted()).to_string();
|
|
assert!(formatted.contains("Artifact"));
|
|
assert!(formatted.contains("artifact.txt"));
|
|
|
|
let web_msg = ChannelMessage {
|
|
id: "m1".to_string(),
|
|
sender: "alice".to_string(),
|
|
content: "hello".to_string(),
|
|
channel: "web".to_string(),
|
|
reply_target: "thread".to_string(),
|
|
timestamp: 1,
|
|
thread_ts: None,
|
|
};
|
|
assert!(build_channel_context_block_for_test(&web_msg).is_empty());
|
|
assert_eq!(
|
|
select_acknowledgment_reaction_for_test("thank you")
|
|
.chars()
|
|
.count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dispatch_harness_round25_covers_streaming_error_and_history_compaction() {
|
|
let observed = run_dispatch_harness(DispatchHarnessOptions {
|
|
channel_name: "mattermost".to_string(),
|
|
content: "can you debug this api issue?".to_string(),
|
|
thread_ts: Some("thread-ts".to_string()),
|
|
streaming: true,
|
|
supports_reactions: false,
|
|
seed_history_len: 40,
|
|
memory_entries: vec![TestMemoryEntry {
|
|
key: "round25-memory".to_string(),
|
|
content: "Mattermost replies should preserve channel context.".to_string(),
|
|
score: Some(0.9),
|
|
}],
|
|
response_text: Some("final streamed response".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
assert!(observed.handler_had_progress);
|
|
assert!(observed.handler_history_text.contains("[Channel context]"));
|
|
assert!(observed.handler_history_text.contains("[Memory context]"));
|
|
assert!(observed.retained_history_len > 0);
|
|
assert!(observed
|
|
.sends
|
|
.iter()
|
|
.any(|send| send.kind == "finalize_draft" && send.content == "final streamed response"));
|
|
|
|
let errored = run_dispatch_harness(DispatchHarnessOptions {
|
|
channel_name: "telegram".to_string(),
|
|
content: "force handler failure".to_string(),
|
|
handler_error: Some("synthetic handler failure".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
assert!(errored
|
|
.sends
|
|
.iter()
|
|
.any(|send| send.content.contains("synthetic handler failure")));
|
|
}
|