chore(rust): enforce warning-free clippy (#4872)

This commit is contained in:
Steven Enamakel
2026-07-16 00:04:07 +03:00
committed by GitHub
parent 1feb87317d
commit d320c43823
140 changed files with 731 additions and 1279 deletions
+18 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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" }
-236
View File
@@ -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 -14
View File
@@ -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};
+2 -2
View File
@@ -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.
+77 -104
View File
@@ -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");
-6
View File
@@ -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()
+1 -1
View File
@@ -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")))]
+4 -2
View File
@@ -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) {}
}
+5 -5
View File
@@ -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,
//! 12 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);
}
}
+7 -25
View File
@@ -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.
+2 -7
View File
@@ -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
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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};
+7 -6
View File
@@ -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]
-9
View File
@@ -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}")
}
}
}
}
+4 -6
View File
@@ -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()
+2
View File
@@ -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()? {
+28 -31
View File
@@ -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,
);
}
}
_ => {}
+1 -1
View File
@@ -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);
}
}
_ => {}
+1 -1
View File
@@ -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,
})
+1 -1
View File
@@ -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;
}
}
+1 -3
View File
@@ -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()
+1
View File
@@ -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",
+4 -2
View File
@@ -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();
+37 -6
View File
@@ -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);
}
}
-12
View File
@@ -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
+1 -1
View File
@@ -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,
-2
View File
@@ -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)]
+2 -3
View File
@@ -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;
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
-1
View File
@@ -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();
+1 -1
View File
@@ -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};
+1 -1
View File
@@ -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,
+15 -15
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
use super::store::JobStore;
use super::types::{JobRecord, JobStatus, RunResult};
use super::types::{JobStatus, RunResult};
use std::time::Duration;
#[test]
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"))]
{
+5 -2
View File
@@ -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))
}
+4 -4
View File
@@ -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> {
+2 -2
View File
@@ -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;
-2
View File
@@ -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};
+2 -2
View File
@@ -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"),
+3 -10
View File
@@ -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)]
+11 -12
View File
@@ -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)) => (
+1 -1
View File
@@ -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(())
+7 -1
View File
@@ -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
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+1 -8
View File
@@ -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 {
+5 -5
View File
@@ -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()
};
+2 -4
View File
@@ -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('?'))
}
+1 -1
View File
@@ -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" } ]
}));
-1
View File
@@ -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()
}
+1
View File
@@ -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;
+1
View File
@@ -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.
+9 -12
View File
@@ -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 {
+1 -1
View File
@@ -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(
&(),
+2 -4
View File
@@ -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()))
}
+4 -4
View File
@@ -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={}",
+1 -1
View File
@@ -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,
}
-1
View File
@@ -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;
+1
View File
@@ -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.
+1 -1
View File
@@ -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)
}
-1
View File
@@ -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;
+2 -1
View File
@@ -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,
+4 -5
View File
@@ -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())
}
+4 -2
View File
@@ -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();
}
+2 -2
View File
@@ -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