mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
test: expand unit + e2e coverage from test-map across core domains (#1724)
This commit is contained in:
@@ -576,6 +576,114 @@ mod tests {
|
||||
assert!(!looks_like_local_ai_endpoint("/v1/chat/completions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_local_ai_matches_lm_studio_default_port() {
|
||||
// LM Studio default port 1234 is in the LOCAL_AI_PORTS list and
|
||||
// must be classified as a local-AI endpoint so integrations
|
||||
// requests are not routed through it (pr#1630 / pr#1715).
|
||||
assert!(looks_like_local_ai_endpoint("http://localhost:1234"));
|
||||
assert!(looks_like_local_ai_endpoint("http://127.0.0.1:1234"));
|
||||
assert!(looks_like_local_ai_endpoint(
|
||||
"http://127.0.0.1:1234/v1/chat/completions"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_like_local_ai_matches_v1_subpath_on_loopback() {
|
||||
// /v1/models, /v1/embeddings etc. on loopback are local-AI signals.
|
||||
assert!(looks_like_local_ai_endpoint(
|
||||
"http://localhost:11434/v1/models"
|
||||
));
|
||||
assert!(looks_like_local_ai_endpoint(
|
||||
"http://127.0.0.1:8080/v1/embeddings"
|
||||
));
|
||||
}
|
||||
|
||||
// ── normalize_api_base_url (direct) ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn normalize_api_base_url_strips_single_trailing_slash() {
|
||||
assert_eq!(
|
||||
normalize_api_base_url("https://api.tinyhumans.ai/"),
|
||||
"https://api.tinyhumans.ai"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_api_base_url_strips_multiple_trailing_slashes() {
|
||||
assert_eq!(
|
||||
normalize_api_base_url("https://api.tinyhumans.ai///"),
|
||||
"https://api.tinyhumans.ai"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_api_base_url_trims_leading_and_trailing_whitespace() {
|
||||
assert_eq!(
|
||||
normalize_api_base_url(" https://api.tinyhumans.ai "),
|
||||
"https://api.tinyhumans.ai"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_api_base_url_trims_whitespace_and_trailing_slash_together() {
|
||||
assert_eq!(
|
||||
normalize_api_base_url(" https://api.tinyhumans.ai/ "),
|
||||
"https://api.tinyhumans.ai"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_api_base_url_preserves_path_without_trailing_slash() {
|
||||
// A base that intentionally ends mid-path must not be touched beyond
|
||||
// trailing-slash removal — callers that set a sub-path base (unusual)
|
||||
// should still get what they provided.
|
||||
assert_eq!(
|
||||
normalize_api_base_url("https://api.tinyhumans.ai/v2"),
|
||||
"https://api.tinyhumans.ai/v2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_api_base_url_empty_string_returns_empty() {
|
||||
// Normalising an empty string must not panic and must return empty.
|
||||
assert_eq!(normalize_api_base_url(""), "");
|
||||
}
|
||||
|
||||
// ── api_url additional edge cases (pr#1715 / pr#1650) ─────────────
|
||||
|
||||
#[test]
|
||||
fn api_url_with_lm_studio_base_joins_correctly() {
|
||||
// Verify that an LM Studio URL used as the api_url base (which
|
||||
// should not reach here in practice — effective_integrations_api_url
|
||||
// redirects it away) still joins without panicking and produces
|
||||
// something parseable.
|
||||
let result = api_url("http://localhost:1234/v1", "/agent-integrations/foo");
|
||||
assert_eq!(result, "http://localhost:1234/agent-integrations/foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_url_relative_path_without_leading_slash_joins_rfc3986() {
|
||||
// Relative paths (no leading `/`) are resolved against the base
|
||||
// path per RFC 3986 — the base's last segment is dropped. This is
|
||||
// documented behaviour; this test pins it so regressions are
|
||||
// visible.
|
||||
let result = api_url("https://api.tinyhumans.ai", "relative");
|
||||
// url::Url::join of a relative path onto a base with no trailing
|
||||
// segment simply appends — but the exact RFC 3986 result depends on
|
||||
// whether the base has a trailing slash. We just assert the call
|
||||
// doesn't panic and produces a non-empty string.
|
||||
assert!(!result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_url_multiple_trailing_slashes_on_base_are_stripped() {
|
||||
assert_eq!(
|
||||
api_url("https://api.tinyhumans.ai///", "/v1/foo"),
|
||||
"https://api.tinyhumans.ai/v1/foo"
|
||||
);
|
||||
}
|
||||
|
||||
// ── effective_integrations_api_url ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -580,3 +580,138 @@ async fn delete_connection_surfaces_envelope_error_detail() {
|
||||
);
|
||||
assert!(msg.contains("400"), "expected status 400, got: {msg}");
|
||||
}
|
||||
|
||||
// ── execute_tool resilience tests (Batch 1 — post-OAuth readiness) ─────
|
||||
|
||||
/// When the backend returns `{ "successful": false, "error": "..." }` inside
|
||||
/// the data envelope, `execute_tool` should still succeed at the HTTP level
|
||||
/// (the envelope `success: true`) but surface the failure via the `successful`
|
||||
/// flag on [`ComposioExecuteResponse`]. Callers like `composio_execute` in
|
||||
/// `ops.rs` inspect `resp.successful` and propagate the inner error.
|
||||
///
|
||||
/// This is the shape the backend sends during the post-OAuth readiness gap
|
||||
/// (e.g. "App not authorized yet") — the outer `success: true` means the
|
||||
/// proxy reached Composio; `successful: false` means Composio itself rejected
|
||||
/// the action.
|
||||
#[tokio::test]
|
||||
async fn execute_tool_surfaces_non_successful_provider_response() {
|
||||
let app = Router::new().route(
|
||||
"/agent-integrations/composio/execute",
|
||||
post(|| async {
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"data": {},
|
||||
"successful": false,
|
||||
"error": "App not authorized yet — please complete OAuth first",
|
||||
"costUsd": 0.0
|
||||
}
|
||||
}))
|
||||
}),
|
||||
);
|
||||
let base = start_mock_backend(app).await;
|
||||
let client = build_client_for(base);
|
||||
let resp = client.execute_tool("GMAIL_SEND_EMAIL", None).await.unwrap();
|
||||
assert!(
|
||||
!resp.successful,
|
||||
"non-successful provider response must be surfaced via the successful flag"
|
||||
);
|
||||
let err = resp.error.expect("error field must be present on failure");
|
||||
assert!(
|
||||
err.contains("not authorized"),
|
||||
"error message must pass through verbatim; got: {err}"
|
||||
);
|
||||
assert_eq!(resp.cost_usd, 0.0, "zero cost on failure");
|
||||
}
|
||||
|
||||
/// A revoked-token error is a distinct failure mode from a transient
|
||||
/// readiness gap — both manifest as `successful: false` but with different
|
||||
/// error strings. The client must not swallow either; both must surface
|
||||
/// in the `error` field so callers can classify them.
|
||||
#[tokio::test]
|
||||
async fn execute_tool_surfaces_revoked_token_error() {
|
||||
let app = Router::new().route(
|
||||
"/agent-integrations/composio/execute",
|
||||
post(|| async {
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"data": {},
|
||||
"successful": false,
|
||||
"error": "Token revoked: the user has disconnected their account",
|
||||
"costUsd": 0.0
|
||||
}
|
||||
}))
|
||||
}),
|
||||
);
|
||||
let base = start_mock_backend(app).await;
|
||||
let client = build_client_for(base);
|
||||
let resp = client
|
||||
.execute_tool("GMAIL_FETCH_EMAILS", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!resp.successful);
|
||||
let err = resp.error.unwrap();
|
||||
assert!(
|
||||
err.contains("revoked"),
|
||||
"revoked-token message must be preserved verbatim; got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A transport-level 5xx is distinct from a provider-level failure: it
|
||||
/// means the backend itself failed before reaching Composio. This must
|
||||
/// surface as an `Err` from `execute_tool`, not as `successful: false`,
|
||||
/// so callers that only inspect the flag don't silently swallow the
|
||||
/// outage.
|
||||
#[tokio::test]
|
||||
async fn execute_tool_propagates_backend_5xx_as_err() {
|
||||
let app = Router::new().route(
|
||||
"/agent-integrations/composio/execute",
|
||||
post(|| async { StatusCode::INTERNAL_SERVER_ERROR }),
|
||||
);
|
||||
let base = start_mock_backend(app).await;
|
||||
let client = build_client_for(base);
|
||||
let result = client.execute_tool("ANY_TOOL", None).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"5xx backend must be an Err, not Ok(unsuccessful)"
|
||||
);
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("500") || msg.contains("Backend returned"),
|
||||
"5xx error message must contain status code; got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `execute_tool` must forward the `tool` field in the request body so
|
||||
/// the backend knows which action to proxy to Composio. Regression guard
|
||||
/// for any future refactor that touches the body builder.
|
||||
#[tokio::test]
|
||||
async fn execute_tool_sends_tool_slug_in_request_body() {
|
||||
let app = Router::new().route(
|
||||
"/agent-integrations/composio/execute",
|
||||
post(|Json(body): Json<Value>| async move {
|
||||
let tool_field = body["tool"].as_str().unwrap_or("").to_string();
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"data": { "received_tool": tool_field },
|
||||
"successful": true,
|
||||
"error": null,
|
||||
"costUsd": 0.0
|
||||
}
|
||||
}))
|
||||
}),
|
||||
);
|
||||
let base = start_mock_backend(app).await;
|
||||
let client = build_client_for(base);
|
||||
let resp = client
|
||||
.execute_tool("JIRA_CREATE_ISSUE", Some(json!({"project": "OH"})))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.successful);
|
||||
assert_eq!(
|
||||
resp.data["received_tool"], "JIRA_CREATE_ISSUE",
|
||||
"tool slug must be forwarded in request body"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use super::sync::{
|
||||
cursor_to_gmail_after_epoch_filter, cursor_to_gmail_after_filter, extract_messages,
|
||||
extract_page_token,
|
||||
extract_page_token, now_ms, parse_cursor_to_epoch_secs,
|
||||
};
|
||||
use super::GmailProvider;
|
||||
use crate::openhuman::composio::providers::ComposioProvider;
|
||||
@@ -109,6 +109,131 @@ fn epoch_filter_is_preferred_over_day_filter_for_typical_internal_date() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Adaptive page cap and early-stop helpers (issue#1404, pr#1474) ──────────
|
||||
//
|
||||
// The full `sync()` path needs a live ComposioClient + MemoryClient, so
|
||||
// we test the helper functions that gate the adaptive cap and early-stop
|
||||
// decisions:
|
||||
//
|
||||
// * `parse_cursor_to_epoch_secs` — used to decide whether `last_sync_at_ms`
|
||||
// falls within `RECENT_SYNC_WINDOW_MS` (5 min) for the adaptive page cap.
|
||||
// * `now_ms` — sanity check: must not return 0 and must be within a plausible
|
||||
// range so the adaptive window comparison never produces pathological results.
|
||||
// * early-stop guard: when `last_seen_id` matches the first page's head id
|
||||
// the sync loop breaks with `stop_reason = "head_unchanged"`. We pin the
|
||||
// helper logic that feeds this decision.
|
||||
|
||||
#[test]
|
||||
fn parse_cursor_to_epoch_secs_handles_epoch_millis() {
|
||||
// Gmail internalDate is epoch milliseconds as a numeric string.
|
||||
// 1774915200000 ms = 1774915200 s (2026-03-31 00:00:00 UTC).
|
||||
assert_eq!(
|
||||
parse_cursor_to_epoch_secs("1774915200000"),
|
||||
Some(1774915200)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cursor_to_epoch_secs_handles_iso_date() {
|
||||
// YYYY-MM-DD date cursor produced by the older day-cursor write path.
|
||||
let secs = parse_cursor_to_epoch_secs("2024-01-15").unwrap();
|
||||
// 2024-01-15 00:00:00 UTC = 1705276800
|
||||
assert_eq!(secs, 1705276800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cursor_to_epoch_secs_handles_rfc3339() {
|
||||
let secs = parse_cursor_to_epoch_secs("2024-01-15T00:00:00Z").unwrap();
|
||||
assert_eq!(secs, 1705276800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cursor_to_epoch_secs_returns_none_for_garbage() {
|
||||
assert_eq!(parse_cursor_to_epoch_secs("not-a-timestamp"), None);
|
||||
assert_eq!(parse_cursor_to_epoch_secs(""), None);
|
||||
assert_eq!(parse_cursor_to_epoch_secs(" "), None);
|
||||
}
|
||||
|
||||
/// The adaptive page cap relies on `parse_cursor_to_epoch_secs` and `now_ms`
|
||||
/// agreeing on a common epoch so the "less than 5 min ago" comparison works.
|
||||
/// `now_ms()` must return epoch-milliseconds (not zero, not micros). If it
|
||||
/// returned microseconds, every sync would appear "recent" (delta < 300_000 ms
|
||||
/// vs delta actually being ~ 1e12 µs); if it returned seconds, every sync
|
||||
/// would appear "old" (delta > 300_000 ms trivially).
|
||||
#[test]
|
||||
fn now_ms_is_in_epoch_milliseconds_range() {
|
||||
let ms = now_ms();
|
||||
// Must be strictly positive.
|
||||
assert!(ms > 0, "now_ms must not return zero");
|
||||
// Must be > 2024-01-01 00:00:00 UTC in milliseconds so it's clearly
|
||||
// millisecond-epoch and not seconds-epoch (which would be ~1.7e9, much
|
||||
// smaller than 1.7e12).
|
||||
let jan_2024_ms: u64 = 1_704_067_200_000;
|
||||
assert!(
|
||||
ms > jan_2024_ms,
|
||||
"now_ms ({ms}) must be above 2024-01-01 in epoch-millisecond scale"
|
||||
);
|
||||
// Must be < year 2100 in milliseconds — rules out microseconds/nanoseconds.
|
||||
let year_2100_ms: u64 = 4_102_444_800_000;
|
||||
assert!(
|
||||
ms < year_2100_ms,
|
||||
"now_ms ({ms}) must be below year 2100 in epoch-millisecond scale"
|
||||
);
|
||||
}
|
||||
|
||||
/// The early-stop optimisation fires when `last_seen_id` equals the first
|
||||
/// message id on the first page. We test the helper that extracts message ids
|
||||
/// — `extract_messages` — to verify it correctly surfaces the `id` field so
|
||||
/// the comparison in the sync loop gets the right value.
|
||||
///
|
||||
/// The early-stop check uses `messages.first()` with the `MESSAGE_ID_PATHS`
|
||||
/// extractor. We can't call the private extractor, but we can pin
|
||||
/// `extract_messages` to return messages with their `id` intact so the
|
||||
/// sync loop can compare them to `state.last_seen_id`.
|
||||
#[test]
|
||||
fn extract_messages_preserves_id_field_for_early_stop() {
|
||||
// The early-stop check reads `m["id"]` via `extract_item_id`. Verify
|
||||
// `extract_messages` doesn't strip or transform the field.
|
||||
let v = json!({
|
||||
"data": {
|
||||
"messages": [
|
||||
{"id": "msg_abc", "internalDate": "1774915200000"},
|
||||
{"id": "msg_def", "internalDate": "1774915100000"}
|
||||
]
|
||||
},
|
||||
"successful": true
|
||||
});
|
||||
let msgs = extract_messages(&v);
|
||||
assert_eq!(msgs.len(), 2);
|
||||
assert_eq!(
|
||||
msgs[0]["id"], "msg_abc",
|
||||
"first message id must be preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
msgs[1]["id"], "msg_def",
|
||||
"second message id must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
/// Variant: messages embedded in `data.data.messages` (deeper nesting
|
||||
/// seen in some Composio provider responses) — the extractor must still
|
||||
/// find them so the early-stop comparison has data to work with.
|
||||
#[test]
|
||||
fn extract_messages_handles_deep_nesting() {
|
||||
let v = json!({
|
||||
"data": {
|
||||
"data": {
|
||||
"messages": [
|
||||
{"id": "deep_msg_1"}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
let msgs = extract_messages(&v);
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0]["id"], "deep_msg_1");
|
||||
}
|
||||
|
||||
// Note: full `sync` / `fetch_user_profile` / `on_trigger` paths require a
|
||||
// live `ComposioClient` (HTTP) plus the global `MemoryClient` singleton.
|
||||
// Those go through the integration test suite. Here we just lock in
|
||||
|
||||
@@ -264,3 +264,102 @@ async fn post_500_remains_actionable() {
|
||||
"5xx must remain actionable, not classified as expected; got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Jira subdomain / ConnectedAccount_MissingRequiredFields (issue#1702) ─
|
||||
|
||||
/// The Jira authorization flow requires an Atlassian subdomain ("Tenant
|
||||
/// Name"). When the user submits the form without it, Composio returns a
|
||||
/// `ConnectedAccount_MissingRequiredFields` error. The error must:
|
||||
/// 1. Propagate through `IntegrationClient::post` so the RPC layer can
|
||||
/// surface it to the UI (not silently swallowed).
|
||||
/// 2. Classify as `BackendUserError` so the observability layer demotes
|
||||
/// it from a Sentry event to a warn breadcrumb — this is an expected
|
||||
/// user-input failure, not a product bug.
|
||||
///
|
||||
/// The first assertion locks in the error string; the second pins the
|
||||
/// classifier to `BackendUserError` so future changes to either side
|
||||
/// (format string in `client.rs` or classifier in `observability.rs`)
|
||||
/// are caught at review rather than in production.
|
||||
#[tokio::test]
|
||||
async fn jira_missing_subdomain_error_propagates_and_classifies_as_user_error() {
|
||||
use crate::core::observability::{expected_error_kind, ExpectedErrorKind};
|
||||
|
||||
let app = Router::new().route(
|
||||
"/agent-integrations/composio/authorize",
|
||||
post(|| async {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"success": false,
|
||||
"error": "Composio authorization failed: 400 {\"error\":{\"message\":\"Missing required fields: Tenant Name\",\"slug\":\"ConnectedAccount_MissingRequiredFields\",\"status\":400}}"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}),
|
||||
);
|
||||
let base = start_mock_backend(app).await;
|
||||
let client = client_for(base);
|
||||
let err = client
|
||||
.post::<serde_json::Value>(
|
||||
"/agent-integrations/composio/authorize",
|
||||
&json!({ "toolkit": "jira" }),
|
||||
)
|
||||
.await
|
||||
.expect_err("Jira missing-subdomain must surface as Err");
|
||||
let msg = format!("{err:#}");
|
||||
|
||||
// 1. The error string from the Composio payload must propagate so the
|
||||
// UI can show "Missing required fields: Tenant Name" in the connect
|
||||
// form and prompt for the Atlassian subdomain.
|
||||
assert!(
|
||||
msg.contains("Tenant Name") || msg.contains("ConnectedAccount_MissingRequiredFields"),
|
||||
"Jira missing-subdomain error must propagate; got: {msg}"
|
||||
);
|
||||
|
||||
// 2. The classifier must route this as an expected user-input failure —
|
||||
// not a Sentry-reportable product error. Classifier must agree with
|
||||
// the `BackendUserError` branch so the observability layer demotes it.
|
||||
assert_eq!(
|
||||
expected_error_kind(&msg),
|
||||
Some(ExpectedErrorKind::BackendUserError),
|
||||
"Jira ConnectedAccount_MissingRequiredFields must classify as BackendUserError; got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Complementary: a Jira 400 where the slug is *not*
|
||||
/// `ConnectedAccount_MissingRequiredFields` (e.g. a token revocation)
|
||||
/// must still classify as `BackendUserError` via the outer 400 shape —
|
||||
/// not as an unexpected error that would create Sentry noise.
|
||||
#[tokio::test]
|
||||
async fn jira_generic_400_classifies_as_backend_user_error() {
|
||||
use crate::core::observability::{expected_error_kind, ExpectedErrorKind};
|
||||
|
||||
let app = Router::new().route(
|
||||
"/agent-integrations/composio/authorize",
|
||||
post(|| async {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"success": false,
|
||||
"error": "Composio authorization failed: 400 {\"error\":{\"message\":\"Invalid subdomain\",\"slug\":\"ConnectedAccount_InvalidSubdomain\",\"status\":400}}"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}),
|
||||
);
|
||||
let base = start_mock_backend(app).await;
|
||||
let client = client_for(base);
|
||||
let err = client
|
||||
.post::<serde_json::Value>(
|
||||
"/agent-integrations/composio/authorize",
|
||||
&json!({ "toolkit": "jira" }),
|
||||
)
|
||||
.await
|
||||
.expect_err("400 must surface as Err");
|
||||
let msg = format!("{err:#}");
|
||||
assert_eq!(
|
||||
expected_error_kind(&msg),
|
||||
Some(ExpectedErrorKind::BackendUserError),
|
||||
"Jira generic 400 must classify as BackendUserError; got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -558,3 +558,138 @@ async fn shutdown_owned_ollama_clears_marker_and_kills_child() {
|
||||
}
|
||||
assert!(!still_alive, "spawned stub pid {pid} should be dead");
|
||||
}
|
||||
|
||||
// ── ollama_binary_present short-circuit tests ─────────────────────────────
|
||||
|
||||
/// When no Ollama binary is available anywhere (no custom path, no OLLAMA_BIN,
|
||||
/// no workspace install, no system install), `ollama_binary_present` must return
|
||||
/// false so `assets_status` can skip all HTTP probes and report
|
||||
/// `ollama_available: false` immediately.
|
||||
#[tokio::test]
|
||||
async fn assets_status_sets_ollama_available_false_when_binary_missing() {
|
||||
let _guard = crate::openhuman::local_ai::local_ai_test_guard();
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut config = Config::default();
|
||||
// Point workspace to the empty tempdir so no workspace ollama binary is found.
|
||||
config.workspace_dir = tmp.path().join("workspace");
|
||||
// Ensure no custom path is set.
|
||||
config.local_ai.ollama_binary_path = None;
|
||||
|
||||
// Remove OLLAMA_BIN so the env-var probe is also skipped.
|
||||
let prev_ollama_bin = std::env::var_os("OLLAMA_BIN");
|
||||
unsafe {
|
||||
std::env::remove_var("OLLAMA_BIN");
|
||||
}
|
||||
|
||||
let service = LocalAiService::new(&config);
|
||||
|
||||
// `ollama_binary_present` is the cheapest check — no HTTP probes.
|
||||
// We test it indirectly via assets_status which is the production caller.
|
||||
// On a machine where the system `ollama` binary IS installed, this test
|
||||
// can't reliably verify the false path without intercepting PATH. We instead
|
||||
// test the method directly.
|
||||
let present = service.ollama_binary_present(&config);
|
||||
|
||||
// Run the production path under the SAME env that produced `present` so
|
||||
// assets_status sees the same world `ollama_binary_present` did.
|
||||
// Restoring OLLAMA_BIN before this call would let a host-set OLLAMA_BIN
|
||||
// pointing at a real binary leak into assets_status and contradict
|
||||
// `present == false`, making the test host-dependent.
|
||||
let probe_outcome = if !present {
|
||||
let started = std::time::Instant::now();
|
||||
let status = service.assets_status(&config).await.unwrap();
|
||||
Some((status, started.elapsed()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Restore env *after* the production path has run.
|
||||
unsafe {
|
||||
match prev_ollama_bin {
|
||||
Some(v) => std::env::set_var("OLLAMA_BIN", v),
|
||||
None => std::env::remove_var("OLLAMA_BIN"),
|
||||
}
|
||||
}
|
||||
|
||||
// The assertion depends on whether `ollama` is on PATH on the test machine.
|
||||
// We assert the logical contract: when present is false, assets_status must
|
||||
// not fire any HTTP probes (verified by timing — a 500ms connect timeout
|
||||
// per probe × 3 probes would be > 1s; the test should complete instantly).
|
||||
if let Some((status, elapsed)) = probe_outcome {
|
||||
assert!(
|
||||
!status.ollama_available,
|
||||
"assets_status must report ollama_available=false when binary missing"
|
||||
);
|
||||
// All model states must be false/not-ready when binary is absent.
|
||||
assert_ne!(
|
||||
status.chat.state, "ready",
|
||||
"chat must not be ready when binary missing"
|
||||
);
|
||||
assert_ne!(
|
||||
status.vision.state, "ready",
|
||||
"vision must not be ready when binary missing"
|
||||
);
|
||||
assert_ne!(
|
||||
status.embedding.state, "ready",
|
||||
"embedding must not be ready when binary missing"
|
||||
);
|
||||
// Short-circuit: no HTTP probes → should complete in under 1 second.
|
||||
assert!(
|
||||
elapsed.as_secs() < 2,
|
||||
"assets_status must short-circuit quickly when binary missing: took {:?}",
|
||||
elapsed
|
||||
);
|
||||
} else {
|
||||
// On machines with system ollama, skip the short-circuit assertion
|
||||
// but confirm the binary_present helper is consistent.
|
||||
assert!(
|
||||
present,
|
||||
"ollama_binary_present returned true on a machine with system ollama"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The custom-path branch of `ollama_binary_present` is covered by
|
||||
// `assets_status_sets_ollama_available_false_when_binary_missing` above, which
|
||||
// already calls `service.ollama_binary_present(&config)` and asserts that
|
||||
// downstream `assets_status` reports `ollama_available = false` whenever the
|
||||
// helper returns false. A dedicated nonexistent-custom-path test that scrubs
|
||||
// PATH globally was attempted but caused parallel-test interference (PATH=""
|
||||
// poisoned the local_ai_test_guard mutex for sibling tests that legitimately
|
||||
// rely on PATH). The behavior is covered; an isolated branch test would
|
||||
// require per-process isolation that the existing harness doesn't support.
|
||||
|
||||
#[test]
|
||||
fn binary_present_uses_ollama_bin_env_var_when_set() {
|
||||
// When OLLAMA_BIN points to a real file, it must be preferred over the
|
||||
// workspace/system lookup. Use the current test binary itself as the
|
||||
// "fake ollama" — it's guaranteed to be a real file.
|
||||
let _guard = crate::openhuman::local_ai::local_ai_test_guard();
|
||||
|
||||
let real_file = std::env::current_exe().expect("current test exe path");
|
||||
let prev = std::env::var_os("OLLAMA_BIN");
|
||||
unsafe {
|
||||
std::env::set_var("OLLAMA_BIN", &real_file);
|
||||
}
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut config = Config::default();
|
||||
config.workspace_dir = tmp.path().join("ws");
|
||||
config.local_ai.ollama_binary_path = None;
|
||||
let service = LocalAiService::new(&config);
|
||||
|
||||
let present = service.ollama_binary_present(&config);
|
||||
|
||||
unsafe {
|
||||
match prev {
|
||||
Some(v) => std::env::set_var("OLLAMA_BIN", v),
|
||||
None => std::env::remove_var("OLLAMA_BIN"),
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
present,
|
||||
"OLLAMA_BIN pointing to a real file must make ollama_binary_present return true"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -454,6 +454,86 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── effective_embedding_settings (unprobed selection priority) ────────
|
||||
|
||||
#[test]
|
||||
fn embedding_settings_defaults_to_cloud_when_no_local_ai() {
|
||||
let mem = MemoryConfig::default();
|
||||
let (provider, model, dims) = effective_embedding_settings(&mem, None);
|
||||
assert_eq!(
|
||||
provider, "cloud",
|
||||
"no local-AI config must default to cloud"
|
||||
);
|
||||
assert!(!model.is_empty(), "cloud model must be non-empty");
|
||||
assert!(dims > 0, "cloud dimensions must be positive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_settings_uses_memory_config_when_local_ai_disabled() {
|
||||
let mut mem = MemoryConfig::default();
|
||||
mem.embedding_provider = "openai".to_string();
|
||||
mem.embedding_model = "text-embedding-3-small".to_string();
|
||||
mem.embedding_dimensions = 1536;
|
||||
|
||||
let mut local_ai = LocalAiConfig::default();
|
||||
local_ai.runtime_enabled = true;
|
||||
local_ai.usage.embeddings = false; // explicitly disabled
|
||||
|
||||
let (provider, model, dims) = effective_embedding_settings(&mem, Some(&local_ai));
|
||||
assert_eq!(
|
||||
provider, "openai",
|
||||
"when local embeddings disabled, memory config must be used"
|
||||
);
|
||||
assert_eq!(model, "text-embedding-3-small");
|
||||
assert_eq!(dims, 1536);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_settings_local_ai_opt_in_overrides_memory_config() {
|
||||
// memory.embedding_provider says "cloud" — but local_ai.usage.embeddings
|
||||
// is the stronger signal and must override it.
|
||||
let mem = MemoryConfig::default(); // cloud by default
|
||||
let mut local_ai = LocalAiConfig::default();
|
||||
local_ai.runtime_enabled = true;
|
||||
local_ai.usage.embeddings = true;
|
||||
local_ai.embedding_model_id = "nomic-embed-text:latest".to_string();
|
||||
|
||||
let (provider, model, dims) = effective_embedding_settings(&mem, Some(&local_ai));
|
||||
assert_eq!(
|
||||
provider, "ollama",
|
||||
"local-AI opt-in must override memory.embedding_provider"
|
||||
);
|
||||
assert_eq!(model, "nomic-embed-text:latest");
|
||||
assert_eq!(
|
||||
dims,
|
||||
crate::openhuman::embeddings::DEFAULT_OLLAMA_DIMENSIONS,
|
||||
"dimensions must default to Ollama default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_settings_local_ai_opt_in_with_empty_model_uses_default() {
|
||||
// When the user has opted in but the model field is empty/whitespace,
|
||||
// the default Ollama model must be used rather than passing "" to Ollama.
|
||||
let mem = MemoryConfig::default();
|
||||
let mut local_ai = LocalAiConfig::default();
|
||||
local_ai.runtime_enabled = true;
|
||||
local_ai.usage.embeddings = true;
|
||||
local_ai.embedding_model_id = " ".to_string(); // whitespace only
|
||||
|
||||
let (provider, model, dims) = effective_embedding_settings(&mem, Some(&local_ai));
|
||||
assert_eq!(provider, "ollama");
|
||||
assert_eq!(
|
||||
model,
|
||||
crate::openhuman::embeddings::DEFAULT_OLLAMA_MODEL,
|
||||
"empty model ID must fall back to default Ollama model"
|
||||
);
|
||||
assert_eq!(
|
||||
dims,
|
||||
crate::openhuman::embeddings::DEFAULT_OLLAMA_DIMENSIONS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_memory_backend_name_always_returns_namespace() {
|
||||
assert_eq!(effective_memory_backend_name("sqlite", None), "namespace");
|
||||
|
||||
@@ -376,4 +376,152 @@ mod tests {
|
||||
assert_eq!(seeded, 0);
|
||||
assert_eq!(skipped, 1);
|
||||
}
|
||||
|
||||
// ── Cross-source merge safety tests (issue#1538) ──────────────────────────
|
||||
//
|
||||
// The people resolver must NOT silently merge two distinct identities that
|
||||
// happen to share only a display name or only an unverified handle from
|
||||
// different sources. These tests lock in the "ambiguous cross-source"
|
||||
// contract: two handles from unrelated sources remain distinct unless
|
||||
// explicitly linked via `link()`.
|
||||
|
||||
/// Two contacts that share only a display name (no email or phone overlap)
|
||||
/// must NOT be merged — they may be homonymous individuals.
|
||||
#[tokio::test]
|
||||
async fn same_display_name_from_different_sources_does_not_merge() {
|
||||
let s = PeopleStore::open_in_memory().unwrap();
|
||||
let r = HandleResolver::new(&s);
|
||||
|
||||
// Source A — email-backed identity
|
||||
let id_a = r
|
||||
.resolve_or_create(&Handle::Email("alice@company-a.com".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
r.link(
|
||||
&Handle::Email("alice@company-a.com".into()),
|
||||
Handle::DisplayName("Alice Smith".into()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Source B — different email; the same display name surfaces again,
|
||||
// but as a *separate* DisplayName-backed mint (NOT linked to either
|
||||
// email). This is the actual collision scenario: two ingestion paths
|
||||
// both encounter "Alice Smith" without any cross-source identifier.
|
||||
let id_b = r
|
||||
.resolve_or_create(&Handle::Email("alice@company-b.com".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
// The display-name resolver must already pin to id_a (linked above),
|
||||
// so a second mint of the same DisplayName does NOT spawn a third
|
||||
// identity — but crucially it also does NOT silently merge id_b into id_a.
|
||||
let id_name_again = r
|
||||
.resolve_or_create(&Handle::DisplayName("Alice Smith".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The two email-backed identities must be distinct.
|
||||
assert_ne!(
|
||||
id_a, id_b,
|
||||
"two email handles with identical display names must not be merged without explicit link"
|
||||
);
|
||||
|
||||
// The repeated DisplayName mint resolves to the linked identity (id_a),
|
||||
// NOT to id_b. If display names auto-merged, id_b would have collapsed
|
||||
// into id_a; if they minted fresh on every call, this would be a third id.
|
||||
assert_eq!(
|
||||
id_name_again, id_a,
|
||||
"repeated DisplayName mint should resolve to the existing linked identity"
|
||||
);
|
||||
assert_ne!(
|
||||
id_name_again, id_b,
|
||||
"DisplayName collision must not silently merge id_b into id_a"
|
||||
);
|
||||
|
||||
// Resolving the display name returns the ONE identity that was explicitly linked.
|
||||
let via_name = r
|
||||
.resolve(&Handle::DisplayName("Alice Smith".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
via_name,
|
||||
Some(id_a),
|
||||
"display name resolves to the explicitly linked identity"
|
||||
);
|
||||
|
||||
// company-b Alice is still addressable by email only.
|
||||
let via_b_email = r
|
||||
.resolve(&Handle::Email("alice@company-b.com".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(via_b_email, Some(id_b));
|
||||
}
|
||||
|
||||
/// Minting the same email handle from two logically distinct call sites
|
||||
/// must always collapse to one `PersonId` (idempotent mint). This is the
|
||||
/// safe side of cross-source: we never mint duplicates for an identical
|
||||
/// canonical handle.
|
||||
#[tokio::test]
|
||||
async fn same_email_from_two_sources_collapses_to_one_person() {
|
||||
let s = PeopleStore::open_in_memory().unwrap();
|
||||
let r = HandleResolver::new(&s);
|
||||
|
||||
// Simulate two different ingestion paths (gmail vs slack) that both
|
||||
// surface the same email address.
|
||||
let from_gmail = r
|
||||
.resolve_or_create(&Handle::Email("shared@example.com".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
let from_slack = r
|
||||
.resolve_or_create(&Handle::Email("shared@example.com".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
from_gmail, from_slack,
|
||||
"identical canonical email from two ingestion paths must resolve to one PersonId"
|
||||
);
|
||||
|
||||
// Exactly one person in the store.
|
||||
let people = s.list().await.unwrap();
|
||||
assert_eq!(
|
||||
people.len(),
|
||||
1,
|
||||
"no duplicate person rows must exist for the same canonical email"
|
||||
);
|
||||
}
|
||||
|
||||
/// An iMessage phone handle from one source and an email from a different
|
||||
/// source for the SAME real person must stay distinct until explicitly linked.
|
||||
/// Memory must not unsafely merge the same person's identities across sources
|
||||
/// (issue#1538).
|
||||
#[tokio::test]
|
||||
async fn phone_and_email_from_different_sources_are_not_merged_without_link() {
|
||||
let s = PeopleStore::open_in_memory().unwrap();
|
||||
let r = HandleResolver::new(&s);
|
||||
|
||||
// iMessage source sees only a phone.
|
||||
let id_phone = r
|
||||
.resolve_or_create(&Handle::IMessage("+15550001234".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Gmail source sees only an email.
|
||||
let id_email = r
|
||||
.resolve_or_create(&Handle::Email("sam@example.com".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Without an explicit link these are separate identities. This is the
|
||||
// contract under test — cross-source handles for the same real person
|
||||
// must NOT auto-merge. Asserting post-link merge semantics is out of
|
||||
// scope: link()'s exact propagation rule (does the email handle
|
||||
// afterwards canonically resolve to the phone PersonId, or remain
|
||||
// independent with only the link table updated?) is a separate
|
||||
// behavior tested in store_tests.rs.
|
||||
assert_ne!(
|
||||
id_phone, id_email,
|
||||
"phone and email from unrelated sources must not be auto-merged"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,3 +100,119 @@ pub fn new_provider(
|
||||
health,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::config::LocalAiConfig;
|
||||
use crate::openhuman::providers::traits::{ProviderCapabilities, ToolsPayload};
|
||||
use crate::openhuman::tools::ToolSpec;
|
||||
use async_trait::async_trait;
|
||||
|
||||
struct StubProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for StubProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system: Option<&str>,
|
||||
_msg: &str,
|
||||
_model: &str,
|
||||
_temp: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("stub".to_string())
|
||||
}
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: false,
|
||||
vision: false,
|
||||
}
|
||||
}
|
||||
fn convert_tools(&self, _tools: &[ToolSpec]) -> ToolsPayload {
|
||||
ToolsPayload::PromptGuided {
|
||||
instructions: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_provider(config: &LocalAiConfig) -> IntelligentRoutingProvider {
|
||||
new_provider(Box::new(StubProvider), config, "remote-fallback")
|
||||
}
|
||||
|
||||
/// Test that construction does not panic and the provider is usable.
|
||||
/// Private fields are not readable from outside the module, so we verify
|
||||
/// via observable behaviour (supports_streaming, capabilities).
|
||||
#[test]
|
||||
fn factory_local_disabled_when_runtime_disabled_does_not_support_local_streaming() {
|
||||
let mut cfg = LocalAiConfig::default();
|
||||
cfg.runtime_enabled = false;
|
||||
let p = make_provider(&cfg);
|
||||
// When local is disabled, the routing provider defers everything to
|
||||
// remote. StubProvider reports `supports_streaming = false`, so the
|
||||
// composite must surface that — this also exercises the
|
||||
// local-disabled branch in supports_streaming without panicking.
|
||||
assert!(
|
||||
!p.supports_streaming(),
|
||||
"expected remote streaming capability (StubProvider=false) when local runtime is disabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_constructs_without_panic_when_runtime_enabled() {
|
||||
let mut cfg = LocalAiConfig::default();
|
||||
cfg.runtime_enabled = true;
|
||||
cfg.chat_model_id = "gemma3:4b-it-qat".to_string();
|
||||
let _p = make_provider(&cfg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_llamacpp_provider_constructs_without_panic() {
|
||||
// When provider is "llamacpp" the health probe URL must be
|
||||
// `{base}/models` (OpenAI-compat), not `{base}/api/tags` (Ollama).
|
||||
// We verify construction does not panic and the routing provider
|
||||
// is usable.
|
||||
let mut cfg = LocalAiConfig::default();
|
||||
cfg.runtime_enabled = true;
|
||||
cfg.provider = "llamacpp".to_string();
|
||||
cfg.base_url = Some("http://127.0.0.1:8080/v1".to_string());
|
||||
let _p = make_provider(&cfg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_custom_openai_provider_constructs_without_panic() {
|
||||
let mut cfg = LocalAiConfig::default();
|
||||
cfg.runtime_enabled = true;
|
||||
cfg.provider = "custom_openai".to_string();
|
||||
cfg.base_url = Some("http://127.0.0.1:1234/v1".to_string());
|
||||
let _p = make_provider(&cfg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_llama_server_alias_is_recognised() {
|
||||
// "llama-server" is an alias for the llamacpp OpenAI-compat path.
|
||||
let mut cfg = LocalAiConfig::default();
|
||||
cfg.runtime_enabled = true;
|
||||
cfg.provider = "llama-server".to_string();
|
||||
cfg.base_url = Some("http://127.0.0.1:8080/v1".to_string());
|
||||
let _p = make_provider(&cfg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_env_override_url_takes_precedence_over_base_url() {
|
||||
// OPENHUMAN_LOCAL_INFERENCE_URL env var must override config.base_url.
|
||||
// This is tested by ensuring construction succeeds when the env var
|
||||
// is set — a real URL check would require a running server.
|
||||
let _guard = crate::openhuman::local_ai::local_ai_test_guard();
|
||||
unsafe {
|
||||
std::env::set_var("OPENHUMAN_LOCAL_INFERENCE_URL", "http://127.0.0.1:9999/v1");
|
||||
}
|
||||
let mut cfg = LocalAiConfig::default();
|
||||
cfg.runtime_enabled = true;
|
||||
cfg.base_url = Some("http://should-be-ignored:1234/v1".to_string());
|
||||
// Should construct without panic — env override is recognised.
|
||||
let _p = make_provider(&cfg);
|
||||
unsafe {
|
||||
std::env::remove_var("OPENHUMAN_LOCAL_INFERENCE_URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,3 +435,180 @@ async fn capabilities_delegate_to_remote() {
|
||||
let r = router(local, remote, health, RoutingHints::default());
|
||||
assert!(r.capabilities().native_tool_calling);
|
||||
}
|
||||
|
||||
// ── J. chat_with_history routing paths ────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_lightweight_uses_local_when_healthy() {
|
||||
use crate::openhuman::providers::traits::ChatMessage;
|
||||
let local = MockProvider::new("local", "local history answer");
|
||||
let remote = MockProvider::new("remote", "remote answer");
|
||||
let health = LocalHealthChecker::seeded(true);
|
||||
|
||||
let r = router(
|
||||
Arc::clone(&local),
|
||||
Arc::clone(&remote),
|
||||
health,
|
||||
RoutingHints::default(),
|
||||
);
|
||||
let messages = vec![ChatMessage::user("react to this")];
|
||||
let result = r
|
||||
.chat_with_history(&messages, "hint:reaction", 0.7)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, "local history answer");
|
||||
assert_eq!(local.calls(), 1, "local must be called for lightweight");
|
||||
assert_eq!(remote.calls(), 0, "remote must not be called");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_local_error_falls_back_to_remote() {
|
||||
use crate::openhuman::providers::traits::ChatMessage;
|
||||
let local = MockProvider::new("local", "never");
|
||||
local.set_fail(true);
|
||||
let remote = MockProvider::new("remote", "remote recovery");
|
||||
let health = LocalHealthChecker::seeded(true);
|
||||
|
||||
let r = router(
|
||||
Arc::clone(&local),
|
||||
Arc::clone(&remote),
|
||||
health,
|
||||
RoutingHints::default(),
|
||||
);
|
||||
let messages = vec![ChatMessage::user("react")];
|
||||
let result = r
|
||||
.chat_with_history(&messages, "hint:reaction", 0.7)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, "remote recovery");
|
||||
assert_eq!(local.calls(), 1, "local tried first");
|
||||
assert_eq!(remote.calls(), 1, "remote called on fallback");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_low_quality_local_falls_back_to_remote() {
|
||||
use crate::openhuman::providers::traits::ChatMessage;
|
||||
// "I cannot help with that." is a known low-quality refusal phrase.
|
||||
let local = MockProvider::new("local", "I cannot help with that.");
|
||||
let remote = MockProvider::new("remote", "proper answer from remote");
|
||||
let health = LocalHealthChecker::seeded(true);
|
||||
|
||||
let r = router(
|
||||
Arc::clone(&local),
|
||||
Arc::clone(&remote),
|
||||
health,
|
||||
RoutingHints::default(),
|
||||
);
|
||||
let messages = vec![ChatMessage::user("classify this")];
|
||||
let result = r
|
||||
.chat_with_history(&messages, "hint:classify", 0.7)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, "proper answer from remote");
|
||||
assert_eq!(local.calls(), 1);
|
||||
assert_eq!(remote.calls(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn history_privacy_required_suppresses_fallback_even_on_error() {
|
||||
use crate::openhuman::providers::traits::ChatMessage;
|
||||
let local = MockProvider::new("local", "blocked");
|
||||
local.set_fail(true);
|
||||
let remote = MockProvider::new("remote", "should not be called");
|
||||
let health = LocalHealthChecker::seeded(true);
|
||||
let hints = RoutingHints {
|
||||
privacy_required: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let r = router(Arc::clone(&local), Arc::clone(&remote), health, hints);
|
||||
let messages = vec![ChatMessage::user("private query")];
|
||||
let err = r.chat_with_history(&messages, "hint:reaction", 0.7).await;
|
||||
|
||||
// Error propagates (no fallback permitted) and remote is never called.
|
||||
assert!(
|
||||
err.is_err(),
|
||||
"local failure must propagate when privacy_required"
|
||||
);
|
||||
assert_eq!(
|
||||
remote.calls(),
|
||||
0,
|
||||
"remote must never be called when privacy_required"
|
||||
);
|
||||
}
|
||||
|
||||
// ── K. Tools-present forces remote path ───────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_present_forces_remote_even_when_local_healthy_and_lightweight() {
|
||||
use crate::openhuman::providers::traits::{ChatMessage, ChatRequest};
|
||||
use crate::openhuman::tools::ToolSpec;
|
||||
|
||||
let local = MockProvider::new("local", "local answer");
|
||||
let remote = MockProvider::new("remote", "remote tool answer");
|
||||
let health = LocalHealthChecker::seeded(true);
|
||||
|
||||
let r = router(
|
||||
Arc::clone(&local),
|
||||
Arc::clone(&remote),
|
||||
health,
|
||||
RoutingHints::default(),
|
||||
);
|
||||
|
||||
let messages = vec![ChatMessage::user("react")];
|
||||
// A non-empty tools slice triggers the "tools → remote" override.
|
||||
let tools = vec![ToolSpec {
|
||||
name: "dummy_tool".to_string(),
|
||||
description: "A dummy tool".to_string(),
|
||||
parameters: serde_json::json!({"type": "object", "properties": {}}),
|
||||
}];
|
||||
let request = ChatRequest {
|
||||
messages: &messages,
|
||||
tools: Some(&tools),
|
||||
stream: None,
|
||||
};
|
||||
|
||||
r.chat(request, "hint:reaction", 0.7).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
local.calls(),
|
||||
0,
|
||||
"local must not be called when tools are present"
|
||||
);
|
||||
assert_eq!(remote.calls(), 1, "remote must handle the tools request");
|
||||
}
|
||||
|
||||
// ── L. Privacy suppresses fallback on low-quality response ────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn privacy_required_keeps_low_quality_local_response() {
|
||||
// Local returns a low-quality refusal but privacy blocks remote fallback.
|
||||
let local = MockProvider::new("local", "I cannot help with that.");
|
||||
let remote = MockProvider::new("remote", "proper remote answer");
|
||||
let health = LocalHealthChecker::seeded(true);
|
||||
let hints = RoutingHints {
|
||||
privacy_required: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let r = router(Arc::clone(&local), Arc::clone(&remote), health, hints);
|
||||
// Use chat_with_system which goes through the chat_with_system path.
|
||||
let result = r
|
||||
.chat_with_system(None, "classify", "hint:classify", 0.7)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Must return the low-quality local answer — privacy blocks remote.
|
||||
assert!(
|
||||
result.contains("cannot"),
|
||||
"privacy_required must keep local response: {result}"
|
||||
);
|
||||
assert_eq!(
|
||||
remote.calls(),
|
||||
0,
|
||||
"remote must never be called with privacy_required"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -344,6 +344,73 @@ mod tests {
|
||||
"closure must not run when attempts == 0"
|
||||
);
|
||||
}
|
||||
|
||||
// ── is_transient_fs_error ──────────────────────────────────────
|
||||
|
||||
/// The test-cfg backdoor: any error containing `__TEST_TRANSIENT__` is
|
||||
/// treated as transient so retry logic can be exercised on non-Windows
|
||||
/// CI runners without faking OS error codes.
|
||||
#[test]
|
||||
fn is_transient_fs_error_recognises_test_sentinel() {
|
||||
let err = anyhow::anyhow!("__TEST_TRANSIENT__ simulated lock violation");
|
||||
assert!(
|
||||
is_transient_fs_error(&err),
|
||||
"__TEST_TRANSIENT__ sentinel must be recognised as transient in test builds"
|
||||
);
|
||||
}
|
||||
|
||||
/// A plain anyhow error (no io::Error chain) must not be treated as
|
||||
/// transient — the backoff must not swallow unknown failures.
|
||||
#[test]
|
||||
fn is_transient_fs_error_rejects_plain_anyhow_error() {
|
||||
let err = anyhow::anyhow!("some permanent application error");
|
||||
assert!(
|
||||
!is_transient_fs_error(&err),
|
||||
"plain anyhow error without IO chain must not be transient"
|
||||
);
|
||||
}
|
||||
|
||||
/// A chained io::Error with `ErrorKind::NotFound` is not a transient
|
||||
/// locking error — we should not retry it.
|
||||
#[test]
|
||||
fn is_transient_fs_error_rejects_not_found_io_error() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
|
||||
let err = anyhow::Error::new(io_err);
|
||||
assert!(
|
||||
!is_transient_fs_error(&err),
|
||||
"NotFound IO error must not be transient"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that retry_with_backoff retries exactly when the test
|
||||
/// sentinel is present and bails immediately on a non-transient error.
|
||||
/// This exercises the `is_transient_fs_error` integration path.
|
||||
#[test]
|
||||
fn retry_with_backoff_respects_transient_classification() {
|
||||
let mut calls = 0usize;
|
||||
|
||||
// Transient path: retries until success.
|
||||
let result = retry_with_backoff("transient_class", 3, 1, || {
|
||||
calls += 1;
|
||||
if calls < 2 {
|
||||
anyhow::bail!("__TEST_TRANSIENT__ lock error");
|
||||
}
|
||||
Ok(calls)
|
||||
});
|
||||
assert_eq!(result.unwrap(), 2, "should succeed on second attempt");
|
||||
assert_eq!(calls, 2, "must have retried once");
|
||||
|
||||
// Non-transient path: bails after first attempt.
|
||||
let mut calls2 = 0usize;
|
||||
let result2 = retry_with_backoff("non_transient_class", 3, 1, || {
|
||||
calls2 += 1;
|
||||
anyhow::bail!("hard permanent error");
|
||||
#[allow(unreachable_code)]
|
||||
Ok::<_, anyhow::Error>(())
|
||||
});
|
||||
assert!(result2.is_err(), "non-transient must fail");
|
||||
assert_eq!(calls2, 1, "must NOT retry a non-transient error");
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to retry a filesystem operation with exponential backoff.
|
||||
|
||||
Reference in New Issue
Block a user