mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
chore(rust): enforce warning-free clippy (#4872)
This commit is contained in:
@@ -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
|
||||
|
||||
+6
-6
@@ -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
|
||||
fi
|
||||
|
||||
+13
@@ -379,6 +379,19 @@ 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"
|
||||
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.
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -226,7 +226,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" }
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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(())
|
||||
}
|
||||
@@ -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_<id>`-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_<id>`-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};
|
||||
|
||||
@@ -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<R: Runtime>(
|
||||
// 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.
|
||||
|
||||
@@ -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<i32>,
|
||||
#[serde(default)]
|
||||
bounds: Vec<Vec<f64>>,
|
||||
}
|
||||
|
||||
/// 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,46 +53,38 @@ pub struct Snapshot {
|
||||
strings: Vec<String>,
|
||||
nodes: NodeTreeSnap,
|
||||
children: Vec<Vec<usize>>,
|
||||
/// `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 `<head>` doesn't). `None` otherwise.
|
||||
rects: Vec<Option<Rect>>,
|
||||
}
|
||||
|
||||
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<Self, String> {
|
||||
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, String> {
|
||||
Self::capture_inner(cdp, session, true).await
|
||||
}
|
||||
|
||||
async fn capture_inner(
|
||||
cdp: &mut CdpConn,
|
||||
session: &str,
|
||||
with_rects: bool,
|
||||
) -> Result<Self, String> {
|
||||
log::debug!("[cdp::snapshot] capture start session={session}");
|
||||
let raw = cdp
|
||||
.call(
|
||||
"DOMSnapshot.captureSnapshot",
|
||||
json!({
|
||||
"computedStyles": [],
|
||||
"includePaintOrder": false,
|
||||
"includeDOMRects": with_rects,
|
||||
}),
|
||||
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
|
||||
@@ -138,12 +101,9 @@ impl Snapshot {
|
||||
let mut merged_node_name: Vec<i32> = Vec::new();
|
||||
let mut merged_node_value: Vec<i32> = Vec::new();
|
||||
let mut merged_attributes: Vec<Vec<i32>> = Vec::new();
|
||||
let mut merged_layout_node_index: Vec<i32> = Vec::new();
|
||||
let mut merged_layout_bounds: Vec<Vec<f64>> = Vec::new();
|
||||
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 });
|
||||
}
|
||||
@@ -162,12 +122,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 {
|
||||
parent_index: merged_parent_index,
|
||||
@@ -176,10 +130,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<usize>> = vec![Vec::new(); count];
|
||||
for (i, &p) in nodes.parent_index.iter().enumerate() {
|
||||
@@ -187,34 +137,11 @@ impl Snapshot {
|
||||
children[p as usize].push(i);
|
||||
}
|
||||
}
|
||||
let mut rects: Vec<Option<Rect>> = 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 {
|
||||
Self {
|
||||
strings,
|
||||
nodes,
|
||||
children,
|
||||
rects,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
@@ -268,13 +195,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.
|
||||
/// `<head>`, comment nodes, `display:none` elements).
|
||||
pub fn rect(&self, idx: usize) -> Option<Rect> {
|
||||
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.
|
||||
@@ -336,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;
|
||||
@@ -357,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");
|
||||
|
||||
@@ -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<CdpTarget> {
|
||||
.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()
|
||||
|
||||
@@ -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")))]
|
||||
|
||||
@@ -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<Arc<Mutex<Vec<String>>>> = OnceLock::new();
|
||||
// Live handler installed by drain_pending_urls — dispatches directly to app.
|
||||
static LIVE_HANDLER: OnceLock<Mutex<Option<Box<dyn Fn(String) + Send + Sync>>>> = OnceLock::new();
|
||||
type LiveHandler = Box<dyn Fn(String) + Send + Sync>;
|
||||
type LiveHandlerSlot = Mutex<Option<LiveHandler>>;
|
||||
static LIVE_HANDLER: OnceLock<LiveHandlerSlot> = OnceLock::new();
|
||||
|
||||
fn pending_queue() -> &'static Arc<Mutex<Vec<String>>> {
|
||||
PENDING_URLS.get_or_init(|| Arc::new(Mutex::new(Vec::new())))
|
||||
}
|
||||
|
||||
fn live_handler() -> &'static Mutex<Option<Box<dyn Fn(String) + Send + Sync>>> {
|
||||
fn live_handler() -> &'static LiveHandlerSlot {
|
||||
LIVE_HANDLER.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -465,13 +465,6 @@ impl ScannerRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
pub fn ensure_scanner<R: tauri::Runtime>(
|
||||
self: std::sync::Arc<Self>,
|
||||
_app: tauri::AppHandle<R>,
|
||||
_account_id: String,
|
||||
) {
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -399,9 +399,7 @@ async fn restart_app(app: tauri::AppHandle<AppRuntime>) -> 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
|
||||
@@ -1214,7 +1212,7 @@ fn mascot_native_window_is_open() -> bool {
|
||||
mascot_native_window::is_open()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "linux")))]
|
||||
fn mascot_native_window_is_open() -> bool {
|
||||
false
|
||||
}
|
||||
@@ -1848,7 +1846,9 @@ async fn perform_early_teardown_async(app_handle: &AppHandle<AppRuntime>) {
|
||||
log::info!("[app] perform_early_teardown_async — early teardown complete");
|
||||
}
|
||||
|
||||
/// Explicitly winds down CEF and Tauri before an app.exit(0)
|
||||
/// 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<AppRuntime>, exit_code: i32) {
|
||||
log::info!("[app] shutdown_app_sync — starting early teardown");
|
||||
perform_early_teardown_sync_once(app_handle, "shutdown_app_sync");
|
||||
|
||||
@@ -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<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
@@ -88,7 +87,6 @@ pub fn start(request_id: String, cdp: CdpConn, session_id: String) -> CaptionLis
|
||||
});
|
||||
|
||||
CaptionListener {
|
||||
request_id,
|
||||
_shutdown_tx: Some(shutdown_tx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// 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<ListenSession, String> {
|
||||
let (tx, rx) = mpsc::channel::<Vec<u8>>(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<Mutex<Resampler>>,
|
||||
tx: &mpsc::Sender<Vec<u8>>,
|
||||
) {
|
||||
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<Vec<u8>>) {
|
||||
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<f32>]) -> Vec<u8> {
|
||||
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<f32> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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<listen_capture::ListenSession>,
|
||||
_speak: speak_pump::SpeakPump,
|
||||
}
|
||||
|
||||
@@ -229,9 +216,7 @@ pub async fn start<R: Runtime>(
|
||||
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.
|
||||
|
||||
@@ -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<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
@@ -130,7 +129,6 @@ pub fn start<R: Runtime>(
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -134,8 +134,8 @@ pub async fn meet_call_open_window<R: Runtime>(
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -191,14 +191,16 @@ 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;
|
||||
}
|
||||
{
|
||||
log::debug!(
|
||||
"[meet-video] initial WebSocket frame send failed; closing writer peer_transport=disconnected"
|
||||
);
|
||||
return;
|
||||
}
|
||||
while latest_rx.changed().await.is_ok() {
|
||||
let frame = latest_rx.borrow().clone();
|
||||
@@ -237,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]
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ pub(crate) fn ensure_window<R: Runtime>(app: &AppHandle<R>) -> Result<(), String
|
||||
return Ok(());
|
||||
}
|
||||
let url = WebviewUrl::App("index.html#/ptt-overlay".into());
|
||||
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)
|
||||
@@ -39,11 +39,9 @@ pub(crate) fn ensure_window<R: Runtime>(app: &AppHandle<R>) -> 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()
|
||||
|
||||
@@ -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()? {
|
||||
|
||||
@@ -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<ExtractedMessage>,
|
||||
HashMap<String, String>,
|
||||
HashMap<String, String>,
|
||||
Option<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) -> HarvestResult {
|
||||
let mut messages: Vec<ExtractedMessage> = Vec::new();
|
||||
let mut users: HashMap<String, String> = HashMap::new();
|
||||
let mut channels: HashMap<String, String> = 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::<Value>(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::<Value>(s) {
|
||||
walk(
|
||||
&inner,
|
||||
channel_hint,
|
||||
messages,
|
||||
users,
|
||||
channels,
|
||||
workspace,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Value>(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::<Value>(s) {
|
||||
walk(&inner, peer_hint, out, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ pub struct MessageRow {
|
||||
pub struct DomScan {
|
||||
pub chat_rows: Vec<ChatRow>,
|
||||
pub messages: Vec<MessageRow>,
|
||||
pub active_chat_name: Option<String>,
|
||||
pub unread: u32,
|
||||
pub hash: u64,
|
||||
}
|
||||
@@ -71,7 +70,6 @@ pub async fn scan(cdp: &mut CdpConn, session: &str) -> Result<DomScan, String> {
|
||||
Ok(DomScan {
|
||||
chat_rows,
|
||||
messages,
|
||||
active_chat_name,
|
||||
unread,
|
||||
hash,
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,11 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
Outcome::Ratelimit
|
||||
} else {
|
||||
Outcome::OtherFail(err.to_string())
|
||||
log::warn!(
|
||||
"[probe-ratelimit] provider failure on call {i}: {}",
|
||||
sanitize_probe_error(err)
|
||||
);
|
||||
Outcome::OtherFail
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -379,11 +386,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::<u128>() / outcomes.len() as u128
|
||||
@@ -568,3 +575,27 @@ fn component_status(endpoint: &Option<String>, model: &Option<String>) -> String
|
||||
_ => "off".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_probe_error(error: &str) -> String {
|
||||
let single_line = error.split_whitespace().collect::<Vec<_>>().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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::Runtime> {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,10 +23,9 @@
|
||||
//! - `<invoke tool=…>` 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;
|
||||
|
||||
@@ -491,7 +491,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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -69,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
|
||||
@@ -128,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) => {
|
||||
@@ -163,10 +160,49 @@ 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 mut provider_response = ChatResponse {
|
||||
text: Some(checkpoint.clone()),
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
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(),
|
||||
"[agent::session] wrap-up model returned a prompt-formatted tool call; using deterministic fallback"
|
||||
);
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1197,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<dyn Provider> = Arc::new(SequenceProvider {
|
||||
responses: AsyncMutex::new(vec![Ok(ChatResponse {
|
||||
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -519,7 +519,6 @@ fn local_catalog_models_from_config(
|
||||
|
||||
fn handle_registry_snapshot(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
use crate::openhuman::tools::Tool as _;
|
||||
use tinyagents::registry::{ComponentKind, ComponentMetadata, RegistrySnapshot};
|
||||
|
||||
let mut components: Vec<ComponentMetadata> = Vec::new();
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -148,29 +148,30 @@ 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.
|
||||
_ => {}
|
||||
} else {
|
||||
log::warn!(
|
||||
"[background_delivery] dropping headless batch session={session} count={}",
|
||||
batch.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<ParallelAgentTask>,
|
||||
definitions: &HashMap<String, AgentDefinition>,
|
||||
parent: &ParentExecutionContext,
|
||||
|
||||
@@ -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
|
||||
@@ -1090,14 +1092,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 reaches the raw flow
|
||||
// controller directly, 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",
|
||||
@@ -1113,7 +1113,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}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::store::JobStore;
|
||||
use super::types::{JobRecord, JobStatus, RunResult};
|
||||
use super::types::{JobStatus, RunResult};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -211,7 +211,7 @@ pub fn resolve_email_capture_dir(config: &Config) -> Option<PathBuf> {
|
||||
}
|
||||
#[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"))]
|
||||
{
|
||||
|
||||
@@ -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<RwLock<Option<Arc<RwLock<Option<String>>>>>> =
|
||||
type ActiveChannelHandle = Arc<RwLock<Option<String>>>;
|
||||
type ActiveChannelHandleSlot = RwLock<Option<ActiveChannelHandle>>;
|
||||
|
||||
static ACTIVE_CHANNEL_HANDLE: std::sync::OnceLock<ActiveChannelHandleSlot> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
fn active_channel_handle_slot() -> &'static RwLock<Option<Arc<RwLock<Option<String>>>>> {
|
||||
fn active_channel_handle_slot() -> &'static ActiveChannelHandleSlot {
|
||||
ACTIVE_CHANNEL_HANDLE.get_or_init(|| RwLock::new(None))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ToolResult> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
#[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<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)> {
|
||||
fn encrypt_for_json(&self, profile: &AuthProfile) -> Result<EncryptedProfileFields> {
|
||||
let (access_token, refresh_token, id_token, expires_at, token_type, scope) =
|
||||
match (&profile.kind, &profile.token_set) {
|
||||
(AuthProfileKind::OAuth, Some(token_set)) => (
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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
|
||||
@@ -29,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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Strin
|
||||
|
||||
/// Check whether `agent_id`'s last read of `resolved_path` was partial.
|
||||
/// Returns an error message when partial, `None` when safe.
|
||||
pub fn check_partial_read(agent_id: &str, resolved_path: &PathBuf) -> Option<String> {
|
||||
pub fn check_partial_read(agent_id: &str, resolved_path: &Path) -> Option<String> {
|
||||
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<Str
|
||||
/// Acquire an async lock on `resolved_path` for a read-modify-write
|
||||
/// section. Returns an `OwnedMutexGuard` that releases when dropped.
|
||||
/// Returns `None` when the coordinator is disabled.
|
||||
pub async fn acquire_path_lock(resolved_path: &PathBuf) -> Option<OwnedMutexGuard<()>> {
|
||||
pub async fn acquire_path_lock(resolved_path: &Path) -> Option<OwnedMutexGuard<()>> {
|
||||
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()
|
||||
};
|
||||
|
||||
@@ -3561,7 +3561,6 @@ pub async fn flows_cancel_run(config: &Config, run_id: &str) -> Result<RpcOutcom
|
||||
/// rejects any non-`pending_approval` status); dropping the checkpoint is
|
||||
/// belt-and-suspenders that also reclaims the storage.
|
||||
async fn drop_checkpoint(config: &Config, thread_id: &str) {
|
||||
use tinyflows::engine::Checkpointer as _;
|
||||
match crate::openhuman::tinyflows::open_flow_checkpointer(config) {
|
||||
Ok(checkpointer) => match checkpointer.delete_thread(thread_id).await {
|
||||
Ok(()) => {
|
||||
@@ -4371,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;
|
||||
@@ -4388,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('?'))
|
||||
}
|
||||
|
||||
|
||||
@@ -2306,7 +2306,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" } ]
|
||||
}));
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<anyhow::Result<(Arc<dyn ChatModel<()>>, String)>> {
|
||||
type ResolvedChatModel = (Arc<dyn ChatModel<()>>, String);
|
||||
type OptionalChatModelResult = Option<anyhow::Result<ResolvedChatModel>>;
|
||||
|
||||
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<anyhow::Result<(Arc<dyn ChatModel<()>>, 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 `@<temp>` suffix still bakes a fixed
|
||||
/// override).
|
||||
fn try_create_cloud_slug_chat_model(
|
||||
role: &str,
|
||||
config: &Config,
|
||||
) -> Option<anyhow::Result<(Arc<dyn ChatModel<()>>, 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<anyhow::Result<(Arc<dyn ChatModel<()>>, 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<anyhow::Result<(Arc<dyn ChatModel<()>>, 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<anyhow::Result<(Arc<dyn ChatModel<()>>, String)>> {
|
||||
) -> OptionalChatModelResult {
|
||||
let p = provider.trim().to_string();
|
||||
|
||||
// Only the "<slug>:<model>[@temp]" cloud form routes here. The managed
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -60,7 +60,7 @@ async fn invoke_cloud_reflection(
|
||||
prompt: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
use tinyagents::harness::message::Message;
|
||||
use tinyagents::harness::model::{ChatModel, ModelRequest};
|
||||
use tinyagents::harness::model::ModelRequest;
|
||||
Ok(provider
|
||||
.invoke(
|
||||
&(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<HashMap<(String, u32, u32), String>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, u32, u32), String>>> = OnceLock::new();
|
||||
type CursorCacheKey = (String, u32, u32);
|
||||
type CursorCache = Mutex<HashMap<CursorCacheKey, String>>;
|
||||
|
||||
fn cursor_cache() -> &'static CursorCache {
|
||||
static CACHE: OnceLock<CursorCache> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
|
||||
@@ -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={}",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ pub(super) async fn tts(text: &str, voice_id: Option<&str>) -> Result<Vec<i16>,
|
||||
// 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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -163,7 +163,7 @@ async fn read_recent_from(path: &Path, limit: usize) -> Result<Vec<MeetCallRecor
|
||||
}
|
||||
// Newest first. Compare on started_at_ms for stability against
|
||||
// future out-of-order writes (e.g. a future async flush race).
|
||||
all.sort_by(|a, b| b.started_at_ms.cmp(&a.started_at_ms));
|
||||
all.sort_by_key(|item| std::cmp::Reverse(item.started_at_ms));
|
||||
all.truncate(limit);
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,7 +10,8 @@ pub const MAX_LIST_LIMIT: u32 = 1_000;
|
||||
/// ways: serialised timestamps are ms-since-epoch (matches the rest of the
|
||||
/// JSON-RPC surface) and the body is replaced with a `≤500-char preview`
|
||||
/// + a flag indicating whether the row has an embedding. UIs needing the
|
||||
/// full body call back via `memory_tree_get_chunk`.
|
||||
///
|
||||
/// The full body calls back via `memory_tree_get_chunk`.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ChunkRow {
|
||||
pub id: String,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,3 @@ pub(crate) fn recover_corrupt_db(config: &Config) -> Result<bool> {
|
||||
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};
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -82,8 +82,10 @@ impl ToolMemoryCaptureHook {
|
||||
// phrases like "I want to stop working" don't trigger false captures.
|
||||
let stop_imperative =
|
||||
lower.starts_with("stop ") || lower.contains(". stop ") || lower.contains("\nstop ");
|
||||
if !(lower.contains("never ") || lower.contains("don't ") || lower.contains("do not "))
|
||||
&& !stop_imperative
|
||||
if !(lower.contains("never ")
|
||||
|| lower.contains("don't ")
|
||||
|| lower.contains("do not ")
|
||||
|| stop_imperative)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
@@ -80,8 +80,8 @@ impl<'a> 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)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user