From 10f406908ca8d6cfa618ec3b19b6e99170e41e21 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 02:06:53 +0000 Subject: [PATCH 01/14] chore(rust): enforce warning-free clippy --- .github/workflows/ci-lite.yml | 19 +- .husky/pre-push | 12 +- Cargo.toml | 14 + app/package.json | 2 +- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/src/cdp/input.rs | 236 ------------- app/src-tauri/src/cdp/mod.rs | 20 +- app/src-tauri/src/cdp/session.rs | 4 +- app/src-tauri/src/cdp/snapshot.rs | 92 +---- app/src-tauri/src/cdp/target.rs | 6 - app/src-tauri/src/claude_code.rs | 2 +- app/src-tauri/src/deep_link_ipc.rs | 6 +- app/src-tauri/src/imessage_scanner/mod.rs | 7 - app/src-tauri/src/lib.rs | 22 +- .../src/meet_audio/caption_listener.rs | 6 +- .../src/meet_audio/listen_capture.rs | 327 ------------------ app/src-tauri/src/meet_audio/mod.rs | 32 +- app/src-tauri/src/meet_audio/speak_pump.rs | 9 +- app/src-tauri/src/meet_call/mod.rs | 4 +- app/src-tauri/src/meet_scanner/mod.rs | 2 +- app/src-tauri/src/meet_video/frame_bus.rs | 9 +- app/src-tauri/src/ptt_hotkeys.rs | 9 - app/src-tauri/src/ptt_overlay.rs | 1 + app/src-tauri/src/screen_capture/mod.rs | 2 + app/src-tauri/src/slack_scanner/extract.rs | 59 ++-- app/src-tauri/src/slack_scanner/idb.rs | 2 +- app/src-tauri/src/telegram_scanner/extract.rs | 15 +- app/src-tauri/src/telegram_scanner/idb.rs | 2 +- .../src/wechat_scanner/dom_snapshot.rs | 2 - app/src-tauri/src/whatsapp_scanner/idb.rs | 2 +- app/src-tauri/src/whatsapp_scanner/mod.rs | 4 +- package.json | 1 + src/bin/memory_tree_init_smoke.rs | 6 +- src/bin/slack_backfill.rs | 15 +- src/core/cli.rs | 12 - src/openhuman/accessibility/automate.rs | 2 +- .../agent/harness/archivist/recap.rs | 1 + .../agent/harness/session/builder/factory.rs | 2 +- src/openhuman/agent/harness/tool_filter.rs | 2 +- src/openhuman/agent/progress_tracing.rs | 2 +- src/openhuman/agent/schemas.rs | 1 - src/openhuman/agent_meetings/summary.rs | 2 +- src/openhuman/agent_memory/tools.rs | 2 +- .../background_delivery.rs | 33 +- .../agent_orchestration/running_subagents.rs | 2 +- .../spawn_parallel_graph.rs | 2 +- src/openhuman/artifacts/store.rs | 2 +- src/openhuman/channels/proactive.rs | 7 +- src/openhuman/composio/tools/direct.rs | 8 +- src/openhuman/credentials/profiles.rs | 23 +- src/openhuman/cron/tools/add.rs | 2 +- src/openhuman/cwd_jail/linux.rs | 6 + src/openhuman/embeddings/ollama_adapter.rs | 9 +- src/openhuman/file_state/ops.rs | 10 +- src/openhuman/flows/ops.rs | 1 - src/openhuman/inference/local/mod.rs | 1 + src/openhuman/inference/local/ollama.rs | 1 + .../inference/provider/claude_code/driver.rs | 1 + src/openhuman/inference/provider/factory.rs | 21 +- src/openhuman/learning/reflection.rs | 2 +- src/openhuman/learning/stability_detector.rs | 6 +- src/openhuman/mcp_registry/connections.rs | 1 + .../mcp_registry/registries/mcp_official.rs | 7 +- src/openhuman/mcp_server/write_dispatch.rs | 8 +- src/openhuman/meet_agent/brain/speech.rs | 1 - src/openhuman/meet_agent/session.rs | 1 + src/openhuman/meet_agent/store.rs | 2 +- src/openhuman/memory/read_rpc/types.rs | 3 +- src/openhuman/memory_tools/capture.rs | 6 +- src/openhuman/memory_tree/tree/factory.rs | 4 +- src/openhuman/memory_tree/tree/registry.rs | 8 +- src/openhuman/people/store.rs | 10 +- src/openhuman/runtime_python/bootstrap.rs | 1 + src/openhuman/security/pii/rules.rs | 2 +- src/openhuman/skills/registry.rs | 5 +- src/openhuman/socket/medulla/mod.rs | 7 +- .../subconscious_triggers/runtime.rs | 3 +- src/openhuman/tinyagents/mod.rs | 115 +++--- src/openhuman/tinyagents/observability.rs | 1 + src/openhuman/tinyagents/replay/mod.rs | 4 +- src/openhuman/tinycortex/embeddings.rs | 1 + src/openhuman/tinycortex/sync.rs | 2 +- src/openhuman/tinyflows/caps.rs | 1 + src/openhuman/tinyplace/manifest.rs | 1 - src/openhuman/todos/graph_shadow.rs | 5 +- .../tools/impl/filesystem/glob_search.rs | 2 +- .../tools/impl/network/polymarket.rs | 8 +- src/openhuman/tools/impl/system/schedule.rs | 8 +- src/openhuman/voice/server.rs | 2 +- src/openhuman/wallet/chains/btc.rs | 2 +- src/openhuman/webview_accounts/ops.rs | 2 +- src/openhuman/webview_apis/client.rs | 4 +- 92 files changed, 325 insertions(+), 1018 deletions(-) delete mode 100644 app/src-tauri/src/cdp/input.rs delete mode 100644 app/src-tauri/src/meet_audio/listen_capture.rs diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 870276ae6d..19822d55f9 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -338,6 +338,7 @@ jobs: with: workspaces: | . -> target + app/src-tauri -> target cache-on-failure: true # shared-key (not key) so the cache name is stable and not suffixed # with the job id. Swatinem caches the full target/, which is why we @@ -349,7 +350,23 @@ jobs: run: cargo fmt --all -- --check - name: Run clippy (core crate) - run: bash scripts/ci-cancel-aware.sh cargo clippy -p openhuman + if: needs.changes.outputs['rust-core'] == 'true' + run: bash scripts/ci-cancel-aware.sh cargo clippy -p openhuman -- -D warnings + + - name: Cache CEF binary distribution + if: needs.changes.outputs['rust-tauri'] == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: | + ~/Library/Caches/tauri-cef + ~/.cache/tauri-cef + key: cef-x86_64-unknown-linux-gnu-v2-${{ hashFiles('app/src-tauri/Cargo.toml') }} + restore-keys: | + cef-x86_64-unknown-linux-gnu-v2- + + - name: Run clippy (Tauri shell) + if: needs.changes.outputs['rust-tauri'] == 'true' + run: bash scripts/ci-cancel-aware.sh cargo clippy --manifest-path app/src-tauri/Cargo.toml -- -D warnings # Feature-gate smoke: proves the core still compiles with a domain gate turned # OFF. The disabled build is the ONLY thing that catches stub-facade signature diff --git a/.husky/pre-push b/.husky/pre-push index 521d8b9c14..d39768b34a 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -93,10 +93,10 @@ pnpm compile COMPILE_EXIT=$? set -e -# Run Rust compile checks for both the core and Tauri codebases +# Run Clippy for both Rust codebases; Clippy also performs compile checks. set +e -pnpm rust:check -RUST_CHECK_EXIT=$? +pnpm rust:clippy +RUST_CLIPPY_EXIT=$? set -e # Enforce scoped cmd-* tokens in components/commands/ @@ -106,7 +106,7 @@ CMD_TOKENS_EXIT=$? set -e # Exit with error if any command still fails after fixes -if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CHECK_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then +if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CLIPPY_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then echo echo "============================================================" echo "Pre-push checks failed." @@ -116,10 +116,10 @@ if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] || echo " git add -A && git commit -m 'chore: apply auto-fixes'" echo " git push" fi - if [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CHECK_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then + if [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CLIPPY_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then echo "Fix the remaining errors above (TypeScript / Rust / cmd-tokens)" echo "before re-pushing — these have no auto-fix path." fi echo "============================================================" exit 1 -fi \ No newline at end of file +fi diff --git a/Cargo.toml b/Cargo.toml index fae99e7176..03df60fd9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -367,6 +367,20 @@ e2e-test-support = [] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage)'] } +# These project-wide shape/documentation lints describe intentional public APIs +# and long-standing module docs. Keep them explicitly baselined so `-D warnings` +# can make every other Clippy and rustc diagnostic a hard failure. +[lints.clippy] +borrowed_box = "allow" +doc_overindented_list_items = "allow" +field_reassign_with_default = "allow" +large_enum_variant = "allow" +manual_checked_ops = "allow" +result_large_err = "allow" +should_implement_trait = "allow" +too_many_arguments = "allow" +while_let_loop = "allow" + # Fix whisper-rs-sys CRT mismatch on Windows MSVC (LNK2038). # Upstream cmake build defaults to /MD but Rust uses /MT. # This fork adds config.static_crt(true) to the build script. diff --git a/app/package.json b/app/package.json index 5e6d0f66f6..9ded9a4522 100644 --- a/app/package.json +++ b/app/package.json @@ -62,7 +62,7 @@ "rust:check": "cargo check --manifest-path src-tauri/Cargo.toml", "rust:format": "cargo fmt --manifest-path ../Cargo.toml --all && cargo fmt --manifest-path src-tauri/Cargo.toml --all", "rust:format:check": "cargo fmt --manifest-path ../Cargo.toml --all --check && cargo fmt --manifest-path src-tauri/Cargo.toml --all --check", - "rust:clippy": "cargo clippy -p openhuman -- -D warnings", + "rust:clippy": "cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings", "format": "prettier --write . && pnpm rust:format", "format:check": "prettier --check . && pnpm rust:format:check", "lint": "eslint . --ext .ts,.tsx --cache", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index f9e8272650..e9d2268ac8 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -216,7 +216,7 @@ tinyagents = { path = "../../vendor/tinyagents" } # TinyAgents so integration work can test crate changes against OpenHuman before # publishing. tinyflows = { path = "../../vendor/tinyflows" } -tinycortex = { path = "../../vendor/tinycortex", features = ["git-diff"] } +tinycortex = { path = "../../vendor/tinycortex" } tinyjuice = { path = "../../vendor/tinyjuice" } tinychannels = { path = "../../vendor/tinychannels" } tinyplace = { path = "../../vendor/tinyplace/sdk/rust" } diff --git a/app/src-tauri/src/cdp/input.rs b/app/src-tauri/src/cdp/input.rs deleted file mode 100644 index cd97330286..0000000000 --- a/app/src-tauri/src/cdp/input.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Thin helpers around `Input.dispatchMouseEvent` and -//! `Input.dispatchKeyEvent` so providers can drive web UIs without -//! touching the page's JavaScript. -//! -//! All coordinates are CSS pixels relative to the viewport — the same -//! frame `DOMSnapshot.captureSnapshot(includeDOMRects=true)` returns -//! bounding rects in. Callers typically pair these with -//! [`crate::cdp::Snapshot::rect`] to find the click target. -//! -//! Everything here is CEF-only — CDP requires a remote-debugging port, -//! which wry doesn't expose. -//! -//! # Cookbook -//! -//! ```ignore -//! let snap = Snapshot::capture_with_rects(&mut cdp, &session).await?; -//! let idx = snap.find_descendant(0, |s, i| s.attr(i, "aria-label") == Some("Search mail")) -//! .ok_or("search box not found")?; -//! let rect = snap.rect(idx).ok_or("search box has no layout rect")?; -//! let (cx, cy) = rect.center(); -//! input::click(&mut cdp, &session, cx, cy).await?; -//! input::type_text(&mut cdp, &session, "from:linkedin.com").await?; -//! input::press_key(&mut cdp, &session, Key::Enter).await?; -//! ``` - -use serde_json::{json, Value}; - -use super::CdpConn; - -#[allow(dead_code)] // helper is used by currently gated input paths. -#[cfg(target_os = "macos")] -const SELECT_ALL_MODIFIER: u32 = 4; -#[allow(dead_code)] // helper is used by currently gated input paths. -#[cfg(not(target_os = "macos"))] -const SELECT_ALL_MODIFIER: u32 = 2; - -/// Names recognised by `Input.dispatchKeyEvent`'s `key` field. We -/// hand-pick the ones Gmail's keyboard handlers care about so callers -/// can use a typed value rather than stringly-typed literals scattered -/// across providers. -#[allow(dead_code)] // variants reserved for upcoming providers / write ops. -#[derive(Debug, Clone, Copy)] -pub enum Key { - Enter, - Escape, - Tab, - Backspace, - ArrowDown, - ArrowUp, -} - -impl Key { - /// `(key, code, windowsVirtualKeyCode)` triple. Gmail's listeners - /// branch on different fields depending on browser; we set all three - /// to maximise compatibility. - fn cdp_fields(self) -> (&'static str, &'static str, u32) { - match self { - Key::Enter => ("Enter", "Enter", 13), - Key::Escape => ("Escape", "Escape", 27), - Key::Tab => ("Tab", "Tab", 9), - Key::Backspace => ("Backspace", "Backspace", 8), - Key::ArrowDown => ("ArrowDown", "ArrowDown", 40), - Key::ArrowUp => ("ArrowUp", "ArrowUp", 38), - } - } -} - -/// Click at `(x, y)` — left button, no modifiers, single click. -/// Issues mouseMoved → mousePressed → mouseReleased so hover handlers -/// (Gmail's search-box has one) fire correctly before the click. -pub async fn click(cdp: &mut CdpConn, session: &str, x: f64, y: f64) -> Result<(), String> { - log::debug!("[cdp::input] click session={session} x={x:.1} y={y:.1}"); - let _ = mouse_event(cdp, session, "mouseMoved", x, y, 0).await?; - let _ = mouse_event(cdp, session, "mousePressed", x, y, 1).await?; - let _ = mouse_event(cdp, session, "mouseReleased", x, y, 1).await?; - log::debug!("[cdp::input] click complete session={session}"); - Ok(()) -} - -async fn mouse_event( - cdp: &mut CdpConn, - session: &str, - kind: &str, - x: f64, - y: f64, - click_count: u32, -) -> Result { - log::debug!( - "[cdp::input] mouse_event session={session} kind={kind} x={x:.1} y={y:.1} clicks={click_count}" - ); - cdp.call( - "Input.dispatchMouseEvent", - json!({ - "type": kind, - "x": x, - "y": y, - "button": "left", - "buttons": if kind == "mousePressed" { 1 } else { 0 }, - "clickCount": click_count, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchMouseEvent {kind}: {e}")) -} - -/// Type a literal string by dispatching one `keyDown`/`char`/`keyUp` -/// triple per character. CDP's `dispatchKeyEvent type=char` is what -/// actually inserts text into focused editable fields — `keyDown` -/// alone leaves the input empty for most letters. The `keyDown` -/// + `keyUp` pair is still needed so listeners (autocomplete, -/// keystroke counters) see a normal keystroke. -pub async fn type_text(cdp: &mut CdpConn, session: &str, text: &str) -> Result<(), String> { - log::debug!( - "[cdp::input] type_text session={session} chars={}", - text.chars().count() - ); - for ch in text.chars() { - let s = ch.to_string(); - // keyDown — Gmail's command/keyboard router observes these. - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "keyDown", - "text": s, - "unmodifiedText": s, - "key": s, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent keyDown {ch:?}: {e}"))?; - // char — actual text insertion into the focused editable. - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "char", - "text": s, - "unmodifiedText": s, - "key": s, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent char {ch:?}: {e}"))?; - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "keyUp", - "text": s, - "unmodifiedText": s, - "key": s, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent keyUp {ch:?}: {e}"))?; - } - log::debug!("[cdp::input] type_text complete session={session}"); - Ok(()) -} - -/// Press a non-character key (Enter, Esc, …). Sends `rawKeyDown` → -/// `keyUp`; no `char` because non-printables don't insert text. -pub async fn press_key(cdp: &mut CdpConn, session: &str, key: Key) -> Result<(), String> { - let (key_name, code, vk) = key.cdp_fields(); - log::debug!("[cdp::input] press_key session={session} key={key_name}"); - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "rawKeyDown", - "key": key_name, - "code": code, - "windowsVirtualKeyCode": vk, - "nativeVirtualKeyCode": vk, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent rawKeyDown {key_name}: {e}"))?; - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "keyUp", - "key": key_name, - "code": code, - "windowsVirtualKeyCode": vk, - "nativeVirtualKeyCode": vk, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent keyUp {key_name}: {e}"))?; - log::debug!("[cdp::input] press_key complete session={session} key={key_name}"); - Ok(()) -} - -/// Dispatch Cmd/Ctrl+A to select-all in the focused contenteditable / input. -/// Useful when the search box already has a previous query in it that -/// we need to overwrite — Gmail keeps the last query rendered in the -/// search input so a fresh visit sees stale text. -pub async fn select_all_in_focused(cdp: &mut CdpConn, session: &str) -> Result<(), String> { - log::debug!( - "[cdp::input] select_all_in_focused session={session} modifier={SELECT_ALL_MODIFIER}" - ); - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "rawKeyDown", - "key": "a", - "code": "KeyA", - "windowsVirtualKeyCode": 65, - "nativeVirtualKeyCode": 65, - "modifiers": SELECT_ALL_MODIFIER, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent select-all keyDown: {e}"))?; - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "keyUp", - "key": "a", - "code": "KeyA", - "windowsVirtualKeyCode": 65, - "nativeVirtualKeyCode": 65, - "modifiers": SELECT_ALL_MODIFIER, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent select-all keyUp: {e}"))?; - log::debug!("[cdp::input] select_all_in_focused complete session={session}"); - Ok(()) -} diff --git a/app/src-tauri/src/cdp/mod.rs b/app/src-tauri/src/cdp/mod.rs index 3c360e5a68..17cede9959 100644 --- a/app/src-tauri/src/cdp/mod.rs +++ b/app/src-tauri/src/cdp/mod.rs @@ -6,29 +6,21 @@ //! and `Webview::on_dev_tools_protocol`. There is no listener and no //! network surface; any same-UID process is shut out by construction. //! -//! Scanners pick up a [`CdpConn`] either via [`conn_for_account`] (for -//! `acct_`-labelled webviews) or [`conn_for_label`] / -//! [`connect_and_attach_matching_in_process_by_label`] (for other +//! Scanners pick up a [`CdpConn`] either via [`target::conn_for_account`] (for +//! `acct_`-labelled webviews) or [`target::conn_for_label`] / +//! [`target::connect_and_attach_matching_in_process_by_label`] (for other //! surfaces such as the Meet call window). pub mod conn; pub mod in_process; -pub mod input; pub mod session; pub mod snapshot; pub mod target; pub use conn::CdpConn; -pub use in_process::{ - install_for_account, install_for_label, install_for_webview, set_cef_app_handle, CdpRegistry, - EventFrame, WebviewCdpTransport, CALL_TIMEOUT, -}; +pub use in_process::{install_for_account, install_for_label, set_cef_app_handle, CdpRegistry}; pub use session::{ placeholder_marker, placeholder_url, spawn_session, target_url_fragment, SpawnedSession, }; -#[allow(unused_imports)] // `Rect` re-export consumed once turn 2 lands; keep stable. -pub use snapshot::{Rect, Snapshot}; -pub use target::{ - conn_for_account, conn_for_label, connect_and_attach_matching_in_process, - connect_and_attach_matching_in_process_by_label, detach_session, find_page_target_where, -}; +pub use snapshot::Snapshot; +pub use target::{detach_session, find_page_target_where}; diff --git a/app/src-tauri/src/cdp/session.rs b/app/src-tauri/src/cdp/session.rs index 92a0626ba9..77a8d429b9 100644 --- a/app/src-tauri/src/cdp/session.rs +++ b/app/src-tauri/src/cdp/session.rs @@ -24,8 +24,8 @@ use tokio::task::JoinHandle; // elapsed check honours `tokio::time::pause()` / `advance()` in unit tests. use tokio::time::{sleep, Instant}; +use super::find_page_target_where; use super::target::conn_for_account; -use super::{find_page_target_where, CdpConn}; use crate::webview_accounts::{emit_load_finished, redact_url_for_log, RevealTrigger}; /// Backoff between failed attach attempts / reconnects. Intentionally @@ -517,7 +517,7 @@ async fn run_session_cycle( // page reaches the CEF helper's notify-IPC, which posts back to // `forward_native_notification` in `webview_accounts`. Without it, // the constructor silently no-ops and no toast ever fires (#1016). - if let Some(origin) = origin_of(&real_url) { + if let Some(origin) = origin_of(real_url) { // Default permission set every embedded provider needs. Origin-scoped // so we don't leak grants across providers running in the same CEF // browser process. diff --git a/app/src-tauri/src/cdp/snapshot.rs b/app/src-tauri/src/cdp/snapshot.rs index 9228e3675e..6771c1883a 100644 --- a/app/src-tauri/src/cdp/snapshot.rs +++ b/app/src-tauri/src/cdp/snapshot.rs @@ -33,35 +33,6 @@ struct CaptureSnapshot { struct DocumentSnap { #[serde(default)] nodes: NodeTreeSnap, - #[serde(default)] - layout: LayoutSnap, -} - -/// Subset of `documents[i].layout` we care about. Each layout node -/// references a DOM node by index and carries its `[x, y, w, h]` bounds -/// in CSS pixels. Only populated when `includeDOMRects: true` is passed -/// to `DOMSnapshot.captureSnapshot`. -#[derive(Deserialize, Debug, Default)] -struct LayoutSnap { - #[serde(rename = "nodeIndex", default)] - node_index: Vec, - #[serde(default)] - bounds: Vec>, -} - -/// Element bounding rect in CSS pixels relative to the viewport. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Rect { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} - -impl Rect { - pub fn center(self) -> (f64, f64) { - (self.x + self.width / 2.0, self.y + self.height / 2.0) - } } #[derive(Deserialize, Debug, Default)] @@ -82,10 +53,6 @@ pub struct Snapshot { strings: Vec, nodes: NodeTreeSnap, children: Vec>, - /// `rects[node_idx]` is `Some(Rect)` when layout info was requested - /// AND the node has a layout box (text + element nodes do; pure - /// metadata like `` doesn't). `None` otherwise. - rects: Vec>, } impl Snapshot { @@ -93,29 +60,13 @@ impl Snapshot { /// the parsed main-document tree. Iframes are ignored — none of the /// migrated providers render chat lists inside iframes. pub async fn capture(cdp: &mut CdpConn, session: &str) -> Result { - Self::capture_inner(cdp, session, false).await - } - - /// Same as [`capture`] but also requests element bounding rects. - /// Use when the caller needs to drive `Input.dispatchMouseEvent` — - /// pulling rects on every snapshot adds protocol overhead, so the - /// cheap path stays cheap. - pub async fn capture_with_rects(cdp: &mut CdpConn, session: &str) -> Result { - Self::capture_inner(cdp, session, true).await - } - - async fn capture_inner( - cdp: &mut CdpConn, - session: &str, - with_rects: bool, - ) -> Result { let raw = cdp .call( "DOMSnapshot.captureSnapshot", json!({ "computedStyles": [], "includePaintOrder": false, - "includeDOMRects": with_rects, + "includeDOMRects": false, }), Some(session), ) @@ -138,8 +89,6 @@ impl Snapshot { let mut merged_node_name: Vec = Vec::new(); let mut merged_node_value: Vec = Vec::new(); let mut merged_attributes: Vec> = Vec::new(); - let mut merged_layout_node_index: Vec = Vec::new(); - let mut merged_layout_bounds: Vec> = Vec::new(); for document in snap.documents { let doc_offset = merged_node_type.len() as i32; let doc_nodes = document.nodes; @@ -162,11 +111,6 @@ impl Snapshot { while merged_attributes.len() < merged_node_type.len() { merged_attributes.push(Vec::new()); } - // Per-document layout indices need the same offset. - for &li in &document.layout.node_index { - merged_layout_node_index.push(if li < 0 { -1 } else { li + doc_offset }); - } - merged_layout_bounds.extend(document.layout.bounds); let _ = doc_count; } let nodes = NodeTreeSnap { @@ -176,10 +120,6 @@ impl Snapshot { node_value: merged_node_value, attributes: merged_attributes, }; - let layout = LayoutSnap { - node_index: merged_layout_node_index, - bounds: merged_layout_bounds, - }; let count = nodes.node_type.len(); let mut children: Vec> = vec![Vec::new(); count]; for (i, &p) in nodes.parent_index.iter().enumerate() { @@ -187,33 +127,10 @@ impl Snapshot { children[p as usize].push(i); } } - let mut rects: Vec> = vec![None; count]; - if with_rects { - for (layout_i, &node_i) in layout.node_index.iter().enumerate() { - if node_i < 0 { - continue; - } - let node_i = node_i as usize; - if node_i >= count { - continue; - } - let bounds = match layout.bounds.get(layout_i) { - Some(b) if b.len() >= 4 => b, - _ => continue, - }; - rects[node_i] = Some(Rect { - x: bounds[0], - y: bounds[1], - width: bounds[2], - height: bounds[3], - }); - } - } Ok(Self { strings, nodes, children, - rects, }) } @@ -268,13 +185,6 @@ impl Snapshot { self.children.get(idx).map(|v| v.as_slice()).unwrap_or(&[]) } - /// Layout rect for `idx`. `None` when the snapshot was captured - /// without `includeDOMRects` OR the node has no layout box (e.g. - /// ``, comment nodes, `display:none` elements). - pub fn rect(&self, idx: usize) -> Option { - self.rects.get(idx).copied().flatten() - } - /// Depth-first pre-order walk of every descendant of `root` (including /// `root` itself). Cheap enough for chat-list scrapes that run every /// 2 seconds — DOM has thousands of nodes, not millions. diff --git a/app/src-tauri/src/cdp/target.rs b/app/src-tauri/src/cdp/target.rs index 93e8736213..7a57c8502e 100644 --- a/app/src-tauri/src/cdp/target.rs +++ b/app/src-tauri/src/cdp/target.rs @@ -18,7 +18,6 @@ pub struct CdpTarget { pub id: String, pub kind: String, pub url: String, - pub title: String, } /// Parse the response of a `Target.getTargets` CDP call into a list of @@ -38,11 +37,6 @@ pub fn parse_targets(v: &Value) -> Vec { .and_then(|u| u.as_str()) .unwrap_or("") .to_string(), - title: t - .get("title") - .and_then(|u| u.as_str()) - .unwrap_or("") - .to_string(), }) }) .collect() diff --git a/app/src-tauri/src/claude_code.rs b/app/src-tauri/src/claude_code.rs index f3b3a3deec..c70f960cc0 100644 --- a/app/src-tauri/src/claude_code.rs +++ b/app/src-tauri/src/claude_code.rs @@ -61,7 +61,7 @@ end tell"#; Err(_) => continue, } } - return Err("no terminal emulator found (tried x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm). Run `claude login` manually.".into()); + Err("no terminal emulator found (tried x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm). Run `claude login` manually.".into()) } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] diff --git a/app/src-tauri/src/deep_link_ipc.rs b/app/src-tauri/src/deep_link_ipc.rs index 42fe98b869..857ee4a855 100644 --- a/app/src-tauri/src/deep_link_ipc.rs +++ b/app/src-tauri/src/deep_link_ipc.rs @@ -108,13 +108,15 @@ pub(crate) fn try_forward_deep_links() -> ForwardResult { // Pending URLs collected before setup() has an app handle. static PENDING_URLS: OnceLock>>> = OnceLock::new(); // Live handler installed by drain_pending_urls — dispatches directly to app. -static LIVE_HANDLER: OnceLock>>> = OnceLock::new(); +type LiveHandler = Box; +type LiveHandlerSlot = Mutex>; +static LIVE_HANDLER: OnceLock = OnceLock::new(); fn pending_queue() -> &'static Arc>> { PENDING_URLS.get_or_init(|| Arc::new(Mutex::new(Vec::new()))) } -fn live_handler() -> &'static Mutex>> { +fn live_handler() -> &'static LiveHandlerSlot { LIVE_HANDLER.get_or_init(|| Mutex::new(None)) } diff --git a/app/src-tauri/src/imessage_scanner/mod.rs b/app/src-tauri/src/imessage_scanner/mod.rs index 2593190a8f..289fd7cac0 100644 --- a/app/src-tauri/src/imessage_scanner/mod.rs +++ b/app/src-tauri/src/imessage_scanner/mod.rs @@ -465,13 +465,6 @@ impl ScannerRegistry { pub fn new() -> Self { Self } - pub fn ensure_scanner( - self: std::sync::Arc, - _app: tauri::AppHandle, - _account_id: String, - ) { - } - pub fn shutdown(&self) {} } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 9cac2be8d8..9d0f642a12 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -399,9 +399,7 @@ async fn restart_app(app: tauri::AppHandle) -> Result<(), String> { perform_early_teardown_async(&app).await; log::info!("[app] restart_app — early teardown complete, restarting"); - app.restart(); - // restart() does not return, but we must satisfy the signature - Ok(()) + app.restart() } /// Read the authoritative active user id from `active_user.toml` so the @@ -1209,16 +1207,6 @@ fn mascot_window_hide(app: AppHandle) -> Result<(), String> { } } -#[cfg(target_os = "macos")] -fn mascot_native_window_is_open() -> bool { - mascot_native_window::is_open() -} - -#[cfg(not(target_os = "macos"))] -fn mascot_native_window_is_open() -> bool { - false -} - /// Dispatch a notch-panel mutation onto the app main thread. /// /// The notch is a native NSPanel + WKWebView; AppKit requires it to be built @@ -1848,14 +1836,6 @@ async fn perform_early_teardown_async(app_handle: &AppHandle) { log::info!("[app] perform_early_teardown_async — early teardown complete"); } -/// Explicitly winds down CEF and Tauri before an app.exit(0) -fn shutdown_app_sync(app_handle: &AppHandle, exit_code: i32) { - log::info!("[app] shutdown_app_sync — starting early teardown"); - perform_early_teardown_sync_once(app_handle, "shutdown_app_sync"); - log::info!("[app] shutdown_app_sync — early teardown complete, exiting"); - app_handle.exit(exit_code); -} - #[cfg(target_os = "linux")] const WSL_X11_DESKTOP_WARNING: &str = "[startup] likely unsupported desktop environment: WSL with classic X11 forwarding detected (DISPLAY is set, but WAYLAND_DISPLAY/WSLg markers are absent). OpenHuman's Tauri/CEF desktop flow is fragile in this setup; use native Windows development or Windows 11 WSLg for desktop GUI work."; diff --git a/app/src-tauri/src/meet_audio/caption_listener.rs b/app/src-tauri/src/meet_audio/caption_listener.rs index 637c4ee7bc..e7e07878ae 100644 --- a/app/src-tauri/src/meet_audio/caption_listener.rs +++ b/app/src-tauri/src/meet_audio/caption_listener.rs @@ -2,8 +2,8 @@ //! `captions_bridge.js` we install at session start, and forwards each //! new line to core's `meet_agent_push_caption` RPC. //! -//! Replaces the old [`super::listen_capture`] (CEF audio handler → -//! Whisper STT) which proved unreliable: CEF's `cef_audio_handler_t` +//! Replaces the old CEF audio-handler and Whisper STT path, which proved +//! unreliable: CEF's `cef_audio_handler_t` //! is queried lazily on first audio output, so a solo agent in a //! lobby never engaged the pipeline. Captions handle that case for //! free — Meet's STT is already running, speaker-attributed, and @@ -34,7 +34,6 @@ const MAX_CONSECUTIVE_ERRORS: u32 = 30; /// RAII handle. Drop to stop the listener task. pub struct CaptionListener { - pub request_id: String, pub(crate) _shutdown_tx: Option>, } @@ -88,7 +87,6 @@ pub fn start(request_id: String, cdp: CdpConn, session_id: String) -> CaptionLis }); CaptionListener { - request_id, _shutdown_tx: Some(shutdown_tx), } } diff --git a/app/src-tauri/src/meet_audio/listen_capture.rs b/app/src-tauri/src/meet_audio/listen_capture.rs deleted file mode 100644 index 493a52e378..0000000000 --- a/app/src-tauri/src/meet_audio/listen_capture.rs +++ /dev/null @@ -1,327 +0,0 @@ -//! Capture the embedded Meet webview's audio output and forward it to -//! the core meet_agent loop. -//! -//! ## Pipeline -//! -//! 1. `tauri_runtime_cef::audio::register_audio_handler` taps the -//! per-browser `cef_audio_handler_t`. CEF delivers planar -//! float32 PCM at the renderer's native rate (typically 48 kHz, -//! 1–2 channels) directly from the audio output device path — -//! *before* it hits the OS speaker. No system permission needed. -//! -//! 2. Downsample-to-mono runs inline on the CEF audio thread: -//! - average across channels → mono float32 -//! - linear-interpolate down to 16 kHz (the rate `voice::streaming` -//! and the smoke test in `meet_agent::session` expect) -//! - clamp + scale to PCM16LE -//! -//! 3. Accumulate ~100 ms per chunk (1 600 samples @ 16 kHz). We push -//! via the core RPC on every flush boundary; smaller pushes would -//! overload the JSON envelope, larger ones would slow VAD. -//! -//! 4. RPC pushes are spawned on the tokio runtime so the audio -//! callback never blocks on network IO. A bounded channel -//! backpressures: if core is wedged, we drop the oldest queued -//! chunk rather than holding CEF's audio thread. - -use std::sync::{Arc, Mutex}; - -use tauri_runtime_cef::audio::{ - register_audio_handler, AudioHandlerRegistration, AudioStreamEvent, -}; -use tokio::sync::mpsc; - -const TARGET_SAMPLE_RATE: u32 = 16_000; -/// 100 ms @ 16 kHz mono. `meet_agent::ops::Vad` pushes hangover counts -/// based on per-frame cadence, so changing this changes the VAD wall -/// time too. 100 ms feels responsive without burning RPC. -const FLUSH_SAMPLES: usize = (TARGET_SAMPLE_RATE as usize) / 10; -/// Bounded channel between the CEF callback (producer) and the -/// async-runtime forwarder (consumer). 32 chunks ≈ 3.2 s at the flush -/// cadence — generous slack for transient core latency, but bounded -/// so a wedged core can't OOM us. -const FORWARD_CHANNEL_CAPACITY: usize = 32; - -/// RAII handle. Drop to release the CEF audio registration and shut -/// down the forwarder task. Both happen synchronously — the channel -/// closes first, the task exits its recv loop, and the registration -/// drop unhooks CEF in the same tick. -pub struct ListenSession { - pub request_id: String, - _registration: AudioHandlerRegistration, - /// Held so `Drop` closes the channel even if there are no in-flight - /// chunks. The forwarder task observes the close and exits. - _shutdown_tx: mpsc::Sender>, -} - -/// Opens the audio capture for `meet_url`. The same exact URL must -/// have been used to build the CEF window — `register_audio_handler` -/// matches by prefix. -pub fn start(meet_url: &str, request_id: String) -> Result { - let (tx, rx) = mpsc::channel::>(FORWARD_CHANNEL_CAPACITY); - let resampler = Arc::new(Mutex::new(Resampler::new())); - - let resampler_for_handler = resampler.clone(); - let tx_for_handler = tx.clone(); - let request_id_for_log = request_id.clone(); - let registration = register_audio_handler(meet_url.to_string(), move |event| { - on_audio_event( - &request_id_for_log, - event, - &resampler_for_handler, - &tx_for_handler, - ); - }); - - spawn_forwarder(request_id.clone(), rx); - - log::info!( - "[meet-audio] listen registered request_id={} url_chars={}", - request_id, - meet_url.chars().count() - ); - - Ok(ListenSession { - request_id, - _registration: registration, - _shutdown_tx: tx, - }) -} - -/// Process one CEF audio event. Speech/Stopped/Error all flow through -/// here; only `Packet` produces RPC traffic, but the others are logged -/// at info so an aborted call leaves a breadcrumb in the file logs. -fn on_audio_event( - request_id: &str, - event: AudioStreamEvent, - resampler: &Arc>, - tx: &mpsc::Sender>, -) { - match event { - AudioStreamEvent::Started { - sample_rate_hz, - channels, - frames_per_buffer, - } => { - log::info!( - "[meet-audio] cef stream start request_id={request_id} hz={sample_rate_hz} channels={channels} frames_per_buffer={frames_per_buffer}" - ); - if let Ok(mut r) = resampler.lock() { - r.reset(sample_rate_hz as u32); - } - } - AudioStreamEvent::Packet { - channels: planes, - pts_ms: _, - } => { - let pcm_bytes = match resampler.lock() { - Ok(mut r) => r.feed_and_drain(&planes), - Err(_) => return, - }; - for chunk in pcm_bytes.chunks(FLUSH_SAMPLES * 2) { - // `try_send` drops the chunk on a full channel rather - // than blocking the CEF audio thread. Better to lose - // a frame than to stall the renderer. - if tx.try_send(chunk.to_vec()).is_err() { - log::warn!( - "[meet-audio] forward channel full; dropping {} bytes request_id={request_id}", - chunk.len() - ); - } - } - } - AudioStreamEvent::Stopped => { - log::info!("[meet-audio] cef stream stopped request_id={request_id}"); - if let Ok(mut r) = resampler.lock() { - r.reset(0); - } - } - AudioStreamEvent::Error(msg) => { - log::warn!("[meet-audio] cef stream error request_id={request_id} msg={msg}"); - } - } -} - -/// Pull chunks off the bounded channel and POST each to core. Lives in -/// its own task so the CEF callback never blocks on HTTP. -fn spawn_forwarder(request_id: String, mut rx: mpsc::Receiver>) { - tauri::async_runtime::spawn(async move { - use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; - while let Some(chunk) = rx.recv().await { - let pcm_b64 = B64.encode(&chunk); - let res = super::rpc_call( - "openhuman.meet_agent_push_listen_pcm", - serde_json::json!({ - "request_id": request_id, - "pcm_base64": pcm_b64, - }), - ) - .await; - if let Err(err) = res { - log::debug!( - "[meet-audio] push_listen_pcm err request_id={request_id} bytes={} err={err}", - chunk.len() - ); - } - } - log::info!("[meet-audio] forwarder exiting request_id={request_id}"); - }); -} - -/// Stateful float32-planar → PCM16LE mono @ 16 kHz resampler. -/// -/// Uses linear interpolation, which is good enough for speech (the -/// downstream STT does not care about ultrasonics or pristine high -/// frequencies). Carry the previous sample across `feed_and_drain` -/// calls so we don't introduce a tick at every CEF buffer boundary. -/// Pick a source sample by signed index. Negative indices return the -/// carry sample from the previous call (so phase < 0 keeps the -/// interpolation continuous across buffer boundaries); past-the-end -/// indices clamp to the last sample (which is what the next call will -/// install as its own carry, so the output stays smooth even if a -/// caller stops feeding mid-stream). -fn sample_at(mono: &[f32], carry: f32, idx: i64) -> f32 { - if idx < 0 { - carry - } else if (idx as usize) < mono.len() { - mono[idx as usize] - } else { - *mono.last().unwrap_or(&0.0) - } -} - -struct Resampler { - source_rate_hz: u32, - /// Fractional position into the source buffer between calls. - /// 0.0 means "start cleanly with the next sample". Negative is - /// not used — the source rate is always known before we feed. - phase: f64, - /// Last source sample of the previous call, used as the "left" - /// neighbour when we interpolate the first sample of the next call. - last_sample: f32, -} - -impl Resampler { - fn new() -> Self { - Self { - source_rate_hz: 0, - phase: 0.0, - last_sample: 0.0, - } - } - - fn reset(&mut self, source_rate_hz: u32) { - self.source_rate_hz = source_rate_hz; - self.phase = 0.0; - self.last_sample = 0.0; - } - - fn feed_and_drain(&mut self, planes: &[Vec]) -> Vec { - if planes.is_empty() || self.source_rate_hz == 0 { - return Vec::new(); - } - let frames = planes[0].len(); - if frames == 0 { - return Vec::new(); - } - // Mono mix. - let mono: Vec = (0..frames) - .map(|i| { - let mut sum = 0.0_f32; - for plane in planes { - if let Some(v) = plane.get(i) { - sum += *v; - } - } - sum / planes.len() as f32 - }) - .collect(); - - let ratio = self.source_rate_hz as f64 / TARGET_SAMPLE_RATE as f64; - let mut out = Vec::with_capacity((mono.len() as f64 / ratio).ceil() as usize * 2); - // `pos` floats through `mono` indices. `pos < 0` means "still - // sampling the carry sample from the previous call"; `pos = 0` - // means "right at mono[0]". - let mut pos = self.phase; - while pos < mono.len() as f64 { - let idx_f = pos.floor(); - let frac = pos - idx_f; - let idx = idx_f as i64; - let s_left = sample_at(mono.as_slice(), self.last_sample, idx); - let s_right = sample_at(mono.as_slice(), self.last_sample, idx + 1); - let sample = s_left as f64 + (s_right as f64 - s_left as f64) * frac; - // Float32 [-1.0, 1.0] → i16. Clamp because Chromium can - // overshoot a touch on heavy compression. - let s_i16 = (sample.clamp(-1.0, 1.0) * i16::MAX as f64) as i16; - out.extend_from_slice(&s_i16.to_le_bytes()); - pos += ratio; - } - // Carry the trailing fractional position into the next call. - // It will be negative when we overshot (next call resumes - // mid-source-sample), so the next call interpolates between - // `last_sample` and the new mono[0]. - self.phase = pos - mono.len() as f64; - self.last_sample = *mono.last().unwrap_or(&0.0); - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resampler_with_no_source_rate_yields_nothing() { - let mut r = Resampler::new(); - let out = r.feed_and_drain(&[vec![0.5; 100]]); - assert!(out.is_empty(), "no source rate set, must produce nothing"); - } - - #[test] - fn resampler_48k_to_16k_mono_drops_samples_3to1() { - let mut r = Resampler::new(); - r.reset(48_000); - let plane = vec![0.5_f32; 4_800]; // 100ms @ 48k - let bytes = r.feed_and_drain(&[plane]); - // 100ms @ 16k = 1600 samples * 2 bytes. Allow ±2 samples slop - // from the fractional phase carry. - let samples = bytes.len() / 2; - assert!( - (1598..=1602).contains(&samples), - "expected ~1600 samples, got {samples}" - ); - } - - #[test] - fn resampler_stereo_to_mono_averages_channels() { - let mut r = Resampler::new(); - r.reset(16_000); - let left = vec![0.8_f32; 1600]; - let right = vec![-0.2_f32; 1600]; - let bytes = r.feed_and_drain(&[left, right]); - // Avg = 0.3 → ~9830 in i16. First two bytes are LE i16. - let first = i16::from_le_bytes([bytes[0], bytes[1]]); - assert!( - (9000..11000).contains(&first), - "expected mid-amplitude i16, got {first}" - ); - } - - #[test] - fn resampler_clamps_out_of_range_floats() { - let mut r = Resampler::new(); - r.reset(16_000); - let bytes = r.feed_and_drain(&[vec![5.0_f32; 100]]); - let first = i16::from_le_bytes([bytes[0], bytes[1]]); - assert_eq!(first, i16::MAX); - } - - #[test] - fn resampler_passthrough_when_rates_match() { - let mut r = Resampler::new(); - r.reset(16_000); - let plane = vec![0.5_f32; 1600]; - let bytes = r.feed_and_drain(&[plane]); - assert_eq!(bytes.len(), 1600 * 2); - } -} diff --git a/app/src-tauri/src/meet_audio/mod.rs b/app/src-tauri/src/meet_audio/mod.rs index a60f582b9b..5af6674b49 100644 --- a/app/src-tauri/src/meet_audio/mod.rs +++ b/app/src-tauri/src/meet_audio/mod.rs @@ -2,12 +2,8 @@ //! //! ## Pieces //! -//! - [`listen_capture`] — taps the embedded Meet webview's audio output -//! via the per-browser `CefAudioHandler` exposed by our vendored -//! `tauri-runtime-cef::audio` extension, downsamples to 16 kHz mono -//! PCM16LE, batches into ~100 ms chunks, and posts them to core via -//! `openhuman.meet_agent_push_listen_pcm`. Zero OS-level audio -//! permission needed: we read frames straight out of the renderer. +//! - [`caption_listener`] — drains Meet's built-in captions through CDP +//! and forwards speaker-attributed lines to core. //! //! - [`speak_pump`] — drains synthesized PCM the brain enqueued (via //! `openhuman.meet_agent_poll_speech`) and writes it into the @@ -19,15 +15,12 @@ //! //! [`start`] is invoked once the meet-call window has been built (in //! `meet_call::meet_call_open_window`). It opens the core session, -//! registers the audio handler keyed by the call's URL, and spawns the -//! poll-speech loop. [`stop`] runs from the window-destroyed handler: -//! it drops the audio handler registration (which silences capture -//! immediately), stops the speak pump, and tells core to close the -//! session and report counters. +//! starts the caption listener and poll-speech loop. [`stop`] runs from +//! the window-destroyed handler: it stops both tasks and tells core to +//! close the session and report counters. pub mod caption_listener; pub mod inject; -pub mod listen_capture; pub mod speak_pump; use std::collections::HashMap; @@ -54,14 +47,8 @@ impl MeetAudioState { /// teardown synchronously — no async drop needed because the caption /// listener and speak pump both shut down on signal/drop. /// -/// The legacy CEF-audio `listen_capture::ListenSession` is kept as an -/// optional field so the pre-register flow still has somewhere to -/// hand the registration off if a future build re-enables it. In the -/// caption-driven path it stays `None`. pub struct MeetAudioSession { - pub request_id: String, _captions: caption_listener::CaptionListener, - _legacy_listen: Option, _speak: speak_pump::SpeakPump, } @@ -229,9 +216,7 @@ pub async fn start( state.inner.lock().unwrap().insert( request_id.clone(), MeetAudioSession { - request_id: request_id.clone(), _captions: captions, - _legacy_listen: None, _speak: speak, }, ); @@ -353,11 +338,8 @@ pub(crate) async fn rpc_call( /// No-op caption listener used when CDP attach failed at session /// start. Lets the rest of the lifecycle hold a uniform value. -fn caption_listener_disabled(request_id: String) -> caption_listener::CaptionListener { - caption_listener::CaptionListener { - request_id, - _shutdown_tx: None, - } +fn caption_listener_disabled(_request_id: String) -> caption_listener::CaptionListener { + caption_listener::CaptionListener { _shutdown_tx: None } } /// Trim a string for logging without panicking on multi-byte chars. diff --git a/app/src-tauri/src/meet_audio/speak_pump.rs b/app/src-tauri/src/meet_audio/speak_pump.rs index 2cda72a79d..a8ab64a743 100644 --- a/app/src-tauri/src/meet_audio/speak_pump.rs +++ b/app/src-tauri/src/meet_audio/speak_pump.rs @@ -48,7 +48,6 @@ const SPEAKING_STATE_EVENT: &str = "meet-video:speaking-state"; /// RAII handle. Drop to stop the pump task. The shutdown channel /// causes the spawned loop to exit on the next select tick. pub struct SpeakPump { - pub request_id: String, _shutdown_tx: Option>, } @@ -130,7 +129,6 @@ pub fn start( }); SpeakPump { - request_id, _shutdown_tx: Some(shutdown_tx), } } @@ -277,11 +275,8 @@ fn next_speaking_state( /// No-op pump used when bridge install failed at session start. Keeps /// the rest of the session lifecycle uniform — `MeetAudioSession` can /// still hold a `SpeakPump` regardless of speak-path readiness. -pub fn start_disabled(request_id: String) -> SpeakPump { - SpeakPump { - request_id, - _shutdown_tx: None, - } +pub fn start_disabled(_request_id: String) -> SpeakPump { + SpeakPump { _shutdown_tx: None } } /// Run a single pump tick. Returns `true` when the tick actually diff --git a/app/src-tauri/src/meet_call/mod.rs b/app/src-tauri/src/meet_call/mod.rs index 592aa2789b..809b7355ec 100644 --- a/app/src-tauri/src/meet_call/mod.rs +++ b/app/src-tauri/src/meet_call/mod.rs @@ -134,8 +134,8 @@ pub async fn meet_call_open_window( } // Only one meet-call window can be live at a time — concurrent bot - // sessions race the CEF audio handler registration (`listen_capture`) - // and confuse the user with multiple "Meet — OpenHuman" windows in + // sessions race the shared audio bridge and confuse the user with + // multiple "Meet — OpenHuman" windows in // their Dock. Close any stragglers from a prior Join before opening // a fresh one. The CloseRequested handler will tear down their // scanner + audio session via the per-window event listeners below. diff --git a/app/src-tauri/src/meet_scanner/mod.rs b/app/src-tauri/src/meet_scanner/mod.rs index 9e8525fd9d..1e96ee81bc 100644 --- a/app/src-tauri/src/meet_scanner/mod.rs +++ b/app/src-tauri/src/meet_scanner/mod.rs @@ -35,7 +35,7 @@ use std::time::Duration; use serde_json::{json, Value}; -use tauri::{AppHandle, Manager, Runtime}; +use tauri::{AppHandle, Runtime}; use crate::cdp::{self, CdpConn}; diff --git a/app/src-tauri/src/meet_video/frame_bus.rs b/app/src-tauri/src/meet_video/frame_bus.rs index c8454ba542..375dd86f7b 100644 --- a/app/src-tauri/src/meet_video/frame_bus.rs +++ b/app/src-tauri/src/meet_video/frame_bus.rs @@ -191,14 +191,13 @@ async fn handle_connection( // while waiting for the producer's next tick. let writer = tokio::spawn(async move { let initial = latest_rx.borrow().clone(); - if !initial.is_empty() { - if sink + if !initial.is_empty() + && sink .send(Message::Binary((*initial).clone())) .await .is_err() - { - return; - } + { + return; } while latest_rx.changed().await.is_ok() { let frame = latest_rx.borrow().clone(); diff --git a/app/src-tauri/src/ptt_hotkeys.rs b/app/src-tauri/src/ptt_hotkeys.rs index 743ccadb6c..04956e0401 100644 --- a/app/src-tauri/src/ptt_hotkeys.rs +++ b/app/src-tauri/src/ptt_hotkeys.rs @@ -14,8 +14,6 @@ pub(crate) enum PttError { EmptyShortcut, ModifierOnlyShortcut, ConflictsWithDictation(String), - UnsupportedOnWayland, - RegistrationFailed(String), } impl std::fmt::Display for PttError { @@ -29,13 +27,6 @@ impl std::fmt::Display for PttError { PttError::ConflictsWithDictation(s) => { write!(f, "ptt shortcut '{s}' conflicts with the dictation hotkey") } - PttError::UnsupportedOnWayland => write!( - f, - "global shortcuts are not supported in this Wayland session — switch to X11 or use in-app dictation" - ), - PttError::RegistrationFailed(s) => { - write!(f, "failed to register ptt shortcut: {s}") - } } } } diff --git a/app/src-tauri/src/ptt_overlay.rs b/app/src-tauri/src/ptt_overlay.rs index 90bdfeb50d..bdb75004bd 100644 --- a/app/src-tauri/src/ptt_overlay.rs +++ b/app/src-tauri/src/ptt_overlay.rs @@ -23,6 +23,7 @@ pub(crate) fn ensure_window(app: &AppHandle) -> Result<(), String return Ok(()); } let url = WebviewUrl::App("index.html#/ptt-overlay".into()); + #[allow(unused_mut)] // Mutated by the macOS-only workspace options below. let mut builder = WebviewWindowBuilder::new(app, OVERLAY_LABEL, url) .title("OpenHuman Push-to-Talk") .inner_size(160.0, 56.0) diff --git a/app/src-tauri/src/screen_capture/mod.rs b/app/src-tauri/src/screen_capture/mod.rs index a18ac07438..3614fd294d 100644 --- a/app/src-tauri/src/screen_capture/mod.rs +++ b/app/src-tauri/src/screen_capture/mod.rs @@ -82,6 +82,7 @@ pub struct ScreenSource { /// What kind of source a parsed DesktopMediaID-format string describes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg(any(target_os = "macos", test))] pub(crate) enum SourceKind { Screen, Window, @@ -93,6 +94,7 @@ pub(crate) enum SourceKind { /// match what the enumerator emits. Pure logic so it can be unit-tested /// without touching platform APIs; macOS callers use it before dispatching /// to the capture backend. +#[cfg(any(target_os = "macos", test))] pub(crate) fn parse_source_id(id: &str) -> Option<(SourceKind, u32)> { let mut parts = id.splitn(3, ':'); let kind = match parts.next()? { diff --git a/app/src-tauri/src/slack_scanner/extract.rs b/app/src-tauri/src/slack_scanner/extract.rs index c2a9e40aa9..9b8600b1d7 100644 --- a/app/src-tauri/src/slack_scanner/extract.rs +++ b/app/src-tauri/src/slack_scanner/extract.rs @@ -35,16 +35,16 @@ pub struct ExtractedMessage { pub ts: String, } -/// Main entry: walks every record in the dump and returns -/// `(messages, user_id → display_name, channel_id → name, workspace_name)`. -pub fn harvest( - dump: &IdbDump, -) -> ( +type HarvestResult = ( Vec, HashMap, HashMap, Option, -) { +); + +/// Main entry: walks every record in the dump and returns +/// `(messages, user_id → display_name, channel_id → name, workspace_name)`. +pub fn harvest(dump: &IdbDump) -> HarvestResult { let mut messages: Vec = Vec::new(); let mut users: HashMap = HashMap::new(); let mut channels: HashMap = HashMap::new(); @@ -173,15 +173,13 @@ fn walk( .or_insert_with(|| n.to_string()); } } - 'T' => { - if workspace.is_none() { - if let Some(n) = map - .get("name") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - *workspace = Some(n.to_string()); - } + 'T' if workspace.is_none() => { + if let Some(n) = map + .get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + *workspace = Some(n.to_string()); } } _ => {} @@ -222,24 +220,23 @@ fn walk( ); } } - Value::String(s) => { - // Redux-persist default: values are JSON-encoded strings. If - // this string is plausibly JSON, parse and recurse. + Value::String(s) if s.len() > 20 && (s.starts_with('{') || s.starts_with('[')) - && (s.ends_with('}') || s.ends_with(']')) - { - if let Ok(inner) = serde_json::from_str::(s) { - walk( - &inner, - channel_hint, - messages, - users, - channels, - workspace, - depth + 1, - ); - } + && (s.ends_with('}') || s.ends_with(']')) => + { + // Redux-persist default: values are JSON-encoded strings. If + // this string is plausibly JSON, parse and recurse. + if let Ok(inner) = serde_json::from_str::(s) { + walk( + &inner, + channel_hint, + messages, + users, + channels, + workspace, + depth + 1, + ); } } _ => {} diff --git a/app/src-tauri/src/slack_scanner/idb.rs b/app/src-tauri/src/slack_scanner/idb.rs index 2631284ce5..d4f5a9031f 100644 --- a/app/src-tauri/src/slack_scanner/idb.rs +++ b/app/src-tauri/src/slack_scanner/idb.rs @@ -283,7 +283,7 @@ async fn serialize_values( chunk.len() )); } - for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) { + for ((idx, _), val) in chunk.iter().zip(serialised) { result[*idx] = val; } } diff --git a/app/src-tauri/src/telegram_scanner/extract.rs b/app/src-tauri/src/telegram_scanner/extract.rs index 40e3b41c09..6f9d02e4b7 100644 --- a/app/src-tauri/src/telegram_scanner/extract.rs +++ b/app/src-tauri/src/telegram_scanner/extract.rs @@ -166,16 +166,15 @@ fn walk(v: &Value, peer_hint: Option<&str>, out: &mut Harvest, depth: u32) { walk(vv, peer_hint, out, depth + 1); } } - Value::String(s) => { - // Some tweb stores persist state as JSON-encoded strings. - // Recurse when the shape looks plausibly JSON. + Value::String(s) if s.len() > 20 && (s.starts_with('{') || s.starts_with('[')) - && (s.ends_with('}') || s.ends_with(']')) - { - if let Ok(inner) = serde_json::from_str::(s) { - walk(&inner, peer_hint, out, depth + 1); - } + && (s.ends_with('}') || s.ends_with(']')) => + { + // Some tweb stores persist state as JSON-encoded strings. + // Recurse when the shape looks plausibly JSON. + if let Ok(inner) = serde_json::from_str::(s) { + walk(&inner, peer_hint, out, depth + 1); } } _ => {} diff --git a/app/src-tauri/src/telegram_scanner/idb.rs b/app/src-tauri/src/telegram_scanner/idb.rs index 84d88dd765..5fd1fe173b 100644 --- a/app/src-tauri/src/telegram_scanner/idb.rs +++ b/app/src-tauri/src/telegram_scanner/idb.rs @@ -283,7 +283,7 @@ async fn serialize_values( chunk.len() )); } - for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) { + for ((idx, _), val) in chunk.iter().zip(serialised) { result[*idx] = val; } } diff --git a/app/src-tauri/src/wechat_scanner/dom_snapshot.rs b/app/src-tauri/src/wechat_scanner/dom_snapshot.rs index b750d9a1b4..5afe84739a 100644 --- a/app/src-tauri/src/wechat_scanner/dom_snapshot.rs +++ b/app/src-tauri/src/wechat_scanner/dom_snapshot.rs @@ -23,7 +23,6 @@ pub struct MessageRow { pub struct DomScan { pub chat_rows: Vec, pub messages: Vec, - pub active_chat_name: Option, pub unread: u32, pub hash: u64, } @@ -71,7 +70,6 @@ pub async fn scan(cdp: &mut CdpConn, session: &str) -> Result { Ok(DomScan { chat_rows, messages, - active_chat_name, unread, hash, }) diff --git a/app/src-tauri/src/whatsapp_scanner/idb.rs b/app/src-tauri/src/whatsapp_scanner/idb.rs index 9a61b6e251..9b8388ab7e 100644 --- a/app/src-tauri/src/whatsapp_scanner/idb.rs +++ b/app/src-tauri/src/whatsapp_scanner/idb.rs @@ -237,7 +237,7 @@ async fn serialize_values( chunk.len() )); } - for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) { + for ((idx, _), val) in chunk.iter().zip(serialised) { result[*idx] = val; } } diff --git a/app/src-tauri/src/whatsapp_scanner/mod.rs b/app/src-tauri/src/whatsapp_scanner/mod.rs index c43ecdbdf6..bb71050528 100644 --- a/app/src-tauri/src/whatsapp_scanner/mod.rs +++ b/app/src-tauri/src/whatsapp_scanner/mod.rs @@ -29,8 +29,6 @@ use tauri::{AppHandle, Emitter, Runtime}; use tokio::task::AbortHandle; use tokio::time::sleep; -use crate::cdp::CdpConn; - mod dom_snapshot; #[cfg(test)] mod dom_snapshot_tests; @@ -1209,7 +1207,7 @@ fn merge_dom_into_snapshot( continue; } if let Some(mid) = mid_opt { - let bare_mid = mid.rsplitn(2, '_').next().map(str::to_string); + let bare_mid = mid.rsplit('_').next().map(str::to_string); let lookup = by_msg_id .get(&mid) .cloned() diff --git a/package.json b/package.json index 9a7a0fd980..e87ca4f8cc 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "debug": "bash scripts/debug/cli.sh", "test:install-ps1": "pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1", "rust:check": "pnpm --filter openhuman-app rust:check", + "rust:clippy": "cargo clippy -p openhuman -- -D warnings && pnpm --filter openhuman-app rust:clippy", "typecheck": "pnpm --filter openhuman-app compile", "tauri:ios:init": "bash scripts/ios-init.sh", "tauri:ios:dev": "pnpm --filter openhuman-app tauri:ios:dev", diff --git a/src/bin/memory_tree_init_smoke.rs b/src/bin/memory_tree_init_smoke.rs index bddd11e9c2..f2c7f05f9d 100644 --- a/src/bin/memory_tree_init_smoke.rs +++ b/src/bin/memory_tree_init_smoke.rs @@ -57,8 +57,10 @@ fn main() -> ExitCode { } }; - let mut cfg = Config::default(); - cfg.workspace_dir = workspace.clone(); + let cfg = Config { + workspace_dir: workspace.clone(), + ..Config::default() + }; let db_path = workspace.join("memory_tree").join("chunks.db"); let cold = !db_path.exists(); diff --git a/src/bin/slack_backfill.rs b/src/bin/slack_backfill.rs index 732ac3c2e7..c5f6c9f191 100644 --- a/src/bin/slack_backfill.rs +++ b/src/bin/slack_backfill.rs @@ -323,8 +323,8 @@ async fn main() -> Result<()> { enum Outcome { Ok, Ratelimit, - OtherFail(String), - Transport(String), + OtherFail, + Transport, } let mut outcomes: Vec<(u32, std::time::Duration, Outcome)> = Vec::with_capacity(n as usize); let probe_started = Instant::now(); @@ -342,7 +342,10 @@ async fn main() -> Result<()> { .await; let dt = t0.elapsed(); let outcome = match resp { - Err(e) => Outcome::Transport(format!("{e:#}")), + Err(e) => { + log::warn!("[probe-ratelimit] transport error on call {i}: {e:#}"); + Outcome::Transport + } Ok(r) if r.successful => Outcome::Ok, Ok(r) => { let err = r.error.as_deref().unwrap_or("provider failure"); @@ -356,7 +359,7 @@ async fn main() -> Result<()> { ); Outcome::Ratelimit } else { - Outcome::OtherFail(err.to_string()) + Outcome::OtherFail } } }; @@ -379,11 +382,11 @@ async fn main() -> Result<()> { .count(); let other = outcomes .iter() - .filter(|(_, _, o)| matches!(o, Outcome::OtherFail(_))) + .filter(|(_, _, o)| matches!(o, Outcome::OtherFail)) .count(); let transport = outcomes .iter() - .filter(|(_, _, o)| matches!(o, Outcome::Transport(_))) + .filter(|(_, _, o)| matches!(o, Outcome::Transport)) .count(); let avg_ms = if !outcomes.is_empty() { outcomes.iter().map(|(_, d, _)| d.as_millis()).sum::() / outcomes.len() as u128 diff --git a/src/core/cli.rs b/src/core/cli.rs index 014b75c08a..2d6b55be97 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -14,10 +14,6 @@ use crate::core::jsonrpc::{default_state, invoke_method, parse_json_params}; use crate::core::logging::CliLogDefault; use crate::core::{ControllerSchema, TypeSchema}; -/// Debug/e2e agent paths can build deep async poll stacks while assembling -/// prompts, provider requests, and sub-agent tool loops. -const CLI_RUNTIME_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024; - /// The ASCII banner displayed when the CLI starts. const CLI_BANNER: &str = r#" @@ -450,14 +446,6 @@ fn run_namespace_command( Ok(()) } -fn build_cli_runtime() -> Result { - tokio::runtime::Builder::new_multi_thread() - .thread_stack_size(CLI_RUNTIME_THREAD_STACK_SIZE) - .enable_all() - .build() - .map_err(Into::into) -} - /// Parses command-line arguments into a JSON map based on a function's schema. /// /// # Arguments diff --git a/src/openhuman/accessibility/automate.rs b/src/openhuman/accessibility/automate.rs index 51e197d9f7..960b91f648 100644 --- a/src/openhuman/accessibility/automate.rs +++ b/src/openhuman/accessibility/automate.rs @@ -664,7 +664,7 @@ impl AutomateBackend for RealBackend { // `summarization` keeps M1 free of Config-schema churn while still keeping // the chat model out of the loop. use tinyagents::harness::message::Message; - use tinyagents::harness::model::{ChatModel, ModelRequest}; + use tinyagents::harness::model::ModelRequest; let model = crate::openhuman::inference::provider::create_chat_model( "summarization", &self.config, diff --git a/src/openhuman/agent/harness/archivist/recap.rs b/src/openhuman/agent/harness/archivist/recap.rs index 2ad95b3ec0..ab7cf2d5bc 100644 --- a/src/openhuman/agent/harness/archivist/recap.rs +++ b/src/openhuman/agent/harness/archivist/recap.rs @@ -64,6 +64,7 @@ impl ArchivistHook { /// - NEVER mutates DB state (no `segment_set_summary`, no embedding). /// - NEVER closes a segment. /// - Safe to call on both open and closed segments. + /// /// Summarize a set of episodic entries into a recap string. /// /// Returns `(text, produced_by_llm)`. `produced_by_llm == false` means the diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 3d8b55bb39..c757da0205 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -486,7 +486,7 @@ impl Agent { )?; let supports_native = resolved_chat_model .profile() - .map_or(true, |profile| profile.tool_calling); + .is_none_or(|profile| profile.tool_calling); log::info!( "[session-builder] agent_id={} provider_role={} resolved_model={} supports_native_tools={}", agent_id, diff --git a/src/openhuman/agent/harness/tool_filter.rs b/src/openhuman/agent/harness/tool_filter.rs index 7b988c651d..5b375d7f17 100644 --- a/src/openhuman/agent/harness/tool_filter.rs +++ b/src/openhuman/agent/harness/tool_filter.rs @@ -81,7 +81,7 @@ pub fn filter_actions_by_prompt( }) .collect(); - scored.sort_by(|a, b| b.0.cmp(&a.0)); + scored.sort_by_key(|item| std::cmp::Reverse(item.0)); // Only keep positively-scored results. Zero-overlap tools would add noise. scored diff --git a/src/openhuman/agent/progress_tracing.rs b/src/openhuman/agent/progress_tracing.rs index b3d7074b51..87577062fd 100644 --- a/src/openhuman/agent/progress_tracing.rs +++ b/src/openhuman/agent/progress_tracing.rs @@ -774,7 +774,7 @@ impl SpanCollector { ("gen_ai.usage.reasoning_tokens", reasoning_tokens), ("gen_ai.usage.cache_creation_tokens", cache_creation_tokens), ] { - if add == 0 && span.attributes.get(key).is_none() { + if add == 0 && !span.attributes.contains_key(key) { continue; } let prior = span diff --git a/src/openhuman/agent/schemas.rs b/src/openhuman/agent/schemas.rs index bbae99bdec..c095855163 100644 --- a/src/openhuman/agent/schemas.rs +++ b/src/openhuman/agent/schemas.rs @@ -519,7 +519,6 @@ fn local_catalog_models_from_config( fn handle_registry_snapshot(_params: Map) -> ControllerFuture { Box::pin(async { - use crate::openhuman::tools::Tool as _; use tinyagents::registry::{ComponentKind, ComponentMetadata, RegistrySnapshot}; let mut components: Vec = Vec::new(); diff --git a/src/openhuman/agent_meetings/summary.rs b/src/openhuman/agent_meetings/summary.rs index 45dfbf862f..bb8470345d 100644 --- a/src/openhuman/agent_meetings/summary.rs +++ b/src/openhuman/agent_meetings/summary.rs @@ -17,7 +17,7 @@ use crate::core::event_bus::BackendMeetTurn; use crate::openhuman::config::{AutoSummarizePolicy, Config}; use crate::openhuman::inference::provider::create_chat_model; use tinyagents::harness::message::Message; -use tinyagents::harness::model::{ChatModel, ModelRequest}; +use tinyagents::harness::model::ModelRequest; use super::types::{ActionItem, ActionItemKind, MeetingSummary}; diff --git a/src/openhuman/agent_memory/tools.rs b/src/openhuman/agent_memory/tools.rs index 42215af356..468ebf8b7d 100644 --- a/src/openhuman/agent_memory/tools.rs +++ b/src/openhuman/agent_memory/tools.rs @@ -116,7 +116,7 @@ impl Tool for CallMemoryAgentTool { let max_turns = args .get("max_turns") .and_then(|v| v.as_u64()) - .map(|v| v.max(1).min(20) as usize); + .map(|v| v.clamp(1, 20) as usize); let is_async = args.get("async").and_then(|v| v.as_bool()).unwrap_or(false); diff --git a/src/openhuman/agent_orchestration/background_delivery.rs b/src/openhuman/agent_orchestration/background_delivery.rs index b1b0e0df21..1a3f811db4 100644 --- a/src/openhuman/agent_orchestration/background_delivery.rs +++ b/src/openhuman/agent_orchestration/background_delivery.rs @@ -148,30 +148,27 @@ async fn try_deliver(session: String) { .remove(&session); return; } - match ( + if let (Some(thread_id), Some(notice)) = ( background_completions::batch_thread_id(&batch), background_completions::build_batched_notice(&batch), ) { - (Some(thread_id), Some(notice)) => { - log::info!( - "[background_delivery] delivering {} batched background result(s) \ - session={session} thread_id={thread_id}", - batch.len() + log::info!( + "[background_delivery] delivering {} batched background result(s) \ + session={session} thread_id={thread_id}", + batch.len() + ); + if let Err(e) = crate::openhuman::agent::task_dispatcher::run_system_turn_on_thread( + thread_id, notice, + ) + .await + { + log::warn!( + "[background_delivery] delivery turn failed session={session} error={e}" ); - if let Err(e) = crate::openhuman::agent::task_dispatcher::run_system_turn_on_thread( - thread_id, notice, - ) - .await - { - log::warn!( - "[background_delivery] delivery turn failed session={session} error={e}" - ); - requeue(&session, batch); // don't lose results on a failed turn - } + requeue(&session, batch); // don't lose results on a failed turn } - // Headless (no originating thread to stream into) — drop the batch. - _ => {} } + // Headless batches (no originating thread to stream into) are dropped. } // Release the slot only AFTER the turn settles. diff --git a/src/openhuman/agent_orchestration/running_subagents.rs b/src/openhuman/agent_orchestration/running_subagents.rs index 4b26363af6..1319585c74 100644 --- a/src/openhuman/agent_orchestration/running_subagents.rs +++ b/src/openhuman/agent_orchestration/running_subagents.rs @@ -847,7 +847,7 @@ pub(crate) fn task_id_for_session_in_workspace( .into_iter() .filter(|record| record_subagent_session_id(record) == Some(subagent_session_id)) .collect(); - matches.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + matches.sort_by_key(|item| std::cmp::Reverse(item.updated_at)); for record in matches { if record_parent_session(&record) != Some(parent_session) { diff --git a/src/openhuman/agent_orchestration/spawn_parallel_graph.rs b/src/openhuman/agent_orchestration/spawn_parallel_graph.rs index 69f66533e5..f449314226 100644 --- a/src/openhuman/agent_orchestration/spawn_parallel_graph.rs +++ b/src/openhuman/agent_orchestration/spawn_parallel_graph.rs @@ -409,7 +409,7 @@ fn snapshot_agent_definitions( .collect() } -pub(crate) fn prepare_spawn_parallel_tasks_from_defs( +pub(super) fn prepare_spawn_parallel_tasks_from_defs( tasks: Vec, definitions: &HashMap, parent: &ParentExecutionContext, diff --git a/src/openhuman/artifacts/store.rs b/src/openhuman/artifacts/store.rs index f895f97077..9d21de44b1 100644 --- a/src/openhuman/artifacts/store.rs +++ b/src/openhuman/artifacts/store.rs @@ -208,7 +208,7 @@ pub(crate) async fn list_artifacts( } // Sort descending by created_at (newest first) - all.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + all.sort_by_key(|item| std::cmp::Reverse(item.created_at)); // Apply thread filter BEFORE pagination so `total` reflects the // per-thread count the UI surfaces, and so a small page doesn't get diff --git a/src/openhuman/channels/proactive.rs b/src/openhuman/channels/proactive.rs index 54af0190d2..9298fb9dea 100644 --- a/src/openhuman/channels/proactive.rs +++ b/src/openhuman/channels/proactive.rs @@ -112,10 +112,13 @@ impl ProactiveMessageSubscriber { /// unit tests — so [`set_runtime_active_channel`] is a no-op there and never /// leaks across the parallel test suite. The choice is also persisted to /// `config.channels_config.active_channel`, which seeds the handle on next start. -static ACTIVE_CHANNEL_HANDLE: std::sync::OnceLock>>>>> = +type ActiveChannelHandle = Arc>>; +type ActiveChannelHandleSlot = RwLock>; + +static ACTIVE_CHANNEL_HANDLE: std::sync::OnceLock = std::sync::OnceLock::new(); -fn active_channel_handle_slot() -> &'static RwLock>>>> { +fn active_channel_handle_slot() -> &'static ActiveChannelHandleSlot { ACTIVE_CHANNEL_HANDLE.get_or_init(|| RwLock::new(None)) } diff --git a/src/openhuman/composio/tools/direct.rs b/src/openhuman/composio/tools/direct.rs index 1e35a0f2ad..baeddfafb5 100644 --- a/src/openhuman/composio/tools/direct.rs +++ b/src/openhuman/composio/tools/direct.rs @@ -783,10 +783,10 @@ impl Tool for ComposioTool { // tool call itself has no outbound side effect to gate. // `action="execute"` (or anything unknown / missing) is the // write path and routes through the approval gate. - match args.get("action").and_then(|v| v.as_str()) { - Some("list") | Some("connect") => false, - _ => true, - } + !matches!( + args.get("action").and_then(|v| v.as_str()), + Some("list") | Some("connect") + ) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { diff --git a/src/openhuman/credentials/profiles.rs b/src/openhuman/credentials/profiles.rs index 8258f1b9f5..5b6b0e7f77 100644 --- a/src/openhuman/credentials/profiles.rs +++ b/src/openhuman/credentials/profiles.rs @@ -70,6 +70,16 @@ const LOCK_TIMEOUT_MS: u64 = STALE_LOCK_AGE_MS + 5_000; const PERSIST_RETRY_ATTEMPTS: u32 = 6; const PERSIST_RETRY_BASE_MS: u64 = 100; +type EncryptedProfileFields = ( + Option, + Option, + Option, + Option, + Option, + Option, + Option, +); + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AuthProfileKind { @@ -921,18 +931,7 @@ impl AuthProfilesStore { } /// Encrypt a profile's secret fields for JSON storage (keychain-unavailable path). - fn encrypt_for_json( - &self, - profile: &AuthProfile, - ) -> Result<( - Option, - Option, - Option, - Option, - Option, - Option, - Option, - )> { + fn encrypt_for_json(&self, profile: &AuthProfile) -> Result { let (access_token, refresh_token, id_token, expires_at, token_type, scope) = match (&profile.kind, &profile.token_set) { (AuthProfileKind::OAuth, Some(token_set)) => ( diff --git a/src/openhuman/cron/tools/add.rs b/src/openhuman/cron/tools/add.rs index 5f6e76fda0..efe2e75bb1 100644 --- a/src/openhuman/cron/tools/add.rs +++ b/src/openhuman/cron/tools/add.rs @@ -63,7 +63,7 @@ fn validate_delivery(config: &Config, delivery: &DeliveryConfig) -> Result<(), S } match allowed_users_for_channel(config, channel) { - Some(list) if list.is_empty() => Ok(()), + Some([]) => Ok(()), Some(list) => { if list.iter().any(|u| u == to) { Ok(()) diff --git a/src/openhuman/cwd_jail/linux.rs b/src/openhuman/cwd_jail/linux.rs index 8747ce14d4..17471b31d0 100644 --- a/src/openhuman/cwd_jail/linux.rs +++ b/src/openhuman/cwd_jail/linux.rs @@ -15,6 +15,12 @@ use super::jail::{Jail, JailBackend}; pub struct LandlockBackend; +impl Default for LandlockBackend { + fn default() -> Self { + Self::new() + } +} + impl LandlockBackend { pub fn new() -> Self { Self diff --git a/src/openhuman/embeddings/ollama_adapter.rs b/src/openhuman/embeddings/ollama_adapter.rs index fe48175c60..0b8162fe12 100644 --- a/src/openhuman/embeddings/ollama_adapter.rs +++ b/src/openhuman/embeddings/ollama_adapter.rs @@ -9,6 +9,7 @@ pub use tinyagents::harness::embeddings::{ DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL, DEFAULT_OLLAMA_URL, }; +#[derive(Default)] pub struct OllamaEmbedding { inner: OllamaEmbeddingModel, } @@ -33,14 +34,6 @@ impl OllamaEmbedding { } } -impl Default for OllamaEmbedding { - fn default() -> Self { - Self { - inner: OllamaEmbeddingModel::default(), - } - } -} - #[async_trait] impl EmbeddingProvider for OllamaEmbedding { fn name(&self) -> &str { diff --git a/src/openhuman/file_state/ops.rs b/src/openhuman/file_state/ops.rs index bf8be1133f..1e90d1cdbf 100644 --- a/src/openhuman/file_state/ops.rs +++ b/src/openhuman/file_state/ops.rs @@ -1,6 +1,6 @@ //! Operational API for the file state coordinator. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use std::time::{Instant, SystemTime}; use tokio::sync::{Mutex, OwnedMutexGuard}; @@ -117,10 +117,10 @@ pub fn check_stale_read(agent_id: &str, resolved_path: &PathBuf) -> Option Option { +pub fn check_partial_read(agent_id: &str, resolved_path: &Path) -> Option { let coord = try_global()?; let reads = coord.reads.read(); - let read_key = (agent_id.to_string(), resolved_path.clone()); + let read_key = (agent_id.to_string(), resolved_path.to_path_buf()); let read_stamp = reads.get(&read_key)?; if read_stamp.partial { let display_path = resolved_path.display(); @@ -138,12 +138,12 @@ pub fn check_partial_read(agent_id: &str, resolved_path: &PathBuf) -> Option Option> { +pub async fn acquire_path_lock(resolved_path: &Path) -> Option> { let coord = try_global()?; let mutex = { let mut locks = coord.path_locks.write(); locks - .entry(resolved_path.clone()) + .entry(resolved_path.to_path_buf()) .or_insert_with(|| Arc::new(Mutex::new(()))) .clone() }; diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 61ec81c24e..23466128a0 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -2740,7 +2740,6 @@ pub async fn flows_cancel_run(config: &Config, run_id: &str) -> Result match checkpointer.delete_thread(thread_id).await { Ok(()) => { diff --git a/src/openhuman/inference/local/mod.rs b/src/openhuman/inference/local/mod.rs index a643700692..472eba8cdd 100644 --- a/src/openhuman/inference/local/mod.rs +++ b/src/openhuman/inference/local/mod.rs @@ -52,5 +52,6 @@ pub use schemas::{ all_controller_schemas as all_local_inference_controller_schemas, all_registered_controllers as all_local_inference_registered_controllers, }; +#[cfg(feature = "voice")] pub(crate) use service::whisper_engine; pub use service::LocalAiService; diff --git a/src/openhuman/inference/local/ollama.rs b/src/openhuman/inference/local/ollama.rs index e2d8462ab0..486c48e823 100644 --- a/src/openhuman/inference/local/ollama.rs +++ b/src/openhuman/inference/local/ollama.rs @@ -386,6 +386,7 @@ impl OllamaShowResponse { /// (unknown): /// * empty / absent capabilities (older Ollama, or an `/api/show` miss); /// * a tag set we don't recognise (e.g. `["insert"]` only). +/// /// Callers treat `None` as "keep visible" — fail-open, never hide a model /// that might be usable for chat. Mirrors the non-rejecting `Unknown` arm of /// [`super::model_requirements::ContextEligibility`]. See Sentry TAURI-RUST-4P6. diff --git a/src/openhuman/inference/provider/claude_code/driver.rs b/src/openhuman/inference/provider/claude_code/driver.rs index eca47e2fec..f2eccb414f 100644 --- a/src/openhuman/inference/provider/claude_code/driver.rs +++ b/src/openhuman/inference/provider/claude_code/driver.rs @@ -40,6 +40,7 @@ const DISALLOWED_CC_BUILTINS: &[&str] = &[ /// Whether the user opted into FULL access for Claude Code (`bypassPermissions` /// + full native toolset incl. Bash/network). Default is **off** → the safer +/// /// `acceptEdits` posture (file edits only). This is a deliberate user choice, /// not the default — enabling Claude Code alone does not grant shell/network /// power. diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 58c83858f1..b074a831c0 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -1605,10 +1605,10 @@ pub(crate) fn create_turn_chat_model_from_string_with_native_tools( /// `make_omlx_provider` / `make_local_openai_provider` — they share the endpoint /// helpers but each builds its own client, so an endpoint/auth change must touch /// both until the `Provider` path is deleted. -fn try_create_local_runtime_chat_model( - role: &str, - config: &Config, -) -> Option>, String)>> { +type ResolvedChatModel = (Arc>, String); +type OptionalChatModelResult = Option>; + +fn try_create_local_runtime_chat_model(role: &str, config: &Config) -> OptionalChatModelResult { let resolved = provider_for_role(role, config); try_create_local_runtime_chat_model_from_string(role, &resolved, config, true) } @@ -1618,7 +1618,7 @@ fn try_create_local_runtime_chat_model_from_string( provider: &str, config: &Config, require_session: bool, -) -> Option>, String)>> { +) -> OptionalChatModelResult { use crate::openhuman::inference::local::profile::{ LOCAL_OPENAI_PROFILE, MLX_PROFILE, OMLX_PROFILE, }; @@ -2514,10 +2514,7 @@ fn make_cloud_provider_by_slug( /// `verify_session_active`) runs before building. Temperature rides the per-call /// `ModelRequest` (managed/local parity; the `@` suffix still bakes a fixed /// override). -fn try_create_cloud_slug_chat_model( - role: &str, - config: &Config, -) -> Option>, String)>> { +fn try_create_cloud_slug_chat_model(role: &str, config: &Config) -> OptionalChatModelResult { try_create_cloud_slug_chat_model_with_native_tools(role, config, true) } @@ -2525,7 +2522,7 @@ fn try_create_cloud_slug_chat_model_with_native_tools( role: &str, config: &Config, native_tool_calling: bool, -) -> Option>, String)>> { +) -> OptionalChatModelResult { // Resolve the role's provider string, expanding the empty / "cloud" sentinel // to the primary cloud target (mirroring create_chat_provider_from_string). let mut resolved = provider_for_role(role, config); @@ -2544,7 +2541,7 @@ fn try_create_cloud_slug_chat_model_from_string( role: &str, provider: &str, config: &Config, -) -> Option>, String)>> { +) -> OptionalChatModelResult { try_create_cloud_slug_chat_model_from_string_with_native_tools(role, provider, config, true) } @@ -2553,7 +2550,7 @@ fn try_create_cloud_slug_chat_model_from_string_with_native_tools( provider: &str, config: &Config, native_tool_calling: bool, -) -> Option>, String)>> { +) -> OptionalChatModelResult { let p = provider.trim().to_string(); // Only the ":[@temp]" cloud form routes here. The managed diff --git a/src/openhuman/learning/reflection.rs b/src/openhuman/learning/reflection.rs index a46a0e240f..f7a248a05c 100644 --- a/src/openhuman/learning/reflection.rs +++ b/src/openhuman/learning/reflection.rs @@ -60,7 +60,7 @@ async fn invoke_cloud_reflection( prompt: &str, ) -> anyhow::Result { use tinyagents::harness::message::Message; - use tinyagents::harness::model::{ChatModel, ModelRequest}; + use tinyagents::harness::model::ModelRequest; Ok(provider .invoke( &(), diff --git a/src/openhuman/learning/stability_detector.rs b/src/openhuman/learning/stability_detector.rs index 231fb6886b..67f040e4de 100644 --- a/src/openhuman/learning/stability_detector.rs +++ b/src/openhuman/learning/stability_detector.rs @@ -288,7 +288,7 @@ impl StabilityDetector { facet_type: FacetType::Preference, key: full_key.clone(), value, - confidence: (agg_score).min(1.0).max(0.0), + confidence: agg_score.clamp(0.0, 1.0), evidence_count: new_evidence_count, source_segment_ids: existing.and_then(|f| f.source_segment_ids.clone()), first_seen_at: first_seen, @@ -551,9 +551,7 @@ fn state_from_stability(score: f64, user_state: UserState) -> FacetState { return FacetState::Dropped; } - if score.is_infinite() { - FacetState::Active - } else if score >= TAU_PROMOTE { + if score.is_infinite() || score >= TAU_PROMOTE { FacetState::Active } else if score >= TAU_PROVISIONAL { FacetState::Provisional diff --git a/src/openhuman/mcp_registry/connections.rs b/src/openhuman/mcp_registry/connections.rs index fdc964593c..6e41e84cf3 100644 --- a/src/openhuman/mcp_registry/connections.rs +++ b/src/openhuman/mcp_registry/connections.rs @@ -595,6 +595,7 @@ pub async fn call_tool( /// and the re-auth affordance keyed off the status alone. /// - other recorded connect failure in `LAST_ERRORS` → `Error` + message. /// - otherwise → `Disconnected`. +/// /// Pure status decision for one installed server, factored out of /// [`all_status`] so the priority order is unit-testable without a live /// connection registry or store. Inputs: diff --git a/src/openhuman/mcp_registry/registries/mcp_official.rs b/src/openhuman/mcp_registry/registries/mcp_official.rs index 269aad977c..453d4002c9 100644 --- a/src/openhuman/mcp_registry/registries/mcp_official.rs +++ b/src/openhuman/mcp_registry/registries/mcp_official.rs @@ -75,8 +75,11 @@ const MAX_CURSOR_WALK_PAGES: u32 = 50; /// `parking_lot::Mutex` matches the rest of the memory subsystem and keeps /// the critical section synchronous — every access is a `HashMap` op, no /// `.await` while the lock is held. -fn cursor_cache() -> &'static Mutex> { - static CACHE: OnceLock>> = OnceLock::new(); +type CursorCacheKey = (String, u32, u32); +type CursorCache = Mutex>; + +fn cursor_cache() -> &'static CursorCache { + static CACHE: OnceLock = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) } diff --git a/src/openhuman/mcp_server/write_dispatch.rs b/src/openhuman/mcp_server/write_dispatch.rs index ae4f1568a8..43c32d0009 100644 --- a/src/openhuman/mcp_server/write_dispatch.rs +++ b/src/openhuman/mcp_server/write_dispatch.rs @@ -142,11 +142,11 @@ pub(super) async fn dispatch_write_tool( fn audit_write(config: &Config, record: NewMcpWriteRecord) { let config = config.clone(); if let Ok(handle) = tokio::runtime::Handle::try_current() { - let _ = handle.spawn_blocking(move || { + std::mem::drop(handle.spawn_blocking(move || { if let Err(err) = mcp_audit::record_write(&config, record) { log::warn!("[mcp_server] mcp write audit insert failed: {err}"); } - }); + })); } else { let _ = std::thread::spawn(move || { if let Err(err) = mcp_audit::record_write(&config, record) { @@ -203,7 +203,7 @@ pub(super) fn audit_write_rejection_without_config( let args_summary = summarize_write_args(&tool_name, audit_arguments); match tokio::runtime::Handle::try_current() { Ok(handle) => { - let _ = handle.spawn(async move { + std::mem::drop(handle.spawn(async move { match config_rpc::load_config_with_timeout().await { Ok(config) => audit_write( &config, @@ -223,7 +223,7 @@ pub(super) fn audit_write_rejection_without_config( err ), } - }); + })); } Err(err) => log::warn!( "[mcp_server] write rejection audit skipped tool={} runtime unavailable error={}", diff --git a/src/openhuman/meet_agent/brain/speech.rs b/src/openhuman/meet_agent/brain/speech.rs index 88eb70f53b..ac84b5f6ea 100644 --- a/src/openhuman/meet_agent/brain/speech.rs +++ b/src/openhuman/meet_agent/brain/speech.rs @@ -58,7 +58,6 @@ pub(super) async fn tts(text: &str, voice_id: Option<&str>) -> Result, // Per-mascot voice for speaker alternation. `None` preserves the // backend's default-voice pick (single-mascot behavior). voice_id: voice_id.map(str::to_owned), - ..Default::default() }; let outcome = synthesize_reply(&config, text, &opts).await?; let result = outcome.value; diff --git a/src/openhuman/meet_agent/session.rs b/src/openhuman/meet_agent/session.rs index edfff41e78..334b5dce53 100644 --- a/src/openhuman/meet_agent/session.rs +++ b/src/openhuman/meet_agent/session.rs @@ -330,6 +330,7 @@ impl MeetAgentSession { /// * none present → `advance_speaker` yields `None` and the /// reply-speech backend keeps picking its own default voice /// (exact previous behavior). + /// /// The active slot starts at 0 so an idle session reports the primary /// mascot; [`Self::advance_speaker`] keeps the first reply there and /// rotates thereafter. diff --git a/src/openhuman/meet_agent/store.rs b/src/openhuman/meet_agent/store.rs index 600f9223f9..4bf527431f 100644 --- a/src/openhuman/meet_agent/store.rs +++ b/src/openhuman/meet_agent/store.rs @@ -163,7 +163,7 @@ async fn read_recent_from(path: &Path, limit: usize) -> Result TreeFactory<'a> { match self.kind() { TreeKind::Topic | TreeKind::Global => slugify_source_id(scope), TreeKind::Source => { - if scope.starts_with("gmail:") { - slugify_source_id(&scope["gmail:".len()..]) + if let Some(gmail_scope) = scope.strip_prefix("gmail:") { + slugify_source_id(gmail_scope) } else { slugify_source_id(scope) } diff --git a/src/openhuman/memory_tree/tree/registry.rs b/src/openhuman/memory_tree/tree/registry.rs index ec6b7386a8..d11ff6e943 100644 --- a/src/openhuman/memory_tree/tree/registry.rs +++ b/src/openhuman/memory_tree/tree/registry.rs @@ -76,10 +76,10 @@ pub fn get_or_create_tree(config: &Config, kind: TreeKind, scope: &str) -> Resul /// Matches both the anyhow-wrapped rusqlite error text and the raw SQLite /// error codes in case the wrapping chain is shorter. pub fn is_unique_violation(err: &anyhow::Error) -> bool { - if let Some(rusqlite_err) = err.downcast_ref::() { - if let rusqlite::Error::SqliteFailure(sqlite_err, _) = rusqlite_err { - return sqlite_err.code == rusqlite::ErrorCode::ConstraintViolation; - } + if let Some(rusqlite::Error::SqliteFailure(sqlite_err, _)) = + err.downcast_ref::() + { + return sqlite_err.code == rusqlite::ErrorCode::ConstraintViolation; } // Fallback for chained/wrapped errors: scan the rendered message. let msg = format!("{err:#}"); diff --git a/src/openhuman/people/store.rs b/src/openhuman/people/store.rs index b9f9fc8beb..d6ec23372d 100644 --- a/src/openhuman/people/store.rs +++ b/src/openhuman/people/store.rs @@ -16,6 +16,14 @@ use crate::openhuman::people::migrations; use crate::openhuman::people::types::{Handle, Interaction, Person, PersonId}; pub type ConnHandle = Arc>; +type PersonRow = ( + String, + Option, + Option, + Option, + i64, + i64, +); /// Process-global handle to the `PeopleStore`, tagged with the workspace it is /// bound to. Controller handlers are free functions with no `&self`, so they @@ -326,7 +334,7 @@ impl PeopleStore { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || -> SqlResult> { let guard = conn.blocking_lock(); - let row: Option<(String, Option, Option, Option, i64, i64)> = + let row: Option = guard .query_row( "SELECT id, display_name, primary_email, primary_phone, created_at, updated_at \ diff --git a/src/openhuman/runtime_python/bootstrap.rs b/src/openhuman/runtime_python/bootstrap.rs index cfffb2a0a5..3bb729d077 100644 --- a/src/openhuman/runtime_python/bootstrap.rs +++ b/src/openhuman/runtime_python/bootstrap.rs @@ -276,6 +276,7 @@ async fn acquire_install_lock(install_dir: &Path) -> Result { let file = OpenOptions::new() .create(true) + .truncate(false) .read(true) .write(true) .open(&lock_path_for_task) diff --git a/src/openhuman/security/pii/rules.rs b/src/openhuman/security/pii/rules.rs index f3671f15b3..b4b6d3d45f 100644 --- a/src/openhuman/security/pii/rules.rs +++ b/src/openhuman/security/pii/rules.rs @@ -71,7 +71,7 @@ pub(crate) fn is_luhn_valid(raw: &str) -> bool { } sum += v; } - sum % 10 == 0 + sum.is_multiple_of(10) } /// Validate that a dotted-quad match is a real IPv4 address (each octet 0–255). diff --git a/src/openhuman/skills/registry.rs b/src/openhuman/skills/registry.rs index 580d27b7ca..dc374b71c4 100644 --- a/src/openhuman/skills/registry.rs +++ b/src/openhuman/skills/registry.rs @@ -148,8 +148,9 @@ pub fn prune_legacy_default_workflows(workspace_dir: &Path) { /// `run_workflow`) returned "unknown workflow" for anything created via the UI. /// /// Per dir: `skill.toml` (id / `when_to_use` / `[[inputs]]` / `[github]`) -/// + the `SKILL.md` body as the inline system prompt; or, when there's no -/// `skill.toml`, a synthesized SKILL.md-only definition so a bare workflow is +/// + the `SKILL.md` body as the inline system prompt. +/// +/// Without `skill.toml`, a synthesized SKILL.md-only definition means a bare workflow is /// still runnable. A bad `skill.toml` falls back to the SKILL.md-only form. pub fn load_workflows(workspace_dir: &Path) -> Vec { // Prune any legacy bundled skills an older build left behind so discover's diff --git a/src/openhuman/socket/medulla/mod.rs b/src/openhuman/socket/medulla/mod.rs index 1a961e4c44..0656761ca4 100644 --- a/src/openhuman/socket/medulla/mod.rs +++ b/src/openhuman/socket/medulla/mod.rs @@ -373,10 +373,9 @@ async fn drain_steer(steer_rx: &mut mpsc::UnboundedReceiver) -> Option msg, - Err(_) => None, - } + tokio::time::timeout(STEER_DRAIN_GRACE, steer_rx.recv()) + .await + .unwrap_or_default() } /// Build (or resume) an agent session for a medulla task. diff --git a/src/openhuman/subconscious_triggers/runtime.rs b/src/openhuman/subconscious_triggers/runtime.rs index 941b1024a0..5ed4ac6e28 100644 --- a/src/openhuman/subconscious_triggers/runtime.rs +++ b/src/openhuman/subconscious_triggers/runtime.rs @@ -132,7 +132,8 @@ impl TriggerOrchestrator { /// Non-blocking ingestion entry point for the bus subscriber. Normalizes /// + admits synchronously, then spawns the gate task for admitted - /// triggers so event dispatch is never blocked on an LLM call. + /// + /// Triggers ensure event dispatch is never blocked on an LLM call. pub fn ingest(self: &Arc, event: &DomainEvent) { let now = now_secs(); let Some(trigger) = normalize(event, now) else { diff --git a/src/openhuman/tinyagents/mod.rs b/src/openhuman/tinyagents/mod.rs index 4ba7386b55..207335ee30 100644 --- a/src/openhuman/tinyagents/mod.rs +++ b/src/openhuman/tinyagents/mod.rs @@ -1170,15 +1170,19 @@ fn tinyagents_depth_error( /// [`assemble_turn_harness`] takes this bundle instead of the raw provider, so /// the harness assembly is expressed purely in crate model types; the /// `Provider` → `ChatModel` adaptation is confined to `build_turn_models`. +type TurnChatModel = Arc>; +type TierRoutes = Vec<(String, TurnChatModel)>; +type BuiltTurnModels = (TurnChatModel, TierRoutes, TurnChatModel); + pub(crate) struct TurnModels { /// The turn's effective/primary model (registry default + dispatch target). - primary: Arc>, + primary: TurnChatModel, /// Additive workload-tier routes (registry name → model), excluding the /// primary; the crate registry resolves fallback/selection across them. - routes: Vec<(String, Arc>)>, + routes: TierRoutes, /// A model for the context-window summarizer (a distinct adapter instance so /// its provider errors don't touch the turn's `error_slot`). - summarizer: Arc>, + summarizer: TurnChatModel, /// Recovers the primary's original (downcastable) provider error on failure. error_slot: crate::openhuman::tinyagents::model::ProviderErrorSlot, /// Provider telemetry id (`{provider_id}.{model}` in Langfuse), captured from @@ -1307,26 +1311,25 @@ fn build_turn_models_crate( // The primary honours an explicit provider-string override when the producer's // effective provider differs from `provider_for_role(role)` (triage #1257). - let build_primary = - |m: &str| -> anyhow::Result>> { - match primary_override { - Some(ps) => factory::create_turn_chat_model_from_string_with_native_tools( - role, - ps, - config, - m, - temperature, - !force_text_mode, - ), - None => factory::create_turn_chat_model_with_native_tools( - role, - config, - m, - temperature, - !force_text_mode, - ), - } - }; + let build_primary = |m: &str| -> anyhow::Result { + match primary_override { + Some(ps) => factory::create_turn_chat_model_from_string_with_native_tools( + role, + ps, + config, + m, + temperature, + !force_text_mode, + ), + None => factory::create_turn_chat_model_with_native_tools( + role, + config, + m, + temperature, + !force_text_mode, + ), + } + }; // Build the primary, every workload-tier route, and the summarizer under one // per-turn egress-dedup ledger: each managed construction resolves through the @@ -1334,44 +1337,39 @@ fn build_turn_models_crate( // separate `ExternalTransferPending` for the same logical destination (codex // P2, PR #4812). `dedup_turn_scope` collapses same-destination repeats to one // disclosure per turn while still surfacing each distinct tier model. - #[allow(clippy::type_complexity)] - let (primary, routes, summarizer): ( - Arc>, - Vec<(String, Arc>)>, - Arc>, - ) = crate::openhuman::security::egress::dedup_turn_scope(|| { - let primary = build_primary(model)?; - - // Additive workload-tier routes: one crate-native model per tier (skipping the - // turn's own model, which is registered as the default primary), each pinned to - // the tier alias so the crate registry resolves cross-route fallback across them. - let mut routes: Vec<(String, Arc>)> = - Vec::new(); - for &tier in routes::WORKLOAD_ROUTE_TIERS { - if tier == model { - continue; - } - let tier_role = factory::role_for_model_tier(tier); - match factory::create_turn_chat_model(tier_role, config, tier, temperature) { - Ok(route_model) => routes.push((tier.to_string(), route_model)), - Err(e) => { - // A route that can't be built (e.g. an unconfigured BYOK tier) is - // skipped, not fatal — the primary still dispatches (parity with the - // `Provider` path, where an unresolved tier simply isn't registered). - tracing::debug!( - route = tier, - error = %e, - "[models] skipping crate-native workload route that failed to build" - ); + let (primary, routes, summarizer): BuiltTurnModels = + crate::openhuman::security::egress::dedup_turn_scope(|| { + let primary = build_primary(model)?; + + // Additive workload-tier routes: one crate-native model per tier (skipping the + // turn's own model, which is registered as the default primary), each pinned to + // the tier alias so the crate registry resolves cross-route fallback across them. + let mut routes: TierRoutes = Vec::new(); + for &tier in routes::WORKLOAD_ROUTE_TIERS { + if tier == model { + continue; + } + let tier_role = factory::role_for_model_tier(tier); + match factory::create_turn_chat_model(tier_role, config, tier, temperature) { + Ok(route_model) => routes.push((tier.to_string(), route_model)), + Err(e) => { + // A route that can't be built (e.g. an unconfigured BYOK tier) is + // skipped, not fatal — the primary still dispatches (parity with the + // `Provider` path, where an unresolved tier simply isn't registered). + tracing::debug!( + route = tier, + error = %e, + "[models] skipping crate-native workload route that failed to build" + ); + } } } - } - // The summarizer is a distinct adapter instance (own empty error slot). - let summarizer = build_primary(model)?; + // The summarizer is a distinct adapter instance (own empty error slot). + let summarizer = build_primary(model)?; - anyhow::Ok((primary, routes, summarizer)) - })?; + anyhow::Ok((primary, routes, summarizer)) + })?; Ok(TurnModels { primary, @@ -1671,7 +1669,8 @@ struct AssembledTurnHarness { /// Shared `call_id → (success, failure, elapsed_ms, output_chars)` /// side-channel: the tool-outcome capture middleware classifies each outcome /// + records its duration/output size; the event bridge reads it to project - /// real success + a user-facing failure + timing onto `ToolCallCompleted`. + /// + /// Real success + a user-facing failure + timing onto `ToolCallCompleted`. failure_map: ToolFailureMap, /// Shared FIFO carry of per-call provider `UsageInfo` (charged USD + context /// window): the model adapter pushes, the event bridge pops when recording diff --git a/src/openhuman/tinyagents/observability.rs b/src/openhuman/tinyagents/observability.rs index c38685a983..8c7bdeea25 100644 --- a/src/openhuman/tinyagents/observability.rs +++ b/src/openhuman/tinyagents/observability.rs @@ -52,6 +52,7 @@ pub(crate) type ToolNameMap = Arc` and re-exposes //! it as both, so the engine drives OpenHuman embeddings without cloning any diff --git a/src/openhuman/tinycortex/sync.rs b/src/openhuman/tinycortex/sync.rs index 37967d7b87..482bc63922 100644 --- a/src/openhuman/tinycortex/sync.rs +++ b/src/openhuman/tinycortex/sync.rs @@ -116,7 +116,7 @@ fn source_sync_context(memory: MemoryClientRef, config: &Config, local: bool) -> documents: adapter.clone(), state: adapter.clone(), local_documents: local.then(|| adapter.clone() as std::sync::Arc), - external_sources: local.then(|| adapter as std::sync::Arc), + external_sources: local.then_some(adapter as std::sync::Arc), summariser: local.then(|| { std::sync::Arc::new(super::HostSummariser::new(config.clone())) as std::sync::Arc diff --git a/src/openhuman/tinyflows/caps.rs b/src/openhuman/tinyflows/caps.rs index f6f7930c56..ad7fce750e 100644 --- a/src/openhuman/tinyflows/caps.rs +++ b/src/openhuman/tinyflows/caps.rs @@ -591,6 +591,7 @@ impl LlmProvider for OpenHumanLlm { /// the node builds a real session agent /// ([`Agent::from_config_for_agent`](crate::openhuman::agent::Agent::from_config_for_agent) /// + `set_agent_definition_name`) and drives one full turn via +/// /// [`Agent::run_single`](crate::openhuman::agent::Agent::run_single) — the /// complete tool loop. The definition's `ToolScope` / `sandbox_mode` / /// `max_iterations` govern the turn, so an agent node gains its curated diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index 574e27f5fa..5a6a488d2b 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -2668,7 +2668,6 @@ use std::sync::Arc; use tinyplace::signal::session::SignalSession; use tinyplace::signal::store::SessionStore; -use tinyplace::signal::store::SessionStore as _; /// Get the `Arc` from the client or fail with a clear message. /// diff --git a/src/openhuman/todos/graph_shadow.rs b/src/openhuman/todos/graph_shadow.rs index f74be76068..81b2af4f6d 100644 --- a/src/openhuman/todos/graph_shadow.rs +++ b/src/openhuman/todos/graph_shadow.rs @@ -4,8 +4,9 @@ //! //! ADAPTER-FIRST / SHADOW ONLY — nothing in this module changes product //! behavior. The legacy [`TaskBoardStore`](crate::openhuman::agent::task_board) -//! + [`todos::ops`](crate::openhuman::todos::ops) remain the single source of -//! truth. This module (C2b first slice) mirrors post-mutation card snapshots +//! + [`todos::ops`](crate::openhuman::todos::ops) remain the single source of truth. +//! +//! This module (C2b first slice) mirrors post-mutation card snapshots //! into a crate `Store` and shadow-runs the crate `claim_card` CAS purely to //! prove parity ahead of the C2 cutover, logging any divergence. All work is //! best-effort and fire-and-forget: a mirror/claim failure is logged and diff --git a/src/openhuman/tools/impl/filesystem/glob_search.rs b/src/openhuman/tools/impl/filesystem/glob_search.rs index 982c5474f2..c73f15d3cc 100644 --- a/src/openhuman/tools/impl/filesystem/glob_search.rs +++ b/src/openhuman/tools/impl/filesystem/glob_search.rs @@ -301,7 +301,7 @@ fn collect_matches( } // Newest first. - hits.sort_by(|a, b| b.0.cmp(&a.0)); + hits.sort_by_key(|item| std::cmp::Reverse(item.0)); let truncated = hits.len() > max_results; let paths: Vec = hits.into_iter().take(max_results).map(|(_, p)| p).collect(); (paths, truncated) diff --git a/src/openhuman/tools/impl/network/polymarket.rs b/src/openhuman/tools/impl/network/polymarket.rs index c0b3218f9e..0da511fc46 100644 --- a/src/openhuman/tools/impl/network/polymarket.rs +++ b/src/openhuman/tools/impl/network/polymarket.rs @@ -1192,10 +1192,10 @@ impl Tool for PolymarketTool { // rejected by the CLOB. Credential derivation is similarly // single-flight (see ensure_clob_credentials OnceCell). Reads remain // concurrency-safe. - match args.get("action").and_then(Value::as_str) { - Some("place_order") | Some("cancel_order") => false, - _ => true, - } + !matches!( + args.get("action").and_then(Value::as_str), + Some("place_order") | Some("cancel_order") + ) } async fn execute(&self, args: Value) -> Result { diff --git a/src/openhuman/tools/impl/system/schedule.rs b/src/openhuman/tools/impl/system/schedule.rs index 712bc86050..50501839ca 100644 --- a/src/openhuman/tools/impl/system/schedule.rs +++ b/src/openhuman/tools/impl/system/schedule.rs @@ -90,10 +90,10 @@ impl Tool for ScheduleTool { // (create/add/once/cancel/remove/pause/resume) persist or remove a // scheduled job and must go through the ApprovalGate // (GHSA-f46p-6vf9-64mm). - match args.get("action").and_then(|v| v.as_str()) { - Some("list") | Some("get") => false, - _ => true, - } + !matches!( + args.get("action").and_then(|v| v.as_str()), + Some("list") | Some("get") + ) } async fn execute(&self, args: serde_json::Value) -> Result { diff --git a/src/openhuman/voice/server.rs b/src/openhuman/voice/server.rs index eaa354f84e..a4ade73efc 100644 --- a/src/openhuman/voice/server.rs +++ b/src/openhuman/voice/server.rs @@ -537,7 +537,7 @@ impl HotkeyListenerKind { fn start_hotkey_listener( hotkey_str: &str, mode: hotkey::ActivationMode, - server_cancel: &CancellationToken, + _server_cancel: &CancellationToken, ) -> Result< ( HotkeyListenerKind, diff --git a/src/openhuman/wallet/chains/btc.rs b/src/openhuman/wallet/chains/btc.rs index 1a5d117bce..49f8b50f77 100644 --- a/src/openhuman/wallet/chains/btc.rs +++ b/src/openhuman/wallet/chains/btc.rs @@ -167,7 +167,7 @@ fn select_utxos( fee_sats: u64, ) -> Result<(Vec, u64), String> { let mut sorted = utxos.to_vec(); - sorted.sort_by(|a, b| b.value.cmp(&a.value)); + sorted.sort_by_key(|item| std::cmp::Reverse(item.value)); let target = amount_sats .checked_add(fee_sats) .ok_or_else(|| "amount + fee overflow".to_string())?; diff --git a/src/openhuman/webview_accounts/ops.rs b/src/openhuman/webview_accounts/ops.rs index 7b45682b76..67b9db7309 100644 --- a/src/openhuman/webview_accounts/ops.rs +++ b/src/openhuman/webview_accounts/ops.rs @@ -34,7 +34,7 @@ struct Provider { /// Providers tracked in the webview-account snapshot. Keep this list aligned /// with the webview accounts system in `app/src-tauri/src/webview_accounts/`. -pub(crate) const PROVIDERS: &[Provider] = &[ +const PROVIDERS: &[Provider] = &[ Provider { key: "gmail", host_suffixes: &[".google.com"], diff --git a/src/openhuman/webview_apis/client.rs b/src/openhuman/webview_apis/client.rs index a5bc26ad5c..8435dcf39a 100644 --- a/src/openhuman/webview_apis/client.rs +++ b/src/openhuman/webview_apis/client.rs @@ -34,6 +34,8 @@ pub const PORT_ENV: &str = "OPENHUMAN_WEBVIEW_APIS_PORT"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); static CLIENT: OnceLock = OnceLock::new(); +type PendingResponse = oneshot::Sender>; +type PendingRequests = Arc>>; fn client() -> &'static Client { CLIENT.get_or_init(Client::new) @@ -75,7 +77,7 @@ where struct Client { next_id: AtomicU64, - pending: Arc>>>>, + pending: PendingRequests, sink: Arc>>>, } From 39b0f526facebc7bca3bdbf037e3248843bbda14 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 03:44:49 +0000 Subject: [PATCH 02/14] feat(analytics): standardize interaction tracking --- AGENTS.md | 2 + app/src/App.tsx | 8 +- .../analytics/AnalyticsTracker.test.tsx | 74 +++++++++++++++++++ .../components/analytics/AnalyticsTracker.tsx | 67 +++++++++++++++++ app/src/components/analytics/index.ts | 6 ++ app/src/components/flows/FlowListRow.tsx | 21 +++--- .../components/skills/WorkflowRunnerBody.tsx | 39 +++++----- .../features/conversations/Conversations.tsx | 17 ++++- app/src/pages/FlowCanvasPage.tsx | 21 +++--- app/src/services/__tests__/analytics.test.ts | 31 ++++++++ app/src/services/analytics.ts | 60 ++++++++++++--- app/src/services/api/flowsApi.ts | 3 + app/src/services/api/skillsApi.ts | 5 ++ app/src/services/api/workflowRunsApi.ts | 6 ++ app/test/e2e/specs/flows.spec.ts | 6 ++ 15 files changed, 312 insertions(+), 54 deletions(-) create mode 100644 app/src/components/analytics/AnalyticsTracker.test.tsx create mode 100644 app/src/components/analytics/AnalyticsTracker.tsx create mode 100644 app/src/components/analytics/index.ts diff --git a/AGENTS.md b/AGENTS.md index 5c05931e21..b97a554c84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,6 +153,8 @@ No `UserProvider`/`AIProvider`/`SkillProvider` — auth lives in `CoreStateProvi **Services** (`services/`): `apiClient`, `socketService`, `coreRpcClient`, `coreCommandClient`, `chatService`, `analytics`, `notificationService`, `webviewAccountService`, `daemonHealthService`, plus domain `api/*` clients. Always use `coreRpcClient` (which invokes the `relay_http_rpc` Tauri command) for core RPC. +**Analytics**: use the standardized exports from `components/analytics` — `AnalyticsPageTracker` once inside the router, `TrackedInteraction` for clickable UI, and `trackAnalyticsEvent` for successful domain outcomes (messages, automation runs, connections, etc.). Use stable content-free identifiers and privacy-safe dimensions only; never send user-authored text, entity IDs, filenames, credentials, or error messages. `services/analytics.ts` is the consent/provider implementation, not the feature-code API. + **Routing** (`AppRoutes.tsx`, HashRouter): `/` (Welcome), `/auth`, `/onboarding/*`, `/chat/:threadId?`, `/human`, `/brain` (+ `/brain/tinyplace-orchestration`), `/orchestration`, `/connections`, `/flows` (+ `/flows/:id`, `/flows/draft`), `/agent-world/*`, `/invites`, `/notifications`, `/rewards`, `/settings/*`, `/feedback`. Back-compat redirects: `/home`→`/chat`, `/skills`→`/connections`, `/channels`→`/connections?tab=messaging`, `/intelligence` & `/activity`→`/settings/notifications`, `/routines` & `/workflows`→`/settings/automations`, `/webhooks`→`/settings/integrations#webhooks`. No `/login`, `/mnemonic`, `/agents`, `/conversations`. **AI config**: bundled prompts in `src/openhuman/agent/prompts/` ship via `tauri.conf.json` resources and are read core-side (`app/src/lib/ai/` holds agent-context helpers, not prompt loaders). diff --git a/app/src/App.tsx b/app/src/App.tsx index a7a7155805..7b718a10b7 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -11,6 +11,7 @@ import { PersistGate } from 'redux-persist/integration/react'; import AppRoutes from './AppRoutes'; import WebviewHost from './components/accounts/WebviewHost'; +import { AnalyticsPageTracker } from './components/analytics'; import AnnouncementGate from './components/Announcement/AnnouncementGate'; import AppBackground from './components/AppBackground'; import AppUpdatePrompt from './components/AppUpdatePrompt'; @@ -51,7 +52,6 @@ import ChatRuntimeProvider from './providers/ChatRuntimeProvider'; import CoreStateProvider, { useCoreState } from './providers/CoreStateProvider'; import SocketProvider from './providers/SocketProvider'; import ThemeProvider from './providers/ThemeProvider'; -import { trackPageView } from './services/analytics'; import { startCoreHealthMonitor, stopCoreHealthMonitor } from './services/coreHealthMonitor'; import { startInternetStatusListener, @@ -149,6 +149,7 @@ function App() { + {!onMobile && } @@ -231,11 +232,6 @@ export function AppShellDesktop() { navigate, ]); - // Track route changes as anonymous page views. - useEffect(() => { - trackPageView(location.pathname); - }, [location.pathname]); - // Hide the active connected-app webview when we navigate away from the chat // surface. Provider CEF selection is intentionally route-independent; any // real route change clears that high-level selection so the native view diff --git a/app/src/components/analytics/AnalyticsTracker.test.tsx b/app/src/components/analytics/AnalyticsTracker.test.tsx new file mode 100644 index 0000000000..40a184f8a6 --- /dev/null +++ b/app/src/components/analytics/AnalyticsTracker.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter, useNavigate } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; + +import { AnalyticsPageTracker, trackAnalyticsEvent, TrackedInteraction } from './AnalyticsTracker'; + +const mocks = vi.hoisted(() => ({ trackEvent: vi.fn(), trackPageView: vi.fn() })); + +vi.mock('../../services/analytics', () => ({ + trackEvent: mocks.trackEvent, + trackPageView: mocks.trackPageView, +})); + +describe('analytics tracking primitives', () => { + it('tracks a page when its path changes', () => { + function PageHarness() { + const navigate = useNavigate(); + return ( + <> + + + + ); + } + + render( + + + + ); + expect(mocks.trackPageView).toHaveBeenLastCalledWith('/chat'); + + fireEvent.click(screen.getByRole('button', { name: 'Navigate' })); + expect(mocks.trackPageView).toHaveBeenLastCalledWith('/flows'); + }); + + it('adds a stable id and preserves the child click handler', () => { + const onClick = vi.fn(); + render( + + + + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(button).toHaveAttribute('data-analytics-id', 'flows-run'); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('can emit a typed semantic click event', () => { + render( + + + + ); + + fireEvent.click(screen.getByRole('button')); + expect(mocks.trackEvent).toHaveBeenCalledWith('account_connect_start', { provider: 'slack' }); + }); + + it('provides the same typed facade for successful non-click outcomes', () => { + trackAnalyticsEvent('chat_message_sent', { send_mode: 'standard' }); + expect(mocks.trackEvent).toHaveBeenCalledWith('chat_message_sent', { send_mode: 'standard' }); + }); +}); diff --git a/app/src/components/analytics/AnalyticsTracker.tsx b/app/src/components/analytics/AnalyticsTracker.tsx new file mode 100644 index 0000000000..c7a7dbbbd9 --- /dev/null +++ b/app/src/components/analytics/AnalyticsTracker.tsx @@ -0,0 +1,67 @@ +import { + Children, + cloneElement, + type ReactElement, + type MouseEvent as ReactMouseEvent, + useEffect, +} from 'react'; +import { useLocation } from 'react-router-dom'; + +import { + type AnalyticsEventName, + type AnalyticsParams, + trackEvent, + trackPageView, +} from '../../services/analytics'; + +interface TrackableChildProps { + 'data-analytics-id'?: string; + onClick?: (event: ReactMouseEvent) => void; +} + +export interface TrackedInteractionProps { + /** Stable, content-free identifier used in analytics dashboards. */ + id: string; + /** Exactly one clickable React element. */ + children: ReactElement; + /** Optional semantic event in addition to the automatic `ui_click`. */ + event?: AnalyticsEventName; + /** Privacy-safe event dimensions. Never pass user-authored content. */ + properties?: AnalyticsParams; +} + +/** + * Standard wrapper for clickable UI. + * + * It stamps the child with the identifier consumed by the app-wide delegated + * interaction tracker, while preserving the child's existing click handler. + * Feature code only needs an explicit `event` for a semantic click-attempt; + * successful lifecycle outcomes should use {@link trackAnalyticsEvent} after + * the operation succeeds. + */ +export function TrackedInteraction({ id, children, event, properties }: TrackedInteractionProps) { + const child = Children.only(children); + return cloneElement(child, { + 'data-analytics-id': id, + onClick: (clickEvent: ReactMouseEvent) => { + child.props.onClick?.(clickEvent); + if (event && !clickEvent.defaultPrevented) { + trackEvent(event, properties); + } + }, + }); +} + +/** Standard route-view tracker. Mount once inside the active router. */ +export function AnalyticsPageTracker() { + const { pathname } = useLocation(); + useEffect(() => { + trackPageView(pathname); + }, [pathname]); + return null; +} + +/** Typed facade for successful non-click outcomes such as a sent message. */ +export function trackAnalyticsEvent(event: AnalyticsEventName, properties?: AnalyticsParams): void { + trackEvent(event, properties); +} diff --git a/app/src/components/analytics/index.ts b/app/src/components/analytics/index.ts new file mode 100644 index 0000000000..e7622db05b --- /dev/null +++ b/app/src/components/analytics/index.ts @@ -0,0 +1,6 @@ +export { + AnalyticsPageTracker, + TrackedInteraction, + trackAnalyticsEvent, + type TrackedInteractionProps, +} from './AnalyticsTracker'; diff --git a/app/src/components/flows/FlowListRow.tsx b/app/src/components/flows/FlowListRow.tsx index 5d5b672a07..28616e50d6 100644 --- a/app/src/components/flows/FlowListRow.tsx +++ b/app/src/components/flows/FlowListRow.tsx @@ -22,6 +22,7 @@ */ import { useT } from '../../lib/i18n/I18nContext'; import type { Flow } from '../../services/api/flowsApi'; +import { TrackedInteraction } from '../analytics'; import SettingsSwitch from '../settings/controls/SettingsSwitch'; import Button from '../ui/Button'; import FlowRowMenu from './FlowRowMenu'; @@ -141,15 +142,17 @@ const FlowListRow = ({ onClick={() => onViewRuns(flow)}> {t('flows.list.viewRuns')} - + + +
- + + + {selectedWorkflow && selectedWorkflow.scope === 'user' && !selectedWorkflow.legacy && ( @@ -1332,13 +1335,15 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr testIdRoot={`scheduled-job-${job.id}`} actions={ <> - + + + + + + ); const headerActions = ( diff --git a/app/src/services/__tests__/analytics.test.ts b/app/src/services/__tests__/analytics.test.ts index 8a14598dc6..f16faa23c4 100644 --- a/app/src/services/__tests__/analytics.test.ts +++ b/app/src/services/__tests__/analytics.test.ts @@ -580,6 +580,19 @@ describe('trackPageView (OpenPanel)', () => { expect(openPanelPayload().payload.properties.__timestamp).toEqual(expect.any(String)); }); + test('records route templates without entity ids or query values', async () => { + window.location.hash = '#/chat/thread-private-123?tab=files'; + const { initGA, trackPageView } = await freshAnalytics(); + initGA(); + trackPageView('/chat/thread-private-123'); + + expect(openPanelPayload().payload.properties).toMatchObject({ + page: '/chat/:threadId', + page_hash: '#/chat/:threadId', + __path: '/chat/:threadId', + }); + }); + test('is a no-op when consent is off', async () => { const { initGA, syncAnalyticsConsent, trackPageView } = await freshAnalytics(); initGA(); @@ -601,6 +614,7 @@ describe('trackEvent (OpenPanel)', () => { hoisted.appEnvironment = 'staging'; hoisted.currentUserId = null; hoisted.isDev = false; + window.location.hash = ''; }); afterEach(() => { @@ -726,6 +740,23 @@ describe('startUiInteractionTracking', () => { stop(); }); + test('distinguishes unlabelled controls without reading their text', async () => { + const { initGA, startUiInteractionTracking } = await freshAnalytics(); + initGA(); + const stop = startUiInteractionTracking(); + const first = document.createElement('button'); + first.textContent = 'Private workspace name'; + const second = document.createElement('button'); + second.textContent = 'Another private value'; + document.body.append(first, second); + + second.click(); + + expect(openPanelPayload().payload.properties.control_id).toBe('button_2'); + expect(JSON.stringify(openPanelPayload())).not.toContain('private'); + stop(); + }); + test('tracks checkbox/radio/select style control changes without raw values', async () => { const { initGA, startUiInteractionTracking } = await freshAnalytics(); initGA(); diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index 6dd0fc29db..c341ab3e0a 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -69,7 +69,7 @@ let opInitialized = false; let analyticsConsentSynced = false; let uiInteractionTrackingStarted = false; -type AnalyticsParams = Record; +export type AnalyticsParams = Record; interface PendingAnalyticsEvent { type: 'event' | 'page_view'; @@ -93,7 +93,7 @@ let analyticsEnabled = false; * Any `trackEvent` call with a name not in this set is dropped and a warning * is logged. */ -export const ALLOWED_EVENTS = new Set([ +const ALLOWED_EVENT_NAMES = [ 'app_open', 'onboarding_start', 'onboarding_step_complete', @@ -101,6 +101,9 @@ export const ALLOWED_EVENTS = new Set([ 'account_connect_start', 'account_connect_success', 'chat_message_sent', + 'automation_run_started', + 'automation_run_resumed', + 'automation_run_cancelled', 'skill_install', 'skill_uninstall', 'tab_bar_change', @@ -108,7 +111,10 @@ export const ALLOWED_EVENTS = new Set([ 'ui_click', 'ui_control_change', 'ui_form_submit', -]); +] as const; + +export type AnalyticsEventName = (typeof ALLOWED_EVENT_NAMES)[number]; +export const ALLOWED_EVENTS: ReadonlySet = new Set(ALLOWED_EVENT_NAMES); /** Check if the current user has opted into analytics. */ export function isAnalyticsEnabled(): boolean { @@ -512,7 +518,15 @@ function controlIdentifier(element: HTMLElement): string { const container = nearestStableContainer(element); const tag = element.tagName.toLowerCase(); if (container) return `${tag}_in_${container}`; - return tag; + // Every native/shared button is still captured even when its call site has + // not yet received a semantic data-analytics-id. Keep those controls + // distinguishable without reading their text/aria-label (both can contain + // user content) by assigning a page-local, DOM-order fallback. + const peers = Array.from(document.querySelectorAll(INTERACTIVE_CLICK_SELECTOR)).filter( + peer => peer instanceof HTMLElement && !shouldSkipInteractionElement(peer) + ); + const position = peers.indexOf(element); + return position >= 0 ? `${tag}_${position + 1}` : tag; } function destinationForElement(element: HTMLElement): string { @@ -585,15 +599,43 @@ function currentAppPath(): string { function currentPageHash(): string { if (typeof window === 'undefined') return ''; - return window.location.hash.startsWith('#/') ? window.location.hash : ''; + return window.location.hash.startsWith('#/') ? `#${currentAppPath()}` : ''; } function normalizeAnalyticsPagePath(path: string): string { - if (typeof window !== 'undefined' && window.location.hash.startsWith('#/')) { - return hashToPath(window.location.hash); + const rawPath = + typeof window !== 'undefined' && window.location.hash.startsWith('#/') + ? hashToPath(window.location.hash) + : path.startsWith('#/') + ? hashToPath(path) + : path || '/'; + + // Analytics records route templates, never entity identifiers or query/hash + // values. Besides avoiding high-cardinality dashboards, this prevents thread, + // flow, profile, and team identifiers from leaving the app. + const pathname = rawPath.split(/[?#]/, 1)[0] || '/'; + if (/^\/chat\/[^/]+/.test(pathname)) return '/chat/:threadId'; + if (/^\/flows\/[^/]+/.test(pathname) && pathname !== '/flows/draft') { + return '/flows/:flowId'; + } + if (/^\/settings\/team\/manage\/[^/]+\/members$/.test(pathname)) { + return '/settings/team/manage/:teamId/members'; + } + if (/^\/settings\/team\/manage\/[^/]+\/invites$/.test(pathname)) { + return '/settings/team/manage/:teamId/invites'; + } + if (/^\/settings\/team\/manage\/[^/]+$/.test(pathname)) { + return '/settings/team/manage/:teamId'; + } + if (/^\/settings\/agents\/edit\/[^/]+$/.test(pathname)) { + return '/settings/agents/edit/:id'; + } + if (/^\/settings\/profiles\/edit\/[^/]+$/.test(pathname)) { + return '/settings/profiles/edit/:id'; } - if (path.startsWith('#/')) return hashToPath(path); - return path || '/'; + if (/^\/callback\/[^/]+\/[^/]+$/.test(pathname)) return '/callback/:kind/:status'; + if (/^\/callback\/[^/]+$/.test(pathname)) return '/callback/:kind'; + return pathname; } function hashToPath(hash: string): string { diff --git a/app/src/services/api/flowsApi.ts b/app/src/services/api/flowsApi.ts index 31bc2e8d03..a9e7c6b228 100644 --- a/app/src/services/api/flowsApi.ts +++ b/app/src/services/api/flowsApi.ts @@ -28,6 +28,7 @@ */ import debug from 'debug'; +import { trackAnalyticsEvent } from '../../components/analytics'; import type { WorkflowGraph } from '../../lib/flows/types'; import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; import { callCoreRpc } from '../coreRpcClient'; @@ -307,6 +308,7 @@ export async function resumeFlow( result.thread_id, result.pending_approvals?.length ?? 0 ); + trackAnalyticsEvent('automation_run_resumed', { automation_kind: 'flow' }); return result; } @@ -410,6 +412,7 @@ export async function runFlow(id: string, input?: unknown): Promise({ method: 'openhuman.workflow_run_start', params }); log('startRun: id=%s status=%s', result.workflowRun.id, result.workflowRun.status); + trackAnalyticsEvent('automation_run_started', { automation_kind: 'orchestration' }); return result.workflowRun; }, @@ -244,6 +246,9 @@ export const workflowRunsApi = { params: { id }, }); log('stopRun: status=%s', result?.workflowRun?.status ?? 'null'); + if (result?.workflowRun) { + trackAnalyticsEvent('automation_run_cancelled', { automation_kind: 'orchestration' }); + } return result?.workflowRun ?? null; }, @@ -255,6 +260,7 @@ export const workflowRunsApi = { params: { id }, }); log('resumeRun: status=%s', result.workflowRun.status); + trackAnalyticsEvent('automation_run_resumed', { automation_kind: 'orchestration' }); return result.workflowRun; }, }; diff --git a/app/test/e2e/specs/flows.spec.ts b/app/test/e2e/specs/flows.spec.ts index c92b30daa0..6592c26cae 100644 --- a/app/test/e2e/specs/flows.spec.ts +++ b/app/test/e2e/specs/flows.spec.ts @@ -116,6 +116,12 @@ describe('Workflows create → run → inspect (real UI flow)', () => { // for a persisted, non-draft flow). await waitForTestId('flow-canvas-page', 15_000); await waitForTestId('flow-canvas-run', 15_000); + + // All clickable analytics use the shared TrackedInteraction contract. + // Assert the stable, content-free identifier reaches the real DOM rather + // than leaking the generated flow id into the analytics dimension. + const runButton = await browser.$('[data-testid="flow-canvas-run"]'); + expect(await runButton.getAttribute('data-analytics-id')).toBe('flow-canvas-run'); }); it('runs the flow from the canvas without error', async function () { From bd214e6833e8865540e4f04f1d23cfe2be056d5a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 04:21:45 +0000 Subject: [PATCH 03/14] refactor(analytics): track shared buttons directly --- AGENTS.md | 2 +- .../analytics/AnalyticsTracker.test.tsx | 33 +------------ .../components/analytics/AnalyticsTracker.tsx | 46 +------------------ app/src/components/analytics/index.ts | 7 +-- app/src/components/flows/FlowListRow.tsx | 22 ++++----- .../components/skills/WorkflowRunnerBody.tsx | 41 ++++++++--------- app/src/components/ui/Button.test.tsx | 7 +++ app/src/components/ui/Button.tsx | 10 +++- app/src/pages/FlowCanvasPage.tsx | 22 ++++----- 9 files changed, 59 insertions(+), 131 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b97a554c84..ff4578a923 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,7 +153,7 @@ No `UserProvider`/`AIProvider`/`SkillProvider` — auth lives in `CoreStateProvi **Services** (`services/`): `apiClient`, `socketService`, `coreRpcClient`, `coreCommandClient`, `chatService`, `analytics`, `notificationService`, `webviewAccountService`, `daemonHealthService`, plus domain `api/*` clients. Always use `coreRpcClient` (which invokes the `relay_http_rpc` Tauri command) for core RPC. -**Analytics**: use the standardized exports from `components/analytics` — `AnalyticsPageTracker` once inside the router, `TrackedInteraction` for clickable UI, and `trackAnalyticsEvent` for successful domain outcomes (messages, automation runs, connections, etc.). Use stable content-free identifiers and privacy-safe dimensions only; never send user-authored text, entity IDs, filenames, credentials, or error messages. `services/analytics.ts` is the consent/provider implementation, not the feature-code API. +**Analytics**: use `Button analyticsId="stable-content-free-id"` for shared button interactions, `AnalyticsPageTracker` once inside the router, and `trackAnalyticsEvent` from `components/analytics` for successful domain outcomes (messages, automation runs, connections, etc.). Native controls and links may use `data-analytics-id` directly. Use privacy-safe dimensions only; never send user-authored text, entity IDs, filenames, credentials, or error messages. `services/analytics.ts` is the consent/provider implementation, not the feature-code API. **Routing** (`AppRoutes.tsx`, HashRouter): `/` (Welcome), `/auth`, `/onboarding/*`, `/chat/:threadId?`, `/human`, `/brain` (+ `/brain/tinyplace-orchestration`), `/orchestration`, `/connections`, `/flows` (+ `/flows/:id`, `/flows/draft`), `/agent-world/*`, `/invites`, `/notifications`, `/rewards`, `/settings/*`, `/feedback`. Back-compat redirects: `/home`→`/chat`, `/skills`→`/connections`, `/channels`→`/connections?tab=messaging`, `/intelligence` & `/activity`→`/settings/notifications`, `/routines` & `/workflows`→`/settings/automations`, `/webhooks`→`/settings/integrations#webhooks`. No `/login`, `/mnemonic`, `/agents`, `/conversations`. diff --git a/app/src/components/analytics/AnalyticsTracker.test.tsx b/app/src/components/analytics/AnalyticsTracker.test.tsx index 40a184f8a6..74d4d7fd7d 100644 --- a/app/src/components/analytics/AnalyticsTracker.test.tsx +++ b/app/src/components/analytics/AnalyticsTracker.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { MemoryRouter, useNavigate } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; -import { AnalyticsPageTracker, trackAnalyticsEvent, TrackedInteraction } from './AnalyticsTracker'; +import { AnalyticsPageTracker, trackAnalyticsEvent } from './AnalyticsTracker'; const mocks = vi.hoisted(() => ({ trackEvent: vi.fn(), trackPageView: vi.fn() })); @@ -36,37 +36,6 @@ describe('analytics tracking primitives', () => { expect(mocks.trackPageView).toHaveBeenLastCalledWith('/flows'); }); - it('adds a stable id and preserves the child click handler', () => { - const onClick = vi.fn(); - render( - - - - ); - - const button = screen.getByRole('button'); - fireEvent.click(button); - - expect(button).toHaveAttribute('data-analytics-id', 'flows-run'); - expect(onClick).toHaveBeenCalledTimes(1); - }); - - it('can emit a typed semantic click event', () => { - render( - - - - ); - - fireEvent.click(screen.getByRole('button')); - expect(mocks.trackEvent).toHaveBeenCalledWith('account_connect_start', { provider: 'slack' }); - }); - it('provides the same typed facade for successful non-click outcomes', () => { trackAnalyticsEvent('chat_message_sent', { send_mode: 'standard' }); expect(mocks.trackEvent).toHaveBeenCalledWith('chat_message_sent', { send_mode: 'standard' }); diff --git a/app/src/components/analytics/AnalyticsTracker.tsx b/app/src/components/analytics/AnalyticsTracker.tsx index c7a7dbbbd9..d58e7345a4 100644 --- a/app/src/components/analytics/AnalyticsTracker.tsx +++ b/app/src/components/analytics/AnalyticsTracker.tsx @@ -1,10 +1,4 @@ -import { - Children, - cloneElement, - type ReactElement, - type MouseEvent as ReactMouseEvent, - useEffect, -} from 'react'; +import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import { @@ -14,44 +8,6 @@ import { trackPageView, } from '../../services/analytics'; -interface TrackableChildProps { - 'data-analytics-id'?: string; - onClick?: (event: ReactMouseEvent) => void; -} - -export interface TrackedInteractionProps { - /** Stable, content-free identifier used in analytics dashboards. */ - id: string; - /** Exactly one clickable React element. */ - children: ReactElement; - /** Optional semantic event in addition to the automatic `ui_click`. */ - event?: AnalyticsEventName; - /** Privacy-safe event dimensions. Never pass user-authored content. */ - properties?: AnalyticsParams; -} - -/** - * Standard wrapper for clickable UI. - * - * It stamps the child with the identifier consumed by the app-wide delegated - * interaction tracker, while preserving the child's existing click handler. - * Feature code only needs an explicit `event` for a semantic click-attempt; - * successful lifecycle outcomes should use {@link trackAnalyticsEvent} after - * the operation succeeds. - */ -export function TrackedInteraction({ id, children, event, properties }: TrackedInteractionProps) { - const child = Children.only(children); - return cloneElement(child, { - 'data-analytics-id': id, - onClick: (clickEvent: ReactMouseEvent) => { - child.props.onClick?.(clickEvent); - if (event && !clickEvent.defaultPrevented) { - trackEvent(event, properties); - } - }, - }); -} - /** Standard route-view tracker. Mount once inside the active router. */ export function AnalyticsPageTracker() { const { pathname } = useLocation(); diff --git a/app/src/components/analytics/index.ts b/app/src/components/analytics/index.ts index e7622db05b..a7caf9cca8 100644 --- a/app/src/components/analytics/index.ts +++ b/app/src/components/analytics/index.ts @@ -1,6 +1 @@ -export { - AnalyticsPageTracker, - TrackedInteraction, - trackAnalyticsEvent, - type TrackedInteractionProps, -} from './AnalyticsTracker'; +export { AnalyticsPageTracker, trackAnalyticsEvent } from './AnalyticsTracker'; diff --git a/app/src/components/flows/FlowListRow.tsx b/app/src/components/flows/FlowListRow.tsx index 28616e50d6..84fe7f3dd1 100644 --- a/app/src/components/flows/FlowListRow.tsx +++ b/app/src/components/flows/FlowListRow.tsx @@ -22,7 +22,6 @@ */ import { useT } from '../../lib/i18n/I18nContext'; import type { Flow } from '../../services/api/flowsApi'; -import { TrackedInteraction } from '../analytics'; import SettingsSwitch from '../settings/controls/SettingsSwitch'; import Button from '../ui/Button'; import FlowRowMenu from './FlowRowMenu'; @@ -142,17 +141,16 @@ const FlowListRow = ({ onClick={() => onViewRuns(flow)}> {t('flows.list.viewRuns')} - - - +
- - - + {selectedWorkflow && selectedWorkflow.scope === 'user' && !selectedWorkflow.legacy && ( @@ -1335,15 +1333,14 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr testIdRoot={`scheduled-job-${job.id}`} actions={ <> - - - + ); + const btn = screen.getByRole('button', { name: 'Run' }); + expect(btn).toHaveAttribute('data-analytics-id', 'flows-run'); + expect(btn).not.toHaveAttribute('analyticsId'); + }); + it('respects disabled: does not fire onClick and has disabled attr', () => { const onClick = vi.fn(); render( diff --git a/app/src/components/ui/Button.tsx b/app/src/components/ui/Button.tsx index 5c56145e92..9972f75243 100644 --- a/app/src/components/ui/Button.tsx +++ b/app/src/components/ui/Button.tsx @@ -26,6 +26,8 @@ export interface ButtonProps extends ButtonHTMLAttributes { iconOnly?: boolean; leadingIcon?: ReactNode; trailingIcon?: ReactNode; + /** Stable, content-free identifier consumed by the app-wide analytics tracker. */ + analyticsId?: string; } const BASE = @@ -84,6 +86,7 @@ const Button = forwardRef((props, ref) => { iconOnly = false, leadingIcon, trailingIcon, + analyticsId, className, type, children, @@ -96,7 +99,12 @@ const Button = forwardRef((props, ref) => { .join(' '); return ( - - + ); const headerActions = ( From 642b1ae6efcb26ad30b53810baa11c61779ffa3a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 04:34:52 +0000 Subject: [PATCH 04/14] fix(analytics): address review feedback --- app/src/services/__tests__/analytics.test.ts | 21 ++ app/src/services/analytics.ts | 221 ++----------------- app/src/services/analyticsInteractions.ts | 168 ++++++++++++++ app/src/services/analyticsRoutes.ts | 54 +++++ app/test/e2e/specs/flows.spec.ts | 4 +- 5 files changed, 259 insertions(+), 209 deletions(-) create mode 100644 app/src/services/analyticsInteractions.ts create mode 100644 app/src/services/analyticsRoutes.ts diff --git a/app/src/services/__tests__/analytics.test.ts b/app/src/services/__tests__/analytics.test.ts index f16faa23c4..05363aee02 100644 --- a/app/src/services/__tests__/analytics.test.ts +++ b/app/src/services/__tests__/analytics.test.ts @@ -601,6 +601,22 @@ describe('trackPageView (OpenPanel)', () => { expect(fetch).not.toHaveBeenCalled(); }); + test('suppresses synchronous analytics provider failures', async () => { + hoisted.analyticsEnabled = true; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { initGA, trackEvent } = await freshAnalytics(); + initGA(); + window.gtag = vi.fn(() => { + throw new Error('provider unavailable'); + }); + + expect(() => trackEvent('app_open')).not.toThrow(); + expect(warn).toHaveBeenCalledWith('[analytics] trackEvent failed', { + eventName: 'app_open', + error: 'Error', + }); + }); + test('is a no-op when OpenPanel was never initialized', async () => { const { trackPageView } = await freshAnalytics(); trackPageView('/home'); @@ -741,6 +757,7 @@ describe('startUiInteractionTracking', () => { }); test('distinguishes unlabelled controls without reading their text', async () => { + const debug = vi.spyOn(console, 'debug').mockImplementation(() => undefined); const { initGA, startUiInteractionTracking } = await freshAnalytics(); initGA(); const stop = startUiInteractionTracking(); @@ -754,6 +771,10 @@ describe('startUiInteractionTracking', () => { expect(openPanelPayload().payload.properties.control_id).toBe('button_2'); expect(JSON.stringify(openPanelPayload())).not.toContain('private'); + expect(debug).toHaveBeenCalledWith('[analytics] controlIdentifier fallback', { + tag: 'button', + position: 1, + }); stop(); }); diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index c341ab3e0a..018a256e19 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -35,6 +35,8 @@ import { SUPPORT_URL, TAURI_CARGO_VERSION, } from '../utils/config'; +import { startInteractionTracking } from './analyticsInteractions'; +import { currentAppPath, currentPageHash, normalizeAnalyticsPagePath } from './analyticsRoutes'; import { CoreRpcError } from './coreRpcClient'; // --------------------------------------------------------------------------- @@ -55,10 +57,6 @@ declare global { const OPENPANEL_TRACK_URL = `${OPENPANEL_API_URL}/track`; const MAX_PENDING_ANALYTICS_EVENTS = 20; -const INTERACTIVE_CLICK_SELECTOR = - 'button,a,[role="button"],summary,[data-track],[data-analytics-id],[data-testid],[data-walkthrough]'; -const CONTROL_CHANGE_SELECTOR = - 'select,input[type="checkbox"],input[type="radio"],input[type="range"],[role="switch"],[role="checkbox"],[role="radio"]'; // --------------------------------------------------------------------------- // Module-level state @@ -67,7 +65,6 @@ const CONTROL_CHANGE_SELECTOR = let gaInitialized = false; let opInitialized = false; let analyticsConsentSynced = false; -let uiInteractionTrackingStarted = false; export type AnalyticsParams = Record; @@ -395,6 +392,17 @@ export function trackPageView(path: string): void { * are dropped and a console warning is emitted. */ export function trackEvent(eventName: string, params?: AnalyticsParams): void { + try { + trackEventUnsafe(eventName, params); + } catch (error) { + console.warn('[analytics] trackEvent failed', { + eventName, + error: error instanceof Error ? error.name : typeof error, + }); + } +} + +function trackEventUnsafe(eventName: string, params?: AnalyticsParams): void { if (!ALLOWED_EVENTS.has(eventName)) { console.warn( `[analytics] trackEvent dropped — '${eventName}' is not in ALLOWED_EVENTS allowlist` @@ -419,56 +427,7 @@ export function trackEvent(eventName: string, params?: AnalyticsParams): void { } export function startUiInteractionTracking(): () => void { - if (uiInteractionTrackingStarted || typeof document === 'undefined') return () => undefined; - uiInteractionTrackingStarted = true; - - const handleClick = (event: MouseEvent) => { - const target = event.target instanceof Element ? event.target : null; - const element = target?.closest(INTERACTIVE_CLICK_SELECTOR); - if (!(element instanceof HTMLElement) || shouldSkipInteractionElement(element)) return; - - trackEvent('ui_click', { - ...interactionBaseProperties(element), - interaction_kind: 'click', - control_id: controlIdentifier(element), - destination: destinationForElement(element), - }); - }; - - const handleChange = (event: Event) => { - const target = event.target instanceof Element ? event.target : null; - const element = target?.closest(CONTROL_CHANGE_SELECTOR); - if (!(element instanceof HTMLElement) || shouldSkipInteractionElement(element)) return; - - trackEvent('ui_control_change', { - ...interactionBaseProperties(element), - interaction_kind: 'change', - control_id: controlIdentifier(element), - control_state: controlState(element), - }); - }; - - const handleSubmit = (event: SubmitEvent) => { - const form = event.target instanceof HTMLFormElement ? event.target : null; - if (!form || shouldSkipInteractionElement(form)) return; - - trackEvent('ui_form_submit', { - ...interactionBaseProperties(form), - interaction_kind: 'submit', - control_id: controlIdentifier(form), - }); - }; - - document.addEventListener('click', handleClick, true); - document.addEventListener('change', handleChange, true); - document.addEventListener('submit', handleSubmit, true); - - return () => { - document.removeEventListener('click', handleClick, true); - document.removeEventListener('change', handleChange, true); - document.removeEventListener('submit', handleSubmit, true); - uiInteractionTrackingStarted = false; - }; + return startInteractionTracking(trackEvent); } function queuePendingAnalyticsEvent(event: PendingAnalyticsEvent): void { @@ -491,158 +450,6 @@ function flushPendingAnalyticsEvents(): void { } } -function interactionBaseProperties(element: HTMLElement): AnalyticsParams { - return { - page: currentAppPath(), - page_hash: currentPageHash(), - element_tag: element.tagName.toLowerCase(), - element_role: scrubIdentifier(element.getAttribute('role')) ?? '', - element_type: scrubIdentifier(element.getAttribute('type')) ?? '', - }; -} - -function controlIdentifier(element: HTMLElement): string { - const explicit = - element.getAttribute('data-analytics-id') ?? - element.getAttribute('data-track') ?? - element.getAttribute('data-testid') ?? - element.getAttribute('data-walkthrough') ?? - element.getAttribute('name') ?? - element.id; - const scrubbed = scrubIdentifier(explicit); - if (scrubbed) return scrubbed; - - const hrefDestination = destinationForElement(element); - if (hrefDestination) return `link_${scrubIdentifier(hrefDestination) ?? 'internal'}`; - - const container = nearestStableContainer(element); - const tag = element.tagName.toLowerCase(); - if (container) return `${tag}_in_${container}`; - // Every native/shared button is still captured even when its call site has - // not yet received a semantic data-analytics-id. Keep those controls - // distinguishable without reading their text/aria-label (both can contain - // user content) by assigning a page-local, DOM-order fallback. - const peers = Array.from(document.querySelectorAll(INTERACTIVE_CLICK_SELECTOR)).filter( - peer => peer instanceof HTMLElement && !shouldSkipInteractionElement(peer) - ); - const position = peers.indexOf(element); - return position >= 0 ? `${tag}_${position + 1}` : tag; -} - -function destinationForElement(element: HTMLElement): string { - const href = element instanceof HTMLAnchorElement ? element.getAttribute('href') : null; - if (!href) return ''; - if (href.startsWith('#/')) return href.slice(1); - if (href.startsWith('/')) return href; - return href.startsWith('http') ? 'external' : ''; -} - -function controlState(element: HTMLElement): string { - if (element instanceof HTMLInputElement) { - if (element.type === 'checkbox' || element.type === 'radio') { - return element.checked ? 'checked' : 'unchecked'; - } - if (element.type === 'range') return 'changed'; - } - if (element instanceof HTMLSelectElement) return 'selected'; - - const ariaChecked = element.getAttribute('aria-checked'); - if (ariaChecked === 'true' || ariaChecked === 'false' || ariaChecked === 'mixed') { - return ariaChecked; - } - return 'changed'; -} - -function nearestStableContainer(element: HTMLElement): string | undefined { - const container = element.closest('[data-testid],[data-walkthrough],[data-analytics-id]'); - if (!(container instanceof HTMLElement) || container === element) return undefined; - return scrubIdentifier( - container.getAttribute('data-analytics-id') ?? - container.getAttribute('data-testid') ?? - container.getAttribute('data-walkthrough') - ); -} - -function shouldSkipInteractionElement(element: HTMLElement): boolean { - if (element.closest('[data-analytics-skip="true"],[data-no-analytics="true"]')) return true; - if (element.closest('[contenteditable="true"]')) return true; - if (element instanceof HTMLInputElement) { - return ['text', 'search', 'email', 'password', 'tel', 'url', 'number', 'file'].includes( - element.type - ); - } - if (element instanceof HTMLTextAreaElement) return true; - return false; -} - -function scrubIdentifier(value: string | null | undefined): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) return undefined; - const withoutQuery = trimmed.split(/[?#]/)[0] ?? trimmed; - const scrubbed = withoutQuery - .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, ':email') - .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id') - .replace(/\b[0-9a-f]{16,}\b/gi, ':id') - .replace(/\b\d{3,}\b/g, ':num') - .replace(/[^a-zA-Z0-9:_/-]+/g, '_') - .replace(/_+/g, '_') - .replace(/^_+|_+$/g, '') - .toLowerCase() - .slice(0, 80); - return scrubbed || undefined; -} - -function currentAppPath(): string { - if (typeof window === 'undefined') return ''; - return normalizeAnalyticsPagePath(window.location.pathname); -} - -function currentPageHash(): string { - if (typeof window === 'undefined') return ''; - return window.location.hash.startsWith('#/') ? `#${currentAppPath()}` : ''; -} - -function normalizeAnalyticsPagePath(path: string): string { - const rawPath = - typeof window !== 'undefined' && window.location.hash.startsWith('#/') - ? hashToPath(window.location.hash) - : path.startsWith('#/') - ? hashToPath(path) - : path || '/'; - - // Analytics records route templates, never entity identifiers or query/hash - // values. Besides avoiding high-cardinality dashboards, this prevents thread, - // flow, profile, and team identifiers from leaving the app. - const pathname = rawPath.split(/[?#]/, 1)[0] || '/'; - if (/^\/chat\/[^/]+/.test(pathname)) return '/chat/:threadId'; - if (/^\/flows\/[^/]+/.test(pathname) && pathname !== '/flows/draft') { - return '/flows/:flowId'; - } - if (/^\/settings\/team\/manage\/[^/]+\/members$/.test(pathname)) { - return '/settings/team/manage/:teamId/members'; - } - if (/^\/settings\/team\/manage\/[^/]+\/invites$/.test(pathname)) { - return '/settings/team/manage/:teamId/invites'; - } - if (/^\/settings\/team\/manage\/[^/]+$/.test(pathname)) { - return '/settings/team/manage/:teamId'; - } - if (/^\/settings\/agents\/edit\/[^/]+$/.test(pathname)) { - return '/settings/agents/edit/:id'; - } - if (/^\/settings\/profiles\/edit\/[^/]+$/.test(pathname)) { - return '/settings/profiles/edit/:id'; - } - if (/^\/callback\/[^/]+\/[^/]+$/.test(pathname)) return '/callback/:kind/:status'; - if (/^\/callback\/[^/]+$/.test(pathname)) return '/callback/:kind'; - return pathname; -} - -function hashToPath(hash: string): string { - const withoutHash = hash.slice(1); - return withoutHash || '/'; -} - async function sendOpenPanelTrack(eventName: string, params?: AnalyticsParams): Promise { const profileId = currentAnalyticsUserId(); const properties = { diff --git a/app/src/services/analyticsInteractions.ts b/app/src/services/analyticsInteractions.ts new file mode 100644 index 0000000000..040bcd063d --- /dev/null +++ b/app/src/services/analyticsInteractions.ts @@ -0,0 +1,168 @@ +import type { AnalyticsParams } from './analytics'; +import { currentAppPath, currentPageHash } from './analyticsRoutes'; + +const INTERACTIVE_CLICK_SELECTOR = + 'button,a,[role="button"],summary,[data-track],[data-analytics-id],[data-testid],[data-walkthrough]'; +const CONTROL_CHANGE_SELECTOR = + 'select,input[type="checkbox"],input[type="radio"],input[type="range"],[role="switch"],[role="checkbox"],[role="radio"]'; + +type InteractionEventName = 'ui_click' | 'ui_control_change' | 'ui_form_submit'; +type InteractionTracker = (eventName: InteractionEventName, params: AnalyticsParams) => void; + +let trackingStarted = false; + +/** Install app-wide delegated listeners for privacy-safe UI interaction events. */ +export function startInteractionTracking(track: InteractionTracker): () => void { + if (trackingStarted || typeof document === 'undefined') return () => undefined; + trackingStarted = true; + + const handleClick = (event: MouseEvent) => { + const target = event.target instanceof Element ? event.target : null; + const element = target?.closest(INTERACTIVE_CLICK_SELECTOR); + if (!(element instanceof HTMLElement) || shouldSkipInteractionElement(element)) return; + + track('ui_click', { + ...interactionBaseProperties(element), + interaction_kind: 'click', + control_id: controlIdentifier(element), + destination: destinationForElement(element), + }); + }; + + const handleChange = (event: Event) => { + const target = event.target instanceof Element ? event.target : null; + const element = target?.closest(CONTROL_CHANGE_SELECTOR); + if (!(element instanceof HTMLElement) || shouldSkipInteractionElement(element)) return; + + track('ui_control_change', { + ...interactionBaseProperties(element), + interaction_kind: 'change', + control_id: controlIdentifier(element), + control_state: controlState(element), + }); + }; + + const handleSubmit = (event: SubmitEvent) => { + const form = event.target instanceof HTMLFormElement ? event.target : null; + if (!form || shouldSkipInteractionElement(form)) return; + + track('ui_form_submit', { + ...interactionBaseProperties(form), + interaction_kind: 'submit', + control_id: controlIdentifier(form), + }); + }; + + document.addEventListener('click', handleClick, true); + document.addEventListener('change', handleChange, true); + document.addEventListener('submit', handleSubmit, true); + + return () => { + document.removeEventListener('click', handleClick, true); + document.removeEventListener('change', handleChange, true); + document.removeEventListener('submit', handleSubmit, true); + trackingStarted = false; + }; +} + +function interactionBaseProperties(element: HTMLElement): AnalyticsParams { + return { + page: currentAppPath(), + page_hash: currentPageHash(), + element_tag: element.tagName.toLowerCase(), + element_role: scrubIdentifier(element.getAttribute('role')) ?? '', + element_type: scrubIdentifier(element.getAttribute('type')) ?? '', + }; +} + +function controlIdentifier(element: HTMLElement): string { + const explicit = + element.getAttribute('data-analytics-id') ?? + element.getAttribute('data-track') ?? + element.getAttribute('data-testid') ?? + element.getAttribute('data-walkthrough') ?? + element.getAttribute('name') ?? + element.id; + const scrubbed = scrubIdentifier(explicit); + if (scrubbed) return scrubbed; + + const hrefDestination = destinationForElement(element); + if (hrefDestination) return `link_${scrubIdentifier(hrefDestination) ?? 'internal'}`; + + const container = nearestStableContainer(element); + const tag = element.tagName.toLowerCase(); + if (container) return `${tag}_in_${container}`; + // Every native/shared button is still captured even when its call site has + // not yet received a semantic data-analytics-id. Keep those controls + // distinguishable without reading their text/aria-label (both can contain + // user content) by assigning a page-local, DOM-order fallback. + const peers = Array.from(document.querySelectorAll(INTERACTIVE_CLICK_SELECTOR)).filter( + peer => peer instanceof HTMLElement && !shouldSkipInteractionElement(peer) + ); + const position = peers.indexOf(element); + console.debug('[analytics] controlIdentifier fallback', { tag, position }); + return position >= 0 ? `${tag}_${position + 1}` : tag; +} + +function destinationForElement(element: HTMLElement): string { + const href = element instanceof HTMLAnchorElement ? element.getAttribute('href') : null; + if (!href) return ''; + if (href.startsWith('#/')) return href.slice(1); + if (href.startsWith('/')) return href; + return href.startsWith('http') ? 'external' : ''; +} + +function controlState(element: HTMLElement): string { + if (element instanceof HTMLInputElement) { + if (element.type === 'checkbox' || element.type === 'radio') { + return element.checked ? 'checked' : 'unchecked'; + } + if (element.type === 'range') return 'changed'; + } + if (element instanceof HTMLSelectElement) return 'selected'; + + const ariaChecked = element.getAttribute('aria-checked'); + if (ariaChecked === 'true' || ariaChecked === 'false' || ariaChecked === 'mixed') { + return ariaChecked; + } + return 'changed'; +} + +function nearestStableContainer(element: HTMLElement): string | undefined { + const container = element.closest('[data-testid],[data-walkthrough],[data-analytics-id]'); + if (!(container instanceof HTMLElement) || container === element) return undefined; + return scrubIdentifier( + container.getAttribute('data-analytics-id') ?? + container.getAttribute('data-testid') ?? + container.getAttribute('data-walkthrough') + ); +} + +function shouldSkipInteractionElement(element: HTMLElement): boolean { + if (element.closest('[data-analytics-skip="true"],[data-no-analytics="true"]')) return true; + if (element.closest('[contenteditable="true"]')) return true; + if (element instanceof HTMLInputElement) { + return ['text', 'search', 'email', 'password', 'tel', 'url', 'number', 'file'].includes( + element.type + ); + } + if (element instanceof HTMLTextAreaElement) return true; + return false; +} + +function scrubIdentifier(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + const withoutQuery = trimmed.split(/[?#]/)[0] ?? trimmed; + const scrubbed = withoutQuery + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, ':email') + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id') + .replace(/\b[0-9a-f]{16,}\b/gi, ':id') + .replace(/\b\d{3,}\b/g, ':num') + .replace(/[^a-zA-Z0-9:_/-]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase() + .slice(0, 80); + return scrubbed || undefined; +} diff --git a/app/src/services/analyticsRoutes.ts b/app/src/services/analyticsRoutes.ts new file mode 100644 index 0000000000..56c75d4fc7 --- /dev/null +++ b/app/src/services/analyticsRoutes.ts @@ -0,0 +1,54 @@ +/** Convert a HashRouter hash into its application path. */ +export function hashToPath(hash: string): string { + const withoutHash = hash.slice(1); + return withoutHash || '/'; +} + +/** Normalize dynamic application paths into privacy-safe analytics templates. */ +export function normalizeAnalyticsPagePath(path: string): string { + const rawPath = + typeof window !== 'undefined' && window.location.hash.startsWith('#/') + ? hashToPath(window.location.hash) + : path.startsWith('#/') + ? hashToPath(path) + : path || '/'; + + // Analytics records route templates, never entity identifiers or query/hash + // values. Besides avoiding high-cardinality dashboards, this prevents thread, + // flow, profile, and team identifiers from leaving the app. + const pathname = rawPath.split(/[?#]/, 1)[0] || '/'; + if (/^\/chat\/[^/]+/.test(pathname)) return '/chat/:threadId'; + if (/^\/flows\/[^/]+/.test(pathname) && pathname !== '/flows/draft') { + return '/flows/:flowId'; + } + if (/^\/settings\/team\/manage\/[^/]+\/members$/.test(pathname)) { + return '/settings/team/manage/:teamId/members'; + } + if (/^\/settings\/team\/manage\/[^/]+\/invites$/.test(pathname)) { + return '/settings/team/manage/:teamId/invites'; + } + if (/^\/settings\/team\/manage\/[^/]+$/.test(pathname)) { + return '/settings/team/manage/:teamId'; + } + if (/^\/settings\/agents\/edit\/[^/]+$/.test(pathname)) { + return '/settings/agents/edit/:id'; + } + if (/^\/settings\/profiles\/edit\/[^/]+$/.test(pathname)) { + return '/settings/profiles/edit/:id'; + } + if (/^\/callback\/[^/]+\/[^/]+$/.test(pathname)) return '/callback/:kind/:status'; + if (/^\/callback\/[^/]+$/.test(pathname)) return '/callback/:kind'; + return pathname; +} + +/** Read the current privacy-normalized application path. */ +export function currentAppPath(): string { + if (typeof window === 'undefined') return ''; + return normalizeAnalyticsPagePath(window.location.pathname); +} + +/** Return the canonical HashRouter value without exposing entity identifiers. */ +export function currentPageHash(): string { + if (typeof window === 'undefined') return ''; + return window.location.hash.startsWith('#/') ? `#${currentAppPath()}` : ''; +} diff --git a/app/test/e2e/specs/flows.spec.ts b/app/test/e2e/specs/flows.spec.ts index 6592c26cae..169d7ff9f5 100644 --- a/app/test/e2e/specs/flows.spec.ts +++ b/app/test/e2e/specs/flows.spec.ts @@ -117,10 +117,10 @@ describe('Workflows create → run → inspect (real UI flow)', () => { await waitForTestId('flow-canvas-page', 15_000); await waitForTestId('flow-canvas-run', 15_000); - // All clickable analytics use the shared TrackedInteraction contract. + // Shared buttons expose analytics through the typed Button contract. // Assert the stable, content-free identifier reaches the real DOM rather // than leaking the generated flow id into the analytics dimension. - const runButton = await browser.$('[data-testid="flow-canvas-run"]'); + const runButton = await waitForTestId('flow-canvas-run', 15_000); expect(await runButton.getAttribute('data-analytics-id')).toBe('flow-canvas-run'); }); From 903b167b846159219dd1f636494e817d7ca52866 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 04:49:51 +0000 Subject: [PATCH 05/14] fix(analytics): decouple API event tracking --- .../components/analytics/AnalyticsTracker.test.tsx | 14 +++----------- app/src/components/analytics/AnalyticsTracker.tsx | 12 +----------- app/src/components/analytics/index.ts | 3 ++- app/src/services/__tests__/analytics.test.ts | 4 ++-- app/src/services/analytics.ts | 13 ++++++++----- app/src/services/api/flowsApi.ts | 2 +- app/src/services/api/skillsApi.ts | 2 +- app/src/services/api/workflowRunsApi.ts | 2 +- 8 files changed, 19 insertions(+), 33 deletions(-) diff --git a/app/src/components/analytics/AnalyticsTracker.test.tsx b/app/src/components/analytics/AnalyticsTracker.test.tsx index 74d4d7fd7d..241c11d88f 100644 --- a/app/src/components/analytics/AnalyticsTracker.test.tsx +++ b/app/src/components/analytics/AnalyticsTracker.test.tsx @@ -2,14 +2,11 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { MemoryRouter, useNavigate } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; -import { AnalyticsPageTracker, trackAnalyticsEvent } from './AnalyticsTracker'; +import { AnalyticsPageTracker } from './AnalyticsTracker'; -const mocks = vi.hoisted(() => ({ trackEvent: vi.fn(), trackPageView: vi.fn() })); +const mocks = vi.hoisted(() => ({ trackPageView: vi.fn() })); -vi.mock('../../services/analytics', () => ({ - trackEvent: mocks.trackEvent, - trackPageView: mocks.trackPageView, -})); +vi.mock('../../services/analytics', () => ({ trackPageView: mocks.trackPageView })); describe('analytics tracking primitives', () => { it('tracks a page when its path changes', () => { @@ -35,9 +32,4 @@ describe('analytics tracking primitives', () => { fireEvent.click(screen.getByRole('button', { name: 'Navigate' })); expect(mocks.trackPageView).toHaveBeenLastCalledWith('/flows'); }); - - it('provides the same typed facade for successful non-click outcomes', () => { - trackAnalyticsEvent('chat_message_sent', { send_mode: 'standard' }); - expect(mocks.trackEvent).toHaveBeenCalledWith('chat_message_sent', { send_mode: 'standard' }); - }); }); diff --git a/app/src/components/analytics/AnalyticsTracker.tsx b/app/src/components/analytics/AnalyticsTracker.tsx index d58e7345a4..cccbde37be 100644 --- a/app/src/components/analytics/AnalyticsTracker.tsx +++ b/app/src/components/analytics/AnalyticsTracker.tsx @@ -1,12 +1,7 @@ import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; -import { - type AnalyticsEventName, - type AnalyticsParams, - trackEvent, - trackPageView, -} from '../../services/analytics'; +import { trackPageView } from '../../services/analytics'; /** Standard route-view tracker. Mount once inside the active router. */ export function AnalyticsPageTracker() { @@ -16,8 +11,3 @@ export function AnalyticsPageTracker() { }, [pathname]); return null; } - -/** Typed facade for successful non-click outcomes such as a sent message. */ -export function trackAnalyticsEvent(event: AnalyticsEventName, properties?: AnalyticsParams): void { - trackEvent(event, properties); -} diff --git a/app/src/components/analytics/index.ts b/app/src/components/analytics/index.ts index a7caf9cca8..18697d12aa 100644 --- a/app/src/components/analytics/index.ts +++ b/app/src/components/analytics/index.ts @@ -1 +1,2 @@ -export { AnalyticsPageTracker, trackAnalyticsEvent } from './AnalyticsTracker'; +export { trackAnalyticsEvent } from '../../services/analytics'; +export { AnalyticsPageTracker } from './AnalyticsTracker'; diff --git a/app/src/services/__tests__/analytics.test.ts b/app/src/services/__tests__/analytics.test.ts index 05363aee02..4f8924daf9 100644 --- a/app/src/services/__tests__/analytics.test.ts +++ b/app/src/services/__tests__/analytics.test.ts @@ -604,13 +604,13 @@ describe('trackPageView (OpenPanel)', () => { test('suppresses synchronous analytics provider failures', async () => { hoisted.analyticsEnabled = true; const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const { initGA, trackEvent } = await freshAnalytics(); + const { initGA, trackAnalyticsEvent } = await freshAnalytics(); initGA(); window.gtag = vi.fn(() => { throw new Error('provider unavailable'); }); - expect(() => trackEvent('app_open')).not.toThrow(); + expect(() => trackAnalyticsEvent('app_open')).not.toThrow(); expect(warn).toHaveBeenCalledWith('[analytics] trackEvent failed', { eventName: 'app_open', error: 'Error', diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts index 018a256e19..5a297272fe 100644 --- a/app/src/services/analytics.ts +++ b/app/src/services/analytics.ts @@ -37,7 +37,6 @@ import { } from '../utils/config'; import { startInteractionTracking } from './analyticsInteractions'; import { currentAppPath, currentPageHash, normalizeAnalyticsPagePath } from './analyticsRoutes'; -import { CoreRpcError } from './coreRpcClient'; // --------------------------------------------------------------------------- // Google Analytics 4 typings — raw gtag.js API @@ -120,13 +119,12 @@ export function isAnalyticsEnabled(): boolean { /** * Cross-realm-safe check for a `CoreRpcError` with `kind === 'timeout'`. - * `instanceof` can fail across module scopes (test harness, dynamic import, - * Vitest module isolation), so also accept a duck-typed match on `name` - * and `kind`. Used by the Sentry `beforeSend` filter to drop the + * Use a duck-typed match on `name` and `kind` so this service stays independent + * from the RPC client and works across test/module realms. Used by the Sentry + * `beforeSend` filter to drop the * OPENHUMAN-REACT-15/11/10/12/Z/Y family at the source. */ function isCoreRpcTimeoutError(err: unknown): boolean { - if (err instanceof CoreRpcError) return err.kind === 'timeout'; if (typeof err !== 'object' || err === null) return false; const candidate = err as { name?: unknown; kind?: unknown }; return candidate.name === 'CoreRpcError' && candidate.kind === 'timeout'; @@ -402,6 +400,11 @@ export function trackEvent(eventName: string, params?: AnalyticsParams): void { } } +/** Typed, best-effort facade for successful domain outcomes. */ +export function trackAnalyticsEvent(eventName: AnalyticsEventName, params?: AnalyticsParams): void { + trackEvent(eventName, params); +} + function trackEventUnsafe(eventName: string, params?: AnalyticsParams): void { if (!ALLOWED_EVENTS.has(eventName)) { console.warn( diff --git a/app/src/services/api/flowsApi.ts b/app/src/services/api/flowsApi.ts index a9e7c6b228..2ddd66aae2 100644 --- a/app/src/services/api/flowsApi.ts +++ b/app/src/services/api/flowsApi.ts @@ -28,9 +28,9 @@ */ import debug from 'debug'; -import { trackAnalyticsEvent } from '../../components/analytics'; import type { WorkflowGraph } from '../../lib/flows/types'; import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; +import { trackAnalyticsEvent } from '../analytics'; import { callCoreRpc } from '../coreRpcClient'; const log = debug('flowsApi'); diff --git a/app/src/services/api/skillsApi.ts b/app/src/services/api/skillsApi.ts index e577d525ef..d25863663b 100644 --- a/app/src/services/api/skillsApi.ts +++ b/app/src/services/api/skillsApi.ts @@ -1,6 +1,6 @@ import debug from 'debug'; -import { trackAnalyticsEvent } from '../../components/analytics'; +import { trackAnalyticsEvent } from '../analytics'; import { callCoreRpc } from '../coreRpcClient'; const log = debug('skillsApi'); diff --git a/app/src/services/api/workflowRunsApi.ts b/app/src/services/api/workflowRunsApi.ts index dbc7af2a8e..b73da626d8 100644 --- a/app/src/services/api/workflowRunsApi.ts +++ b/app/src/services/api/workflowRunsApi.ts @@ -18,7 +18,7 @@ */ import debug from 'debug'; -import { trackAnalyticsEvent } from '../../components/analytics'; +import { trackAnalyticsEvent } from '../analytics'; import { callCoreRpc } from '../coreRpcClient'; const log = debug('workflowRunsApi'); From aaf133cdadcb809aa56c15f7234daf5997709481 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 05:26:46 +0000 Subject: [PATCH 06/14] fix(rust): address clippy review feedback --- Cargo.toml | 1 - app/src-tauri/src/cdp/snapshot.rs | 91 ++++++++-- .../src/discord_scanner/mod_tests.rs | 2 - app/src-tauri/src/lib.rs | 20 +++ app/src-tauri/src/meet_video/frame_bus.rs | 4 +- app/src-tauri/src/ptt_overlay.rs | 11 +- src/bin/slack_backfill.rs | 28 +++ src/openhuman/accessibility/mod.rs | 2 - .../accessibility/permissions_tests.rs | 3 +- src/openhuman/agent/harness/archivist/mod.rs | 2 - .../agent/harness/harness_gap_tests.rs | 3 +- .../agent/harness/session/transcript_tests.rs | 4 +- .../agent/harness/session/turn_tests.rs | 6 +- .../agent/harness/subagent_runner/ops/mod.rs | 4 +- src/openhuman/agent/harness/tests.rs | 5 +- .../background_delivery.rs | 6 +- src/openhuman/agentbox/store_tests.rs | 2 +- src/openhuman/config/ops/mod.rs | 4 +- src/openhuman/config/schema/load/mod.rs | 2 - src/openhuman/config/schema/load_tests.rs | 4 +- src/openhuman/config/schemas/mod.rs | 13 +- .../desktop_companion/pipeline_tests.rs | 1 - .../desktop_companion/session_tests.rs | 1 - src/openhuman/devices/rpc.rs | 2 +- src/openhuman/devices/store.rs | 2 +- src/openhuman/flows/ops_tests.rs | 2 +- src/openhuman/inference/local/install.rs | 1 - .../inference/provider/factory_tests.rs | 16 +- src/openhuman/learning/extract/heuristics.rs | 2 - src/openhuman/meet_agent/brain/llm.rs | 2 +- src/openhuman/memory/ops/learn.rs | 1 - src/openhuman/memory_queue/worker.rs | 9 +- .../memory_store/chunks/connection.rs | 3 - .../memory_store/chunks/embeddings.rs | 3 - src/openhuman/memory_tree/tree/rpc.rs | 3 +- src/openhuman/skills/e2e_plumbing_tests.rs | 1 - src/openhuman/skills/e2e_run_tests.rs | 1 - src/openhuman/skills/ops_tests.rs | 1 - src/openhuman/tinyagents/mod.rs | 6 +- src/openhuman/tinyagents/model.rs | 4 + .../tinyagents/payload_summarizer.rs | 10 +- src/openhuman/tinyagents/retriever.rs | 2 +- src/openhuman/tinyagents/tests.rs | 1 - src/openhuman/voice/factory/tests.rs | 6 +- src/openhuman/voice/schemas/mod.rs | 5 +- src/openhuman/voice/server.rs | 2 +- src/openhuman/x402/x402_tests.rs | 4 +- tests/json_rpc_e2e.rs | 161 ++++++++---------- tests/keyring_secretstore_e2e.rs | 13 +- tests/subconscious_conversation_e2e.rs | 16 +- tests/transcript_search_e2e.rs | 24 +-- 51 files changed, 285 insertions(+), 237 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 03df60fd9d..e6ddbf7f4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -375,7 +375,6 @@ borrowed_box = "allow" doc_overindented_list_items = "allow" field_reassign_with_default = "allow" large_enum_variant = "allow" -manual_checked_ops = "allow" result_large_err = "allow" should_implement_trait = "allow" too_many_arguments = "allow" diff --git a/app/src-tauri/src/cdp/snapshot.rs b/app/src-tauri/src/cdp/snapshot.rs index 6771c1883a..dc33a1b3ce 100644 --- a/app/src-tauri/src/cdp/snapshot.rs +++ b/app/src-tauri/src/cdp/snapshot.rs @@ -57,22 +57,34 @@ pub struct Snapshot { impl Snapshot { /// Run `DOMSnapshot.captureSnapshot` on an attached session and return - /// the parsed main-document tree. Iframes are ignored — none of the - /// migrated providers render chat lists inside iframes. + /// one parsed tree containing the main document and any iframe documents. pub async fn capture(cdp: &mut CdpConn, session: &str) -> Result { + log::debug!("[cdp::snapshot] capture start session={session}"); let raw = cdp .call( "DOMSnapshot.captureSnapshot", - json!({ - "computedStyles": [], - "includePaintOrder": false, - "includeDOMRects": false, - }), + capture_request(), Some(session), ) - .await?; - let snap: CaptureSnapshot = - serde_json::from_value(raw).map_err(|e| format!("decode DOMSnapshot: {e}"))?; + .await + .map_err(|error| { + log::warn!("[cdp::snapshot] capture call failed session={session} error={error}"); + error + })?; + log::debug!("[cdp::snapshot] capture call complete session={session}"); + let snap: CaptureSnapshot = serde_json::from_value(raw).map_err(|error| { + log::warn!("[cdp::snapshot] decode failed session={session} error={error}"); + format!("decode DOMSnapshot: {error}") + })?; + let snapshot = Self::from_capture(snap); + log::debug!( + "[cdp::snapshot] decode complete session={session} nodes={}", + snapshot.len() + ); + Ok(snapshot) + } + + fn from_capture(snap: CaptureSnapshot) -> Self { let strings = snap.strings; // Merge every document (main frame + all iframes) into a single // flat node array. CDP returns each frame as its own document @@ -92,7 +104,6 @@ impl Snapshot { for document in snap.documents { let doc_offset = merged_node_type.len() as i32; let doc_nodes = document.nodes; - let doc_count = doc_nodes.node_type.len(); for &p in &doc_nodes.parent_index { merged_parent_index.push(if p < 0 { -1 } else { p + doc_offset }); } @@ -111,7 +122,6 @@ impl Snapshot { while merged_attributes.len() < merged_node_type.len() { merged_attributes.push(Vec::new()); } - let _ = doc_count; } let nodes = NodeTreeSnap { parent_index: merged_parent_index, @@ -127,11 +137,11 @@ impl Snapshot { children[p as usize].push(i); } } - Ok(Self { + Self { strings, nodes, children, - }) + } } pub fn len(&self) -> usize { @@ -246,6 +256,14 @@ impl Snapshot { } } +fn capture_request() -> serde_json::Value { + json!({ + "computedStyles": [], + "includePaintOrder": false, + "includeDOMRects": false, + }) +} + fn collapse_ws(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut last_space = true; @@ -267,6 +285,51 @@ fn collapse_ws(s: &str) -> String { mod tests { use super::*; + #[test] + fn capture_request_disables_dom_rects() { + let request = capture_request(); + assert_eq!(request["includeDOMRects"], false); + assert_eq!(request["includePaintOrder"], false); + assert_eq!(request["computedStyles"], json!([])); + } + + #[test] + fn from_capture_offsets_documents_and_builds_child_adjacency_without_layout() { + let capture: CaptureSnapshot = serde_json::from_value(json!({ + "strings": ["DIV", "first", "SPAN", "second"], + "documents": [ + { + "nodes": { + "parentIndex": [-1, 0], + "nodeType": [1, 3], + "nodeName": [0, -1], + "nodeValue": [-1, 1] + } + }, + { + "nodes": { + "parentIndex": [-1, 0], + "nodeType": [1, 3], + "nodeName": [2, -1], + "nodeValue": [-1, 3] + } + } + ] + })) + .expect("snapshot fixture should decode without layout data"); + + let snapshot = Snapshot::from_capture(capture); + + assert_eq!(snapshot.len(), 4); + assert_eq!(snapshot.children(0), &[1]); + assert_eq!(snapshot.children(2), &[3]); + assert!(snapshot.children(1).is_empty()); + assert_eq!(snapshot.tag(0), "DIV"); + assert_eq!(snapshot.tag(2), "SPAN"); + assert_eq!(snapshot.text_content(0), "first"); + assert_eq!(snapshot.text_content(2), "second"); + } + #[test] fn collapse_ws_collapses_and_trims() { assert_eq!(collapse_ws(" hello world "), "hello world"); diff --git a/app/src-tauri/src/discord_scanner/mod_tests.rs b/app/src-tauri/src/discord_scanner/mod_tests.rs index ad7345d2d8..7278419079 100644 --- a/app/src-tauri/src/discord_scanner/mod_tests.rs +++ b/app/src-tauri/src/discord_scanner/mod_tests.rs @@ -339,7 +339,6 @@ fn page(id: &str, url: &str) -> crate::cdp::target::CdpTarget { id: id.to_string(), kind: "page".to_string(), url: url.to_string(), - title: String::new(), } } @@ -409,7 +408,6 @@ fn resolve_none_when_no_prefix_target() { id: "iframe".to_string(), kind: "iframe".to_string(), url: "https://discord.com/channels/@me".to_string(), - title: String::new(), }, ]; assert!( diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 9d0f642a12..5ca2b6375b 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1207,6 +1207,16 @@ fn mascot_window_hide(app: AppHandle) -> Result<(), String> { } } +#[cfg(target_os = "macos")] +fn mascot_native_window_is_open() -> bool { + mascot_native_window::is_open() +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "linux")))] +fn mascot_native_window_is_open() -> bool { + false +} + /// Dispatch a notch-panel mutation onto the app main thread. /// /// The notch is a native NSPanel + WKWebView; AppKit requires it to be built @@ -1836,6 +1846,16 @@ async fn perform_early_teardown_async(app_handle: &AppHandle) { log::info!("[app] perform_early_teardown_async — early teardown complete"); } +/// Explicitly wind down CEF and the core before exiting from desktop-owned +/// quit actions. Linux does not build the tray or macOS application menu. +#[cfg(not(target_os = "linux"))] +fn shutdown_app_sync(app_handle: &AppHandle, exit_code: i32) { + log::info!("[app] shutdown_app_sync — starting early teardown"); + perform_early_teardown_sync_once(app_handle, "shutdown_app_sync"); + log::info!("[app] shutdown_app_sync — early teardown complete, exiting"); + app_handle.exit(exit_code); +} + #[cfg(target_os = "linux")] const WSL_X11_DESKTOP_WARNING: &str = "[startup] likely unsupported desktop environment: WSL with classic X11 forwarding detected (DISPLAY is set, but WAYLAND_DISPLAY/WSLg markers are absent). OpenHuman's Tauri/CEF desktop flow is fragile in this setup; use native Windows development or Windows 11 WSLg for desktop GUI work."; diff --git a/app/src-tauri/src/meet_video/frame_bus.rs b/app/src-tauri/src/meet_video/frame_bus.rs index 375dd86f7b..537d0bd349 100644 --- a/app/src-tauri/src/meet_video/frame_bus.rs +++ b/app/src-tauri/src/meet_video/frame_bus.rs @@ -197,6 +197,9 @@ async fn handle_connection( .await .is_err() { + log::debug!( + "[meet-video] initial WebSocket frame send failed; closing writer peer_transport=disconnected" + ); return; } while latest_rx.changed().await.is_ok() { @@ -236,7 +239,6 @@ async fn handle_connection( #[cfg(test)] mod tests { use super::*; - use futures_util::{SinkExt as _, StreamExt as _}; use tokio_tungstenite::connect_async; #[tokio::test] diff --git a/app/src-tauri/src/ptt_overlay.rs b/app/src-tauri/src/ptt_overlay.rs index bdb75004bd..6cfb824e68 100644 --- a/app/src-tauri/src/ptt_overlay.rs +++ b/app/src-tauri/src/ptt_overlay.rs @@ -23,8 +23,7 @@ pub(crate) fn ensure_window(app: &AppHandle) -> Result<(), String return Ok(()); } let url = WebviewUrl::App("index.html#/ptt-overlay".into()); - #[allow(unused_mut)] // Mutated by the macOS-only workspace options below. - let mut builder = WebviewWindowBuilder::new(app, OVERLAY_LABEL, url) + let builder = WebviewWindowBuilder::new(app, OVERLAY_LABEL, url) .title("OpenHuman Push-to-Talk") .inner_size(160.0, 56.0) .decorations(false) @@ -40,11 +39,9 @@ pub(crate) fn ensure_window(app: &AppHandle) -> Result<(), String .visible(false); #[cfg(target_os = "macos")] - { - builder = builder - .visible_on_all_workspaces(true) - .accept_first_mouse(false); - } + let builder = builder + .visible_on_all_workspaces(true) + .accept_first_mouse(false); let _window = builder .build() diff --git a/src/bin/slack_backfill.rs b/src/bin/slack_backfill.rs index c5f6c9f191..e5b77d41fc 100644 --- a/src/bin/slack_backfill.rs +++ b/src/bin/slack_backfill.rs @@ -359,6 +359,10 @@ async fn main() -> Result<()> { ); Outcome::Ratelimit } else { + log::warn!( + "[probe-ratelimit] provider failure on call {i}: {}", + sanitize_probe_error(err) + ); Outcome::OtherFail } } @@ -571,3 +575,27 @@ fn component_status(endpoint: &Option, model: &Option) -> String _ => "off".to_string(), } } + +fn sanitize_probe_error(error: &str) -> String { + let single_line = error.split_whitespace().collect::>().join(" "); + openhuman_core::openhuman::inference::provider::ops::sanitize_api_error(&single_line) +} + +#[cfg(test)] +mod tests { + use super::sanitize_probe_error; + + #[test] + fn probe_error_summary_is_single_line_bounded_and_secret_safe() { + let raw = format!( + "provider failed\nwith xoxb-secret-token {}", + "x".repeat(300) + ); + let summary = sanitize_probe_error(&raw); + + assert!(!summary.contains('\n')); + assert!(!summary.contains("xoxb-secret-token")); + assert!(summary.contains("[REDACTED]")); + assert!(summary.chars().count() <= 203); + } +} diff --git a/src/openhuman/accessibility/mod.rs b/src/openhuman/accessibility/mod.rs index a7b1de0ab6..a88ba08607 100644 --- a/src/openhuman/accessibility/mod.rs +++ b/src/openhuman/accessibility/mod.rs @@ -28,8 +28,6 @@ mod vision_click; #[cfg(target_os = "windows")] mod uia_interact; -#[cfg(test)] -pub(crate) use automation_state::test_lock as automation_state_test_lock; pub use automation_state::{ clear as clear_automation_denial, mark_system_events_denied, system_events_denied, }; diff --git a/src/openhuman/accessibility/permissions_tests.rs b/src/openhuman/accessibility/permissions_tests.rs index 2009636aac..671cedab3f 100644 --- a/src/openhuman/accessibility/permissions_tests.rs +++ b/src/openhuman/accessibility/permissions_tests.rs @@ -158,8 +158,7 @@ fn permission_state_serde_round_trip() { mod automation_state_stale_cache { use crate::openhuman::accessibility::automation_state; use crate::openhuman::accessibility::{ - automation_state_test_lock, clear_automation_denial, mark_system_events_denied, - system_events_denied, + clear_automation_denial, mark_system_events_denied, system_events_denied, }; #[test] diff --git a/src/openhuman/agent/harness/archivist/mod.rs b/src/openhuman/agent/harness/archivist/mod.rs index 7f9887c7b2..d2943f7ba1 100644 --- a/src/openhuman/agent/harness/archivist/mod.rs +++ b/src/openhuman/agent/harness/archivist/mod.rs @@ -30,8 +30,6 @@ pub use types::ArchivistHook; #[cfg(test)] pub(crate) use crate::openhuman::agent::hooks::PostTurnHook; #[cfg(test)] -pub(crate) use crate::openhuman::config::Config; -#[cfg(test)] pub(crate) use crate::openhuman::memory_store::profile; #[cfg(test)] pub(crate) use helpers::extract_profile_key; diff --git a/src/openhuman/agent/harness/harness_gap_tests.rs b/src/openhuman/agent/harness/harness_gap_tests.rs index 668f3dbf61..7d624553cb 100644 --- a/src/openhuman/agent/harness/harness_gap_tests.rs +++ b/src/openhuman/agent/harness/harness_gap_tests.rs @@ -23,10 +23,9 @@ //! - `` XML attribute form — the parser does not parse attributes; //! only the tag body (JSON) is used. -use crate::openhuman::agent::error::AgentError; use crate::openhuman::inference::provider::traits::ProviderCapabilities; use crate::openhuman::inference::provider::Provider; -use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, UsageInfo}; +use crate::openhuman::inference::provider::{ChatRequest, ChatResponse}; use crate::openhuman::tool_timeout::parse_tool_timeout_secs; use crate::openhuman::tools::{Tool, ToolResult}; use async_trait::async_trait; diff --git a/src/openhuman/agent/harness/session/transcript_tests.rs b/src/openhuman/agent/harness/session/transcript_tests.rs index cb35c7f89a..333d1b9777 100644 --- a/src/openhuman/agent/harness/session/transcript_tests.rs +++ b/src/openhuman/agent/harness/session/transcript_tests.rs @@ -871,7 +871,7 @@ fn read_thread_usage_summary_sums_multiple_transcripts() { let raw = raw_session_dir(ws.path()); std::fs::create_dir_all(&raw).unwrap(); - let mut mk = |stem: &str, input: u64, cost: f64| { + let mk = |stem: &str, input: u64, cost: f64| { let mut meta = sample_meta(); meta.thread_id = Some("thr-multi".into()); meta.input_tokens = input; @@ -928,7 +928,7 @@ fn read_thread_usage_summary_groups_subagents_by_archetype() { .unwrap(); // Sub-agent transcripts (stems contain `__`): coder x2 + researcher x1. - let mut sub = |stem: &str, agent: &str, input: u64, output: u64| { + let sub = |stem: &str, agent: &str, input: u64, output: u64| { let mut m = sample_meta(); m.thread_id = Some("thr-sub".into()); m.agent_name = agent.into(); diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index ae4afa6870..4bc467b2cd 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -1,5 +1,4 @@ use super::*; -use crate::core::event_bus::{global, init_global, DomainEvent}; use crate::openhuman::agent::dispatcher::XmlToolDispatcher; use crate::openhuman::agent::hooks::{PostTurnHook, TurnContext}; use crate::openhuman::agent::tool_policy::{ @@ -8,8 +7,7 @@ use crate::openhuman::agent::tool_policy::{ }; use crate::openhuman::agent_memory::memory_loader::MemoryLoader; use crate::openhuman::inference::provider::{ - ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ToolResultMessage, - UsageInfo, + ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, UsageInfo, }; use crate::openhuman::memory::Memory; use crate::openhuman::tools::ToolResult; @@ -20,7 +18,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use tokio::sync::Mutex as AsyncMutex; use tokio::sync::Notify; -use tokio::time::{sleep, timeout, Duration}; +use tokio::time::{timeout, Duration}; struct DummyProvider; diff --git a/src/openhuman/agent/harness/subagent_runner/ops/mod.rs b/src/openhuman/agent/harness/subagent_runner/ops/mod.rs index c8ba6c102c..9e0ea2692c 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/mod.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/mod.rs @@ -61,9 +61,7 @@ pub(super) use provider::LazyToolkitResolver; pub(super) use super::tool_prep::filter_tool_indices; // Types used by tests that were previously in scope via the flat ops.rs imports. #[cfg(test)] -pub(super) use super::types::{ - SubagentMode, SubagentRunError, SubagentRunOptions, SubagentRunOutcome, -}; +pub(super) use super::types::{SubagentMode, SubagentRunError, SubagentRunOptions}; #[cfg(test)] pub(super) use crate::openhuman::agent::harness::definition::{AgentDefinition, PromptSource}; #[cfg(test)] diff --git a/src/openhuman/agent/harness/tests.rs b/src/openhuman/agent/harness/tests.rs index 472e5463d3..aa73dc0e3b 100644 --- a/src/openhuman/agent/harness/tests.rs +++ b/src/openhuman/agent/harness/tests.rs @@ -5,10 +5,9 @@ use super::parse::{ parse_tool_calls, parse_tool_calls_from_json_value, tools_to_openai_format, }; use crate::openhuman::inference::provider::traits::ProviderCapabilities; -use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, Provider}; -use crate::openhuman::tools::{self, Tool}; +use crate::openhuman::inference::provider::{ChatRequest, ChatResponse, Provider}; +use crate::openhuman::tools; use async_trait::async_trait; -use base64::{engine::general_purpose::STANDARD, Engine as _}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; diff --git a/src/openhuman/agent_orchestration/background_delivery.rs b/src/openhuman/agent_orchestration/background_delivery.rs index 1a3f811db4..bae691c788 100644 --- a/src/openhuman/agent_orchestration/background_delivery.rs +++ b/src/openhuman/agent_orchestration/background_delivery.rs @@ -167,8 +167,12 @@ async fn try_deliver(session: String) { ); requeue(&session, batch); // don't lose results on a failed turn } + } else { + log::warn!( + "[background_delivery] dropping headless batch session={session} count={}", + batch.len() + ); } - // Headless batches (no originating thread to stream into) are dropped. } // Release the slot only AFTER the turn settles. diff --git a/src/openhuman/agentbox/store_tests.rs b/src/openhuman/agentbox/store_tests.rs index 052ffc8d75..10ddd51101 100644 --- a/src/openhuman/agentbox/store_tests.rs +++ b/src/openhuman/agentbox/store_tests.rs @@ -1,5 +1,5 @@ use super::store::JobStore; -use super::types::{JobRecord, JobStatus, RunResult}; +use super::types::{JobStatus, RunResult}; use std::time::Duration; #[test] diff --git a/src/openhuman/config/ops/mod.rs b/src/openhuman/config/ops/mod.rs index 53558d3acd..1a24b834dc 100644 --- a/src/openhuman/config/ops/mod.rs +++ b/src/openhuman/config/ops/mod.rs @@ -33,8 +33,8 @@ pub(crate) use crate::openhuman::config::Config; #[cfg(test)] pub(crate) use loader::{ active_workspace_marker_path, config_openhuman_dir, default_openhuman_dir, env_flag_enabled, - fallback_workspace_dir, reset_local_data_for_paths, reset_local_data_remove_error, - BROWSER_ALLOW_ALL_ENV, BROWSER_ALLOW_ALL_RPC_ENABLE_ENV, + fallback_workspace_dir, reset_local_data_for_paths, BROWSER_ALLOW_ALL_ENV, + BROWSER_ALLOW_ALL_RPC_ENABLE_ENV, }; #[cfg(test)] pub(crate) use std::path::PathBuf; diff --git a/src/openhuman/config/schema/load/mod.rs b/src/openhuman/config/schema/load/mod.rs index 828e5d001c..72e52588d5 100644 --- a/src/openhuman/config/schema/load/mod.rs +++ b/src/openhuman/config/schema/load/mod.rs @@ -32,8 +32,6 @@ pub(crate) use super::Config; #[cfg(test)] pub(crate) use dirs::ACTIVE_USER_STATE_FILE; #[cfg(test)] -pub(crate) use env::ProcessEnvWithoutWorkspace; -#[cfg(test)] pub(crate) use impl_load::parse_config_with_recovery; #[cfg(test)] pub(crate) use migrate::{migrate_cloud_provider_slugs, migrate_legacy_inference_url}; diff --git a/src/openhuman/config/schema/load_tests.rs b/src/openhuman/config/schema/load_tests.rs index 1e16eaa594..9e87902ef2 100644 --- a/src/openhuman/config/schema/load_tests.rs +++ b/src/openhuman/config/schema/load_tests.rs @@ -1360,7 +1360,7 @@ async fn test_save_preserves_backup_file() { let config_path = tmp.path().join("config.toml"); let backup_path = tmp.path().join("config.toml.bak"); - let mut config = Config { + let config = Config { config_path: config_path.clone(), workspace_dir: tmp.path().join("workspace"), action_dir: tmp.path().join("workspace"), @@ -1387,7 +1387,7 @@ async fn test_save_then_corrupt_then_recover() { let tmp = tempfile::tempdir().unwrap(); let config_path = tmp.path().join("config.toml"); - let mut config = Config { + let config = Config { config_path: config_path.clone(), workspace_dir: tmp.path().join("workspace"), action_dir: tmp.path().join("workspace"), diff --git a/src/openhuman/config/schemas/mod.rs b/src/openhuman/config/schemas/mod.rs index 9981018657..aa31c8df4a 100644 --- a/src/openhuman/config/schemas/mod.rs +++ b/src/openhuman/config/schemas/mod.rs @@ -17,18 +17,11 @@ use controllers::{ #[cfg(test)] use helpers::{ deserialize_params, json_output, optional_bool, optional_json, optional_string, - required_string, to_json, ActivityLevelSettingsUpdate, AgentPathsUpdate, AgentSettingsUpdate, - AnalyticsSettingsUpdate, AutonomySettingsUpdate, BrowserSettingsUpdate, - ComposioTriggerSettingsUpdate, DictationSettingsUpdate, LocalAiSettingsUpdate, - MeetSettingsUpdate, MemorySettingsUpdate, MemorySyncSettingsUpdate, ModelSettingsUpdate, - OnboardingCompletedSetParams, RuntimeSettingsUpdate, SandboxSettingsUpdate, - ScreenIntelligenceSettingsUpdate, SearchSettingsUpdate, SetBrowserAllowAllParams, - VoiceServerSettingsUpdate, WorkspaceOnboardingFlagParams, WorkspaceOnboardingFlagSetParams, - DEFAULT_ONBOARDING_FLAG_NAME, + required_string, to_json, AutonomySettingsUpdate, LocalAiSettingsUpdate, MemorySettingsUpdate, + ModelSettingsUpdate, OnboardingCompletedSetParams, SetBrowserAllowAllParams, + WorkspaceOnboardingFlagParams, WorkspaceOnboardingFlagSetParams, DEFAULT_ONBOARDING_FLAG_NAME, }; #[cfg(test)] -use schema_defs::schemas; -#[cfg(test)] use serde_json::{Map, Value}; #[cfg(test)] diff --git a/src/openhuman/desktop_companion/pipeline_tests.rs b/src/openhuman/desktop_companion/pipeline_tests.rs index f5b858e184..70cfa13a1a 100644 --- a/src/openhuman/desktop_companion/pipeline_tests.rs +++ b/src/openhuman/desktop_companion/pipeline_tests.rs @@ -8,7 +8,6 @@ use super::*; use crate::openhuman::desktop_companion::pointing::ScreenGeometry; use crate::openhuman::desktop_companion::session; -use crate::openhuman::desktop_companion::types::*; /// Serialize tests that touch the process-global session state. Shared with /// `session_tests` via `session::lock_test_state()` so transitions in one test diff --git a/src/openhuman/desktop_companion/session_tests.rs b/src/openhuman/desktop_companion/session_tests.rs index d4ca0cd2b1..a2e6ea96a5 100644 --- a/src/openhuman/desktop_companion/session_tests.rs +++ b/src/openhuman/desktop_companion/session_tests.rs @@ -1,7 +1,6 @@ //! Tests for companion session lifecycle and state machine. use super::*; -use crate::openhuman::desktop_companion::types::*; /// Serialize tests that mutate the process-global session state. Shared with /// `pipeline_tests` via `lock_test_state()` (defined in `session`) so a diff --git a/src/openhuman/devices/rpc.rs b/src/openhuman/devices/rpc.rs index 11c3d97dc8..427666b26a 100644 --- a/src/openhuman/devices/rpc.rs +++ b/src/openhuman/devices/rpc.rs @@ -334,7 +334,7 @@ mod tests { fn test_config() -> Config { let dir = tempfile::tempdir().expect("tempdir"); let mut config = Config::default(); - config.workspace_dir = dir.into_path(); + config.workspace_dir = dir.keep(); config } diff --git a/src/openhuman/devices/store.rs b/src/openhuman/devices/store.rs index ab4998ec6d..2b89128ff5 100644 --- a/src/openhuman/devices/store.rs +++ b/src/openhuman/devices/store.rs @@ -154,7 +154,7 @@ mod tests { fn test_config() -> Config { let dir = tempfile::tempdir().expect("tempdir"); let mut config = Config::default(); - config.workspace_dir = dir.into_path(); + config.workspace_dir = dir.keep(); config } diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 95f9939891..05fbfee4df 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -1896,7 +1896,7 @@ async fn validate_tool_contracts_rejects_a_hallucinated_slug() { { "id": "t", "kind": "trigger", "name": "Manual" }, { "id": "post", "kind": "tool_call", "name": "Post", "config": { "slug": "SLACK_POST_MESSAGE_TO_CHANNEL", - "args": { "channel": "#general", "text": "hi" } } } + "args": { "channel": "#general", "markdown_text": "hi" } } } ], "edges": [ { "from_node": "t", "to_node": "post" } ] })); diff --git a/src/openhuman/inference/local/install.rs b/src/openhuman/inference/local/install.rs index d6d8c6de6f..ae5ca6b8f3 100644 --- a/src/openhuman/inference/local/install.rs +++ b/src/openhuman/inference/local/install.rs @@ -339,7 +339,6 @@ mod tests { /// (OLLAMA_BIN, PATH) with other local-AI tests that also read these /// variables. Without this, cargo's test runner can interleave set/remove /// calls and cause flakes. - fn env_lock() -> std::sync::MutexGuard<'static, ()> { crate::openhuman::inference::inference_test_guard() } diff --git a/src/openhuman/inference/provider/factory_tests.rs b/src/openhuman/inference/provider/factory_tests.rs index 7be21342d8..d98cd1919c 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -927,7 +927,7 @@ fn verify_session_active_rejects_when_no_session_token() { #[test] fn verify_session_active_rejects_when_token_is_empty() { let tmp = TempDir::new().expect("tempdir"); - let mut config = config_in_tempdir(&tmp); + let config = config_in_tempdir(&tmp); let auth = AuthService::new(tmp.path(), config.secrets.encrypt); auth.store_provider_token("app-session", "default", "", Default::default(), false) .expect("store empty token"); @@ -941,7 +941,7 @@ fn verify_session_active_rejects_when_token_is_empty() { #[test] fn verify_session_active_passes_when_session_token_present() { let tmp = TempDir::new().expect("tempdir"); - let mut config = config_in_tempdir(&tmp); + let config = config_in_tempdir(&tmp); let auth = AuthService::new(tmp.path(), config.secrets.encrypt); auth.store_provider_token( "app-session", @@ -2780,7 +2780,7 @@ async fn create_chat_model_wraps_provider_and_round_trips() { use async_trait::async_trait; use std::sync::Arc; use tinyagents::harness::message::Message; - use tinyagents::harness::model::{ChatModel, ModelRequest}; + use tinyagents::harness::model::ModelRequest; let _guard = crate::openhuman::inference::inference_test_guard(); @@ -2834,8 +2834,6 @@ fn resolves_to_managed_backend_for_default_config_but_not_for_local() { #[test] fn create_chat_model_routes_managed_backend_to_crate_native() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); // No test-provider override installed → the managed short-circuit engages. let config = Config::default(); @@ -2851,8 +2849,6 @@ fn create_chat_model_routes_managed_backend_to_crate_native() { #[test] fn create_chat_model_routes_local_runtime_to_crate_native() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); let mut config = Config::default(); config.chat_provider = Some("ollama:qwen2.5".to_string()); @@ -2917,8 +2913,6 @@ fn deepseek_entry(id: &str) -> CloudProviderCreds { #[test] fn create_chat_model_routes_plain_bearer_cloud_slug_to_crate_native() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); // DeepSeek is a built-in chat-completions-only Bearer provider: no // `/v1/responses` fallback and no codex-oauth, so it is wire-equivalent and @@ -2961,8 +2955,6 @@ fn explicit_cloud_provider_string_routes_to_crate_native_model() { #[test] fn create_chat_model_routes_anthropic_auth_cloud_slug_to_crate_native() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); // Anthropic-auth cloud slugs are always wire-equivalent (their endpoints have // no `/v1/responses`, so the host's dormant fallback is behavior-neutral). @@ -2982,8 +2974,6 @@ fn create_chat_model_routes_anthropic_auth_cloud_slug_to_crate_native() { #[test] fn try_create_cloud_slug_flips_openai_but_declines_non_cloud() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); // `openai` (API-key Bearer, no codex OAuth) now flips crate-native on Chat // Completions — the legacy `/v1/responses` fallback is not replicated. diff --git a/src/openhuman/learning/extract/heuristics.rs b/src/openhuman/learning/extract/heuristics.rs index ac44cf6fe0..7cb749bb58 100644 --- a/src/openhuman/learning/extract/heuristics.rs +++ b/src/openhuman/learning/extract/heuristics.rs @@ -413,8 +413,6 @@ mod heuristics_tests { #[test] fn length_ratio_emits_compressed_when_user_msgs_shrink() { let session = fresh_session_id(); - let buf = Buffer::new(1024); - // First 15 turns: high ratio (user talks a lot). let now = now_secs(); for i in 0..15 { diff --git a/src/openhuman/meet_agent/brain/llm.rs b/src/openhuman/meet_agent/brain/llm.rs index f0fd583743..5d54872bf8 100644 --- a/src/openhuman/meet_agent/brain/llm.rs +++ b/src/openhuman/meet_agent/brain/llm.rs @@ -15,7 +15,7 @@ use crate::openhuman::agent::harness::session::Agent; /// One rolling-history entry handed to the LLM. #[derive(Debug, Clone)] -pub(super) struct ConversationTurn { +pub(crate) struct ConversationTurn { pub role: &'static str, pub content: String, } diff --git a/src/openhuman/memory/ops/learn.rs b/src/openhuman/memory/ops/learn.rs index 024ac1dde9..5d88de0c27 100644 --- a/src/openhuman/memory/ops/learn.rs +++ b/src/openhuman/memory/ops/learn.rs @@ -156,7 +156,6 @@ pub async fn memory_learn_all( #[cfg(test)] mod tests { use std::ffi::OsString; - use std::path::PathBuf; use serde_json::json; use tempfile::TempDir; diff --git a/src/openhuman/memory_queue/worker.rs b/src/openhuman/memory_queue/worker.rs index da2cba7e3f..67b6137450 100644 --- a/src/openhuman/memory_queue/worker.rs +++ b/src/openhuman/memory_queue/worker.rs @@ -1047,12 +1047,11 @@ mod tests { job.available_at_ms > Utc::now().timestamp_millis(), "deferred job should be rescheduled into the future" ); + let defer_reason = job.last_error.as_deref().unwrap_or(""); assert!( - job.last_error - .as_deref() - .unwrap_or("") - .contains("re-embed backfill"), - "defer reason should be recorded for visibility" + defer_reason.contains("re-embed backfill") + || defer_reason.contains("llm concurrency gate busy"), + "defer reason should identify the backfill or the shared gate: {defer_reason:?}" ); assert_eq!(count_by_status(&cfg, JobStatus::Ready).unwrap(), 1); } diff --git a/src/openhuman/memory_store/chunks/connection.rs b/src/openhuman/memory_store/chunks/connection.rs index 87fe5a2e4a..f21278718a 100644 --- a/src/openhuman/memory_store/chunks/connection.rs +++ b/src/openhuman/memory_store/chunks/connection.rs @@ -18,6 +18,3 @@ pub(crate) fn recover_corrupt_db(config: &Config) -> Result { log::warn!("[memory:chunks] checking corrupt database recovery"); tinycortex::memory::chunks::recover_corrupt_db(&engine_config(config)) } - -#[cfg(test)] -pub(crate) use tinycortex::memory::chunks::{is_transient_cold_start, try_cleanup_stale_files}; diff --git a/src/openhuman/memory_store/chunks/embeddings.rs b/src/openhuman/memory_store/chunks/embeddings.rs index 703c541a76..beedf1733e 100644 --- a/src/openhuman/memory_store/chunks/embeddings.rs +++ b/src/openhuman/memory_store/chunks/embeddings.rs @@ -7,9 +7,6 @@ use rusqlite::{Connection, Transaction}; use crate::openhuman::config::Config; -#[cfg(test)] -pub use tinycortex::memory::chunks::{embedding_to_blob, REEMBED_SKIP_KEY_MAX_LEN}; - fn engine_config(config: &Config) -> tinycortex::memory::MemoryConfig { crate::openhuman::tinycortex::memory_config_from(config, config.workspace_dir.clone()) } diff --git a/src/openhuman/memory_tree/tree/rpc.rs b/src/openhuman/memory_tree/tree/rpc.rs index 88301c201a..c277a850f4 100644 --- a/src/openhuman/memory_tree/tree/rpc.rs +++ b/src/openhuman/memory_tree/tree/rpc.rs @@ -832,10 +832,9 @@ pub async fn set_enabled_rpc( mod tests { use super::*; use crate::openhuman::memory_queue as jobs; - use crate::openhuman::memory_queue::store::count_total; use crate::openhuman::memory_store::chunks::types::SourceKind; use crate::openhuman::memory_sync::canonicalize::document::DocumentInput; - use chrono::{Duration as ChronoDuration, Utc}; + use chrono::Utc; use serde_json::json; use tempfile::TempDir; diff --git a/src/openhuman/skills/e2e_plumbing_tests.rs b/src/openhuman/skills/e2e_plumbing_tests.rs index 6281524eb5..ee35174c1e 100644 --- a/src/openhuman/skills/e2e_plumbing_tests.rs +++ b/src/openhuman/skills/e2e_plumbing_tests.rs @@ -39,7 +39,6 @@ use crate::openhuman::skills::ops_create::{ use crate::openhuman::skills::ops_types::WorkflowScope; use crate::openhuman::skills::registry::get_workflow; use crate::openhuman::skills::run_log; -use crate::openhuman::tools::policy::DefaultToolPolicy; use crate::openhuman::tools::traits::Tool; // ── Mock LLM ───────────────────────────────────────────────────────────── diff --git a/src/openhuman/skills/e2e_run_tests.rs b/src/openhuman/skills/e2e_run_tests.rs index a7ff46bc66..03349f3e2c 100644 --- a/src/openhuman/skills/e2e_run_tests.rs +++ b/src/openhuman/skills/e2e_run_tests.rs @@ -42,7 +42,6 @@ use crate::openhuman::skill_runtime::{await_run_outcome, spawn_workflow_run_back use crate::openhuman::skills::schemas::resolve_workspace_dir; use crate::openhuman::todos::ops as board_ops; use crate::openhuman::todos::ops::{BoardLocation, CardPatch}; -use crate::openhuman::tools::policy::DefaultToolPolicy; use crate::openhuman::tools::traits::Tool; /// Serialize this module's tests (each touches process-global state). diff --git a/src/openhuman/skills/ops_tests.rs b/src/openhuman/skills/ops_tests.rs index d49ccfba77..75e2deb6bc 100644 --- a/src/openhuman/skills/ops_tests.rs +++ b/src/openhuman/skills/ops_tests.rs @@ -1678,7 +1678,6 @@ fn uninstall_resolves_agents_skills_legacy_root() { // discover_workflows surfaces ~/.agents/skills/, so uninstall must reach it // too — otherwise a listed workflow can never be deleted via this API. let home = tempfile::tempdir().unwrap(); - let ws = tempfile::tempdir().unwrap(); let dir = home.path().join(".agents").join("skills").join("agenty"); write( &dir.join(SKILL_MD), diff --git a/src/openhuman/tinyagents/mod.rs b/src/openhuman/tinyagents/mod.rs index 207335ee30..4412ae314f 100644 --- a/src/openhuman/tinyagents/mod.rs +++ b/src/openhuman/tinyagents/mod.rs @@ -79,7 +79,7 @@ pub(crate) use embeddings::ProviderEmbeddingModel; pub(crate) use middleware::{ HandoffConfig, SuperContextConfig, TranscriptSnapshotSink, TurnContextMiddleware, }; -use model::ProviderModel; +use model::{BuiltTurnModels, ProviderModel, TierRoutes, TurnChatModel}; pub(crate) use observability::SubagentScope; use observability::{ CapPauser, IterationCursor, OpenhumanEventBridge, ProviderUsageCarry, ToolFailureMap, @@ -1170,10 +1170,6 @@ fn tinyagents_depth_error( /// [`assemble_turn_harness`] takes this bundle instead of the raw provider, so /// the harness assembly is expressed purely in crate model types; the /// `Provider` → `ChatModel` adaptation is confined to `build_turn_models`. -type TurnChatModel = Arc>; -type TierRoutes = Vec<(String, TurnChatModel)>; -type BuiltTurnModels = (TurnChatModel, TierRoutes, TurnChatModel); - pub(crate) struct TurnModels { /// The turn's effective/primary model (registry default + dispatch target). primary: TurnChatModel, diff --git a/src/openhuman/tinyagents/model.rs b/src/openhuman/tinyagents/model.rs index dc8807c505..f6ca880061 100644 --- a/src/openhuman/tinyagents/model.rs +++ b/src/openhuman/tinyagents/model.rs @@ -26,6 +26,10 @@ use crate::openhuman::inference::provider::{ }; use crate::openhuman::tools::ToolSpec; +pub(super) type TurnChatModel = Arc>; +pub(super) type TierRoutes = Vec<(String, TurnChatModel)>; +pub(super) type BuiltTurnModels = (TurnChatModel, TierRoutes, TurnChatModel); + /// Translate a harness [`ModelRequest`] into openhuman's message list + tool /// specs (shared by the buffered and streaming paths). fn build_chat_inputs( diff --git a/src/openhuman/tinyagents/payload_summarizer.rs b/src/openhuman/tinyagents/payload_summarizer.rs index afd895a4b1..a61f0b7b0f 100644 --- a/src/openhuman/tinyagents/payload_summarizer.rs +++ b/src/openhuman/tinyagents/payload_summarizer.rs @@ -379,11 +379,11 @@ impl SubagentPayloadSummarizer { self.record_success(); let summary_bytes = summary.len(); let original_bytes = raw.len(); - let reduction_pct = if original_bytes == 0 { - 0 - } else { - 100usize.saturating_sub(summary_bytes.saturating_mul(100) / original_bytes) - }; + let used_pct = summary_bytes + .saturating_mul(100) + .checked_div(original_bytes) + .unwrap_or(0); + let reduction_pct = 100usize.saturating_sub(used_pct); info!( tool = tool_name, original_bytes = original_bytes, diff --git a/src/openhuman/tinyagents/retriever.rs b/src/openhuman/tinyagents/retriever.rs index f486c50acb..7fd1fd7c39 100644 --- a/src/openhuman/tinyagents/retriever.rs +++ b/src/openhuman/tinyagents/retriever.rs @@ -207,7 +207,7 @@ pub(crate) fn build_retriever(provider: Arc) -> Retriever mod tests { use super::*; use async_trait::async_trait; - use tinyagents::harness::events::{EventListener, EventRecord, RecordingListener}; + use tinyagents::harness::events::{EventListener, EventRecord}; use crate::openhuman::memory::{MemoryCategory, MemoryEntry, NamespaceSummary}; diff --git a/src/openhuman/tinyagents/tests.rs b/src/openhuman/tinyagents/tests.rs index 8c54cf9e24..bce571eeeb 100644 --- a/src/openhuman/tinyagents/tests.rs +++ b/src/openhuman/tinyagents/tests.rs @@ -584,7 +584,6 @@ fn adapter_inventory_registers_model_tools_and_middleware() { // Capability profile (issue #4249, Phase 2): derived from the wrapped // provider plus the runner-threaded token limits. - use tinyagents::harness::model::ChatModel; let registered = assembled .harness .models() diff --git a/src/openhuman/voice/factory/tests.rs b/src/openhuman/voice/factory/tests.rs index a5884ff09c..7ad2d2fa6d 100644 --- a/src/openhuman/voice/factory/tests.rs +++ b/src/openhuman/voice/factory/tests.rs @@ -2,14 +2,12 @@ use super::entry::{ create_stt_provider, create_tts_provider, default_stt_provider, default_tts_provider, - DEFAULT_WHISPER_MODEL, WHISPER_MODEL_PRESETS, + WHISPER_MODEL_PRESETS, }; use super::helpers::{effective_stt_provider, effective_tts_provider, split_slug_model}; use super::stt_providers::WhisperSttProvider; use super::traits::SttProvider; -use crate::openhuman::config::schema::voice_providers::{ - SttApiStyle, TtsApiStyle, VoiceCapability, -}; +use crate::openhuman::config::schema::voice_providers::{SttApiStyle, VoiceCapability}; use crate::openhuman::config::Config; fn cfg() -> Config { diff --git a/src/openhuman/voice/schemas/mod.rs b/src/openhuman/voice/schemas/mod.rs index e4900a2bdc..034276fba0 100644 --- a/src/openhuman/voice/schemas/mod.rs +++ b/src/openhuman/voice/schemas/mod.rs @@ -23,9 +23,8 @@ use helpers::{ }; #[cfg(test)] use params::{ - OverlaySttNotifyParams, OverlaySttState, ReplySynthesizeParams, SetProvidersParams, - SttDispatchParams, TranscribeBytesParams, TranscribeParams, TtsDispatchParams, TtsParams, - VoiceListModelsParams, VoiceProviderCredUpdate, VoiceTestProviderParams, + SetProvidersParams, SttDispatchParams, TranscribeBytesParams, TranscribeParams, + TtsDispatchParams, TtsParams, VoiceListModelsParams, VoiceTestProviderParams, VoiceUpdateProviderSettingsParams, }; diff --git a/src/openhuman/voice/server.rs b/src/openhuman/voice/server.rs index a4ade73efc..d6cc73118d 100644 --- a/src/openhuman/voice/server.rs +++ b/src/openhuman/voice/server.rs @@ -548,7 +548,7 @@ fn start_hotkey_listener( #[cfg(target_os = "macos")] { if hotkey_str.trim().eq_ignore_ascii_case("fn") { - return start_globe_hotkey_listener(mode, server_cancel); + return start_globe_hotkey_listener(mode, _server_cancel); } // rdev calls TSMGetInputSourceProperty off the main thread; macOS 26 // enforces main-queue-only access and crashes the process. Only the diff --git a/src/openhuman/x402/x402_tests.rs b/src/openhuman/x402/x402_tests.rs index 9cfc74cbc1..d1b8746294 100644 --- a/src/openhuman/x402/x402_tests.rs +++ b/src/openhuman/x402/x402_tests.rs @@ -1,8 +1,6 @@ -use base64::engine::{general_purpose::STANDARD as B64, Engine as _}; -use serde_json::json; - use super::ops::{build_evm_payment_with_signer, eip3009_struct_hash, eip712_domain_separator}; use super::types::*; +use base64::engine::{general_purpose::STANDARD as B64, Engine as _}; #[test] fn parse_payment_required_round_trips() { diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 90668c3263..bcd7c0e0d6 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -485,12 +485,6 @@ fn mock_upstream_router() -> Router { let checkout_url = "http://127.0.0.1/mock-checkout"; let session_id = "cs_mock_abc"; - if checkout_url.is_empty() || session_id.is_empty() { - return Err(error_json( - StatusCode::BAD_REQUEST, - "missing checkoutUrl or sessionId", - )); - } Ok(Json(json!({ "success": true, @@ -501,9 +495,6 @@ fn mock_upstream_router() -> Router { async fn stripe_portal(headers: HeaderMap) -> Result, (StatusCode, Json)> { require_bearer(&headers, BILLING_TOKEN)?; let portal_url = "http://127.0.0.1/mock-portal"; - if portal_url.is_empty() { - return Err(error_json(StatusCode::BAD_REQUEST, "missing portalUrl")); - } Ok(Json(json!({ "success": true, @@ -1141,7 +1132,7 @@ async fn json_rpc_config_update_browser_settings_persists_backend() { let updated = post_json_rpc( &rpc_base, - 4124_1, + 41_241, "openhuman.config_update_browser_settings", json!({ "enabled": true, @@ -1167,7 +1158,7 @@ async fn json_rpc_config_update_browser_settings_persists_backend() { "browser backend should persist in update response: {updated_snapshot}" ); - let get = post_json_rpc(&rpc_base, 4124_2, "openhuman.config_get", json!({})).await; + let get = post_json_rpc(&rpc_base, 41_242, "openhuman.config_get", json!({})).await; let get_result = assert_no_jsonrpc_error(&get, "config_get"); let snapshot = peel_logs_envelope(get_result); assert_eq!( @@ -1180,7 +1171,7 @@ async fn json_rpc_config_update_browser_settings_persists_backend() { let invalid = post_json_rpc( &rpc_base, - 4124_3, + 41_243, "openhuman.config_update_browser_settings", json!({ "backend": "netscape" @@ -1201,7 +1192,7 @@ async fn json_rpc_tokenjuice_detect_and_cache_stats() { // detect: a JSON array of objects classifies as `json`. let detect = post_json_rpc( &rpc_base, - 1860_1, + 18_601, "openhuman.tokenjuice_detect", json!({ "content": r#"[{"a":1,"b":2},{"a":3,"b":4}]"# }), ) @@ -1215,7 +1206,7 @@ async fn json_rpc_tokenjuice_detect_and_cache_stats() { // cache_stats: returns numeric occupancy fields. let stats = post_json_rpc( &rpc_base, - 1860_2, + 18_602, "openhuman.tokenjuice_cache_stats", json!({}), ) @@ -1240,7 +1231,7 @@ async fn json_rpc_tokenjuice_settings_and_savings() { // CCR token threshold (default 500) and the router master switch. let get = post_json_rpc( &rpc_base, - 1861_1, + 18_611, "openhuman.tokenjuice_settings_get", json!({}), ) @@ -1261,7 +1252,7 @@ async fn json_rpc_tokenjuice_settings_and_savings() { // settings_update: flip the CCR token threshold and confirm it round-trips. let updated = post_json_rpc( &rpc_base, - 1861_2, + 18_612, "openhuman.tokenjuice_settings_update", json!({ "patch": { "ccr_min_tokens": 750 } }), ) @@ -1278,7 +1269,7 @@ async fn json_rpc_tokenjuice_settings_and_savings() { // savings_stats: returns the aggregate shape (total + attribution model + cache). let savings = post_json_rpc( &rpc_base, - 1861_3, + 18_613, "openhuman.tokenjuice_savings_stats", json!({}), ) @@ -1295,7 +1286,7 @@ async fn json_rpc_tokenjuice_settings_and_savings() { // savings_reset: zeroes the totals. let reset = post_json_rpc( &rpc_base, - 1861_4, + 18_614, "openhuman.tokenjuice_savings_reset", json!({}), ) @@ -1312,7 +1303,7 @@ async fn json_rpc_tool_registry_lists_and_gets_entries() { let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; let rpc_base = format!("http://{rpc_addr}"); - let list = post_json_rpc(&rpc_base, 1848_1, "openhuman.tool_registry_list", json!({})).await; + let list = post_json_rpc(&rpc_base, 18_481, "openhuman.tool_registry_list", json!({})).await; let list_result = assert_no_jsonrpc_error(&list, "tool_registry_list"); let tools = list_result .get("tools") @@ -1359,7 +1350,7 @@ async fn json_rpc_tool_registry_lists_and_gets_entries() { let get = post_json_rpc( &rpc_base, - 1848_2, + 18_482, "openhuman.tool_registry_get", json!({ "tool_id": "tools.web_search" }), ) @@ -1381,7 +1372,7 @@ async fn json_rpc_tool_registry_lists_and_gets_entries() { let missing = post_json_rpc( &rpc_base, - 1848_3, + 18_483, "openhuman.tool_registry_get", json!({ "tool_id": "missing.tool" }), ) @@ -1405,7 +1396,7 @@ async fn json_rpc_monitor_list_and_read_surface() { let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; let rpc_base = format!("http://{rpc_addr}"); - let list = post_json_rpc(&rpc_base, 3371_1, "openhuman.monitor_list", json!({})).await; + let list = post_json_rpc(&rpc_base, 33_711, "openhuman.monitor_list", json!({})).await; let list_result = assert_no_jsonrpc_error(&list, "monitor_list"); let monitors = list_result .get("monitors") @@ -1418,7 +1409,7 @@ async fn json_rpc_monitor_list_and_read_surface() { let missing = post_json_rpc( &rpc_base, - 3371_2, + 33_712, "openhuman.monitor_read", json!({ "monitor_id": "mon_missing" }), ) @@ -1447,7 +1438,7 @@ async fn json_rpc_harness_init_status_returns_snapshot_envelope() { // attempt real Python/Node/spaCy downloads). let resp = post_json_rpc( &rpc_base, - 4471_1, + 44_711, "openhuman.harness_init_status", json!({}), ) @@ -1500,7 +1491,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let list = post_json_rpc( &rpc_base, - 2862_1, + 28_621, "openhuman.agent_registry_list", json!({ "include_disabled": true }), ) @@ -1525,7 +1516,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let definitions = post_json_rpc( &rpc_base, - 2862_1_1, + 286_211, "openhuman.agent_list_definitions", json!({}), ) @@ -1560,7 +1551,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let missing = post_json_rpc( &rpc_base, - 2862_10, + 286_210, "openhuman.agent_registry_get", json!({ "id": "does_not_exist" }), ) @@ -1572,7 +1563,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let update_default = post_json_rpc( &rpc_base, - 2862_11, + 286_211, "openhuman.agent_registry_update", json!({ "id": "researcher", @@ -1604,7 +1595,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let update_missing = post_json_rpc( &rpc_base, - 2862_12, + 286_212, "openhuman.agent_registry_update", json!({ "id": "missing_agent", "enabled": false }), ) @@ -1622,7 +1613,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let disabled = post_json_rpc( &rpc_base, - 2862_2, + 28_622, "openhuman.agent_registry_set_enabled", json!({ "id": "code_executor", "enabled": false }), ) @@ -1645,7 +1636,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let visible = post_json_rpc( &rpc_base, - 2862_3, + 28_623, "openhuman.agent_registry_list", json!({}), ) @@ -1664,7 +1655,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let all_after_disable = post_json_rpc( &rpc_base, - 2862_13, + 286_213, "openhuman.agent_registry_list", json!({ "include_disabled": true }), ) @@ -1689,7 +1680,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let reenabled = post_json_rpc( &rpc_base, - 2862_14, + 286_214, "openhuman.agent_registry_set_enabled", json!({ "id": "code_executor", "enabled": true }), ) @@ -1704,7 +1695,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let disabled_orchestrator = post_json_rpc( &rpc_base, - 2862_31, + 286_231, "openhuman.agent_registry_set_enabled", json!({ "id": "orchestrator", "enabled": false }), ) @@ -1724,7 +1715,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let update_orchestrator_disabled = post_json_rpc( &rpc_base, - 2862_32, + 286_232, "openhuman.agent_registry_update", json!({ "id": "orchestrator", "enabled": false }), ) @@ -1744,7 +1735,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let created = post_json_rpc( &rpc_base, - 2862_4, + 28_624, "openhuman.agent_registry_create_custom", json!({ "id": "custom_writer", @@ -1780,7 +1771,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let get_custom = post_json_rpc( &rpc_base, - 2862_5, + 28_625, "openhuman.agent_registry_get", json!({ "id": "custom_writer" }), ) @@ -1797,7 +1788,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let updated_custom = post_json_rpc( &rpc_base, - 2862_15, + 286_215, "openhuman.agent_registry_update", json!({ "id": "custom_writer", @@ -1838,7 +1829,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let reenabled_custom = post_json_rpc( &rpc_base, - 2862_16, + 286_216, "openhuman.agent_registry_set_enabled", json!({ "id": "custom_writer", "enabled": true }), ) @@ -1853,7 +1844,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let full_upsert = post_json_rpc( &rpc_base, - 2862_17, + 286_217, "openhuman.agent_registry_upsert_custom", json!({ "agent": { @@ -1901,7 +1892,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let visible_after_custom_disable = post_json_rpc( &rpc_base, - 2862_18, + 286_218, "openhuman.agent_registry_list", json!({}), ) @@ -1922,7 +1913,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let default_collision = post_json_rpc( &rpc_base, - 2862_6, + 28_626, "openhuman.agent_registry_create_custom", json!({ "id": "orchestrator", @@ -1946,7 +1937,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let removed_reviewer = post_json_rpc( &rpc_base, - 2862_19, + 286_219, "openhuman.agent_registry_remove", json!({ "id": "custom_reviewer" }), ) @@ -1960,7 +1951,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let removed_custom = post_json_rpc( &rpc_base, - 2862_7, + 28_627, "openhuman.agent_registry_remove", json!({ "id": "custom_writer" }), ) @@ -1976,7 +1967,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let removed_missing = post_json_rpc( &rpc_base, - 2862_20, + 286_220, "openhuman.agent_registry_remove", json!({ "id": "missing_agent" }), ) @@ -1990,7 +1981,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let reset_default = post_json_rpc( &rpc_base, - 2862_21, + 286_221, "openhuman.agent_registry_remove", json!({ "id": "researcher" }), ) @@ -2004,7 +1995,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let reset_code_executor = post_json_rpc( &rpc_base, - 2862_22, + 286_222, "openhuman.agent_registry_remove", json!({ "id": "code_executor" }), ) @@ -2021,7 +2012,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let code_executor = post_json_rpc( &rpc_base, - 2862_23, + 286_223, "openhuman.agent_registry_get", json!({ "id": "code_executor" }), ) @@ -2038,7 +2029,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { // a {name, description} pair whose name is a valid tool_allowlist value. let available_tools = post_json_rpc( &rpc_base, - 2862_24, + 286_224, "openhuman.agent_registry_available_tools", json!({}), ) @@ -2201,12 +2192,11 @@ async fn json_rpc_protocol_auth_and_agent_hello_inner() { Some(thread_id) ); assert!( - sse_event + !sse_event .get("full_response") .and_then(Value::as_str) .unwrap_or_default() - .len() - > 0, + .is_empty(), "expected non-empty chat_done response payload: {sse_event}" ); @@ -3011,7 +3001,7 @@ async fn json_rpc_thread_goal_lifecycle() { // Pull the `{ goal }` payload out of either response shape: a bare // `{ goal }` (get) or the `{ result: { goal }, logs }` CLI envelope // (mutations). - fn goal_of<'a>(result: &'a Value) -> Option<&'a Value> { + fn goal_of(result: &Value) -> Option<&Value> { let envelope = result.get("result").unwrap_or(result); envelope.get("goal").filter(|g| !g.is_null()) } @@ -5535,9 +5525,9 @@ async fn json_rpc_web_chat_custom_chat_provider_with_auth_none_omits_auth_header ) .await; assert_no_jsonrpc_error(&update, "update_model_settings"); - let cfg = post_json_rpc(&rpc_base, 6102_1, "openhuman.config_get", json!({})).await; + let cfg = post_json_rpc(&rpc_base, 61_021, "openhuman.config_get", json!({})).await; let cfg_outer = assert_no_jsonrpc_error(&cfg, "config_get auth-none"); - let cfg_payload = cfg_outer.get("result").unwrap_or(&cfg_outer); + let cfg_payload = cfg_outer.get("result").unwrap_or(cfg_outer); let config = cfg_payload.get("config").unwrap_or(cfg_payload); assert_eq!( config.get("chat_provider").and_then(Value::as_str), @@ -6079,7 +6069,7 @@ async fn json_rpc_app_state_update_local_state_round_trips_into_snapshot() { ) .await; let update_result = assert_no_jsonrpc_error(&update, "app_state_update_local_state"); - let updated_state = update_result.get("result").unwrap_or(&update_result); + let updated_state = update_result.get("result").unwrap_or(update_result); assert_eq!( updated_state.get("encryptionKey").and_then(Value::as_str), Some("secret-key") @@ -6087,7 +6077,7 @@ async fn json_rpc_app_state_update_local_state_round_trips_into_snapshot() { let snapshot = post_json_rpc(&rpc_base, 10042, "openhuman.app_state_snapshot", json!({})).await; let snapshot_result = assert_no_jsonrpc_error(&snapshot, "app_state_snapshot after update"); - let body = snapshot_result.get("result").unwrap_or(&snapshot_result); + let body = snapshot_result.get("result").unwrap_or(snapshot_result); let local_state = body .get("localState") .and_then(Value::as_object) @@ -6275,7 +6265,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { ) .await; let body = assert_no_jsonrpc_error(&assets, "wallet_supported_assets"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); let list = result.as_array().expect("supported_assets array"); // Pin the actual expected multi-chain catalog (not just a lower bound) so a // regression that silently drops a network is caught. @@ -6320,7 +6310,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { // chain_status: every chain is configured, so the provider row is ready. let cs = post_json_rpc(&rpc_base, 2003, "openhuman.wallet_chain_status", json!({})).await; let body = assert_no_jsonrpc_error(&cs, "wallet_chain_status"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); let rows = result.as_array().expect("chain_status array"); // 6 EVM rows (one per L2 / mainnet, incl. BNB Chain) + 3 non-EVM chains. assert_eq!(rows.len(), 9); @@ -6335,7 +6325,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { // BTC + Solana + Tron = 6. let balances = post_json_rpc(&rpc_base, 2004, "openhuman.wallet_balances", json!({})).await; let body = assert_no_jsonrpc_error(&balances, "wallet_balances"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); let rows = result.as_array().expect("balances array"); assert_eq!(rows.len(), 6); assert_eq!( @@ -6369,7 +6359,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { ) .await; let body = assert_no_jsonrpc_error(&prep, "wallet_prepare_transfer"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); let quote_id = result .get("quoteId") .and_then(Value::as_str) @@ -6406,7 +6396,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { ) .await; let body = assert_no_jsonrpc_error(&exec, "wallet_execute_prepared"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); assert_eq!( result.get("status").and_then(Value::as_str), Some("broadcasted"), @@ -6482,7 +6472,7 @@ async fn json_rpc_wallet_tx_reads_and_web3_gates_round_trip() { ) .await; let body = assert_no_jsonrpc_error(&status, "wallet_tx_status"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); assert_eq!( result.get("state").and_then(Value::as_str), Some("confirmed") @@ -6497,7 +6487,7 @@ async fn json_rpc_wallet_tx_reads_and_web3_gates_round_trip() { ) .await; let body = assert_no_jsonrpc_error(&receipt, "wallet_tx_receipt"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); assert_eq!(result.get("success").and_then(Value::as_bool), Some(true)); assert_eq!(result.get("gasUsed").and_then(Value::as_str), Some("21000")); @@ -6510,7 +6500,7 @@ async fn json_rpc_wallet_tx_reads_and_web3_gates_round_trip() { ) .await; let body = assert_no_jsonrpc_error(&lookup, "wallet_lookup_tx"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); assert_eq!(result.get("found").and_then(Value::as_bool), Some(true)); // web3_bridge rejects same-chain requests. This gate runs *before* any @@ -7110,7 +7100,7 @@ async fn json_rpc_wallet_tron_prepare_execute_round_trips() { ); assert_eq!( exec_result.get("transactionHash").and_then(Value::as_str), - Some(format!("{}", "cd".repeat(32)).as_str()), + Some("cd".repeat(32).to_string().as_str()), ); // Native TRX must go through createtransaction, NOT triggersmartcontract. let create_hits = *tron_mock.state.create_hits.lock().unwrap(); @@ -12158,8 +12148,7 @@ async fn json_rpc_task_sources_crud_and_status() { mod task_sources_stub { use async_trait::async_trait; use openhuman_core::openhuman::memory_sync::composio::providers::{ - ComposioProvider, NormalizedTask, ProviderContext, ProviderUserProfile, SyncOutcome, - SyncReason, TaskFetchFilter, + ComposioProvider, NormalizedTask, ProviderContext, ProviderUserProfile, TaskFetchFilter, }; pub struct StubGithubProvider { @@ -13785,7 +13774,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let initial_outer = assert_no_jsonrpc_error(&initial, "get_memory_sync_settings initial"); - let initial_result = initial_outer.get("result").unwrap_or(&initial_outer); + let initial_result = initial_outer.get("result").unwrap_or(initial_outer); assert!( initial_result.get("sync_interval_secs").map(Value::is_null) == Some(true), "default stored value should be null, envelope: {initial_outer}" @@ -13810,7 +13799,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let update_outer = assert_no_jsonrpc_error(&update, "update_memory_sync_settings 4h"); - let update_result = update_outer.get("result").unwrap_or(&update_outer); + let update_result = update_outer.get("result").unwrap_or(update_outer); assert_eq!( update_result .get("sync_interval_secs") @@ -13832,7 +13821,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let after_outer = assert_no_jsonrpc_error(&after, "get_memory_sync_settings after 4h"); - let after_result = after_outer.get("result").unwrap_or(&after_outer); + let after_result = after_outer.get("result").unwrap_or(after_outer); assert_eq!( after_result .get("sync_interval_secs") @@ -13850,7 +13839,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let manual_outer = assert_no_jsonrpc_error(&manual, "update_memory_sync_settings manual"); - let manual_result = manual_outer.get("result").unwrap_or(&manual_outer); + let manual_result = manual_outer.get("result").unwrap_or(manual_outer); assert_eq!( manual_result.get("is_manual").and_then(Value::as_bool), Some(true), @@ -13866,7 +13855,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let manual_get_outer = assert_no_jsonrpc_error(&manual_get, "get_memory_sync_settings manual"); - let manual_get_result = manual_get_outer.get("result").unwrap_or(&manual_get_outer); + let manual_get_result = manual_get_outer.get("result").unwrap_or(manual_get_outer); assert_eq!( manual_get_result.get("is_manual").and_then(Value::as_bool), Some(true), @@ -13918,7 +13907,7 @@ async fn json_rpc_memory_sync_settings_env_override_is_reflected() { ) .await; let outer = assert_no_jsonrpc_error(&resp, "get_memory_sync_settings env-override"); - let result = outer.get("result").unwrap_or(&outer); + let result = outer.get("result").unwrap_or(outer); assert_eq!( result.get("sync_interval_secs").and_then(Value::as_u64), Some(28_800), @@ -14517,7 +14506,7 @@ async fn json_rpc_workflow_run_engine_executes_builtin_to_completion_inner() { // Authenticate so the child agents' provider has a backend session. let store = post_json_rpc( &rpc_base, - 37_500_1, + 375_001, "openhuman.auth_store_session", json!({ "token": "e2e-test-jwt", "user_id": "e2e-user" }), ) @@ -14527,7 +14516,7 @@ async fn json_rpc_workflow_run_engine_executes_builtin_to_completion_inner() { // Sanity: the builtin definition is listed. let defs = post_json_rpc( &rpc_base, - 37_500_2, + 375_002, "openhuman.workflow_run_list_definitions", json!({}), ) @@ -14548,7 +14537,7 @@ async fn json_rpc_workflow_run_engine_executes_builtin_to_completion_inner() { // `modelOverride` pin → every phase hits the mock chat-completions route. let start = post_json_rpc( &rpc_base, - 37_500_3, + 375_003, "openhuman.workflow_run_start", json!({ "definitionId": definition_id, @@ -14662,7 +14651,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Authenticate so the spawned workers' provider has a backend session. let store = post_json_rpc( &rpc_base, - 38_000_1, + 380_001, "openhuman.auth_store_session", json!({ "token": "e2e-test-jwt", "user_id": "e2e-user" }), ) @@ -14672,7 +14661,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Lead creates a team with two named teammates. let created = post_json_rpc( &rpc_base, - 38_000_2, + 380_002, "openhuman.agent_team_create", json!({ "leadAgentId": "lead", @@ -14709,7 +14698,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Task A (no deps) owned by alice; Task B depends on A, owned by bob. let assign_a = post_json_rpc( &rpc_base, - 38_000_3, + 380_003, "openhuman.agent_team_assign_task", json!({ "teamId": team_id, "title": "Task A", "ownerMemberId": alice_id, "dependsOn": [] }), ) @@ -14722,7 +14711,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { .to_string(); let assign_b = post_json_rpc( &rpc_base, - 38_000_4, + 380_004, "openhuman.agent_team_assign_task", json!({ "teamId": team_id, "title": "Task B", "ownerMemberId": bob_id, "dependsOn": [task_a_id.clone()] }), ) @@ -14737,7 +14726,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Lead messages a named teammate (fromMemberId omitted → lead origin). let msg = post_json_rpc( &rpc_base, - 38_000_5, + 380_005, "openhuman.agent_team_message_member", json!({ "teamId": team_id, "toMemberId": alice_id, "content": "please start Task A" }), ) @@ -14747,7 +14736,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Start alice live on Task A → kind "started". let start_a = post_json_rpc( &rpc_base, - 38_000_6, + 380_006, "openhuman.agent_team_start_member", json!({ "teamId": team_id, "memberId": alice_id, "taskId": task_a_id, "modelOverride": "e2e-mock-model" }), ) @@ -14770,7 +14759,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // With A done, start bob live on Task B. let start_b = post_json_rpc( &rpc_base, - 38_000_7, + 380_007, "openhuman.agent_team_start_member", json!({ "teamId": team_id, "memberId": bob_id, "taskId": task_b_id, "modelOverride": "e2e-mock-model" }), ) @@ -14792,7 +14781,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // lead message is in the team timeline. let got = post_json_rpc( &rpc_base, - 38_000_8, + 380_008, "openhuman.agent_team_get", json!({ "teamId": team_id }), ) @@ -14833,7 +14822,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { let messages = post_json_rpc( &rpc_base, - 38_000_9, + 380_009, "openhuman.agent_team_list_messages", json!({ "teamId": team_id }), ) diff --git a/tests/keyring_secretstore_e2e.rs b/tests/keyring_secretstore_e2e.rs index 5adaea2b6f..3d0e6c849c 100644 --- a/tests/keyring_secretstore_e2e.rs +++ b/tests/keyring_secretstore_e2e.rs @@ -1,10 +1,12 @@ use openhuman_core::openhuman::config::schema::{Config, StreamMode, TelegramConfig}; use openhuman_core::openhuman::keyring; -use std::sync::{Mutex, OnceLock}; +use std::sync::OnceLock; -fn env_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() +async fn env_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await } struct EnvGuard { @@ -45,7 +47,7 @@ impl Drop for EnvGuard { #[tokio::test] async fn config_secrets_roundtrip_via_keyring_backed_master_key_migration() { - let _guard = env_lock(); + let _guard = env_lock().await; let tmp = tempfile::tempdir().expect("tempdir"); let openhuman_dir = tmp.path().join("user-123"); let workspace_dir = openhuman_dir.join("workspace"); @@ -72,7 +74,6 @@ async fn config_secrets_roundtrip_via_keyring_backed_master_key_migration() { api_key: Some("parallel-secret".into()), ..Default::default() }, - ..Default::default() }, channels_config: openhuman_core::openhuman::config::schema::ChannelsConfig { telegram: Some(TelegramConfig { diff --git a/tests/subconscious_conversation_e2e.rs b/tests/subconscious_conversation_e2e.rs index 8b4e2fd6cb..713c940ceb 100644 --- a/tests/subconscious_conversation_e2e.rs +++ b/tests/subconscious_conversation_e2e.rs @@ -217,11 +217,11 @@ impl SessionExecutor for ScriptedSession { // ───────────────────────────────────────────────────────────────────────────── /// Serializes tests that touch the process-global event bus. -fn bus_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| StdMutex::new(())) +async fn bus_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) .lock() - .unwrap_or_else(|p| p.into_inner()) + .await } struct Harness { @@ -350,7 +350,7 @@ fn human_msg(channel: &str, sender: &str, message: &str) -> DomainEvent { #[tokio::test] async fn conversation_human_delegates_then_subagent_reports_back() { - let _g = bus_lock(); + let _g = bus_lock().await; let h = Harness::new(OrchestratorConfig::default()); // Human asks for deep work. @@ -382,7 +382,7 @@ async fn conversation_human_delegates_then_subagent_reports_back() { #[tokio::test] async fn conversation_subagent_failure_recovers_with_retry() { - let _g = bus_lock(); + let _g = bus_lock().await; let h = Harness::new(OrchestratorConfig::default()); // Inject a sub-agent FAILURE conclusion directly (as if a prior spawn failed). @@ -408,7 +408,7 @@ async fn conversation_subagent_failure_recovers_with_retry() { #[tokio::test] async fn conversation_interleaved_traffic_is_handled() { - let _g = bus_lock(); + let _g = bus_lock().await; let h = Harness::new(OrchestratorConfig::default()); // Routine cron tick — gate should drop it (no session run). @@ -446,7 +446,7 @@ async fn conversation_interleaved_traffic_is_handled() { #[tokio::test] async fn conversation_promotion_budget_caps_a_burst() { - let _g = bus_lock(); + let _g = bus_lock().await; // The scripted gate intentionally has no promotion budget (that lives in // the real GatePass), so this scenario isolates the *rate limiter*: a // flood of distinct user messages from one source is capped by the token diff --git a/tests/transcript_search_e2e.rs b/tests/transcript_search_e2e.rs index c2cecc013c..84b1f8b9f0 100644 --- a/tests/transcript_search_e2e.rs +++ b/tests/transcript_search_e2e.rs @@ -13,7 +13,7 @@ //! Run with: `cargo test --test transcript_search_e2e` use std::path::Path; -use std::sync::{Mutex, OnceLock}; +use std::sync::OnceLock; use serde_json::json; use tempfile::tempdir; @@ -53,13 +53,13 @@ impl Drop for EnvVarGuard { } /// Serialises tests: `HOME` + `OPENHUMAN_WORKSPACE` are process-global. -static ENV_LOCK: OnceLock> = OnceLock::new(); +static ENV_LOCK: OnceLock> = OnceLock::new(); -fn env_lock() -> std::sync::MutexGuard<'static, ()> { +async fn env_lock() -> tokio::sync::MutexGuard<'static, ()> { ENV_LOCK - .get_or_init(|| Mutex::new(())) + .get_or_init(|| tokio::sync::Mutex::new(())) .lock() - .expect("env lock poisoned") + .await } // ── Fixture helpers ────────────────────────────────────────────────────────── @@ -143,7 +143,7 @@ fn seed_workspace(workspace: &Path) -> ConversationStore { /// scopes the hit to the thread that actually contains it. #[tokio::test] async fn transcript_search_op_finds_message_in_prior_thread() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -174,7 +174,7 @@ async fn transcript_search_op_finds_message_in_prior_thread() { /// orchestrator can use to omit the active chat it already has in hand. #[tokio::test] async fn transcript_search_op_honours_exclude_thread() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -195,7 +195,7 @@ async fn transcript_search_op_honours_exclude_thread() { /// A query that matches nothing returns no hits (not an error). #[tokio::test] async fn transcript_search_op_returns_empty_on_no_match() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -215,7 +215,7 @@ async fn transcript_search_op_returns_empty_on_no_match() { /// source thread and quotes a snippet of the matched message. #[tokio::test] async fn transcript_search_tool_formats_hits_for_the_agent() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -247,7 +247,7 @@ async fn transcript_search_tool_formats_hits_for_the_agent() { /// can record "nothing in past chats" and move on. #[tokio::test] async fn transcript_search_tool_reports_no_match_cleanly() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -276,7 +276,7 @@ async fn transcript_search_tool_reports_no_match_cleanly() { /// clean no-match line. #[tokio::test] async fn transcript_search_tool_excludes_named_thread() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -301,7 +301,7 @@ async fn transcript_search_tool_excludes_named_thread() { /// searches all — this pins the empty-string contract regardless.) #[tokio::test] async fn transcript_search_tool_empty_exclude_searches_all_threads() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); From aaef9f7307de5ade94e9ce05d6d597734cfa6054 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 05:34:36 +0000 Subject: [PATCH 07/14] test(flows): align canvas validation with header controls --- .../EditableFlowCanvas.validation.test.tsx | 99 +++++++++++++------ 1 file changed, 71 insertions(+), 28 deletions(-) diff --git a/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx b/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx index 6636e9aa24..1cfbd5d406 100644 --- a/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx +++ b/app/src/components/flows/canvas/__tests__/EditableFlowCanvas.validation.test.tsx @@ -9,10 +9,12 @@ * - dirty tracking gates Save/Discard, Discard resets to baseline, and a * successful Save clears the dirty flag. */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { createRef } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { FlowNode } from '../../../../lib/flows/graphAdapter'; +import type { EditableFlowCanvasHandle, EditorSaveMeta } from '../EditableFlowCanvas'; import FlowCanvas from '../FlowCanvas'; const validateFlow = vi.hoisted(() => vi.fn()); @@ -37,9 +39,13 @@ function triggerNode(): FlowNode { const META = { schema_version: 1, id: 'wf_1', name: 'My flow' } as const; -function renderCanvas(props: Partial> = {}) { +function renderCanvas( + props: Partial> = {}, + ref?: React.Ref +) { return render( { errors: ['invalid config for node t: missing schedule'], warnings: [], }); - const { container } = renderCanvas(); + const onSave = vi.fn().mockResolvedValue(undefined); + const onSaveMetaChange = vi.fn<(meta: EditorSaveMeta) => void>(); + const ref = createRef(); + const { container } = renderCanvas({ onSave, onSaveMetaChange }, ref); // Make an edit so the graph is dirty (Save is only ever enabled when dirty). fireEvent.click(screen.getByTestId('flow-palette-item-agent')); @@ -73,8 +82,17 @@ describe('EditableFlowCanvas — validation + dirty state', () => { const errors = await screen.findByTestId('flow-editor-errors'); expect(errors).toHaveTextContent('invalid config for node t: missing schedule'); - // Save is blocked while there are hard errors, even though the graph is dirty. - expect(screen.getByTestId('flow-editor-save')).toBeDisabled(); + // The host header receives a blocked Save state, and the imperative handle + // enforces the same guard if a host tries to invoke it anyway. + await waitFor(() => + expect(onSaveMetaChange).toHaveBeenLastCalledWith({ + dirty: true, + hasErrors: true, + saving: false, + }) + ); + act(() => ref.current?.save()); + expect(onSave).not.toHaveBeenCalled(); // The named node ('t') is ringed with the error class on its RF wrapper. await waitFor(() => @@ -90,7 +108,10 @@ describe('EditableFlowCanvas — validation + dirty state', () => { errors: [], warnings: ['this trigger kind does not fire automatically yet'], }); - renderCanvas(); + const onSave = vi.fn().mockResolvedValue(undefined); + const onSaveMetaChange = vi.fn<(meta: EditorSaveMeta) => void>(); + const ref = createRef(); + renderCanvas({ onSave, onSaveMetaChange }, ref); fireEvent.click(screen.getByTestId('flow-palette-item-agent')); // Auto-validation (debounced) surfaces the warning. @@ -99,71 +120,93 @@ describe('EditableFlowCanvas — validation + dirty state', () => { expect(warnings).toHaveTextContent('does not fire automatically'); // A valid graph never renders the errors list… expect(screen.queryByTestId('flow-editor-errors')).not.toBeInTheDocument(); - // …and Save is allowed (warnings don't block). - expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled(); + // …and the host receives an enabled state (warnings don't block). + await waitFor(() => + expect(onSaveMetaChange).toHaveBeenLastCalledWith({ + dirty: true, + hasErrors: false, + saving: false, + }) + ); + act(() => ref.current?.save()); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); }); it('tracks dirty state: Save/Discard gate on it, Discard resets, Save clears it', async () => { validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); const onSave = vi.fn().mockResolvedValue(undefined); const onDirtyChange = vi.fn(); - renderCanvas({ onSave, onDirtyChange }); - - // Pristine: no dirty badge, Save + Discard disabled. - expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument(); - expect(screen.getByTestId('flow-editor-save')).toBeDisabled(); - expect(screen.getByTestId('flow-editor-discard')).toBeDisabled(); + const onSaveMetaChange = vi.fn<(meta: EditorSaveMeta) => void>(); + const ref = createRef(); + renderCanvas({ onSave, onDirtyChange, onSaveMetaChange }, ref); + + // Pristine: the host receives disabled Save/Discard state. + expect(onSaveMetaChange).toHaveBeenLastCalledWith({ + dirty: false, + hasErrors: false, + saving: false, + }); // Edit → dirty. fireEvent.click(screen.getByTestId('flow-palette-item-agent')); - expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument(); - expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled(); - expect(screen.getByTestId('flow-editor-discard')).not.toBeDisabled(); + expect(onSaveMetaChange).toHaveBeenLastCalledWith({ + dirty: true, + hasErrors: false, + saving: false, + }); expect(onDirtyChange).toHaveBeenLastCalledWith(true); expect(screen.getAllByTestId('flow-node')).toHaveLength(2); // Discard → back to the single trigger, no longer dirty. - fireEvent.click(screen.getByTestId('flow-editor-discard')); + act(() => ref.current?.discard()); expect(screen.getAllByTestId('flow-node')).toHaveLength(1); - expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument(); expect(onDirtyChange).toHaveBeenLastCalledWith(false); // Edit again and Save → onSave called, dirty cleared once it resolves. fireEvent.click(screen.getByTestId('flow-palette-item-agent')); - fireEvent.click(screen.getByTestId('flow-editor-save')); + act(() => ref.current?.save()); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); const graph = onSave.mock.calls[0][0]; expect(graph.nodes.map((n: { kind: string }) => n.kind).sort()).toEqual(['agent', 'trigger']); - await waitFor(() => expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument()); - expect(onDirtyChange).toHaveBeenLastCalledWith(false); + await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(false)); }); it('starts dirty when the host passes initialDirty (a remount carrying unsaved content)', async () => { validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); const onDirtyChange = vi.fn(); + const onSaveMetaChange = vi.fn<(meta: EditorSaveMeta) => void>(); // Mirrors `FlowCanvasPage` remounting the canvas (`key={canvasVersion}`) // after accepting a copilot proposal: the incoming nodes/edges ARE the // component's "initial" graph, so without `initialDirty` the canvas would // seed its baseline from them and instantly read as clean even though // nothing was persisted (the P1 this regression test guards against). - renderCanvas({ onDirtyChange, initialDirty: true }); + renderCanvas({ onDirtyChange, onSaveMetaChange, initialDirty: true }); - expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument(); - expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled(); + expect(onSaveMetaChange).toHaveBeenLastCalledWith({ + dirty: true, + hasErrors: false, + saving: false, + }); expect(onDirtyChange).toHaveBeenLastCalledWith(true); }); it('surfaces a Save failure inline and leaves the graph dirty', async () => { validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); const onSave = vi.fn().mockRejectedValue(new Error('core unreachable')); - renderCanvas({ onSave }); + const onSaveMetaChange = vi.fn<(meta: EditorSaveMeta) => void>(); + const ref = createRef(); + renderCanvas({ onSave, onSaveMetaChange }, ref); fireEvent.click(screen.getByTestId('flow-palette-item-agent')); - fireEvent.click(screen.getByTestId('flow-editor-save')); + act(() => ref.current?.save()); const saveError = await screen.findByTestId('flow-editor-save-error'); expect(saveError).toHaveTextContent('core unreachable'); // Still dirty — nothing persisted. - expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument(); + expect(onSaveMetaChange).toHaveBeenLastCalledWith({ + dirty: true, + hasErrors: false, + saving: false, + }); }); }); From 57fbf9b80422b3f390796208b5374ad9ca0d2379 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 07:25:12 +0000 Subject: [PATCH 08/14] fix(ci): align merged flow checks and clippy --- src/openhuman/agent_registry/agents/loader.rs | 40 ++++++++++++------- src/openhuman/audio_toolkit/ops.rs | 2 +- src/openhuman/cwd_jail/linux.rs | 2 +- src/openhuman/flows/schemas.rs | 4 +- src/openhuman/test_support/introspect.rs | 4 +- 5 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index 7fd4eca00d..628688b4f2 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -1001,18 +1001,20 @@ mod tests { } #[test] - fn workflow_builder_is_registered_worker_with_narrow_propose_or_read_scope() { + fn workflow_builder_is_registered_worker_with_bounded_authoring_scope() { // Phase 5a/5b: the workflow-builder must be a Worker-tier leaf whose - // tool scope is EXACTLY the propose-or-read + Composio discovery/connect - // + confirmed test-run + save-onto-existing belt — no flow creation - // (flows_create/set_enabled), no shell, no file writes, no channel - // sends, and no composio_execute. It can list toolkits/connections, + // tool scope is EXACTLY the bounded authoring/read + Composio + // discovery/connect belt. Creation is limited to `create_workflow` + // and `duplicate_flow`, which always produce disabled flows; the raw + // flows_create/update/set_enabled tools remain unavailable, as do + // shell, file writes, channel sends, and composio_execute. It can list + // toolkits/connections, // raise the inline connect card, `run_flow` a flow the user already // SAVED to test it (a real run the prompt gates behind user // confirmation), and `save_workflow` a built graph onto a flow the host // ALREADY created (the prompt bar's instant-create path) — but it can - // never create/enable a flow or perform an arbitrary raw integration - // action. One narrow, deliberate carve-out (B12): `get_tool_output_sample` + // never enable a flow or perform an arbitrary raw integration action. + // One narrow, deliberate carve-out (B12): `get_tool_output_sample` // DOES make a real Composio call, but only ever a Read-scope one // (hard-refused otherwise, regardless of the user's scope preference) // against an already-connected toolkit — see `builder_tools.rs`'s @@ -1042,16 +1044,27 @@ mod tests { let expected = [ "propose_workflow", "revise_workflow", + "edit_workflow", + "validate_workflow", "save_workflow", "list_flows", "get_flow", + "get_flow_history", "get_flow_run", "list_flow_connections", "search_tool_catalog", "get_tool_contract", "get_tool_output_sample", "list_agent_profiles", + "list_connectable_toolkits", + "list_node_kinds", + "get_node_kind_contract", "dry_run_workflow", + "list_flow_runs", + "resume_flow_run", + "cancel_flow_run", + "create_workflow", + "duplicate_flow", "run_flow", "composio_list_toolkits", "composio_list_connections", @@ -1068,13 +1081,12 @@ mod tests { assert_eq!( names.len(), expected.len(), - "workflow_builder scope must be EXACTLY the propose-or-read belt (got {names:?})" + "workflow_builder scope must be EXACTLY the bounded authoring belt (got {names:?})" ); - // Hard exclusions: nothing that creates/enables a flow, - // executes raw integration actions, or touches the host. - // (Persistence onto an EXISTING flow is the deliberate - // `save_workflow` carve-out above; raw `flows_update` — which - // could also rename/re-gate arbitrary flows — stays out.) + // Hard exclusions: no unrestricted flow mutation, raw + // integration actions, or host access. Creation is exposed + // only through the bounded tools above; raw `flows_update` + // could rename or re-gate arbitrary flows, so it stays out. for forbidden in [ "flows_create", "flows_update", @@ -1090,7 +1102,7 @@ mod tests { ] { assert!( !names.iter().any(|n| n == forbidden), - "workflow_builder must NOT have `{forbidden}` — propose/read only" + "workflow_builder must NOT have unrestricted tool `{forbidden}`" ); } } diff --git a/src/openhuman/audio_toolkit/ops.rs b/src/openhuman/audio_toolkit/ops.rs index 22ea57ec15..fb77b99a5b 100644 --- a/src/openhuman/audio_toolkit/ops.rs +++ b/src/openhuman/audio_toolkit/ops.rs @@ -211,7 +211,7 @@ pub fn resolve_email_capture_dir(config: &Config) -> Option { } #[cfg(feature = "e2e-test-support")] { - return Some(config.workspace_dir.join(DEFAULT_CAPTURE_DIR)); + Some(config.workspace_dir.join(DEFAULT_CAPTURE_DIR)) } #[cfg(not(feature = "e2e-test-support"))] { diff --git a/src/openhuman/cwd_jail/linux.rs b/src/openhuman/cwd_jail/linux.rs index 17471b31d0..c7297ec5fa 100644 --- a/src/openhuman/cwd_jail/linux.rs +++ b/src/openhuman/cwd_jail/linux.rs @@ -35,7 +35,7 @@ impl JailBackend for LandlockBackend { fn is_available(&self) -> bool { #[cfg(feature = "sandbox-landlock")] { - use landlock::{AccessFs, Ruleset, RulesetAttr, RulesetCreatedAttr}; + use landlock::{AccessFs, Ruleset, RulesetAttr}; Ruleset::default() .handle_access(AccessFs::ReadFile) .and_then(|r| r.create()) diff --git a/src/openhuman/flows/schemas.rs b/src/openhuman/flows/schemas.rs index dac1f64167..feee1c1a7c 100644 --- a/src/openhuman/flows/schemas.rs +++ b/src/openhuman/flows/schemas.rs @@ -1694,6 +1694,7 @@ mod tests { "resume", "cancel_run", "list_runs", + "list_all_runs", "get_run", "prune_runs", "build", @@ -1719,7 +1720,7 @@ mod tests { #[test] fn all_registered_controllers_has_handler_per_schema() { let controllers = all_registered_controllers(); - assert_eq!(controllers.len(), 32); + assert_eq!(controllers.len(), 33); let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect(); assert_eq!( names, @@ -1738,6 +1739,7 @@ mod tests { "resume", "cancel_run", "list_runs", + "list_all_runs", "get_run", "prune_runs", "build", diff --git a/src/openhuman/test_support/introspect.rs b/src/openhuman/test_support/introspect.rs index 4bc75daa76..8198deb2c2 100644 --- a/src/openhuman/test_support/introspect.rs +++ b/src/openhuman/test_support/introspect.rs @@ -144,7 +144,7 @@ async fn walk_dir( size: if is_dir { 0 } else { meta.len() }, is_dir, }); - if is_dir && depth + 1 <= max_depth { + if is_dir && depth < max_depth { stack.push((path, depth + 1)); } } @@ -168,7 +168,7 @@ pub async fn list_workspace_files( Ok(RpcOutcome::single_log( ListResult { root: root.display().to_string(), - entries: entries.iter().cloned().collect(), + entries: entries.to_vec(), truncated, }, format!( From 13c94f8922202f59fdd57e29a1062d5f87db4a9f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 09:03:31 +0000 Subject: [PATCH 09/14] fix(i18n): remove forbidden Russian em dash --- app/src/lib/i18n/ru.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index dbd89b9e29..e0f07a5cab 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4111,7 +4111,7 @@ const messages: TranslationMap = { 'flows.promptBar.submit': 'Создать', 'flows.promptBar.startBuilding': 'Начать создание', 'flows.promptBar.disclaimer': - 'Копайлот — это ИИ, и он может ошибаться. Пожалуйста, проверяйте ответы.', + 'Копайлот является ИИ и может ошибаться. Пожалуйста, проверяйте ответы.', 'flows.promptBar.thinking': 'Создание…', 'flows.promptBar.heroTitle': 'Опишите рабочий процесс', 'flows.promptBar.heroSubtitle': From f5da14174c9fe2555bd1cce4e9d557698d925f67 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 10:01:23 +0000 Subject: [PATCH 10/14] fix(test): follow agent iteration policy --- tests/agent_harness_e2e.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/agent_harness_e2e.rs b/tests/agent_harness_e2e.rs index 9f854b7694..8bbd69760a 100644 --- a/tests/agent_harness_e2e.rs +++ b/tests/agent_harness_e2e.rs @@ -1861,7 +1861,8 @@ async fn approval_gate_timeout_inner() { // ─── Task 7: Max iterations + empty provider response ──────────────────────── // // max_iterations_exceeded: -// Default max_tool_iterations = 10 (tool_loop.rs:15). +// The orchestrator's effective max_tool_iterations comes from its agent +// definition (currently 15), rather than the global default of 10. // Circuit breakers (REPEAT_FAILURE_THRESHOLD=3 on failing calls, // NO_PROGRESS_FAILURE_THRESHOLD=6 on consecutive fails) only fire on // success=false outcomes. We must pick a tool that: @@ -1879,9 +1880,9 @@ async fn approval_gate_timeout_inner() { // resolve_time IS in ops.rs (line 192) and in orchestrator named (agent.toml:173). // It's a pure chrono calculation, no I/O, always succeeds. Varying the // `expression` arg (format!("{i}m ago")) gives a different hash each iteration, -// preventing REPEAT_OUTPUT_THRESHOLD from firing. Queuing 12 calls trips the -// max_tool_iterations cap at 10 (DEFAULT_MAX_TOOL_ITERATIONS, tool_loop.rs:15). -// AgentError::MaxIterationsExceeded → "Agent exceeded maximum tool iterations (10)" +// preventing REPEAT_OUTPUT_THRESHOLD from firing. Queuing beyond the +// definition-derived cap trips max_tool_iterations. AgentError::MaxIterationsExceeded → +// "Agent exceeded maximum tool iterations (N)" // (error.rs:89-90; MAX_ITERATIONS_ERROR_PREFIX at error.rs:176). // // empty_provider_response: @@ -1910,11 +1911,19 @@ fn max_iterations_exceeded() { async fn max_iterations_exceeded_inner() { let _lock = env_lock(); - // 12 tool calls > default max_tool_iterations (10). Each uses a unique + init_agent_def_registry(); + let max_iterations = AgentDefinitionRegistry::global() + .and_then(|registry| registry.get("orchestrator")) + .map(|definition| definition.effective_max_iterations()) + .expect("built-in orchestrator definition must exist"); + + // Queue beyond the definition-derived cap. Deriving this count from the + // same definition used by the session builder keeps the regression valid + // when the orchestrator's policy changes. Each call uses a unique // expression to prevent REPEAT_OUTPUT_THRESHOLD from firing first. // resolve_time is a pure computation tool (no I/O) that always succeeds. // The required parameter name is "expr" (resolve_time.rs schema). - let responses: Vec = (0..12) + let responses: Vec = (0..max_iterations + 5) .map(|i| tool_call_completion("resolve_time", json!({ "expr": format!("{}m ago", i + 1) }))) .collect(); reset_script(responses); From 1cdaf43badfdd10bc39c75c10593f1a095d2625c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 10:41:33 +0000 Subject: [PATCH 11/14] fix(test): default new search config fields --- tests/keyring_secretstore_e2e.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/keyring_secretstore_e2e.rs b/tests/keyring_secretstore_e2e.rs index 3d0e6c849c..a5d3fa1cd5 100644 --- a/tests/keyring_secretstore_e2e.rs +++ b/tests/keyring_secretstore_e2e.rs @@ -74,6 +74,7 @@ async fn config_secrets_roundtrip_via_keyring_backed_master_key_migration() { api_key: Some("parallel-secret".into()), ..Default::default() }, + ..Default::default() }, channels_config: openhuman_core::openhuman::config::schema::ChannelsConfig { telegram: Some(TelegramConfig { From cd600d71d70dae195e2b0842b217d497f6cb78d0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 12:10:59 +0000 Subject: [PATCH 12/14] fix(agent): reject tool calls in turn wrapups --- .../agent/harness/session/turn/session_io.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index cce7b01509..115643e056 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -5,7 +5,9 @@ use super::super::types::Agent; use crate::openhuman::agent::harness; use crate::openhuman::agent::progress::AgentProgress; use crate::openhuman::context::ARCHIVIST_EXTRACTION_PROMPT; -use crate::openhuman::inference::provider::{ChatMessage, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS}; +use crate::openhuman::inference::provider::{ + ChatMessage, ChatResponse, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS, +}; use futures::StreamExt; use tinyagents::harness::model::{ModelRequest, ModelStreamItem}; @@ -167,6 +169,22 @@ impl Agent { } else { String::new() }; + if !checkpoint.trim().is_empty() { + let provider_response = ChatResponse { + text: Some(checkpoint.clone()), + tool_calls: Vec::new(), + usage: None, + reasoning_content: None, + }; + let (_, prompt_tool_calls) = self.tool_dispatcher.parse_response(&provider_response); + if !prompt_tool_calls.is_empty() { + tracing::warn!( + parsed_tool_calls = prompt_tool_calls.len(), + "[agent::session] wrap-up model returned a prompt-formatted tool call; using deterministic fallback" + ); + return (String::new(), usage); + } + } (checkpoint, usage) } From efe14af788df8879d1c5511863e439428855c16c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 12:54:54 +0000 Subject: [PATCH 13/14] fix(flows): satisfy warning-denied clippy --- src/openhuman/flows/ops.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 03f399fbde..72de2d8eae 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -4370,7 +4370,7 @@ pub async fn flows_build( fn text_looks_like_question(text: &str) -> bool { let trimmed = text .trim() - .trim_end_matches(|c: char| matches!(c, '"' | '\'' | ')' | ']' | '*' | '_' | '`' | '.')) + .trim_end_matches(['"', '\'', ')', ']', '*', '_', '`', '.']) .trim_end(); if trimmed.is_empty() { return false; @@ -4387,8 +4387,7 @@ fn text_looks_like_question(text: &str) -> bool { // invariant this function exists to protect. trimmed .lines() - .filter(|line| !line.trim().is_empty()) - .next_back() + .rfind(|line| !line.trim().is_empty()) .is_some_and(|last_line| last_line.trim_end().ends_with('?')) } From d9c1c4500a3c83958b25b11701a771fd016d3c21 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 15 Jul 2026 14:16:37 +0000 Subject: [PATCH 14/14] fix(agent): validate wrapups before streaming --- .../agent/harness/session/turn/session_io.rs | 44 +++++++++++++------ .../agent/harness/session/turn_tests.rs | 43 ++++++++++++++++++ .../agent_session_round24_raw_coverage_e2e.rs | 4 +- 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index 115643e056..e98911a9a2 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -71,8 +71,9 @@ impl Agent { /// Ask the provider for a short wrap-up message with native tools /// **disabled** so the model returns prose rather than another tool call. - /// Streams text deltas to the progress sink (when attached) so the summary - /// appears in the UI like any other reply. + /// Buffers text deltas and forwards them to the progress sink (when + /// attached) only after the completed response is validated as prose, so + /// prompt-formatted tool calls cannot flash in the UI before fallback. /// /// `instruction` is the synthetic user turn that steers the wrap-up — the /// tool-call-cap checkpoint (`MAX_ITER_CHECKPOINT_INSTRUCTION`) or the @@ -130,19 +131,13 @@ impl Agent { }; let mut streamed_text = String::new(); + let mut streamed_deltas = Vec::new(); let mut completed = None; while let Some(item) = stream.next().await { match item { ModelStreamItem::MessageDelta(delta) if !delta.text.is_empty() => { streamed_text.push_str(&delta.text); - if let Some(sink) = &self.on_progress { - let _ = sink - .send(AgentProgress::TextDelta { - delta: delta.text, - iteration: iteration_for_stream, - }) - .await; - } + streamed_deltas.push(delta.text); } ModelStreamItem::Completed(response) => completed = Some(response), ModelStreamItem::Failed(error) => { @@ -165,18 +160,25 @@ impl Agent { let checkpoint = if !text.trim().is_empty() { text } else if response.tool_calls().is_empty() { - streamed_text + streamed_text.clone() } else { String::new() }; if !checkpoint.trim().is_empty() { - let provider_response = ChatResponse { + let mut provider_response = ChatResponse { text: Some(checkpoint.clone()), tool_calls: Vec::new(), usage: None, reasoning_content: None, }; - let (_, prompt_tool_calls) = self.tool_dispatcher.parse_response(&provider_response); + let (_, mut prompt_tool_calls) = + self.tool_dispatcher.parse_response(&provider_response); + if streamed_text != checkpoint { + provider_response.text = Some(streamed_text); + let (_, streamed_tool_calls) = + self.tool_dispatcher.parse_response(&provider_response); + prompt_tool_calls.extend(streamed_tool_calls); + } if !prompt_tool_calls.is_empty() { tracing::warn!( parsed_tool_calls = prompt_tool_calls.len(), @@ -184,6 +186,22 @@ impl Agent { ); return (String::new(), usage); } + tracing::debug!( + checkpoint_chars = checkpoint.chars().count(), + buffered_deltas = streamed_deltas.len(), + iteration = iteration_for_stream, + "[agent::session] wrap-up checkpoint validation passed" + ); + if let Some(sink) = &self.on_progress { + for delta in streamed_deltas { + let _ = sink + .send(AgentProgress::TextDelta { + delta, + iteration: iteration_for_stream, + }) + .await; + } + } } (checkpoint, usage) } diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index 4bc467b2cd..c837f21c98 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -1195,6 +1195,49 @@ async fn turn_final_answer_falls_back_to_deterministic_summary_when_reprompt_emp ); } +#[tokio::test] +async fn summarize_turn_wrapup_rejects_prompt_tool_call_and_preserves_usage() { + let provider: Arc = Arc::new(SequenceProvider { + responses: AsyncMutex::new(vec![Ok(ChatResponse { + text: Some("{\"name\":\"echo\",\"arguments\":{}}".into()), + tool_calls: vec![], + usage: Some(UsageInfo { + input_tokens: 13, + output_tokens: 5, + cached_input_tokens: 3, + charged_amount_usd: 0.07, + ..UsageInfo::default() + }), + reasoning_content: None, + })]), + requests: AsyncMutex::new(Vec::new()), + }); + let agent = make_agent_with_builder( + provider, + vec![], + Box::new(FixedMemoryLoader { + context: String::new(), + }), + vec![], + crate::openhuman::config::AgentConfig::default(), + crate::openhuman::config::ContextConfig::default(), + ); + + let (summary, usage) = agent + .summarize_turn_wrapup(&[], "test-model", 1, "write a wrap-up") + .await; + + assert!( + summary.is_empty(), + "prompt-formatted tool calls must trigger the deterministic fallback" + ); + let usage = usage.expect("rejected wrap-up must preserve provider usage"); + assert_eq!(usage.input_tokens, 13); + assert_eq!(usage.output_tokens, 5); + assert_eq!(usage.cached_input_tokens, 3); + assert_eq!(usage.charged_amount_usd, 0.07); +} + #[tokio::test] async fn turn_checkpoint_usage_is_folded_into_transcript_accounting() { // The extra checkpoint provider call costs tokens; those must land in diff --git a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs index 6865e8da0c..c9cd28b790 100644 --- a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs +++ b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs @@ -480,13 +480,13 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { while let Ok(event) = progress_rx.try_recv() { streamed.push(event); } - assert!(streamed.iter().any(|event| matches!( + assert!(!streamed.iter().any(|event| matches!( event, openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta { delta, iteration: 2 } if delta == "checkpoint delta" - ))); + )), "rejected checkpoint deltas must not leak to progress consumers"); } #[tokio::test]