diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index 870276ae6d..a00dc98a56 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -338,6 +338,7 @@ jobs: with: workspaces: | . -> target + app/src-tauri -> target cache-on-failure: true # shared-key (not key) so the cache name is stable and not suffixed # with the job id. Swatinem caches the full target/, which is why we @@ -349,13 +350,31 @@ 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 # drift (see AGENTS.md "Compile-time domain gates"), so it must run in CI, not - # just locally. Pathfinder lane for #4803 (voice); extend the --features list - # as sibling gates (#4797–#4802, #4804) land. + # just locally. Pathfinder lane for #4803 (voice); the explicit --features + # list (only tokenjuice-treesitter) turns every default gate OFF, so it also + # covers #4804 (media) and each sibling gate as it lands — no edit needed + # unless a new gate must stay ON here. rust-feature-gate-smoke: name: Rust Feature-Gate Smoke (gates off) needs: [changes] @@ -382,7 +401,7 @@ jobs: cache-on-failure: true shared-key: pr-rust-feature-gate-smoke - - name: Check core builds with the voice gate disabled + - name: Check core builds with the voice + media gates disabled run: bash scripts/ci-cancel-aware.sh cargo check --manifest-path Cargo.toml --no-default-features --features tokenjuice-treesitter rust-core-coverage: diff --git a/.husky/pre-push b/.husky/pre-push index 521d8b9c14..d39768b34a 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -93,10 +93,10 @@ pnpm compile COMPILE_EXIT=$? set -e -# Run Rust compile checks for both the core and Tauri codebases +# Run Clippy for both Rust codebases; Clippy also performs compile checks. set +e -pnpm rust:check -RUST_CHECK_EXIT=$? +pnpm rust:clippy +RUST_CLIPPY_EXIT=$? set -e # Enforce scoped cmd-* tokens in components/commands/ @@ -106,7 +106,7 @@ CMD_TOKENS_EXIT=$? set -e # Exit with error if any command still fails after fixes -if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CHECK_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then +if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CLIPPY_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then echo echo "============================================================" echo "Pre-push checks failed." @@ -116,10 +116,10 @@ if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] || echo " git add -A && git commit -m 'chore: apply auto-fixes'" echo " git push" fi - if [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CHECK_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then + if [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CLIPPY_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then echo "Fix the remaining errors above (TypeScript / Rust / cmd-tokens)" echo "before re-pushing — these have no auto-fix path." fi echo "============================================================" exit 1 -fi \ No newline at end of file +fi diff --git a/AGENTS.md b/AGENTS.md index 03ecc7e77c..b279ed219f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -233,11 +233,14 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml \ | Feature | Default | Gates | Drops deps | | ------- | ------- | ----- | ---------- | | `voice` | ON | `openhuman::voice` + `openhuman::audio_toolkit` domains — STT/TTS providers, dictation server, always-on listening, podcast audio + email | `hound`, `lettre` | +| `media` | ON | `openhuman::media_generation` (the `media_generate_*` agent tools) + `openhuman::image` scaffold | none (surface-only) | **Facade pattern (pathfinder for the other gates).** `pub mod voice;` is **always compiled** as a facade: the real submodules are `#[cfg(feature = "voice")]`, and a `#[cfg(not(feature = "voice"))] mod stub;` (`src/openhuman/voice/stub.rs`) re-exposes the same public surface that always-on / other-gated callers use (`server`, `dictation_listener`, `streaming`, `reply_speech`, `cloud_transcribe`, `cli`, `create_stt_provider`, `effective_stt_provider`, `publish_ptt_transcript_committed`) with no-op / `None` / disabled-error bodies. Callers therefore do **not** need per-call `#[cfg]`. When voice is off: the voice/audio controllers are unregistered (unknown-method over `/rpc`, absent from `/schema`), the `audio_generate_podcast` agent tools are absent, and `openhuman voice` returns a "voice disabled" error. Stub signatures must match the real ones exactly — the disabled build (`--no-default-features --features tokenjuice-treesitter`) is the **only** thing that catches drift, so run it before pushing any change to the voice surface. **Scope note:** the `voice` gate does **not** drop `whisper-rs` / `llama` / `cpal`. Those live in the inference domain (`src/openhuman/inference/local/service/whisper_engine.rs`; `cpal` is shared with accessibility) and await a separate future `inference` gate. The issue-level DoD line claiming whisper is dropped is superseded by this scope correction. +**Leaf-gate variant (`media`, #4804).** Unlike `voice`, the `media` gate needs **no** stub facade: `media_generation` has a single caller (the `build_media_tools` call in `src/openhuman/tools/ops.rs`, itself `#[cfg(feature = "media")]`) and `openhuman::image` is unwired scaffold (#2997), so both modules are simply `#[cfg(feature = "media")] pub mod …`. It is a **surface-only** gate: media generation is backend-proxied (`reqwest`, shared) and the `image` crate is shared with channel upload, so no exclusive deps are shed — the issue's "sheds media processing dependencies" / "controllers unregistered" DoD lines are superseded (Media is agent-tools-only; no controller/store/subscriber is tagged `Media`). When a gated domain is a true leaf, prefer this over the facade+stub. + ### Event bus (`src/core/event_bus/`) Typed pub/sub + native request/response. Both singletons — use module-level functions. diff --git a/Cargo.toml b/Cargo.toml index a97f9bcf4d..687d98be92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -334,7 +334,7 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice"] +default = ["tokenjuice-treesitter", "voice", "media"] # AST-aware code compression (tree-sitter Rust/TS/Python grammars; C build). # On by default; disable to fall back to the brace-depth heuristic. tokenjuice-treesitter = [ @@ -351,6 +351,18 @@ tokenjuice-treesitter = [ # inference domain (shared with accessibility for cpal) and await a separate # `inference` gate. voice = ["dep:hound", "dep:lettre"] +# Media-generation + image domains: the `media_generate_*` agent tools +# (image/video via GMI through the backend) and the `openhuman::image` tool +# contracts scaffold. Default-ON. Slim builds opt out via +# `--no-default-features --features ""`. +# Composes with the runtime `DomainSet::media` flag (#4796). +# NOTE: this gate sheds NO exclusive dependencies — media generation is +# backend-proxied (reqwest, shared) and the `image` crate is shared with +# channel media upload. It is a surface-only gate (drops the tool code + +# module from the compile), not a dependency-shedding one. There are no +# controllers / stores / subscribers tagged `Media` (agent tools only), and +# `openhuman::image` is currently unwired scaffold (added #2997). +media = [] sandbox-landlock = ["dep:landlock"] sandbox-bubblewrap = [] peripheral-rpi = ["dep:rppal"] @@ -367,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. diff --git a/app/package.json b/app/package.json index 27ffa59526..3375a3c36b 100644 --- a/app/package.json +++ b/app/package.json @@ -62,7 +62,7 @@ "rust:check": "cargo check --manifest-path src-tauri/Cargo.toml", "rust:format": "cargo fmt --manifest-path ../Cargo.toml --all && cargo fmt --manifest-path src-tauri/Cargo.toml --all", "rust:format:check": "cargo fmt --manifest-path ../Cargo.toml --all --check && cargo fmt --manifest-path src-tauri/Cargo.toml --all --check", - "rust:clippy": "cargo clippy -p openhuman -- -D warnings", + "rust:clippy": "cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings", "format": "prettier --write . && pnpm rust:format", "format:check": "prettier --check . && pnpm rust:format:check", "lint": "eslint . --ext .ts,.tsx --cache", diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index e412f0fc8f..80bcd618fb 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -3564,6 +3564,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "html5ever" version = "0.29.1" @@ -5608,9 +5614,11 @@ dependencies = [ "hkdf", "hmac 0.12.1", "hostname", + "hound", "iana-time-zone", "image", "keyring", + "lettre", "libc", "log", "mail-parser", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 087eccdf11..cda89b62c1 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -132,7 +132,31 @@ cef = { version = "=146.4.1", default-features = false } # by tying the core's lifetime to the GUI process. The existing port-7788 # probe in `core_process::ensure_running` still attaches to a running # `openhuman-core run` harness when one is already listening. -openhuman_core = { path = "../..", package = "openhuman", default-features = false } +# +# `default-features = false` (set in #1061, before the compile-time domain +# gates existed) means the embedded core does NOT inherit the root crate's +# default gate set, so each default-ON gate must be forwarded explicitly to +# keep the shipped desktop build byte-identical (AGENTS.md "Compile-time +# domain gates"). +# +# This list is NOT optional polish — a gate missing here vanishes from the +# shipped app silently, with no build error and no test failure: +# +# - `voice` — without it the `#[cfg(feature = "voice")]` controllers in +# `src/core/all.rs` are never registered, so the whole `openhuman.voice_*` +# namespace answers "unknown method" at runtime. This shipped broken from +# v0.58.19 to v0.61.x (#4901); the `VOICE_COMPILED_IN` const assert at the +# top of `src/lib.rs` now fails the build if it is dropped again. +# - `media` — re-registers the `media_generate_*` agent tools that #4804 moved +# behind `#[cfg(feature = "media")]`; it sheds no deps, so this only restores +# the pre-gate desktop tool surface. +# +# `tokenjuice-treesitter` is the remaining un-forwarded default — tracked in +# #4918, deliberately not bundled here because dropping it may be intentional. +openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ + "media", + "voice", +] } tinyjuice = { version = "0.2.1", default-features = false } [target.'cfg(unix)'.dependencies] @@ -216,7 +240,7 @@ tinyagents = { path = "../../vendor/tinyagents" } # TinyAgents so integration work can test crate changes against OpenHuman before # publishing. tinyflows = { path = "../../vendor/tinyflows" } -tinycortex = { path = "../../vendor/tinycortex", features = ["git-diff"] } +tinycortex = { path = "../../vendor/tinycortex" } tinyjuice = { path = "../../vendor/tinyjuice" } tinychannels = { path = "../../vendor/tinychannels" } tinyplace = { path = "../../vendor/tinyplace/sdk/rust" } diff --git a/app/src-tauri/src/cdp/input.rs b/app/src-tauri/src/cdp/input.rs deleted file mode 100644 index cd97330286..0000000000 --- a/app/src-tauri/src/cdp/input.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Thin helpers around `Input.dispatchMouseEvent` and -//! `Input.dispatchKeyEvent` so providers can drive web UIs without -//! touching the page's JavaScript. -//! -//! All coordinates are CSS pixels relative to the viewport — the same -//! frame `DOMSnapshot.captureSnapshot(includeDOMRects=true)` returns -//! bounding rects in. Callers typically pair these with -//! [`crate::cdp::Snapshot::rect`] to find the click target. -//! -//! Everything here is CEF-only — CDP requires a remote-debugging port, -//! which wry doesn't expose. -//! -//! # Cookbook -//! -//! ```ignore -//! let snap = Snapshot::capture_with_rects(&mut cdp, &session).await?; -//! let idx = snap.find_descendant(0, |s, i| s.attr(i, "aria-label") == Some("Search mail")) -//! .ok_or("search box not found")?; -//! let rect = snap.rect(idx).ok_or("search box has no layout rect")?; -//! let (cx, cy) = rect.center(); -//! input::click(&mut cdp, &session, cx, cy).await?; -//! input::type_text(&mut cdp, &session, "from:linkedin.com").await?; -//! input::press_key(&mut cdp, &session, Key::Enter).await?; -//! ``` - -use serde_json::{json, Value}; - -use super::CdpConn; - -#[allow(dead_code)] // helper is used by currently gated input paths. -#[cfg(target_os = "macos")] -const SELECT_ALL_MODIFIER: u32 = 4; -#[allow(dead_code)] // helper is used by currently gated input paths. -#[cfg(not(target_os = "macos"))] -const SELECT_ALL_MODIFIER: u32 = 2; - -/// Names recognised by `Input.dispatchKeyEvent`'s `key` field. We -/// hand-pick the ones Gmail's keyboard handlers care about so callers -/// can use a typed value rather than stringly-typed literals scattered -/// across providers. -#[allow(dead_code)] // variants reserved for upcoming providers / write ops. -#[derive(Debug, Clone, Copy)] -pub enum Key { - Enter, - Escape, - Tab, - Backspace, - ArrowDown, - ArrowUp, -} - -impl Key { - /// `(key, code, windowsVirtualKeyCode)` triple. Gmail's listeners - /// branch on different fields depending on browser; we set all three - /// to maximise compatibility. - fn cdp_fields(self) -> (&'static str, &'static str, u32) { - match self { - Key::Enter => ("Enter", "Enter", 13), - Key::Escape => ("Escape", "Escape", 27), - Key::Tab => ("Tab", "Tab", 9), - Key::Backspace => ("Backspace", "Backspace", 8), - Key::ArrowDown => ("ArrowDown", "ArrowDown", 40), - Key::ArrowUp => ("ArrowUp", "ArrowUp", 38), - } - } -} - -/// Click at `(x, y)` — left button, no modifiers, single click. -/// Issues mouseMoved → mousePressed → mouseReleased so hover handlers -/// (Gmail's search-box has one) fire correctly before the click. -pub async fn click(cdp: &mut CdpConn, session: &str, x: f64, y: f64) -> Result<(), String> { - log::debug!("[cdp::input] click session={session} x={x:.1} y={y:.1}"); - let _ = mouse_event(cdp, session, "mouseMoved", x, y, 0).await?; - let _ = mouse_event(cdp, session, "mousePressed", x, y, 1).await?; - let _ = mouse_event(cdp, session, "mouseReleased", x, y, 1).await?; - log::debug!("[cdp::input] click complete session={session}"); - Ok(()) -} - -async fn mouse_event( - cdp: &mut CdpConn, - session: &str, - kind: &str, - x: f64, - y: f64, - click_count: u32, -) -> Result { - log::debug!( - "[cdp::input] mouse_event session={session} kind={kind} x={x:.1} y={y:.1} clicks={click_count}" - ); - cdp.call( - "Input.dispatchMouseEvent", - json!({ - "type": kind, - "x": x, - "y": y, - "button": "left", - "buttons": if kind == "mousePressed" { 1 } else { 0 }, - "clickCount": click_count, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchMouseEvent {kind}: {e}")) -} - -/// Type a literal string by dispatching one `keyDown`/`char`/`keyUp` -/// triple per character. CDP's `dispatchKeyEvent type=char` is what -/// actually inserts text into focused editable fields — `keyDown` -/// alone leaves the input empty for most letters. The `keyDown` -/// + `keyUp` pair is still needed so listeners (autocomplete, -/// keystroke counters) see a normal keystroke. -pub async fn type_text(cdp: &mut CdpConn, session: &str, text: &str) -> Result<(), String> { - log::debug!( - "[cdp::input] type_text session={session} chars={}", - text.chars().count() - ); - for ch in text.chars() { - let s = ch.to_string(); - // keyDown — Gmail's command/keyboard router observes these. - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "keyDown", - "text": s, - "unmodifiedText": s, - "key": s, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent keyDown {ch:?}: {e}"))?; - // char — actual text insertion into the focused editable. - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "char", - "text": s, - "unmodifiedText": s, - "key": s, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent char {ch:?}: {e}"))?; - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "keyUp", - "text": s, - "unmodifiedText": s, - "key": s, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent keyUp {ch:?}: {e}"))?; - } - log::debug!("[cdp::input] type_text complete session={session}"); - Ok(()) -} - -/// Press a non-character key (Enter, Esc, …). Sends `rawKeyDown` → -/// `keyUp`; no `char` because non-printables don't insert text. -pub async fn press_key(cdp: &mut CdpConn, session: &str, key: Key) -> Result<(), String> { - let (key_name, code, vk) = key.cdp_fields(); - log::debug!("[cdp::input] press_key session={session} key={key_name}"); - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "rawKeyDown", - "key": key_name, - "code": code, - "windowsVirtualKeyCode": vk, - "nativeVirtualKeyCode": vk, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent rawKeyDown {key_name}: {e}"))?; - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "keyUp", - "key": key_name, - "code": code, - "windowsVirtualKeyCode": vk, - "nativeVirtualKeyCode": vk, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent keyUp {key_name}: {e}"))?; - log::debug!("[cdp::input] press_key complete session={session} key={key_name}"); - Ok(()) -} - -/// Dispatch Cmd/Ctrl+A to select-all in the focused contenteditable / input. -/// Useful when the search box already has a previous query in it that -/// we need to overwrite — Gmail keeps the last query rendered in the -/// search input so a fresh visit sees stale text. -pub async fn select_all_in_focused(cdp: &mut CdpConn, session: &str) -> Result<(), String> { - log::debug!( - "[cdp::input] select_all_in_focused session={session} modifier={SELECT_ALL_MODIFIER}" - ); - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "rawKeyDown", - "key": "a", - "code": "KeyA", - "windowsVirtualKeyCode": 65, - "nativeVirtualKeyCode": 65, - "modifiers": SELECT_ALL_MODIFIER, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent select-all keyDown: {e}"))?; - cdp.call( - "Input.dispatchKeyEvent", - json!({ - "type": "keyUp", - "key": "a", - "code": "KeyA", - "windowsVirtualKeyCode": 65, - "nativeVirtualKeyCode": 65, - "modifiers": SELECT_ALL_MODIFIER, - }), - Some(session), - ) - .await - .map_err(|e| format!("Input.dispatchKeyEvent select-all keyUp: {e}"))?; - log::debug!("[cdp::input] select_all_in_focused complete session={session}"); - Ok(()) -} diff --git a/app/src-tauri/src/cdp/mod.rs b/app/src-tauri/src/cdp/mod.rs index 3c360e5a68..17cede9959 100644 --- a/app/src-tauri/src/cdp/mod.rs +++ b/app/src-tauri/src/cdp/mod.rs @@ -6,29 +6,21 @@ //! and `Webview::on_dev_tools_protocol`. There is no listener and no //! network surface; any same-UID process is shut out by construction. //! -//! Scanners pick up a [`CdpConn`] either via [`conn_for_account`] (for -//! `acct_`-labelled webviews) or [`conn_for_label`] / -//! [`connect_and_attach_matching_in_process_by_label`] (for other +//! Scanners pick up a [`CdpConn`] either via [`target::conn_for_account`] (for +//! `acct_`-labelled webviews) or [`target::conn_for_label`] / +//! [`target::connect_and_attach_matching_in_process_by_label`] (for other //! surfaces such as the Meet call window). pub mod conn; pub mod in_process; -pub mod input; pub mod session; pub mod snapshot; pub mod target; pub use conn::CdpConn; -pub use in_process::{ - install_for_account, install_for_label, install_for_webview, set_cef_app_handle, CdpRegistry, - EventFrame, WebviewCdpTransport, CALL_TIMEOUT, -}; +pub use in_process::{install_for_account, install_for_label, set_cef_app_handle, CdpRegistry}; pub use session::{ placeholder_marker, placeholder_url, spawn_session, target_url_fragment, SpawnedSession, }; -#[allow(unused_imports)] // `Rect` re-export consumed once turn 2 lands; keep stable. -pub use snapshot::{Rect, Snapshot}; -pub use target::{ - conn_for_account, conn_for_label, connect_and_attach_matching_in_process, - connect_and_attach_matching_in_process_by_label, detach_session, find_page_target_where, -}; +pub use snapshot::Snapshot; +pub use target::{detach_session, find_page_target_where}; diff --git a/app/src-tauri/src/cdp/session.rs b/app/src-tauri/src/cdp/session.rs index 92a0626ba9..77a8d429b9 100644 --- a/app/src-tauri/src/cdp/session.rs +++ b/app/src-tauri/src/cdp/session.rs @@ -24,8 +24,8 @@ use tokio::task::JoinHandle; // elapsed check honours `tokio::time::pause()` / `advance()` in unit tests. use tokio::time::{sleep, Instant}; +use super::find_page_target_where; use super::target::conn_for_account; -use super::{find_page_target_where, CdpConn}; use crate::webview_accounts::{emit_load_finished, redact_url_for_log, RevealTrigger}; /// Backoff between failed attach attempts / reconnects. Intentionally @@ -517,7 +517,7 @@ async fn run_session_cycle( // page reaches the CEF helper's notify-IPC, which posts back to // `forward_native_notification` in `webview_accounts`. Without it, // the constructor silently no-ops and no toast ever fires (#1016). - if let Some(origin) = origin_of(&real_url) { + if let Some(origin) = origin_of(real_url) { // Default permission set every embedded provider needs. Origin-scoped // so we don't leak grants across providers running in the same CEF // browser process. diff --git a/app/src-tauri/src/cdp/snapshot.rs b/app/src-tauri/src/cdp/snapshot.rs index 9228e3675e..dc33a1b3ce 100644 --- a/app/src-tauri/src/cdp/snapshot.rs +++ b/app/src-tauri/src/cdp/snapshot.rs @@ -33,35 +33,6 @@ struct CaptureSnapshot { struct DocumentSnap { #[serde(default)] nodes: NodeTreeSnap, - #[serde(default)] - layout: LayoutSnap, -} - -/// Subset of `documents[i].layout` we care about. Each layout node -/// references a DOM node by index and carries its `[x, y, w, h]` bounds -/// in CSS pixels. Only populated when `includeDOMRects: true` is passed -/// to `DOMSnapshot.captureSnapshot`. -#[derive(Deserialize, Debug, Default)] -struct LayoutSnap { - #[serde(rename = "nodeIndex", default)] - node_index: Vec, - #[serde(default)] - bounds: Vec>, -} - -/// Element bounding rect in CSS pixels relative to the viewport. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Rect { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} - -impl Rect { - pub fn center(self) -> (f64, f64) { - (self.x + self.width / 2.0, self.y + self.height / 2.0) - } } #[derive(Deserialize, Debug, Default)] @@ -82,46 +53,38 @@ pub struct Snapshot { strings: Vec, nodes: NodeTreeSnap, children: Vec>, - /// `rects[node_idx]` is `Some(Rect)` when layout info was requested - /// AND the node has a layout box (text + element nodes do; pure - /// metadata like `` doesn't). `None` otherwise. - rects: Vec>, } impl Snapshot { /// 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::capture_inner(cdp, session, false).await - } - - /// Same as [`capture`] but also requests element bounding rects. - /// Use when the caller needs to drive `Input.dispatchMouseEvent` — - /// pulling rects on every snapshot adds protocol overhead, so the - /// cheap path stays cheap. - pub async fn capture_with_rects(cdp: &mut CdpConn, session: &str) -> Result { - Self::capture_inner(cdp, session, true).await - } - - async fn capture_inner( - cdp: &mut CdpConn, - session: &str, - with_rects: bool, - ) -> Result { + 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 = Vec::new(); let mut merged_node_value: Vec = Vec::new(); let mut merged_attributes: Vec> = Vec::new(); - let mut merged_layout_node_index: Vec = Vec::new(); - let mut merged_layout_bounds: Vec> = Vec::new(); for document in snap.documents { let doc_offset = merged_node_type.len() as i32; let doc_nodes = document.nodes; - 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![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> = 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. - /// ``, comment nodes, `display:none` elements). - pub fn rect(&self, idx: usize) -> Option { - self.rects.get(idx).copied().flatten() - } - /// Depth-first pre-order walk of every descendant of `root` (including /// `root` itself). Cheap enough for chat-list scrapes that run every /// 2 seconds — DOM has thousands of nodes, not millions. @@ -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"); diff --git a/app/src-tauri/src/cdp/target.rs b/app/src-tauri/src/cdp/target.rs index 93e8736213..7a57c8502e 100644 --- a/app/src-tauri/src/cdp/target.rs +++ b/app/src-tauri/src/cdp/target.rs @@ -18,7 +18,6 @@ pub struct CdpTarget { pub id: String, pub kind: String, pub url: String, - pub title: String, } /// Parse the response of a `Target.getTargets` CDP call into a list of @@ -38,11 +37,6 @@ pub fn parse_targets(v: &Value) -> Vec { .and_then(|u| u.as_str()) .unwrap_or("") .to_string(), - title: t - .get("title") - .and_then(|u| u.as_str()) - .unwrap_or("") - .to_string(), }) }) .collect() diff --git a/app/src-tauri/src/claude_code.rs b/app/src-tauri/src/claude_code.rs index f3b3a3deec..c70f960cc0 100644 --- a/app/src-tauri/src/claude_code.rs +++ b/app/src-tauri/src/claude_code.rs @@ -61,7 +61,7 @@ end tell"#; Err(_) => continue, } } - return Err("no terminal emulator found (tried x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm). Run `claude login` manually.".into()); + Err("no terminal emulator found (tried x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm). Run `claude login` manually.".into()) } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] diff --git a/app/src-tauri/src/deep_link_ipc.rs b/app/src-tauri/src/deep_link_ipc.rs index 42fe98b869..857ee4a855 100644 --- a/app/src-tauri/src/deep_link_ipc.rs +++ b/app/src-tauri/src/deep_link_ipc.rs @@ -108,13 +108,15 @@ pub(crate) fn try_forward_deep_links() -> ForwardResult { // Pending URLs collected before setup() has an app handle. static PENDING_URLS: OnceLock>>> = OnceLock::new(); // Live handler installed by drain_pending_urls — dispatches directly to app. -static LIVE_HANDLER: OnceLock>>> = OnceLock::new(); +type LiveHandler = Box; +type LiveHandlerSlot = Mutex>; +static LIVE_HANDLER: OnceLock = OnceLock::new(); fn pending_queue() -> &'static Arc>> { PENDING_URLS.get_or_init(|| Arc::new(Mutex::new(Vec::new()))) } -fn live_handler() -> &'static Mutex>> { +fn live_handler() -> &'static LiveHandlerSlot { LIVE_HANDLER.get_or_init(|| Mutex::new(None)) } diff --git a/app/src-tauri/src/discord_scanner/mod_tests.rs b/app/src-tauri/src/discord_scanner/mod_tests.rs index ad7345d2d8..7278419079 100644 --- a/app/src-tauri/src/discord_scanner/mod_tests.rs +++ b/app/src-tauri/src/discord_scanner/mod_tests.rs @@ -339,7 +339,6 @@ fn page(id: &str, url: &str) -> crate::cdp::target::CdpTarget { id: id.to_string(), kind: "page".to_string(), url: url.to_string(), - title: String::new(), } } @@ -409,7 +408,6 @@ fn resolve_none_when_no_prefix_target() { id: "iframe".to_string(), kind: "iframe".to_string(), url: "https://discord.com/channels/@me".to_string(), - title: String::new(), }, ]; assert!( diff --git a/app/src-tauri/src/imessage_scanner/mod.rs b/app/src-tauri/src/imessage_scanner/mod.rs index 2593190a8f..289fd7cac0 100644 --- a/app/src-tauri/src/imessage_scanner/mod.rs +++ b/app/src-tauri/src/imessage_scanner/mod.rs @@ -465,13 +465,6 @@ impl ScannerRegistry { pub fn new() -> Self { Self } - pub fn ensure_scanner( - self: std::sync::Arc, - _app: tauri::AppHandle, - _account_id: String, - ) { - } - pub fn shutdown(&self) {} } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 9cac2be8d8..ee15b7897e 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,6 +3,20 @@ #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] compile_error!("src-tauri host supports desktop (Windows/macOS/Linux) only. Mobile lives in app/src-tauri-mobile."); +// The shipped desktop app must always embed the real voice domain. Cargo +// features are per-crate, so `#[cfg(feature = "voice")]` here would test THIS +// crate's features, not the core's — a voice-less core is only observable via +// the core's own always-compiled facade. Without this assert the failure is +// silent and runtime-only: every `openhuman.voice_*` RPC answers "unknown +// method" and the UI blames a stale sidecar (#4901). Keep `voice` in the +// `openhuman_core` feature list in Cargo.toml to satisfy this. +const _: () = assert!( + openhuman_core::openhuman::voice::VOICE_COMPILED_IN, + "openhuman_core must be built with the `voice` feature: the desktop app ships voice, \ + and without it every openhuman.voice_* controller is unregistered (#4901). \ + Add \"voice\" to the openhuman_core `features` list in app/src-tauri/Cargo.toml." +); + mod app_update; // Artifact export commands (#2779, #3162) — both cross-platform // (macOS/Windows/Linux): native Save-As dialog (rfd) + Downloads copy. @@ -399,9 +413,7 @@ async fn restart_app(app: tauri::AppHandle) -> Result<(), String> { perform_early_teardown_async(&app).await; log::info!("[app] restart_app — early teardown complete, restarting"); - app.restart(); - // restart() does not return, but we must satisfy the signature - Ok(()) + app.restart() } /// Read the authoritative active user id from `active_user.toml` so the @@ -1214,7 +1226,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 +1860,9 @@ async fn perform_early_teardown_async(app_handle: &AppHandle) { log::info!("[app] perform_early_teardown_async — early teardown complete"); } -/// Explicitly winds down CEF and Tauri before an app.exit(0) +/// Explicitly wind down CEF and the core before exiting from desktop-owned +/// quit actions. Linux does not build the tray or macOS application menu. +#[cfg(not(target_os = "linux"))] fn shutdown_app_sync(app_handle: &AppHandle, exit_code: i32) { log::info!("[app] shutdown_app_sync — starting early teardown"); perform_early_teardown_sync_once(app_handle, "shutdown_app_sync"); diff --git a/app/src-tauri/src/meet_audio/caption_listener.rs b/app/src-tauri/src/meet_audio/caption_listener.rs index 637c4ee7bc..e7e07878ae 100644 --- a/app/src-tauri/src/meet_audio/caption_listener.rs +++ b/app/src-tauri/src/meet_audio/caption_listener.rs @@ -2,8 +2,8 @@ //! `captions_bridge.js` we install at session start, and forwards each //! new line to core's `meet_agent_push_caption` RPC. //! -//! Replaces the old [`super::listen_capture`] (CEF audio handler → -//! Whisper STT) which proved unreliable: CEF's `cef_audio_handler_t` +//! Replaces the old CEF audio-handler and Whisper STT path, which proved +//! unreliable: CEF's `cef_audio_handler_t` //! is queried lazily on first audio output, so a solo agent in a //! lobby never engaged the pipeline. Captions handle that case for //! free — Meet's STT is already running, speaker-attributed, and @@ -34,7 +34,6 @@ const MAX_CONSECUTIVE_ERRORS: u32 = 30; /// RAII handle. Drop to stop the listener task. pub struct CaptionListener { - pub request_id: String, pub(crate) _shutdown_tx: Option>, } @@ -88,7 +87,6 @@ pub fn start(request_id: String, cdp: CdpConn, session_id: String) -> CaptionLis }); CaptionListener { - request_id, _shutdown_tx: Some(shutdown_tx), } } diff --git a/app/src-tauri/src/meet_audio/listen_capture.rs b/app/src-tauri/src/meet_audio/listen_capture.rs deleted file mode 100644 index 493a52e378..0000000000 --- a/app/src-tauri/src/meet_audio/listen_capture.rs +++ /dev/null @@ -1,327 +0,0 @@ -//! Capture the embedded Meet webview's audio output and forward it to -//! the core meet_agent loop. -//! -//! ## Pipeline -//! -//! 1. `tauri_runtime_cef::audio::register_audio_handler` taps the -//! per-browser `cef_audio_handler_t`. CEF delivers planar -//! float32 PCM at the renderer's native rate (typically 48 kHz, -//! 1–2 channels) directly from the audio output device path — -//! *before* it hits the OS speaker. No system permission needed. -//! -//! 2. Downsample-to-mono runs inline on the CEF audio thread: -//! - average across channels → mono float32 -//! - linear-interpolate down to 16 kHz (the rate `voice::streaming` -//! and the smoke test in `meet_agent::session` expect) -//! - clamp + scale to PCM16LE -//! -//! 3. Accumulate ~100 ms per chunk (1 600 samples @ 16 kHz). We push -//! via the core RPC on every flush boundary; smaller pushes would -//! overload the JSON envelope, larger ones would slow VAD. -//! -//! 4. RPC pushes are spawned on the tokio runtime so the audio -//! callback never blocks on network IO. A bounded channel -//! backpressures: if core is wedged, we drop the oldest queued -//! chunk rather than holding CEF's audio thread. - -use std::sync::{Arc, Mutex}; - -use tauri_runtime_cef::audio::{ - register_audio_handler, AudioHandlerRegistration, AudioStreamEvent, -}; -use tokio::sync::mpsc; - -const TARGET_SAMPLE_RATE: u32 = 16_000; -/// 100 ms @ 16 kHz mono. `meet_agent::ops::Vad` pushes hangover counts -/// based on per-frame cadence, so changing this changes the VAD wall -/// time too. 100 ms feels responsive without burning RPC. -const FLUSH_SAMPLES: usize = (TARGET_SAMPLE_RATE as usize) / 10; -/// Bounded channel between the CEF callback (producer) and the -/// async-runtime forwarder (consumer). 32 chunks ≈ 3.2 s at the flush -/// cadence — generous slack for transient core latency, but bounded -/// so a wedged core can't OOM us. -const FORWARD_CHANNEL_CAPACITY: usize = 32; - -/// RAII handle. Drop to release the CEF audio registration and shut -/// down the forwarder task. Both happen synchronously — the channel -/// closes first, the task exits its recv loop, and the registration -/// drop unhooks CEF in the same tick. -pub struct ListenSession { - pub request_id: String, - _registration: AudioHandlerRegistration, - /// Held so `Drop` closes the channel even if there are no in-flight - /// chunks. The forwarder task observes the close and exits. - _shutdown_tx: mpsc::Sender>, -} - -/// Opens the audio capture for `meet_url`. The same exact URL must -/// have been used to build the CEF window — `register_audio_handler` -/// matches by prefix. -pub fn start(meet_url: &str, request_id: String) -> Result { - let (tx, rx) = mpsc::channel::>(FORWARD_CHANNEL_CAPACITY); - let resampler = Arc::new(Mutex::new(Resampler::new())); - - let resampler_for_handler = resampler.clone(); - let tx_for_handler = tx.clone(); - let request_id_for_log = request_id.clone(); - let registration = register_audio_handler(meet_url.to_string(), move |event| { - on_audio_event( - &request_id_for_log, - event, - &resampler_for_handler, - &tx_for_handler, - ); - }); - - spawn_forwarder(request_id.clone(), rx); - - log::info!( - "[meet-audio] listen registered request_id={} url_chars={}", - request_id, - meet_url.chars().count() - ); - - Ok(ListenSession { - request_id, - _registration: registration, - _shutdown_tx: tx, - }) -} - -/// Process one CEF audio event. Speech/Stopped/Error all flow through -/// here; only `Packet` produces RPC traffic, but the others are logged -/// at info so an aborted call leaves a breadcrumb in the file logs. -fn on_audio_event( - request_id: &str, - event: AudioStreamEvent, - resampler: &Arc>, - tx: &mpsc::Sender>, -) { - match event { - AudioStreamEvent::Started { - sample_rate_hz, - channels, - frames_per_buffer, - } => { - log::info!( - "[meet-audio] cef stream start request_id={request_id} hz={sample_rate_hz} channels={channels} frames_per_buffer={frames_per_buffer}" - ); - if let Ok(mut r) = resampler.lock() { - r.reset(sample_rate_hz as u32); - } - } - AudioStreamEvent::Packet { - channels: planes, - pts_ms: _, - } => { - let pcm_bytes = match resampler.lock() { - Ok(mut r) => r.feed_and_drain(&planes), - Err(_) => return, - }; - for chunk in pcm_bytes.chunks(FLUSH_SAMPLES * 2) { - // `try_send` drops the chunk on a full channel rather - // than blocking the CEF audio thread. Better to lose - // a frame than to stall the renderer. - if tx.try_send(chunk.to_vec()).is_err() { - log::warn!( - "[meet-audio] forward channel full; dropping {} bytes request_id={request_id}", - chunk.len() - ); - } - } - } - AudioStreamEvent::Stopped => { - log::info!("[meet-audio] cef stream stopped request_id={request_id}"); - if let Ok(mut r) = resampler.lock() { - r.reset(0); - } - } - AudioStreamEvent::Error(msg) => { - log::warn!("[meet-audio] cef stream error request_id={request_id} msg={msg}"); - } - } -} - -/// Pull chunks off the bounded channel and POST each to core. Lives in -/// its own task so the CEF callback never blocks on HTTP. -fn spawn_forwarder(request_id: String, mut rx: mpsc::Receiver>) { - tauri::async_runtime::spawn(async move { - use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; - while let Some(chunk) = rx.recv().await { - let pcm_b64 = B64.encode(&chunk); - let res = super::rpc_call( - "openhuman.meet_agent_push_listen_pcm", - serde_json::json!({ - "request_id": request_id, - "pcm_base64": pcm_b64, - }), - ) - .await; - if let Err(err) = res { - log::debug!( - "[meet-audio] push_listen_pcm err request_id={request_id} bytes={} err={err}", - chunk.len() - ); - } - } - log::info!("[meet-audio] forwarder exiting request_id={request_id}"); - }); -} - -/// Stateful float32-planar → PCM16LE mono @ 16 kHz resampler. -/// -/// Uses linear interpolation, which is good enough for speech (the -/// downstream STT does not care about ultrasonics or pristine high -/// frequencies). Carry the previous sample across `feed_and_drain` -/// calls so we don't introduce a tick at every CEF buffer boundary. -/// Pick a source sample by signed index. Negative indices return the -/// carry sample from the previous call (so phase < 0 keeps the -/// interpolation continuous across buffer boundaries); past-the-end -/// indices clamp to the last sample (which is what the next call will -/// install as its own carry, so the output stays smooth even if a -/// caller stops feeding mid-stream). -fn sample_at(mono: &[f32], carry: f32, idx: i64) -> f32 { - if idx < 0 { - carry - } else if (idx as usize) < mono.len() { - mono[idx as usize] - } else { - *mono.last().unwrap_or(&0.0) - } -} - -struct Resampler { - source_rate_hz: u32, - /// Fractional position into the source buffer between calls. - /// 0.0 means "start cleanly with the next sample". Negative is - /// not used — the source rate is always known before we feed. - phase: f64, - /// Last source sample of the previous call, used as the "left" - /// neighbour when we interpolate the first sample of the next call. - last_sample: f32, -} - -impl Resampler { - fn new() -> Self { - Self { - source_rate_hz: 0, - phase: 0.0, - last_sample: 0.0, - } - } - - fn reset(&mut self, source_rate_hz: u32) { - self.source_rate_hz = source_rate_hz; - self.phase = 0.0; - self.last_sample = 0.0; - } - - fn feed_and_drain(&mut self, planes: &[Vec]) -> Vec { - if planes.is_empty() || self.source_rate_hz == 0 { - return Vec::new(); - } - let frames = planes[0].len(); - if frames == 0 { - return Vec::new(); - } - // Mono mix. - let mono: Vec = (0..frames) - .map(|i| { - let mut sum = 0.0_f32; - for plane in planes { - if let Some(v) = plane.get(i) { - sum += *v; - } - } - sum / planes.len() as f32 - }) - .collect(); - - let ratio = self.source_rate_hz as f64 / TARGET_SAMPLE_RATE as f64; - let mut out = Vec::with_capacity((mono.len() as f64 / ratio).ceil() as usize * 2); - // `pos` floats through `mono` indices. `pos < 0` means "still - // sampling the carry sample from the previous call"; `pos = 0` - // means "right at mono[0]". - let mut pos = self.phase; - while pos < mono.len() as f64 { - let idx_f = pos.floor(); - let frac = pos - idx_f; - let idx = idx_f as i64; - let s_left = sample_at(mono.as_slice(), self.last_sample, idx); - let s_right = sample_at(mono.as_slice(), self.last_sample, idx + 1); - let sample = s_left as f64 + (s_right as f64 - s_left as f64) * frac; - // Float32 [-1.0, 1.0] → i16. Clamp because Chromium can - // overshoot a touch on heavy compression. - let s_i16 = (sample.clamp(-1.0, 1.0) * i16::MAX as f64) as i16; - out.extend_from_slice(&s_i16.to_le_bytes()); - pos += ratio; - } - // Carry the trailing fractional position into the next call. - // It will be negative when we overshot (next call resumes - // mid-source-sample), so the next call interpolates between - // `last_sample` and the new mono[0]. - self.phase = pos - mono.len() as f64; - self.last_sample = *mono.last().unwrap_or(&0.0); - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resampler_with_no_source_rate_yields_nothing() { - let mut r = Resampler::new(); - let out = r.feed_and_drain(&[vec![0.5; 100]]); - assert!(out.is_empty(), "no source rate set, must produce nothing"); - } - - #[test] - fn resampler_48k_to_16k_mono_drops_samples_3to1() { - let mut r = Resampler::new(); - r.reset(48_000); - let plane = vec![0.5_f32; 4_800]; // 100ms @ 48k - let bytes = r.feed_and_drain(&[plane]); - // 100ms @ 16k = 1600 samples * 2 bytes. Allow ±2 samples slop - // from the fractional phase carry. - let samples = bytes.len() / 2; - assert!( - (1598..=1602).contains(&samples), - "expected ~1600 samples, got {samples}" - ); - } - - #[test] - fn resampler_stereo_to_mono_averages_channels() { - let mut r = Resampler::new(); - r.reset(16_000); - let left = vec![0.8_f32; 1600]; - let right = vec![-0.2_f32; 1600]; - let bytes = r.feed_and_drain(&[left, right]); - // Avg = 0.3 → ~9830 in i16. First two bytes are LE i16. - let first = i16::from_le_bytes([bytes[0], bytes[1]]); - assert!( - (9000..11000).contains(&first), - "expected mid-amplitude i16, got {first}" - ); - } - - #[test] - fn resampler_clamps_out_of_range_floats() { - let mut r = Resampler::new(); - r.reset(16_000); - let bytes = r.feed_and_drain(&[vec![5.0_f32; 100]]); - let first = i16::from_le_bytes([bytes[0], bytes[1]]); - assert_eq!(first, i16::MAX); - } - - #[test] - fn resampler_passthrough_when_rates_match() { - let mut r = Resampler::new(); - r.reset(16_000); - let plane = vec![0.5_f32; 1600]; - let bytes = r.feed_and_drain(&[plane]); - assert_eq!(bytes.len(), 1600 * 2); - } -} diff --git a/app/src-tauri/src/meet_audio/mod.rs b/app/src-tauri/src/meet_audio/mod.rs index a60f582b9b..5af6674b49 100644 --- a/app/src-tauri/src/meet_audio/mod.rs +++ b/app/src-tauri/src/meet_audio/mod.rs @@ -2,12 +2,8 @@ //! //! ## Pieces //! -//! - [`listen_capture`] — taps the embedded Meet webview's audio output -//! via the per-browser `CefAudioHandler` exposed by our vendored -//! `tauri-runtime-cef::audio` extension, downsamples to 16 kHz mono -//! PCM16LE, batches into ~100 ms chunks, and posts them to core via -//! `openhuman.meet_agent_push_listen_pcm`. Zero OS-level audio -//! permission needed: we read frames straight out of the renderer. +//! - [`caption_listener`] — drains Meet's built-in captions through CDP +//! and forwards speaker-attributed lines to core. //! //! - [`speak_pump`] — drains synthesized PCM the brain enqueued (via //! `openhuman.meet_agent_poll_speech`) and writes it into the @@ -19,15 +15,12 @@ //! //! [`start`] is invoked once the meet-call window has been built (in //! `meet_call::meet_call_open_window`). It opens the core session, -//! registers the audio handler keyed by the call's URL, and spawns the -//! poll-speech loop. [`stop`] runs from the window-destroyed handler: -//! it drops the audio handler registration (which silences capture -//! immediately), stops the speak pump, and tells core to close the -//! session and report counters. +//! starts the caption listener and poll-speech loop. [`stop`] runs from +//! the window-destroyed handler: it stops both tasks and tells core to +//! close the session and report counters. pub mod caption_listener; pub mod inject; -pub mod listen_capture; pub mod speak_pump; use std::collections::HashMap; @@ -54,14 +47,8 @@ impl MeetAudioState { /// teardown synchronously — no async drop needed because the caption /// listener and speak pump both shut down on signal/drop. /// -/// The legacy CEF-audio `listen_capture::ListenSession` is kept as an -/// optional field so the pre-register flow still has somewhere to -/// hand the registration off if a future build re-enables it. In the -/// caption-driven path it stays `None`. pub struct MeetAudioSession { - pub request_id: String, _captions: caption_listener::CaptionListener, - _legacy_listen: Option, _speak: speak_pump::SpeakPump, } @@ -229,9 +216,7 @@ pub async fn start( state.inner.lock().unwrap().insert( request_id.clone(), MeetAudioSession { - request_id: request_id.clone(), _captions: captions, - _legacy_listen: None, _speak: speak, }, ); @@ -353,11 +338,8 @@ pub(crate) async fn rpc_call( /// No-op caption listener used when CDP attach failed at session /// start. Lets the rest of the lifecycle hold a uniform value. -fn caption_listener_disabled(request_id: String) -> caption_listener::CaptionListener { - caption_listener::CaptionListener { - request_id, - _shutdown_tx: None, - } +fn caption_listener_disabled(_request_id: String) -> caption_listener::CaptionListener { + caption_listener::CaptionListener { _shutdown_tx: None } } /// Trim a string for logging without panicking on multi-byte chars. diff --git a/app/src-tauri/src/meet_audio/speak_pump.rs b/app/src-tauri/src/meet_audio/speak_pump.rs index 2cda72a79d..a8ab64a743 100644 --- a/app/src-tauri/src/meet_audio/speak_pump.rs +++ b/app/src-tauri/src/meet_audio/speak_pump.rs @@ -48,7 +48,6 @@ const SPEAKING_STATE_EVENT: &str = "meet-video:speaking-state"; /// RAII handle. Drop to stop the pump task. The shutdown channel /// causes the spawned loop to exit on the next select tick. pub struct SpeakPump { - pub request_id: String, _shutdown_tx: Option>, } @@ -130,7 +129,6 @@ pub fn start( }); SpeakPump { - request_id, _shutdown_tx: Some(shutdown_tx), } } @@ -277,11 +275,8 @@ fn next_speaking_state( /// No-op pump used when bridge install failed at session start. Keeps /// the rest of the session lifecycle uniform — `MeetAudioSession` can /// still hold a `SpeakPump` regardless of speak-path readiness. -pub fn start_disabled(request_id: String) -> SpeakPump { - SpeakPump { - request_id, - _shutdown_tx: None, - } +pub fn start_disabled(_request_id: String) -> SpeakPump { + SpeakPump { _shutdown_tx: None } } /// Run a single pump tick. Returns `true` when the tick actually diff --git a/app/src-tauri/src/meet_call/mod.rs b/app/src-tauri/src/meet_call/mod.rs index 592aa2789b..809b7355ec 100644 --- a/app/src-tauri/src/meet_call/mod.rs +++ b/app/src-tauri/src/meet_call/mod.rs @@ -134,8 +134,8 @@ pub async fn meet_call_open_window( } // Only one meet-call window can be live at a time — concurrent bot - // sessions race the CEF audio handler registration (`listen_capture`) - // and confuse the user with multiple "Meet — OpenHuman" windows in + // sessions race the shared audio bridge and confuse the user with + // multiple "Meet — OpenHuman" windows in // their Dock. Close any stragglers from a prior Join before opening // a fresh one. The CloseRequested handler will tear down their // scanner + audio session via the per-window event listeners below. diff --git a/app/src-tauri/src/meet_scanner/mod.rs b/app/src-tauri/src/meet_scanner/mod.rs index 9e8525fd9d..1e96ee81bc 100644 --- a/app/src-tauri/src/meet_scanner/mod.rs +++ b/app/src-tauri/src/meet_scanner/mod.rs @@ -35,7 +35,7 @@ use std::time::Duration; use serde_json::{json, Value}; -use tauri::{AppHandle, Manager, Runtime}; +use tauri::{AppHandle, Runtime}; use crate::cdp::{self, CdpConn}; diff --git a/app/src-tauri/src/meet_video/frame_bus.rs b/app/src-tauri/src/meet_video/frame_bus.rs index c8454ba542..537d0bd349 100644 --- a/app/src-tauri/src/meet_video/frame_bus.rs +++ b/app/src-tauri/src/meet_video/frame_bus.rs @@ -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] diff --git a/app/src-tauri/src/ptt_hotkeys.rs b/app/src-tauri/src/ptt_hotkeys.rs index 743ccadb6c..04956e0401 100644 --- a/app/src-tauri/src/ptt_hotkeys.rs +++ b/app/src-tauri/src/ptt_hotkeys.rs @@ -14,8 +14,6 @@ pub(crate) enum PttError { EmptyShortcut, ModifierOnlyShortcut, ConflictsWithDictation(String), - UnsupportedOnWayland, - RegistrationFailed(String), } impl std::fmt::Display for PttError { @@ -29,13 +27,6 @@ impl std::fmt::Display for PttError { PttError::ConflictsWithDictation(s) => { write!(f, "ptt shortcut '{s}' conflicts with the dictation hotkey") } - PttError::UnsupportedOnWayland => write!( - f, - "global shortcuts are not supported in this Wayland session — switch to X11 or use in-app dictation" - ), - PttError::RegistrationFailed(s) => { - write!(f, "failed to register ptt shortcut: {s}") - } } } } diff --git a/app/src-tauri/src/ptt_overlay.rs b/app/src-tauri/src/ptt_overlay.rs index 90bdfeb50d..6cfb824e68 100644 --- a/app/src-tauri/src/ptt_overlay.rs +++ b/app/src-tauri/src/ptt_overlay.rs @@ -23,7 +23,7 @@ pub(crate) fn ensure_window(app: &AppHandle) -> 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(app: &AppHandle) -> Result<(), String .visible(false); #[cfg(target_os = "macos")] - { - builder = builder - .visible_on_all_workspaces(true) - .accept_first_mouse(false); - } + let builder = builder + .visible_on_all_workspaces(true) + .accept_first_mouse(false); let _window = builder .build() diff --git a/app/src-tauri/src/screen_capture/mod.rs b/app/src-tauri/src/screen_capture/mod.rs index a18ac07438..3614fd294d 100644 --- a/app/src-tauri/src/screen_capture/mod.rs +++ b/app/src-tauri/src/screen_capture/mod.rs @@ -82,6 +82,7 @@ pub struct ScreenSource { /// What kind of source a parsed DesktopMediaID-format string describes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg(any(target_os = "macos", test))] pub(crate) enum SourceKind { Screen, Window, @@ -93,6 +94,7 @@ pub(crate) enum SourceKind { /// match what the enumerator emits. Pure logic so it can be unit-tested /// without touching platform APIs; macOS callers use it before dispatching /// to the capture backend. +#[cfg(any(target_os = "macos", test))] pub(crate) fn parse_source_id(id: &str) -> Option<(SourceKind, u32)> { let mut parts = id.splitn(3, ':'); let kind = match parts.next()? { diff --git a/app/src-tauri/src/slack_scanner/extract.rs b/app/src-tauri/src/slack_scanner/extract.rs index c2a9e40aa9..9b8600b1d7 100644 --- a/app/src-tauri/src/slack_scanner/extract.rs +++ b/app/src-tauri/src/slack_scanner/extract.rs @@ -35,16 +35,16 @@ pub struct ExtractedMessage { pub ts: String, } -/// Main entry: walks every record in the dump and returns -/// `(messages, user_id → display_name, channel_id → name, workspace_name)`. -pub fn harvest( - dump: &IdbDump, -) -> ( +type HarvestResult = ( Vec, HashMap, HashMap, Option, -) { +); + +/// Main entry: walks every record in the dump and returns +/// `(messages, user_id → display_name, channel_id → name, workspace_name)`. +pub fn harvest(dump: &IdbDump) -> HarvestResult { let mut messages: Vec = Vec::new(); let mut users: HashMap = HashMap::new(); let mut channels: HashMap = HashMap::new(); @@ -173,15 +173,13 @@ fn walk( .or_insert_with(|| n.to_string()); } } - 'T' => { - if workspace.is_none() { - if let Some(n) = map - .get("name") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - *workspace = Some(n.to_string()); - } + 'T' if workspace.is_none() => { + if let Some(n) = map + .get("name") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + *workspace = Some(n.to_string()); } } _ => {} @@ -222,24 +220,23 @@ fn walk( ); } } - Value::String(s) => { - // Redux-persist default: values are JSON-encoded strings. If - // this string is plausibly JSON, parse and recurse. + Value::String(s) if s.len() > 20 && (s.starts_with('{') || s.starts_with('[')) - && (s.ends_with('}') || s.ends_with(']')) - { - if let Ok(inner) = serde_json::from_str::(s) { - walk( - &inner, - channel_hint, - messages, - users, - channels, - workspace, - depth + 1, - ); - } + && (s.ends_with('}') || s.ends_with(']')) => + { + // Redux-persist default: values are JSON-encoded strings. If + // this string is plausibly JSON, parse and recurse. + if let Ok(inner) = serde_json::from_str::(s) { + walk( + &inner, + channel_hint, + messages, + users, + channels, + workspace, + depth + 1, + ); } } _ => {} diff --git a/app/src-tauri/src/slack_scanner/idb.rs b/app/src-tauri/src/slack_scanner/idb.rs index 2631284ce5..d4f5a9031f 100644 --- a/app/src-tauri/src/slack_scanner/idb.rs +++ b/app/src-tauri/src/slack_scanner/idb.rs @@ -283,7 +283,7 @@ async fn serialize_values( chunk.len() )); } - for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) { + for ((idx, _), val) in chunk.iter().zip(serialised) { result[*idx] = val; } } diff --git a/app/src-tauri/src/telegram_scanner/extract.rs b/app/src-tauri/src/telegram_scanner/extract.rs index 40e3b41c09..6f9d02e4b7 100644 --- a/app/src-tauri/src/telegram_scanner/extract.rs +++ b/app/src-tauri/src/telegram_scanner/extract.rs @@ -166,16 +166,15 @@ fn walk(v: &Value, peer_hint: Option<&str>, out: &mut Harvest, depth: u32) { walk(vv, peer_hint, out, depth + 1); } } - Value::String(s) => { - // Some tweb stores persist state as JSON-encoded strings. - // Recurse when the shape looks plausibly JSON. + Value::String(s) if s.len() > 20 && (s.starts_with('{') || s.starts_with('[')) - && (s.ends_with('}') || s.ends_with(']')) - { - if let Ok(inner) = serde_json::from_str::(s) { - walk(&inner, peer_hint, out, depth + 1); - } + && (s.ends_with('}') || s.ends_with(']')) => + { + // Some tweb stores persist state as JSON-encoded strings. + // Recurse when the shape looks plausibly JSON. + if let Ok(inner) = serde_json::from_str::(s) { + walk(&inner, peer_hint, out, depth + 1); } } _ => {} diff --git a/app/src-tauri/src/telegram_scanner/idb.rs b/app/src-tauri/src/telegram_scanner/idb.rs index 84d88dd765..5fd1fe173b 100644 --- a/app/src-tauri/src/telegram_scanner/idb.rs +++ b/app/src-tauri/src/telegram_scanner/idb.rs @@ -283,7 +283,7 @@ async fn serialize_values( chunk.len() )); } - for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) { + for ((idx, _), val) in chunk.iter().zip(serialised) { result[*idx] = val; } } diff --git a/app/src-tauri/src/wechat_scanner/dom_snapshot.rs b/app/src-tauri/src/wechat_scanner/dom_snapshot.rs index b750d9a1b4..5afe84739a 100644 --- a/app/src-tauri/src/wechat_scanner/dom_snapshot.rs +++ b/app/src-tauri/src/wechat_scanner/dom_snapshot.rs @@ -23,7 +23,6 @@ pub struct MessageRow { pub struct DomScan { pub chat_rows: Vec, pub messages: Vec, - pub active_chat_name: Option, pub unread: u32, pub hash: u64, } @@ -71,7 +70,6 @@ pub async fn scan(cdp: &mut CdpConn, session: &str) -> Result { Ok(DomScan { chat_rows, messages, - active_chat_name, unread, hash, }) diff --git a/app/src-tauri/src/whatsapp_scanner/idb.rs b/app/src-tauri/src/whatsapp_scanner/idb.rs index 9a61b6e251..9b8388ab7e 100644 --- a/app/src-tauri/src/whatsapp_scanner/idb.rs +++ b/app/src-tauri/src/whatsapp_scanner/idb.rs @@ -237,7 +237,7 @@ async fn serialize_values( chunk.len() )); } - for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) { + for ((idx, _), val) in chunk.iter().zip(serialised) { result[*idx] = val; } } diff --git a/app/src-tauri/src/whatsapp_scanner/mod.rs b/app/src-tauri/src/whatsapp_scanner/mod.rs index c43ecdbdf6..bb71050528 100644 --- a/app/src-tauri/src/whatsapp_scanner/mod.rs +++ b/app/src-tauri/src/whatsapp_scanner/mod.rs @@ -29,8 +29,6 @@ use tauri::{AppHandle, Emitter, Runtime}; use tokio::task::AbortHandle; use tokio::time::sleep; -use crate::cdp::CdpConn; - mod dom_snapshot; #[cfg(test)] mod dom_snapshot_tests; @@ -1209,7 +1207,7 @@ fn merge_dom_into_snapshot( continue; } if let Some(mid) = mid_opt { - let bare_mid = mid.rsplitn(2, '_').next().map(str::to_string); + let bare_mid = mid.rsplit('_').next().map(str::to_string); let lookup = by_msg_id .get(&mid) .cloned() diff --git a/app/src/components/flows/FlowListRow.test.tsx b/app/src/components/flows/FlowListRow.test.tsx index 05e7c24105..6405143cff 100644 --- a/app/src/components/flows/FlowListRow.test.tsx +++ b/app/src/components/flows/FlowListRow.test.tsx @@ -50,13 +50,13 @@ describe('FlowListRow', () => { it('renders the flow name and reflects enabled state on the toggle', () => { renderRow(); expect(screen.getByText('Daily digest')).toBeInTheDocument(); - // The toggle is an icon button; state is conveyed via aria-pressed, not text. - expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-pressed', 'true'); + // The toggle is a SettingsSwitch (role=switch); state is conveyed via aria-checked. + expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-checked', 'true'); }); it('reflects paused state on the toggle when disabled', () => { renderRow({ flow: makeFlow({ enabled: false }) }); - expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-checked', 'false'); }); it('shows "Never run" when the flow has no last_run_at', () => { @@ -147,9 +147,16 @@ describe('FlowListRow', () => { expect(onDuplicate).toHaveBeenCalledWith(makeFlow()); }); - it('deletes via the direct Delete icon (not the menu)', () => { + it('routes Delete through the overflow menu', () => { const { onDelete } = renderRow(); - fireEvent.click(screen.getByTestId('flow-delete-flow-1')); + // Delete is a destructive secondary action now — behind the "⋯" menu, not + // a standalone icon button in the flat row. + expect(screen.queryByTestId('flow-delete-flow-1')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('flow-menu-flow-1')); + + const deleteItem = screen.getByTestId('flow-delete-flow-1'); + expect(deleteItem).toHaveTextContent('Delete'); + fireEvent.click(deleteItem); expect(onDelete).toHaveBeenCalledWith(makeFlow()); }); }); diff --git a/app/src/components/flows/FlowListRow.tsx b/app/src/components/flows/FlowListRow.tsx index ea063788a3..b1c6e5dcc5 100644 --- a/app/src/components/flows/FlowListRow.tsx +++ b/app/src/components/flows/FlowListRow.tsx @@ -3,14 +3,17 @@ * * Mirrors the row layout of `CoreJobList` * (`app/src/components/settings/panels/cron/CoreJobList.tsx`): name + status - * badge header, a line of run metadata, then a row of `Button` actions. Swaps - * the cron "pause/resume" text button for a `SettingsSwitch` toggle (the - * canonical boolean control — see `components/settings/controls`) since - * enable/disable here is a persistent setting, not a one-off action. + * badge header, a line of run metadata, then a row of `Button` actions. Uses + * the canonical `SettingsSwitch` boolean control (`components/settings/controls`) + * for enable/disable, since that's a persistent setting, not a one-off action — + * not an icon `Button`, so its state reads at a glance instead of needing a + * hover/title to disambiguate on vs. off. * * "View runs" (issue B5a.1) opens `FlowRunsDrawer` (mounted by `FlowsPage`) * for this flow's run history — re-added now that B3b's run inspector has - * landed and the drawer has somewhere to send the user. + * landed and the drawer has somewhere to send the user. Delete lives in the + * same overflow menu (destructive actions shouldn't sit in the flat button + * row next to Run/toggle, where a mis-click is one tap away). * * The flow name (issue B5b.1) is itself the "View" affordance for the new * read-only Workflow Canvas: it's rendered as a button that calls `onView`, @@ -22,6 +25,7 @@ */ import { useT } from '../../lib/i18n/I18nContext'; import type { Flow } from '../../services/api/flowsApi'; +import SettingsSwitch from '../settings/controls/SettingsSwitch'; import Button from '../ui/Button'; import FlowRowMenu from './FlowRowMenu'; @@ -33,41 +37,6 @@ function PlayIcon() { ); } -function PowerIcon() { - // On/off — enabled vs. paused (distinct from Run's play triangle). - return ( - - ); -} - -function TrashIcon() { - return ( - - ); -} - /** Which of this row's actions currently has a request in flight, if any. */ export type FlowListRowBusy = 'toggle' | 'run' | null; @@ -153,27 +122,18 @@ const FlowListRow = ({ {/* All controls sit together on the right: the toggle (enabled/paused — - the switch alone communicates state), then Run, Delete, and an overflow - menu with the secondary actions (view runs / export / duplicate). */} + the switch alone communicates state), then Run, and an overflow menu + with the secondary/destructive actions (view runs / export / + duplicate / delete). */}
- + aria-label={t('flows.list.toggleEnabled')} + data-testid={`flow-toggle-${flow.id}`} + /> - onDuplicate(flow), testId: `flow-duplicate-${flow.id}`, }, + { + key: 'delete', + label: t('flows.list.delete'), + onSelect: () => onDelete(flow), + danger: true, + testId: `flow-delete-${flow.id}`, + }, ]} />
diff --git a/app/src/components/flows/FlowRunsDrawer.test.tsx b/app/src/components/flows/FlowRunsDrawer.test.tsx index ad61f6ee90..d762ff6806 100644 --- a/app/src/components/flows/FlowRunsDrawer.test.tsx +++ b/app/src/components/flows/FlowRunsDrawer.test.tsx @@ -12,7 +12,7 @@ * `FlowRunInspectorDrawer.test.tsx`) so this suite only exercises the * run-history list + the nesting contract between the two drawers. */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -23,6 +23,9 @@ import { FlowRunsDrawer } from './FlowRunsDrawer'; const listFlowRuns = vi.hoisted(() => vi.fn()); vi.mock('../../services/api/flowsApi', () => ({ listFlowRuns })); +const fetchPendingApprovals = vi.hoisted(() => vi.fn()); +vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals })); + const FlowRunInspectorDrawer = vi.hoisted(() => vi.fn()); vi.mock('./FlowRunInspectorDrawer', () => ({ FLOW_RUN_STATUS_ACCENT: { @@ -87,6 +90,7 @@ function renderDrawer(flowId: string | null, onClose: () => void, flowName?: str describe('FlowRunsDrawer', () => { beforeEach(() => { vi.clearAllMocks(); + fetchPendingApprovals.mockResolvedValue([]); }); it('renders null when flowId is null', () => { @@ -123,6 +127,41 @@ describe('FlowRunsDrawer', () => { expect(row).toHaveTextContent('Completed with warnings'); }); + it('shows "Awaiting approval" for a running run halted at a matching flow approval gate', async () => { + listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1', status: 'running' })]); + fetchPendingApprovals.mockResolvedValue([ + { + request_id: 'req-1', + tool_name: 'SLACK_SEND_MESSAGE', + action_summary: 'Send Slack message', + args_redacted: {}, + session_id: 'session-1', + created_at: '2026-01-01T00:00:00Z', + expires_at: null, + source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' }, + }, + ]); + renderDrawer('flow-1', vi.fn()); + + const row = await screen.findByTestId('flow-run-row-run-1'); + await waitFor(() => expect(row).toHaveTextContent('Awaiting approval')); + expect(screen.getByTestId('flow-run-row-dot-run-1').className.includes('dot-pending')).toBe( + true + ); + }); + + it('leaves a running run without a matching flow approval labeled "Running"', async () => { + listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1', status: 'running' })]); + fetchPendingApprovals.mockResolvedValue([]); + renderDrawer('flow-1', vi.fn()); + + const row = await screen.findByTestId('flow-run-row-run-1'); + await waitFor(() => expect(row).toHaveTextContent('Running')); + expect(screen.getByTestId('flow-run-row-dot-run-1').className.includes('dot-running')).toBe( + true + ); + }); + it('falls back to a generic title when no flowName is given', async () => { listFlowRuns.mockResolvedValue([]); renderDrawer('flow-1', vi.fn()); @@ -207,4 +246,80 @@ describe('FlowRunsDrawer', () => { fireEvent.keyDown(document, { key: 'Escape' }); expect(onClose).not.toHaveBeenCalled(); }); + + it('discards a stale background refetch after the drawer flips to a different flow (race guard)', async () => { + // Regression test for a codex review finding on this PR: the live-refresh + // `refetch` in FlowRunsDrawer didn't guard against a response landing + // after the drawer had already switched to a different flowId, so a slow + // flow-A refetch could clobber flow-B's already-rendered runs. + vi.useFakeTimers(); + try { + let flowACalls = 0; + let resolveStaleA: ((runs: FlowRun[]) => void) | undefined; + listFlowRuns.mockImplementation((flowId: string) => { + if (flowId === 'flow-a') { + flowACalls += 1; + if (flowACalls === 1) { + // Initial load: one active run so the live-refresh poll subscribes. + return Promise.resolve([ + makeRun({ id: 'run-a', flow_id: 'flow-a', status: 'running' }), + ]); + } + // The poll-triggered refetch — stays pending until resolved below, + // simulating a slow response that outlives the flow switch. + return new Promise(resolve => { + resolveStaleA = resolve; + }); + } + if (flowId === 'flow-b') { + return Promise.resolve([ + makeRun({ id: 'run-b', flow_id: 'flow-b', status: 'completed' }), + ]); + } + return Promise.resolve([]); + }); + + const { rerender } = render( + + + + ); + + // Flush the initial load's already-resolved promise. + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('flow-run-row-run-a')).toBeInTheDocument(); + + // Trigger the live-refresh poll fallback — issues the second, hanging + // listFlowRuns('flow-a') call. + await act(async () => { + vi.advanceTimersByTime(5_000); + }); + expect(flowACalls).toBe(2); + + // Flip the drawer to a different flow while that refetch is still in flight. + rerender( + + + + ); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('flow-run-row-run-b')).toBeInTheDocument(); + + // Now let the stale flow-a response land. + await act(async () => { + resolveStaleA?.([makeRun({ id: 'run-a-late', flow_id: 'flow-a', status: 'completed' })]); + await Promise.resolve(); + }); + + // Flow-b's runs must be unaffected by the late flow-a response. + expect(screen.getByTestId('flow-run-row-run-b')).toBeInTheDocument(); + expect(screen.queryByTestId('flow-run-row-run-a-late')).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/app/src/components/flows/FlowRunsDrawer.tsx b/app/src/components/flows/FlowRunsDrawer.tsx index d2d55bd8ac..368c73b779 100644 --- a/app/src/components/flows/FlowRunsDrawer.tsx +++ b/app/src/components/flows/FlowRunsDrawer.tsx @@ -8,10 +8,11 @@ * to-close + Escape-to-close via `useEscapeKey`) so it renders as a fixed * overlay regardless of where the parent mounts it. * - * Data is a one-shot fetch via `listFlowRuns` — no polling here. The run - * inspector already polls a single run's live status via `useFlowRunPoller`; - * polling the whole list here would duplicate that logic for no benefit - * (the list only needs to be fresh when the drawer opens). + * Data loads via `listFlowRuns` on open, then stays live via + * {@link useFlowRunsLiveRefresh} while any run in the list is still active — + * so a run stuck on "Running" here updates without the user having to close + * and reopen the drawer. The run inspector separately polls a single run's + * live status via `useFlowRunPoller`; that's unrelated to this list refresh. * * Clicking a run sets `selectedRunId` and renders the existing * `FlowRunInspectorDrawer` stacked on top: both are `fixed inset-0 z-50` @@ -24,9 +25,14 @@ * single Escape press closes only the topmost overlay (the inspector) first. */ import debug from 'debug'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; +import { + resolveDisplayStatus, + useRunsPendingApprovalSet, +} from '../../hooks/useRunsPendingApprovalSet'; import { useT } from '../../lib/i18n/I18nContext'; import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi'; import { @@ -72,8 +78,15 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedRunId, setSelectedRunId] = useState(null); + // Tracks the flowId this drawer instance is *currently* showing, so an + // in-flight `refetch()` started for a previous flow (see below) can detect + // it's stale once the drawer flips to a new flowId and bail instead of + // clobbering the new flow's already-loaded runs. + const currentFlowIdRef = useRef(flowId); useEffect(() => { + currentFlowIdRef.current = flowId; + // Reset for the new target so a previous flow's runs/error can't linger // under a different flowId while the new fetch is in flight. setSelectedRunId(null); @@ -109,6 +122,32 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr }; }, [flowId]); + // Background refresh for the live-update hook below — deliberately doesn't + // touch `loading`/`error` so a poll tick or progress event never flashes + // the loading state or clobbers a real load error with a transient one. + // Guards against a stale response: if the drawer flips from flow A to flow + // B while an A refetch is still in flight, the late A response must not + // overwrite B's already-loaded runs (mirrors the `cancelled` guard on the + // main load effect above). + const refetch = useCallback(() => { + if (!flowId) return; + const requestFlowId = flowId; + listFlowRuns(requestFlowId) + .then(result => { + if (currentFlowIdRef.current !== requestFlowId) return; + setRuns(result); + log('refetched runs: flowId=%s count=%d', requestFlowId, result.length); + }) + .catch(err => { + if (currentFlowIdRef.current !== requestFlowId) return; + const msg = err instanceof Error ? err.message : String(err); + log('refetch failed: flowId=%s err=%s', requestFlowId, msg); + }); + }, [flowId]); + + useFlowRunsLiveRefresh(runs, refetch); + const pendingRunIds = useRunsPendingApprovalSet(runs); + useEscapeKey( () => { log('escape: closing flowId=%s', flowId); @@ -179,6 +218,7 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr
    {runs.map(run => { const startedAt = formatTimestamp(run.started_at); + const displayStatus = resolveDisplayStatus(run, pendingRunIds); return (
  • -
  • - ))} + + + ); + })}
diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index 0fe3fdea73..d46ae467ef 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -301,8 +301,8 @@ describe('WorkflowCopilotPanel', () => { expect(screen.getByTestId('workflow-copilot-removed')).toBeInTheDocument(); }); - it('Accept applies to the draft and clears the proposal (never persists)', () => { - const onAccept = vi.fn(); + it('Accept calls onAccept (host applies + saves) and clears the proposal once it resolves', async () => { + const onAccept = vi.fn().mockResolvedValue(undefined); hookState.proposal = proposalWith(['a', 'c']); render( { ); fireEvent.click(screen.getByTestId('workflow-copilot-accept')); expect(onAccept).toHaveBeenCalledWith(hookState.proposal); - expect(hookState.clearProposal).toHaveBeenCalledTimes(1); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + }); + + it('shows the saving label and disables Accept while the host save is in flight', async () => { + // Deferred promise so the test controls exactly when the host's save + // (`onAccept`) resolves, to observe the in-between "saving" state. + let resolveSave!: () => void; + const savePromise = new Promise(resolve => { + resolveSave = resolve; + }); + const onAccept = vi.fn().mockReturnValue(savePromise); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept')); + await waitFor(() => + expect(screen.getByTestId('workflow-copilot-accept')).toHaveTextContent( + 'flows.copilot.saving' + ) + ); + expect(screen.getByTestId('workflow-copilot-accept')).toBeDisabled(); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + + resolveSave(); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + }); + + it('leaves the proposal visible for retry when the host save rejects', async () => { + const onAccept = vi.fn().mockRejectedValue(new Error('save failed')); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept')); + await waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1)); + // The button re-enables once the rejected save settles, and the proposal + // was never cleared — the card stays up so the user can retry. + await waitFor(() => expect(screen.getByTestId('workflow-copilot-accept')).not.toBeDisabled()); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + }); + + it('disables Reject while an Accept save is in flight, so it cannot race the persisted save', async () => { + // Regression for the CodeRabbit finding: Reject must not stay clickable + // while `onAccept`'s save is still pending, otherwise the user's cancel + // can be silently overridden by the earlier Accept's save landing after. + let resolveSave!: () => void; + const savePromise = new Promise(resolve => { + resolveSave = resolve; + }); + const onAccept = vi.fn().mockReturnValue(savePromise); + const onReject = vi.fn(); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept')); + await waitFor(() => expect(screen.getByTestId('workflow-copilot-reject')).toBeDisabled()); + + // A click while disabled is a no-op in jsdom/RTL — Reject must not fire. + fireEvent.click(screen.getByTestId('workflow-copilot-reject')); + expect(onReject).not.toHaveBeenCalled(); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + + resolveSave(); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('workflow-copilot-reject')).not.toBeDisabled(); }); it('Reject discards the proposal without applying it', () => { diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index d897fcedcb..582f3ea7cd 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -16,8 +16,13 @@ * `streamingAssistantByThread`, streamed here by Phase B). So the copilot reads * like a real chat rather than a one-shot form. * - * Invariant: the copilot only PROPOSES. Accept applies to the UNSAVED local - * draft (no `flows_update`); persistence stays behind the canvas's own Save. + * Invariant: the copilot only PROPOSES — the agent turn itself never + * persists. Accept applies the proposal to the local draft AND immediately + * saves it (review + save in one click) via the host's `onAccept`, which + * awaits the host's own persistence call; the panel shows a saving state + * meanwhile and, if the save fails, leaves the proposal visible for retry + * rather than silently discarding it. Reject remains local-only (revert the + * overlay, no persistence call). */ import createDebug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -65,8 +70,13 @@ interface Props { * reflects it. */ onProposal: (proposal: WorkflowProposal) => void; - /** Accept the pending proposal into the local draft (host commits it). */ - onAccept: (proposal: WorkflowProposal) => void; + /** + * Accept the pending proposal (host applies it to the local draft AND + * persists it — "accept" is now review + save in one step). May return a + * promise the panel awaits to show a saving state; a rejected promise + * leaves the proposal visible so the user can retry. + */ + onAccept: (proposal: WorkflowProposal) => void | Promise; /** Reject the pending proposal (host reverts the overlay). */ onReject: () => void; /** Close the panel. */ @@ -376,12 +386,35 @@ export default function WorkflowCopilotPanel({ const noopAttach = useCallback(async () => {}, []); const noop = useCallback(() => {}, []); - const accept = useCallback(() => { - if (!proposal) return; - onAccept(proposal); - clearProposal(); - lastSurfacedRef.current = null; - }, [proposal, onAccept, clearProposal]); + // Accept now review-and-saves: `onAccept` (the host's `handleAcceptProposal`) + // applies the proposal to the draft AND persists it. Track a local + // `acceptSaving` flag so the button can show a saving state and disable + // re-clicks while that's in flight. If the host's save throws, leave the + // proposal card visible (don't `clearProposal()`) so the user can retry — + // otherwise a failed autosave would silently vanish the only affordance to + // try again from the copilot itself (the header Save button is a fallback, + // but this keeps the copilot's own flow self-contained). + const [acceptSaving, setAcceptSaving] = useState(false); + const accept = useCallback(async () => { + // Self-guard against re-entrance: the JSX `disabled={acceptSaving}` on + // the Accept button prevents a normal double-click, but `acceptSaving` + // only flips after the FIRST call's `setAcceptSaving(true)` commits — a + // second invocation racing ahead of that render (e.g. programmatic + // re-fire) must not start a second concurrent save. + if (!proposal || acceptSaving) return; + setAcceptSaving(true); + log('accept: saving proposal via host onAccept'); + try { + await onAccept(proposal); + log('accept: save succeeded, clearing proposal'); + clearProposal(); + lastSurfacedRef.current = null; + } catch (err) { + log('accept: save failed, leaving proposal visible for retry err=%o', err); + } finally { + setAcceptSaving(false); + } + }, [proposal, acceptSaving, onAccept, clearProposal]); const reject = useCallback(() => { onReject(); @@ -533,14 +566,16 @@ export default function WorkflowCopilotPanel({ type="button" variant="primary" size="sm" + disabled={acceptSaving} data-testid="workflow-copilot-accept" - onClick={accept}> - {t('flows.copilot.accept')} + onClick={() => void accept()}> + {acceptSaving ? t('flows.copilot.saving') : t('flows.copilot.acceptAndSave')} + ); +} + /** * The root-shell sidebar, split top-to-bottom into: * @@ -33,18 +81,48 @@ export default function AppSidebar() { const { t } = useT(); const location = useLocation(); const navigate = useNavigate(); + const { snapshot: coreSnapshot, isReady } = useCoreState(); + // Rewards is a cloud-only surface (credits/referrals/coupons live behind the + // backend rewards API); the page itself renders an "unavailable" state for + // local sessions, so there's no point offering the entry there. Mirrors the + // `cloudOnly` intent recorded for rewards in navConfig's AVATAR_MENU_ITEMS. + // + // Show it only once core state has bootstrapped to a real, non-local session. + // The initial snapshot is `{ isReady: false, sessionToken: null }`, and + // `isLocalSessionToken(null)` is `false`, so gating on the token alone would + // briefly flash Rewards for a local session until the first refresh resolves. + const showRewards = + isReady && + Boolean(coreSnapshot.sessionToken) && + !isLocalSessionToken(coreSnapshot.sessionToken); const feedbackActive = location.pathname === '/feedback'; + const rewardsActive = location.pathname === '/rewards'; + + // Log the gate outcome whenever it resolves/flips. Booleans only — never the + // session token or a raw path. + useEffect(() => { + log( + 'rewards footer entry visibility resolved: visible=%s isReady=%s hasSession=%s local=%s', + showRewards, + isReady, + Boolean(coreSnapshot.sessionToken), + isLocalSessionToken(coreSnapshot.sessionToken) + ); + }, [showRewards, isReady, coreSnapshot.sessionToken]); - const handleFeedbackClick = () => { - if (!feedbackActive) { + const handleFooterNav = (tab: string, path: string, active: boolean) => { + log('footer nav click: tab=%s active=%s', tab, active); + if (!active) { trackEvent('tab_bar_change', { from_tab: 'unknown', - to_tab: 'feedback', - from_path: location.pathname, - to_path: '/feedback', + to_tab: tab, + // Normalize to a route template so route-scoped entity IDs (thread, + // flow, team, …) never leave the app via analytics. + from_path: normalizeAnalyticsPagePath(location.pathname), + to_path: path, }); } - navigate('/feedback'); + navigate(path); }; return ( @@ -66,23 +144,24 @@ export default function AppSidebar() { app rail above its thread list) can order them via Tailwind `order-*`. */} - {/* Slim feedback row — pinned just above the status bar. Kept thin and - low-profile so it reads as a footer affordance, not a primary nav tab - (it used to live in SidebarNav). */} - + {/* Slim account affordances pinned above the status bar — Rewards then + Feedback. Rewards is shown only for a resolved cloud session. */} + {showRewards && ( + handleFooterNav('rewards', '/rewards', rewardsActive)} + /> + )} + handleFooterNav('feedback', '/feedback', feedbackActive)} + /> {/* App-wide footer: connectivity status + build/version, pinned to the bottom of the sidebar. */}
diff --git a/app/src/components/layout/shell/navIcons.tsx b/app/src/components/layout/shell/navIcons.tsx index dc785dae60..fefd6299c9 100644 --- a/app/src/components/layout/shell/navIcons.tsx +++ b/app/src/components/layout/shell/navIcons.tsx @@ -193,6 +193,19 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) { /> ); + case 'rewards': + // Gift box — mirrors the glyph the Rewards page uses for its own header + // and welcome icon, so the sidebar entry reads as the same destination. + return ( + + + + ); default: return null; } diff --git a/app/src/features/human/MicComposer.test.tsx b/app/src/features/human/MicComposer.test.tsx index 42d418cf10..da5411b31d 100644 --- a/app/src/features/human/MicComposer.test.tsx +++ b/app/src/features/human/MicComposer.test.tsx @@ -7,12 +7,19 @@ import { MicComposer, STT_MAX_RETRIES, } from './MicComposer'; +import { VoiceNotCompiledError } from './voice/sttClient'; // transcribeWithFactory + encodeBlobToWav are the network/heavy boundaries — // mock them here so we can drive the state machine without touching real APIs. const transcribeWithFactoryMock = vi.fn(); const encodeBlobToWavMock = vi.fn(); -vi.mock('./voice/sttClient', () => ({ +// Spread the real module so `VoiceNotCompiledError` / `isVoiceNotCompiledError` +// keep their real behaviour; only the network-touching call is stubbed. A +// factory listing exports by hand silently yields `undefined` for anything it +// forgets, which fails as a TypeError at the call site rather than a clear mock +// error. +vi.mock('./voice/sttClient', async importOriginal => ({ + ...(await importOriginal()), transcribeWithFactory: (...args: unknown[]) => transcribeWithFactoryMock(...args), })); vi.mock('./voice/wavEncoder', () => ({ @@ -794,10 +801,11 @@ describe('MicComposer', () => { expect(onError).not.toHaveBeenCalled(); }); - it('does not retry permanent errors (stale sidecar)', async () => { + it('does not retry permanent errors (voice not compiled into the core)', async () => { transcribeWithFactoryMock.mockRejectedValueOnce( new Error( - 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.' + 'Voice transcription is unavailable in this build — the voice module was not compiled into the app. ' + + 'Update OpenHuman to the latest version; restarting will not help.' ) ); const onError = vi.fn(); @@ -816,6 +824,31 @@ describe('MicComposer', () => { expect(onSubmit).not.toHaveBeenCalled(); }); + // #4901: the raw error text is untranslated developer copy, so a + // VoiceNotCompiledError must surface the localized `mic.voiceNotCompiled` + // string instead of being spliced into the `{message}` slot. + it('renders translated copy for a voice-not-compiled error, not the raw message', async () => { + transcribeWithFactoryMock.mockRejectedValueOnce(new VoiceNotCompiledError()); + const onError = vi.fn(); + const onSubmit = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /start recording/i })); + await waitFor(() => expect(getUserMediaMock).toHaveBeenCalled()); + await waitFor(() => + expect(screen.getByRole('button', { name: /stop recording and send/i })).toBeInTheDocument() + ); + fireEvent.click(screen.getByRole('button', { name: /stop recording and send/i })); + + await waitFor(() => expect(onError).toHaveBeenCalled()); + const shown = onError.mock.calls[0][0] as string; + // The English `mic.voiceNotCompiled` value, resolved through useT(). + expect(shown).toMatch(/not included in this version/i); + // The untranslated developer copy must not leak into the UI. + expect(shown).not.toMatch(/not compiled into the app/i); + expect(onSubmit).not.toHaveBeenCalled(); + }); + // ── Low-confidence transcript detection (#1206) ──────────────────────────── it('rejects a single-character transcript as low confidence', async () => { diff --git a/app/src/features/human/MicComposer.tsx b/app/src/features/human/MicComposer.tsx index 992a83f014..b843cac268 100644 --- a/app/src/features/human/MicComposer.tsx +++ b/app/src/features/human/MicComposer.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import { createPortal } from 'react-dom'; import { useT } from '../../lib/i18n/I18nContext'; -import { transcribeWithFactory } from './voice/sttClient'; +import { isVoiceNotCompiledError, transcribeWithFactory } from './voice/sttClient'; import { encodeBlobToWav } from './voice/wavEncoder'; /** Minimal descriptor for an audio input device. */ @@ -33,7 +33,7 @@ const STT_RETRY_BASE_MS = 500; * Matched case-insensitively against the error message. */ const PERMANENT_ERROR_PATTERNS = [ - 'unknown method', // stale sidecar + 'unknown method', // core built without the `voice` feature (#4901) 'audio blob is empty', 'unavailable in this build', ]; @@ -576,6 +576,13 @@ export function MicComposer({ } const msg = err instanceof Error ? err.message : String(err); composerLog('transcribe failed: %s', msg); + // The core has no voice domain compiled in (#4901). `msg` is untranslated + // developer copy, so render the localized string rather than splicing it + // into the `{message}` slot. + if (isVoiceNotCompiledError(err)) { + onError?.(t('mic.voiceNotCompiled')); + return; + } onError?.(t('mic.transcriptionFailed').replace('{message}', msg)); } finally { if (!disposedRef.current) setState('idle'); diff --git a/app/src/features/human/voice/sttClient.test.ts b/app/src/features/human/voice/sttClient.test.ts index b4d93cc942..924bc98a8d 100644 --- a/app/src/features/human/voice/sttClient.test.ts +++ b/app/src/features/human/voice/sttClient.test.ts @@ -93,14 +93,31 @@ describe('transcribeCloud', () => { expect(await transcribeCloud(blob)).toBe(''); }); - // Issue #1289: stale sidecar binaries surface a generic - // "unknown method" error. Frontend rewrites it to an actionable - // message so users know to restart the desktop app. - it('rewrites "unknown method" errors to an actionable restart hint', async () => { + // #4901: a core built without the `voice` feature never registers the + // `openhuman.voice_*` controllers, so they answer "unknown method". That is a + // compile-time property of the binary, so the message must NOT tell users to + // restart (the pre-#4901 copy did, which could never work). + it('rewrites "unknown method" errors to a not-compiled-in message', async () => { const mock = callCoreRpc as ReturnType; mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_cloud_transcribe')); const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); - await expect(transcribeCloud(blob)).rejects.toThrow(/Restart the OpenHuman desktop app/i); + await expect(transcribeCloud(blob)).rejects.toThrow(/not compiled into the app/i); + }); + + it('does not advise restarting for a compile-time voice gate', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_cloud_transcribe')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeCloud(blob)).rejects.not.toThrow(/restart the openhuman desktop app/i); + }); + + // `MicComposer`'s PERMANENT_ERROR_PATTERNS matches this substring to skip the + // retry/backoff loop — a compile-time gate can never succeed on retry. + it('keeps the "unavailable in this build" substring for retry suppression', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_cloud_transcribe')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeCloud(blob)).rejects.toThrow(/unavailable in this build/i); }); it('passes through non-unknown-method errors verbatim', async () => { @@ -148,11 +165,21 @@ describe('transcribeWithFactory', () => { expect(mock).not.toHaveBeenCalled(); }); - it('rewrites stale-sidecar "unknown method" errors', async () => { + // #4901 — same compile-time gate as the cloud path above. + it('rewrites "unknown method" errors to a not-compiled-in message', async () => { + const mock = callCoreRpc as ReturnType; + mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_stt_dispatch')); + const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); + await expect(transcribeWithFactory(blob)).rejects.toThrow(/not compiled into the app/i); + }); + + it('does not advise restarting for a compile-time voice gate', async () => { const mock = callCoreRpc as ReturnType; mock.mockRejectedValueOnce(new Error('unknown method: openhuman.voice_stt_dispatch')); const blob = new Blob([new Uint8Array([1])], { type: 'audio/webm' }); - await expect(transcribeWithFactory(blob)).rejects.toThrow(/Restart the OpenHuman desktop app/i); + await expect(transcribeWithFactory(blob)).rejects.not.toThrow( + /restart the openhuman desktop app/i + ); }); it('passes through non-unknown-method errors verbatim', async () => { diff --git a/app/src/features/human/voice/sttClient.ts b/app/src/features/human/voice/sttClient.ts index 1700b067d0..4432a69a10 100644 --- a/app/src/features/human/voice/sttClient.ts +++ b/app/src/features/human/voice/sttClient.ts @@ -4,6 +4,52 @@ import { callCoreRpc } from '../../../services/coreRpcClient'; const sttLog = debug('human:stt'); +/** + * Stable identifier for "the core has no voice domain compiled in" (#4901). + * + * Callers must branch on this code rather than on the message text: the message + * is untranslated developer/log copy, and the UI is responsible for rendering + * translated copy via `useT()` (see `MicComposer`'s `mic.voiceNotCompiled`). + */ +export const VOICE_NOT_COMPILED_CODE = 'voice_not_compiled'; + +/** + * Thrown when the core answers `unknown method` for a `voice_*` RPC — i.e. the + * core was compiled without the `voice` feature, so the domain is absent from + * the binary entirely (#4901). + * + * The `message` is English on purpose: it is what reaches debug logs and Sentry. + * User-facing copy is resolved from `code` at the UI boundary. + * + * Keep the `unavailable in this build` substring: `MicComposer`'s + * `PERMANENT_ERROR_PATTERNS` matches on it to skip the retry/backoff loop, + * which can never succeed for a compile-time gate. + */ +export class VoiceNotCompiledError extends Error { + readonly code = VOICE_NOT_COMPILED_CODE; + + constructor() { + super( + 'Voice transcription is unavailable in this build — the voice module was not compiled into the app. ' + + 'Update OpenHuman to the latest version; restarting will not help.' + ); + this.name = 'VoiceNotCompiledError'; + } +} + +/** + * Duck-typed on `code` rather than `instanceof`: the error crosses async + + * retry boundaries, and structured-clone/serialization paths can strip the + * prototype while preserving own properties. + */ +export function isVoiceNotCompiledError(err: unknown): err is VoiceNotCompiledError { + return ( + typeof err === 'object' && + err !== null && + (err as { code?: unknown }).code === VOICE_NOT_COMPILED_CODE + ); +} + export interface CloudTranscribeOptions { /** Override the backend STT model id. Default is whatever the backend * resolves `whisper-v1` to today. */ @@ -64,17 +110,16 @@ export async function transcribeCloud( params, }); } catch (err) { - // Issue #1289: an "unknown method" error means the bundled core - // sidecar is older than the frontend (e.g. a stale dev build, or a - // cached binary the desktop auto-update hasn't refreshed yet). - // The raw "unknown method: openhuman.voice_cloud_transcribe" string - // is opaque to end users — surface an actionable message instead. + // An "unknown method" error means the core serving this app was built + // without the `voice` Cargo feature, so the `openhuman.voice_*` + // controllers were never registered (#4901). This is a compile-time + // property of the binary — restarting cannot change it, which is why the + // old #1289-era "restart to pick up the latest core sidecar" copy was + // unactionable (and the sidecar itself is gone since #1061). const msg = err instanceof Error ? err.message : String(err); if (msg.includes('unknown method')) { - sttLog('transcribe rpc stale-sidecar path hit; rewriting unknown-method error: %s', msg); - throw new Error( - 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.' - ); + sttLog('[voice-stt] transcribe rpc: voice domain absent from core build: %s', msg); + throw new VoiceNotCompiledError(); } sttLog('transcribe rpc failed (passthrough): %O', err); throw err; @@ -149,10 +194,8 @@ export async function transcribeWithFactory( } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('unknown method')) { - sttLog('[voice-stt] dispatch stale-sidecar path: %s', msg); - throw new Error( - 'Voice transcription is unavailable in this build. Restart the OpenHuman desktop app to pick up the latest core sidecar.' - ); + sttLog('[voice-stt] dispatch: voice domain absent from core build: %s', msg); + throw new VoiceNotCompiledError(); } sttLog('[voice-stt] dispatch failed (passthrough): %O', err); throw err; diff --git a/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts b/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts new file mode 100644 index 0000000000..2ba90066a3 --- /dev/null +++ b/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts @@ -0,0 +1,145 @@ +/** + * useFlowRunsLiveRefresh — unit tests. + * + * Verifies: no subscription/poll when every run is terminal, subscribe + + * poll when a run is active, a trailing-debounced refetch on a matching + * `flow:run_progress`/`flow_run_progress` event, teardown once the runs + * settle to all-terminal, and cleanup on unmount. + */ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FlowRun } from '../../services/api/flowsApi'; +import { useFlowRunsLiveRefresh } from '../useFlowRunsLiveRefresh'; + +const handlers = vi.hoisted(() => new Map void>>()); +const on = vi.hoisted(() => + vi.fn((event: string, cb: (data: unknown) => void) => { + const set = handlers.get(event) ?? new Set(); + set.add(cb); + handlers.set(event, set); + }) +); +const off = vi.hoisted(() => + vi.fn((event: string, cb: (data: unknown) => void) => { + handlers.get(event)?.delete(cb); + }) +); +vi.mock('../../services/socketService', () => ({ socketService: { on, off } })); + +function emit(event: 'flow:run_progress' | 'flow_run_progress', payload: unknown) { + act(() => { + for (const cb of handlers.get(event) ?? []) cb(payload); + }); +} + +function makeRun(overrides: Partial = {}): FlowRun { + return { + id: 'run-1', + flow_id: 'flow-1', + thread_id: 'run-1', + status: 'running', + started_at: '2026-01-01T00:00:00Z', + steps: [], + pending_approvals: [], + ...overrides, + }; +} + +describe('useFlowRunsLiveRefresh', () => { + beforeEach(() => { + handlers.clear(); + on.mockClear(); + off.mockClear(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not subscribe or poll when every run is terminal', () => { + const refetch = vi.fn(); + renderHook(() => + useFlowRunsLiveRefresh( + [makeRun({ status: 'completed' }), makeRun({ id: 'run-2', status: 'failed' })], + refetch + ) + ); + + expect(on).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(20_000); + expect(refetch).not.toHaveBeenCalled(); + }); + + it('subscribes to both event aliases and polls on a fallback interval when a run is active', () => { + const refetch = vi.fn(); + renderHook(() => useFlowRunsLiveRefresh([makeRun({ status: 'running' })], refetch)); + + expect(on).toHaveBeenCalledWith('flow:run_progress', expect.any(Function)); + expect(on).toHaveBeenCalledWith('flow_run_progress', expect.any(Function)); + + vi.advanceTimersByTime(5_000); + expect(refetch).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(5_000); + expect(refetch).toHaveBeenCalledTimes(2); + }); + + it('debounces a burst of flow:run_progress events into a single trailing refetch', () => { + const refetch = vi.fn(); + renderHook(() => useFlowRunsLiveRefresh([makeRun({ status: 'running' })], refetch)); + + // Keep every emit + the final debounce settle comfortably inside the + // first 5s poll tick so the poll fallback doesn't also fire here — that + // interplay is covered separately by the "poll fallback" test above. + emit('flow:run_progress', { run_id: 'run-1', node_id: 'a', status: 'success' }); + vi.advanceTimersByTime(500); + emit('flow:run_progress', { run_id: 'run-1', node_id: 'b', status: 'success' }); + vi.advanceTimersByTime(500); + emit('flow_run_progress', { run_id: 'run-1', node_id: 'c', status: 'success' }); + + // Still within the 3s trailing window from the last event — no refetch yet. + expect(refetch).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(3_000); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it('tears down the subscription and poll once the runs settle to all-terminal', () => { + const refetch = vi.fn(); + const { rerender } = renderHook(({ runs }) => useFlowRunsLiveRefresh(runs, refetch), { + initialProps: { runs: [makeRun({ status: 'running' })] }, + }); + + expect(on).toHaveBeenCalledTimes(2); + + rerender({ runs: [makeRun({ status: 'completed' })] }); + + expect(off).toHaveBeenCalledWith('flow:run_progress', expect.any(Function)); + expect(off).toHaveBeenCalledWith('flow_run_progress', expect.any(Function)); + + refetch.mockClear(); + vi.advanceTimersByTime(20_000); + expect(refetch).not.toHaveBeenCalled(); + }); + + it('cleans up the subscription, poll, and any pending debounce on unmount', () => { + const refetch = vi.fn(); + const { unmount } = renderHook(() => + useFlowRunsLiveRefresh([makeRun({ status: 'running' })], refetch) + ); + + emit('flow:run_progress', { run_id: 'run-1', node_id: 'a', status: 'success' }); + + unmount(); + + expect(off).toHaveBeenCalledWith('flow:run_progress', expect.any(Function)); + expect(off).toHaveBeenCalledWith('flow_run_progress', expect.any(Function)); + + refetch.mockClear(); + vi.advanceTimersByTime(20_000); + expect(refetch).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/hooks/__tests__/useRunsPendingApprovalSet.test.ts b/app/src/hooks/__tests__/useRunsPendingApprovalSet.test.ts new file mode 100644 index 0000000000..80abba31e9 --- /dev/null +++ b/app/src/hooks/__tests__/useRunsPendingApprovalSet.test.ts @@ -0,0 +1,214 @@ +/** + * useRunsPendingApprovalSet — unit tests. + * + * Verifies: no poll when every run is terminal (or the list is empty); polls + * `approval_list_pending` every 3s while a run is `running`; collects only + * flow-origin (`source_context.kind === 'flow'`) matches into the returned + * Set; a chat-origin approval (no flow `source_context`) is excluded; a + * failed poll keeps the last-known set instead of clearing it; teardown once + * every run settles to terminal; cleanup on unmount. + */ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PendingApproval } from '../../services/api/approvalApi'; +import type { FlowRun } from '../../services/api/flowsApi'; +import { resolveDisplayStatus, useRunsPendingApprovalSet } from '../useRunsPendingApprovalSet'; + +const fetchPendingApprovals = vi.hoisted(() => vi.fn()); +vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals })); + +function makeRun(overrides: Partial = {}): FlowRun { + return { + id: 'run-1', + flow_id: 'flow-1', + thread_id: 'run-1', + status: 'running', + started_at: '2026-01-01T00:00:00Z', + steps: [], + pending_approvals: [], + ...overrides, + }; +} + +function makeApproval(overrides: Partial = {}): PendingApproval { + return { + request_id: 'req-1', + tool_name: 'shell', + action_summary: 'Run `shell`', + args_redacted: {}, + session_id: 'session-1', + created_at: '2026-01-01T00:00:00Z', + expires_at: null, + source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' }, + ...overrides, + }; +} + +describe('useRunsPendingApprovalSet', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not poll when every run is terminal', async () => { + fetchPendingApprovals.mockResolvedValue([]); + renderHook(() => + useRunsPendingApprovalSet([ + makeRun({ status: 'completed' }), + makeRun({ id: 'run-2', status: 'failed' }), + ]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + }); + + it('does not poll for an empty runs list', async () => { + fetchPendingApprovals.mockResolvedValue([]); + renderHook(() => useRunsPendingApprovalSet([])); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + }); + + it('polls every 3s while a run is running and includes it when a matching flow approval exists', async () => { + fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]); + const { result } = renderHook(() => + useRunsPendingApprovalSet([makeRun({ status: 'running' })]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(1); + expect(result.current.has('run-1')).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(2); + }); + + it('excludes a running run with no matching pending approval', async () => { + fetchPendingApprovals.mockResolvedValue([]); + const { result } = renderHook(() => + useRunsPendingApprovalSet([makeRun({ status: 'running' })]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.size).toBe(0); + }); + + it('excludes a chat-origin approval (no flow source_context)', async () => { + fetchPendingApprovals.mockResolvedValue([ + makeApproval({ request_id: 'req-chat', source_context: undefined }), + ]); + const { result } = renderHook(() => + useRunsPendingApprovalSet([makeRun({ status: 'running' })]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.size).toBe(0); + }); + + it('keeps the last-known set when a poll fails', async () => { + fetchPendingApprovals.mockResolvedValueOnce([makeApproval({ request_id: 'req-a' })]); + const { result, rerender } = renderHook( + ({ runs }: { runs: FlowRun[] }) => useRunsPendingApprovalSet(runs), + { initialProps: { runs: [makeRun({ status: 'running' })] } } + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.has('run-1')).toBe(true); + + fetchPendingApprovals.mockRejectedValueOnce(new Error('network down')); + rerender({ runs: [makeRun({ status: 'running' })] }); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(2); + expect(result.current.has('run-1')).toBe(true); + + // A subsequent successful tick keeps polling on the same cadence. + fetchPendingApprovals.mockResolvedValueOnce([makeApproval({ request_id: 'req-a' })]); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(3); + }); + + it('tears down polling once every run settles to terminal', async () => { + fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]); + const { rerender } = renderHook( + ({ runs }: { runs: FlowRun[] }) => useRunsPendingApprovalSet(runs), + { initialProps: { runs: [makeRun({ status: 'running' })] } } + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(1); + + rerender({ runs: [makeRun({ status: 'completed' })] }); + + fetchPendingApprovals.mockClear(); + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + }); + + it('cleans up pending timers on unmount', async () => { + fetchPendingApprovals.mockResolvedValue([]); + const { unmount } = renderHook(() => + useRunsPendingApprovalSet([makeRun({ status: 'running' })]) + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetchPendingApprovals).toHaveBeenCalledTimes(1); + + unmount(); + + fetchPendingApprovals.mockClear(); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(fetchPendingApprovals).not.toHaveBeenCalled(); + }); +}); + +describe('resolveDisplayStatus', () => { + it('overrides to pending_approval when running and the run id is in the pending set', () => { + const run = makeRun({ status: 'running' }); + const pendingRunIds = new Set(['run-1']); + expect(resolveDisplayStatus(run, pendingRunIds)).toBe('pending_approval'); + }); + + it('leaves the status untouched when running but not in the pending set', () => { + const run = makeRun({ status: 'running' }); + expect(resolveDisplayStatus(run, new Set())).toBe('running'); + }); + + it('leaves non-running statuses untouched even if the id is (stale) in the pending set', () => { + const run = makeRun({ status: 'completed' }); + const pendingRunIds = new Set(['run-1']); + expect(resolveDisplayStatus(run, pendingRunIds)).toBe('completed'); + }); +}); diff --git a/app/src/hooks/useFlowRunsLiveRefresh.ts b/app/src/hooks/useFlowRunsLiveRefresh.ts new file mode 100644 index 0000000000..ee96d16687 --- /dev/null +++ b/app/src/hooks/useFlowRunsLiveRefresh.ts @@ -0,0 +1,100 @@ +/** + * useFlowRunsLiveRefresh — keeps a runs LIST fresh while any run in it is + * still active, so "Running" doesn't go stale until the user manually + * refreshes or navigates away. + * + * The backend's `FlowRunObserver` publishes `DomainEvent::FlowRunProgress` on + * each finished step, and the core socket bridge re-emits it to the frontend + * as both `flow:run_progress` and `flow_run_progress` (colon + underscore + * aliases — see `useFlowRunProgress`, whose subscribe/teardown style this + * mirrors). There is, however, no terminal/completion socket event today — + * a run transitioning `running` -> `completed`/`failed`/etc. emits nothing — + * so a step-progress subscription alone can't catch that final refresh. This + * hook therefore layers a 5s poll fallback on top of the socket subscription: + * the socket keeps the refetch snappy while steps are landing, and the poll + * guarantees the terminal transition (which has no event) still gets picked + * up within a few seconds. + * + * This is deliberately dumb about *which* run changed — callers pass the + * list they already fetched and a `refetch` that reloads it; this hook just + * decides *when* to call `refetch` again. `FlowRunsSidebar`, `FlowRunsDrawer`, + * and `WorkflowRunsPage` all wire it onto their existing one-shot fetchers. + */ +import debug from 'debug'; +import { useEffect, useRef } from 'react'; + +import type { FlowRun, FlowRunStatus } from '../services/api/flowsApi'; +import { socketService } from '../services/socketService'; + +const log = debug('flows:runs-live-refresh'); + +/** Socket event aliases the core bridge emits (colon + underscore forms). */ +const EVENT_COLON = 'flow:run_progress'; +const EVENT_UNDERSCORE = 'flow_run_progress'; + +/** Statuses a run never leaves once reached — no further refetch needed for it. */ +const TERMINAL_STATUSES = new Set([ + 'completed', + 'completed_with_warnings', + 'failed', + 'cancelled', +]); + +/** Trailing debounce window for a burst of `flow:run_progress` events. */ +const DEBOUNCE_MS = 3_000; + +/** Poll fallback cadence — catches the terminal transition, which has no socket event. */ +const POLL_INTERVAL_MS = 5_000; + +/** + * Subscribes to live run-progress events (debounced) and polls on a fallback + * interval while `runs` contains at least one non-terminal run, calling + * `refetch` to reload the list. Subscribes to nothing — and tears down any + * existing subscription/poll — once every run has settled or on unmount. + */ +export function useFlowRunsLiveRefresh(runs: FlowRun[], refetch: () => void): void { + // Keep the latest `refetch` available to the effect without retriggering + // subscribe/unsubscribe every time the caller passes a new closure. + const refetchRef = useRef(refetch); + refetchRef.current = refetch; + + const hasActive = runs.some(run => !TERMINAL_STATUSES.has(run.status)); + + useEffect(() => { + if (!hasActive) return; + + let debounceTimer: ReturnType | null = null; + const scheduleRefetch = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + log('debounced refetch firing'); + refetchRef.current(); + }, DEBOUNCE_MS); + }; + + const handleProgress = () => { + log('progress event received — scheduling debounced refetch'); + scheduleRefetch(); + }; + + log('subscribing: at least one active run'); + socketService.on(EVENT_COLON, handleProgress); + socketService.on(EVENT_UNDERSCORE, handleProgress); + + const pollId = setInterval(() => { + log('poll fallback refetch'); + refetchRef.current(); + }, POLL_INTERVAL_MS); + + return () => { + log('tearing down: unsubscribing + clearing poll/debounce timers'); + socketService.off(EVENT_COLON, handleProgress); + socketService.off(EVENT_UNDERSCORE, handleProgress); + clearInterval(pollId); + if (debounceTimer) clearTimeout(debounceTimer); + }; + }, [hasActive]); +} + +export default useFlowRunsLiveRefresh; diff --git a/app/src/hooks/useRunsPendingApprovalSet.ts b/app/src/hooks/useRunsPendingApprovalSet.ts new file mode 100644 index 0000000000..0d185a3ae7 --- /dev/null +++ b/app/src/hooks/useRunsPendingApprovalSet.ts @@ -0,0 +1,118 @@ +/** + * useRunsPendingApprovalSet — cross-references the shared approval queue + * against a runs LIST so surfaces that show many runs at once (sidebar, + * drawer, all-runs page) can distinguish a run merely `running` from one + * that's actually halted at an interactive `ApprovalGate` mid-run. + * + * The core has no separate DB status for "parked at an approval gate" — a + * run stays `status: "running"` in `FlowRun` while it waits; only the shared + * `openhuman.approval_list_pending` queue (see `useFlowPendingApprovals`, + * the single-run analogue used by `FlowRunInspectorDrawer`) knows it's + * parked, via `PendingApproval.source_context = { kind: "flow", run_id }`. + * + * While at least one run in the list is `running`, this hook polls that + * shared queue every {@link POLL_INTERVAL_MS} and returns the Set of + * `run_id`s with a matching flow-origin pending approval. Callers combine + * this with `run.status` via {@link resolveDisplayStatus} to override the + * DISPLAY status only — `run.status` itself, and `useFlowRunsLiveRefresh` + * (which reads the ORIGINAL runs array), are untouched, so `running` stays + * non-terminal there and the list keeps refetching until the run truly + * finishes. + * + * Mirrors the poll-loop + cleanup shape of `useFlowPendingApprovals` + * (cancelled flag + `setTimeout` reschedule + teardown) and the + * gate-on-activity contract of `useFlowRunsLiveRefresh`. Unlike + * `useFlowPendingApprovals`, a failed poll here does NOT surface an error or + * stop polling — this is a best-effort display hint, not a source of truth, + * so a transient failure just keeps showing the last-known set and retries + * on the next tick. + */ +import debug from 'debug'; +import { useEffect, useState } from 'react'; + +import { fetchPendingApprovals } from '../services/api/approvalApi'; +import type { FlowRun, FlowRunStatus } from '../services/api/flowsApi'; + +const log = debug('flows:runs-pending-approval-set'); + +/** How often to re-poll the shared approval queue while any run is `running`. */ +const POLL_INTERVAL_MS = 3000; + +/** + * Poll `openhuman.approval_list_pending` every {@link POLL_INTERVAL_MS}ms + * while `runs` contains at least one `status === 'running'` entry, and + * return the Set of `run_id`s with a flow-origin pending approval + * (`source_context.kind === 'flow'`). Stops polling — but does not clear the + * last-known set — once every run has left `running` (completed, failed, + * cancelled, or already reflected as `pending_approval`). Best-effort: a + * failed poll is logged and simply retried next tick, keeping the + * last-known set rather than surfacing an error to the caller. + */ +export function useRunsPendingApprovalSet(runs: FlowRun[]): Set { + const [pendingRunIds, setPendingRunIds] = useState>(() => new Set()); + + const hasRunning = runs.some(run => run.status === 'running'); + + useEffect(() => { + if (!hasRunning) { + log('approval polling skipped: no-running-runs'); + return; + } + log('approval polling started'); + + let cancelled = false; + let pollHandle: number | undefined; + + const tick = async () => { + if (cancelled) return; + try { + log('approval poll: calling fetchPendingApprovals'); + const all = await fetchPendingApprovals(); + if (cancelled) return; + const next = new Set(); + for (const approval of all) { + if (approval.source_context?.kind === 'flow') { + next.add(approval.source_context.run_id); + } + } + log('approval poll: succeeded total=%d flow-scoped=%d', all.length, next.size); + setPendingRunIds(next); + } catch (err) { + const errorType = err instanceof Error ? err.name : typeof err; + log('approval poll: failed error_type=%s; preserving-last-known-set', errorType); + // Best-effort — leave `pendingRunIds` as-is and retry next tick. + } finally { + if (!cancelled) { + log('approval poll: scheduling retry delay_ms=%d', POLL_INTERVAL_MS); + pollHandle = window.setTimeout(() => void tick(), POLL_INTERVAL_MS); + } + } + }; + + void tick(); + return () => { + cancelled = true; + if (pollHandle !== undefined) window.clearTimeout(pollHandle); + log('approval polling stopped'); + }; + }, [hasRunning]); + + return pendingRunIds; +} + +/** + * Overrides a run's DISPLAY status to `pending_approval` when it's currently + * `running` AND the shared approval queue (via {@link useRunsPendingApprovalSet}) + * has a flow-origin pending approval for it. Does not mutate `run` or touch + * any other status — callers substitute the result at render sites only + * (status dot / accent / label), leaving `run.status` itself (and anything + * keyed on it, like `useFlowRunsLiveRefresh`'s terminal-status check) intact. + */ +export function resolveDisplayStatus(run: FlowRun, pendingRunIds: Set): FlowRunStatus { + if (run.status === 'running' && pendingRunIds.has(run.id)) { + return 'pending_approval'; + } + return run.status; +} + +export default useRunsPendingApprovalSet; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 98ad5d24fb..9b9662d400 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3163,6 +3163,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'تعذّر فهم الصوت بوضوح: يرجى المحاولة مرة أخرى', 'mic.failedToStopRecording': 'فشل إيقاف التسجيل: {message}', 'mic.transcriptionFailed': 'فشل النسخ: {message}', + 'mic.voiceNotCompiled': + 'خاصية تحويل الصوت إلى نص غير متوفرة في هذا الإصدار من التطبيق. حدّث OpenHuman لتفعيلها.', 'reflections.kind.retrospective': 'مراجعة', 'reflections.kind.derivedFact': 'حقيقة مستنتجة', 'reflections.kind.moodInsight': 'رؤية المزاج', @@ -4014,6 +4016,8 @@ const messages: TranslationMap = { 'flows.copilot.removed': 'أُزيل {count}', 'flows.copilot.noChanges': 'هذا الاقتراح لا يغيّر أي عقدة.', 'flows.copilot.accept': 'تطبيق على المسودة', + 'flows.copilot.acceptAndSave': 'قبول وحفظ', + 'flows.copilot.saving': 'جارٍ الحفظ…', 'flows.copilot.reject': 'تجاهل', 'flows.copilot.previewHint': 'جارٍ مراجعة مسودة مقترحة: لم يُحفظ شيء بعد.', 'flows.copilot.repairDisplay': 'فشل تشغيل؛ راجعه واقترح إصلاحًا.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 7909b0efad..1e34ff6cc7 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3238,6 +3238,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'অডিও স্পষ্টভাবে বোঝা যায়নি: আবার চেষ্টা করুন', 'mic.failedToStopRecording': 'রেকর্ডিং বন্ধ করতে ব্যর্থ: {message}', 'mic.transcriptionFailed': 'ট্রান্সক্রিপশন ব্যর্থ: {message}', + 'mic.voiceNotCompiled': + 'অ্যাপের এই সংস্করণে ভয়েস ট্রান্সক্রিপশন অন্তর্ভুক্ত নেই। এটি চালু করতে OpenHuman আপডেট করুন।', 'reflections.kind.retrospective': 'পূর্বদর্শন', 'reflections.kind.derivedFact': 'ডেরাইভড ফ্যাক্ট', 'reflections.kind.moodInsight': 'মুড ইনসাইট', @@ -4114,6 +4116,8 @@ const messages: TranslationMap = { 'flows.copilot.removed': '{count}টি সরানো হয়েছে', 'flows.copilot.noChanges': 'এই প্রস্তাব কোনো নোড পরিবর্তন করে না।', 'flows.copilot.accept': 'খসড়ায় প্রয়োগ করুন', + 'flows.copilot.acceptAndSave': 'গ্রহণ ও সংরক্ষণ করুন', + 'flows.copilot.saving': 'সংরক্ষণ করা হচ্ছে…', 'flows.copilot.reject': 'বাতিল করুন', 'flows.copilot.previewHint': 'একটি প্রস্তাবিত খসড়া পর্যালোচনা হচ্ছে: এখনও কিছু সংরক্ষণ করা হয়নি।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index fe6585713a..871f54a665 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3328,6 +3328,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Audio konnte nicht klar verstanden werden: bitte erneut versuchen', 'mic.failedToStopRecording': 'Aufzeichnung konnte nicht gestoppt werden: {message}', 'mic.transcriptionFailed': 'Transkription fehlgeschlagen: {message}', + 'mic.voiceNotCompiled': + 'Sprachtranskription ist in dieser App-Version nicht enthalten. Aktualisiere OpenHuman, um sie zu aktivieren.', 'reflections.kind.retrospective': 'Retrospektive', 'reflections.kind.derivedFact': 'Abgeleitete Tatsache', 'reflections.kind.moodInsight': 'Stimmungseinsicht', @@ -4229,6 +4231,8 @@ const messages: TranslationMap = { 'flows.copilot.removed': '{count} entfernt', 'flows.copilot.noChanges': 'Dieser Vorschlag ändert keine Knoten.', 'flows.copilot.accept': 'Auf Entwurf anwenden', + 'flows.copilot.acceptAndSave': 'Übernehmen & speichern', + 'flows.copilot.saving': 'Wird gespeichert…', 'flows.copilot.reject': 'Verwerfen', 'flows.copilot.previewHint': 'Ein vorgeschlagener Entwurf wird geprüft: es wurde noch nichts gespeichert.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 3571a4664c..0291d524c1 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3629,6 +3629,8 @@ const en: TranslationMap = { 'mic.lowConfidenceResult': 'Could not understand the audio clearly: please try again', 'mic.failedToStopRecording': 'Failed to stop recording: {message}', 'mic.transcriptionFailed': 'Transcription failed: {message}', + 'mic.voiceNotCompiled': + 'Voice transcription is not included in this version of the app. Update OpenHuman to enable it.', // Reflections: kind labels 'reflections.kind.retrospective': 'Retrospective', @@ -4804,6 +4806,8 @@ const en: TranslationMap = { 'flows.copilot.removed': '{count} removed', 'flows.copilot.noChanges': 'No node changes in this proposal.', 'flows.copilot.accept': 'Apply to draft', + 'flows.copilot.acceptAndSave': 'Accept & save', + 'flows.copilot.saving': 'Saving…', 'flows.copilot.reject': 'Dismiss', 'flows.copilot.previewHint': 'Reviewing a proposed draft: nothing is saved yet.', 'flows.copilot.repairDisplay': 'A run failed: please look at it and propose a fix.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 3c5dde2d4b..b41d056590 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3299,6 +3299,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'No se pudo entender el audio con claridad: intenta de nuevo', 'mic.failedToStopRecording': 'No se pudo detener la grabación: {message}', 'mic.transcriptionFailed': 'Transcripción fallida: {message}', + 'mic.voiceNotCompiled': + 'La transcripción de voz no está incluida en esta versión de la aplicación. Actualiza OpenHuman para activarla.', 'reflections.kind.retrospective': 'Retrospectiva', 'reflections.kind.derivedFact': 'Hecho derivado', 'reflections.kind.moodInsight': 'Insight de ánimo', @@ -4185,6 +4187,8 @@ const messages: TranslationMap = { 'flows.copilot.removed': '{count} eliminados', 'flows.copilot.noChanges': 'Esta propuesta no cambia ningún nodo.', 'flows.copilot.accept': 'Aplicar al borrador', + 'flows.copilot.acceptAndSave': 'Aceptar y guardar', + 'flows.copilot.saving': 'Guardando…', 'flows.copilot.reject': 'Descartar', 'flows.copilot.previewHint': 'Revisando un borrador propuesto: aún no se ha guardado nada.', 'flows.copilot.repairDisplay': 'Falló una ejecución; revísala y propón una solución.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 6e47f9938b..4d34ed6dfa 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3323,6 +3323,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': "Impossible de comprendre l'audio clairement: réessaie", 'mic.failedToStopRecording': "Échec de l'arrêt de l'enregistrement : {message}", 'mic.transcriptionFailed': 'Échec de la transcription : {message}', + 'mic.voiceNotCompiled': + "La transcription vocale n'est pas incluse dans cette version de l'application. Mettez à jour OpenHuman pour l'activer.", 'reflections.kind.retrospective': 'Rétrospective', 'reflections.kind.derivedFact': 'Fait dérivé', 'reflections.kind.moodInsight': 'Insight émotionnel', @@ -4214,6 +4216,8 @@ const messages: TranslationMap = { 'flows.copilot.removed': '{count} supprimés', 'flows.copilot.noChanges': 'Cette proposition ne modifie aucun nœud.', 'flows.copilot.accept': 'Appliquer au brouillon', + 'flows.copilot.acceptAndSave': 'Accepter et enregistrer', + 'flows.copilot.saving': 'Enregistrement…', 'flows.copilot.reject': 'Ignorer', 'flows.copilot.previewHint': 'Examen d’un brouillon proposé: rien n’est encore enregistré.', 'flows.copilot.repairDisplay': 'Une exécution a échoué ; examinez-la et proposez une correction.', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index b62ed9b82c..ce2250f929 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3236,6 +3236,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'ऑडियो स्पष्ट रूप से समझ नहीं आया: कृपया पुनः प्रयास करें', 'mic.failedToStopRecording': 'रिकॉर्डिंग रोकने में दिक्कत: {message}', 'mic.transcriptionFailed': 'ट्रांसक्रिप्शन विफल: {message}', + 'mic.voiceNotCompiled': + 'इस ऐप संस्करण में वॉइस ट्रांसक्रिप्शन शामिल नहीं है। इसे चालू करने के लिए OpenHuman को अपडेट करें।', 'reflections.kind.retrospective': 'रेट्रोस्पेक्टिव', 'reflections.kind.derivedFact': 'डिराइव्ड फैक्ट', 'reflections.kind.moodInsight': 'मूड इनसाइट', @@ -4112,6 +4114,8 @@ const messages: TranslationMap = { 'flows.copilot.removed': '{count} हटाए गए', 'flows.copilot.noChanges': 'यह प्रस्ताव किसी नोड को नहीं बदलता।', 'flows.copilot.accept': 'ड्राफ़्ट पर लागू करें', + 'flows.copilot.acceptAndSave': 'स्वीकार करें और सहेजें', + 'flows.copilot.saving': 'सहेजा जा रहा है…', 'flows.copilot.reject': 'रद्द करें', 'flows.copilot.previewHint': 'एक प्रस्तावित ड्राफ़्ट की समीक्षा हो रही है: अभी कुछ सहेजा नहीं गया।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index d2fbdd4d2c..2c9eddecaf 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3248,6 +3248,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Tidak dapat memahami audio dengan jelas: silakan coba lagi', 'mic.failedToStopRecording': 'Gagal menghentikan perekaman: {message}', 'mic.transcriptionFailed': 'Transkripsi gagal: {message}', + 'mic.voiceNotCompiled': + 'Transkripsi suara tidak tersedia di versi aplikasi ini. Perbarui OpenHuman untuk mengaktifkannya.', 'reflections.kind.retrospective': 'Retrospektif', 'reflections.kind.derivedFact': 'Fakta Turunan', 'reflections.kind.moodInsight': 'Wawasan Suasana Hati', @@ -4129,6 +4131,8 @@ const messages: TranslationMap = { 'flows.copilot.removed': '{count} dihapus', 'flows.copilot.noChanges': 'Usulan ini tidak mengubah simpul apa pun.', 'flows.copilot.accept': 'Terapkan ke draf', + 'flows.copilot.acceptAndSave': 'Terima & simpan', + 'flows.copilot.saving': 'Menyimpan…', 'flows.copilot.reject': 'Buang', 'flows.copilot.previewHint': 'Meninjau draf yang diusulkan: belum ada yang disimpan.', 'flows.copilot.repairDisplay': 'Sebuah eksekusi gagal; periksa dan usulkan perbaikan.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 5ef79d5314..6ca7a6820e 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3297,6 +3297,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': "Impossibile comprendere l'audio chiaramente: riprova", 'mic.failedToStopRecording': 'Impossibile fermare la registrazione: {message}', 'mic.transcriptionFailed': 'Trascrizione fallita: {message}', + 'mic.voiceNotCompiled': + "La trascrizione vocale non è inclusa in questa versione dell'app. Aggiorna OpenHuman per attivarla.", 'reflections.kind.retrospective': 'Retrospettiva', 'reflections.kind.derivedFact': 'Fatto derivato', 'reflections.kind.moodInsight': "Insight sull'umore", @@ -4182,6 +4184,8 @@ const messages: TranslationMap = { 'flows.copilot.removed': '{count} rimossi', 'flows.copilot.noChanges': 'Questa proposta non modifica alcun nodo.', 'flows.copilot.accept': 'Applica alla bozza', + 'flows.copilot.acceptAndSave': 'Accetta e salva', + 'flows.copilot.saving': 'Salvataggio…', 'flows.copilot.reject': 'Ignora', 'flows.copilot.previewHint': 'Revisione di una bozza proposta: non è stato ancora salvato nulla.', 'flows.copilot.repairDisplay': 'Un’esecuzione è fallita; esaminala e proponi una correzione.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 03a4e9aea7..0f484310e5 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3201,6 +3201,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': '오디오를 명확하게 이해할 수 없습니다: 다시 시도해 주세요', 'mic.failedToStopRecording': '녹음을 중지하지 못했습니다: {message}', 'mic.transcriptionFailed': '전사에 실패했습니다: {message}', + 'mic.voiceNotCompiled': + '이 버전의 앱에는 음성 받아쓰기가 포함되어 있지 않습니다. OpenHuman을 업데이트하면 사용할 수 있습니다.', 'reflections.kind.retrospective': '회고', 'reflections.kind.derivedFact': '파생된 사실', 'reflections.kind.moodInsight': '기분 인사이트', @@ -4067,6 +4069,8 @@ const messages: TranslationMap = { 'flows.copilot.removed': '{count}개 제거', 'flows.copilot.noChanges': '이 제안은 노드를 변경하지 않습니다.', 'flows.copilot.accept': '초안에 적용', + 'flows.copilot.acceptAndSave': '수락 및 저장', + 'flows.copilot.saving': '저장 중…', 'flows.copilot.reject': '버리기', 'flows.copilot.previewHint': '제안된 초안을 검토 중입니다: 아직 저장되지 않았습니다.', 'flows.copilot.repairDisplay': '실행이 실패했습니다. 확인하고 수정을 제안하세요.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 66901f9fe6..71a6700c09 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3274,6 +3274,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Nie udało się wyraźnie zrozumieć dźwięku: spróbuj ponownie', 'mic.failedToStopRecording': 'Nie udało się zatrzymać nagrywania: {message}', 'mic.transcriptionFailed': 'Transkrypcja nie powiodła się: {message}', + 'mic.voiceNotCompiled': + 'Transkrypcja głosu nie jest dostępna w tej wersji aplikacji. Zaktualizuj OpenHuman, aby ją włączyć.', 'reflections.kind.retrospective': 'Retrospektywa', 'reflections.kind.derivedFact': 'Wywiedziony fakt', 'reflections.kind.moodInsight': 'Wnioski o nastroju', @@ -4166,6 +4168,8 @@ const messages: TranslationMap = { 'flows.copilot.removed': 'usunięto: {count}', 'flows.copilot.noChanges': 'Ta propozycja nie zmienia żadnego węzła.', 'flows.copilot.accept': 'Zastosuj do wersji roboczej', + 'flows.copilot.acceptAndSave': 'Zaakceptuj i zapisz', + 'flows.copilot.saving': 'Zapisywanie…', 'flows.copilot.reject': 'Odrzuć', 'flows.copilot.previewHint': 'Przeglądasz proponowaną wersję roboczą: nic nie zostało jeszcze zapisane.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 7b80068c3f..f88efaeb90 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3291,6 +3291,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Não foi possível entender o áudio com clareza: tente novamente', 'mic.failedToStopRecording': 'Falha ao parar a gravação: {message}', 'mic.transcriptionFailed': 'Falha na transcrição: {message}', + 'mic.voiceNotCompiled': + 'A transcrição de voz não está incluída nesta versão do aplicativo. Atualize o OpenHuman para ativá-la.', 'reflections.kind.retrospective': 'Retrospectiva', 'reflections.kind.derivedFact': 'Fato Derivado', 'reflections.kind.moodInsight': 'Insight de Humor', @@ -4174,6 +4176,8 @@ const messages: TranslationMap = { 'flows.copilot.removed': '{count} removidos', 'flows.copilot.noChanges': 'Esta proposta não altera nenhum nó.', 'flows.copilot.accept': 'Aplicar ao rascunho', + 'flows.copilot.acceptAndSave': 'Aceitar e salvar', + 'flows.copilot.saving': 'Salvando…', 'flows.copilot.reject': 'Descartar', 'flows.copilot.previewHint': 'Revisando um rascunho proposto: nada foi salvo ainda.', 'flows.copilot.repairDisplay': 'Uma execução falhou; analise-a e proponha uma correção.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index dbd89b9e29..45ea259140 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3264,6 +3264,8 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': 'Не удалось чётко распознать аудио: попробуйте ещё раз', 'mic.failedToStopRecording': 'Не удалось остановить запись: {message}', 'mic.transcriptionFailed': 'Ошибка транскрипции: {message}', + 'mic.voiceNotCompiled': + 'Расшифровка речи не включена в эту версию приложения. Обновите OpenHuman, чтобы включить её.', 'reflections.kind.retrospective': 'Ретроспектива', 'reflections.kind.derivedFact': 'Выведенный факт', 'reflections.kind.moodInsight': 'Инсайт о настроении', @@ -4111,7 +4113,7 @@ const messages: TranslationMap = { 'flows.promptBar.submit': 'Создать', 'flows.promptBar.startBuilding': 'Начать создание', 'flows.promptBar.disclaimer': - 'Копайлот — это ИИ, и он может ошибаться. Пожалуйста, проверяйте ответы.', + 'Копайлот работает на основе ИИ и может ошибаться. Пожалуйста, проверяйте ответы.', 'flows.promptBar.thinking': 'Создание…', 'flows.promptBar.heroTitle': 'Опишите рабочий процесс', 'flows.promptBar.heroSubtitle': @@ -4152,6 +4154,8 @@ const messages: TranslationMap = { 'flows.copilot.removed': 'удалено: {count}', 'flows.copilot.noChanges': 'Это предложение не меняет ни одного узла.', 'flows.copilot.accept': 'Применить к черновику', + 'flows.copilot.acceptAndSave': 'Принять и сохранить', + 'flows.copilot.saving': 'Сохранение…', 'flows.copilot.reject': 'Отклонить', 'flows.copilot.previewHint': 'Просмотр предложенного черновика: пока ничего не сохранено.', 'flows.copilot.repairDisplay': 'Запуск завершился ошибкой; изучите его и предложите исправление.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index dcd209f635..517b1b8fc0 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3065,6 +3065,7 @@ const messages: TranslationMap = { 'mic.lowConfidenceResult': '无法清楚地理解音频:请重试', 'mic.failedToStopRecording': '停止录音失败: {message}', 'mic.transcriptionFailed': '转录失败: {message}', + 'mic.voiceNotCompiled': '此版本的应用未包含语音转文字功能。请更新 OpenHuman 后再使用。', 'reflections.kind.retrospective': '回顾', 'reflections.kind.derivedFact': '派生事实', 'reflections.kind.moodInsight': '情绪洞察', @@ -3896,6 +3897,8 @@ const messages: TranslationMap = { 'flows.copilot.removed': '移除 {count} 个', 'flows.copilot.noChanges': '此方案未更改任何节点。', 'flows.copilot.accept': '应用到草稿', + 'flows.copilot.acceptAndSave': '接受并保存', + 'flows.copilot.saving': '保存中…', 'flows.copilot.reject': '放弃', 'flows.copilot.previewHint': '正在查看建议的草稿:尚未保存任何内容。', 'flows.copilot.repairDisplay': '一次运行失败了,请查看并提出修复方案。', diff --git a/app/src/lib/theme/color.test.ts b/app/src/lib/theme/color.test.ts index d524195423..50e7d68f15 100644 --- a/app/src/lib/theme/color.test.ts +++ b/app/src/lib/theme/color.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { channelLuminance, channelsToHex, hexToChannels, isChannelTriple } from './color'; +import { + channelContrast, + channelLuminance, + channelsToHex, + hexToChannels, + isChannelTriple, +} from './color'; describe('theme colour helpers', () => { it('converts channel triples to hex', () => { @@ -38,4 +44,12 @@ describe('theme colour helpers', () => { expect(channelLuminance('0 0 0')).toBeCloseTo(0, 5); expect(channelLuminance('255 255 255')).toBeCloseTo(1, 5); }); + + it('computes WCAG contrast ratios (symmetric, 1…21)', () => { + expect(channelContrast('255 255 255', '0 0 0')).toBeCloseTo(21, 1); + expect(channelContrast('0 0 0', '255 255 255')).toBeCloseTo(21, 1); // symmetric + expect(channelContrast('128 128 128', '128 128 128')).toBeCloseTo(1, 5); // self + // sanity: a known AA-passing pair (stone-900 on white) is ≥ 4.5 + expect(channelContrast('23 23 23', '255 255 255')).toBeGreaterThan(4.5); + }); }); diff --git a/app/src/lib/theme/color.ts b/app/src/lib/theme/color.ts index 8a1f04db75..d6112d91df 100644 --- a/app/src/lib/theme/color.ts +++ b/app/src/lib/theme/color.ts @@ -63,3 +63,17 @@ export function channelLuminance(channels: string): number { const lin = parts.map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; } + +/** + * WCAG contrast ratio between two channel triples, in `[1, 21]`. Symmetric in + * its arguments. AA wants ≥ 4.5:1 for body text and ≥ 3:1 for large text / UI. + * Used to warn on custom themes and to gate the built-in presets (see + * `presets.contrast.test.ts`). + */ +export function channelContrast(a: string, b: string): number { + const la = channelLuminance(a); + const lb = channelLuminance(b); + const hi = Math.max(la, lb); + const lo = Math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); +} diff --git a/app/src/lib/theme/presets.contrast.test.ts b/app/src/lib/theme/presets.contrast.test.ts new file mode 100644 index 0000000000..ad5459e62a --- /dev/null +++ b/app/src/lib/theme/presets.contrast.test.ts @@ -0,0 +1,187 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { channelContrast } from './color'; +import { PRESET_THEMES } from './presets'; +import type { Theme } from './types'; + +/** + * WCAG AA gate for every shipped **dark** preset. + * + * A preset only carries the tokens it overrides; everything else falls through + * to the `:root.dark` defaults in `app/src/styles/tokens.css`. Those defaults are + * not importable as JS, so we mirror the relevant subset here and resolve each + * theme by merging its `colors` over this base — the same layering ThemeProvider + * does at runtime. + * + * This mirror is not trusted blindly: the `DARK_BASE parity` test below parses + * tokens.css and fails if any value here drifts from the CSS source of truth, so + * a future token edit can't leave this gate green while runtime colours change. + */ +const DARK_BASE: Record = { + surface: '23 23 23', + 'surface-canvas': '0 0 0', + 'surface-muted': '38 38 38', + 'surface-subtle': '38 38 38', + 'surface-strong': '38 38 38', + 'surface-hover': '38 38 38', + 'surface-overlay': '0 0 0', + content: '245 245 245', + 'content-secondary': '212 212 212', + 'content-muted': '163 163 163', + 'content-faint': '115 115 115', + 'content-inverted': '255 255 255', + 'primary-200': '191 219 254', + 'primary-300': '147 197 253', + 'primary-400': '96 165 250', + 'primary-500': '47 110 244', + 'primary-600': '37 99 235', + 'primary-700': '29 78 216', +}; + +const AA_TEXT = 4.5; // body text +const AA_LARGE = 3.0; // large text / UI elements / disabled-placeholder + +/** + * Every surface layer text can land on — base, canvas, recessed wells, and the + * hover/pressed states — plus `surface-overlay` (the modal scrim, tested as a + * solid fill, which is the conservative worst case since it renders at < full + * opacity over another surface). + */ +const SURFACES = [ + 'surface', + 'surface-canvas', + 'surface-muted', + 'surface-subtle', + 'surface-strong', + 'surface-hover', + 'surface-overlay', +] as const; + +/** Text tiers held to full body contrast against every surface. */ +const BODY_TIERS = ['content', 'content-secondary', 'content-muted'] as const; + +function resolve(theme: Theme): Record { + return { ...DARK_BASE, ...theme.colors }; +} + +/** + * Parse the `--token: R G B;` declarations inside a single CSS rule block + * (`selector { … }`) from tokens.css into a `{ token: 'R G B' }` map. The theme + * blocks contain no nested braces, so a non-greedy `{ … }` match is sufficient. + */ +function parseTokenBlock(css: string, selector: string): Record { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`)); + if (!match) throw new Error(`tokens.css: block not found for "${selector}"`); + const out: Record = {}; + for (const line of match[1].split('\n')) { + const decl = line.match(/^\s*--([a-z0-9-]+):\s*([^;]+);/i); + if (decl) out[decl[1]] = decl[2].trim(); + } + return out; +} + +// Effective dark values = `:root` defaults overlaid by `:root.dark` — the same +// cascade the browser applies. Accents (primary-*) live only in `:root` and are +// inherited under dark, exactly as DARK_BASE encodes them. +const tokensCss = readFileSync( + resolvePath(dirname(fileURLToPath(import.meta.url)), '../../styles/tokens.css'), + 'utf8' +); +const effectiveDarkTokens = { + ...parseTokenBlock(tokensCss, ':root'), + ...parseTokenBlock(tokensCss, ':root.dark'), +}; + +describe('DARK_BASE parity with tokens.css', () => { + // If this fails, a tokens.css edit drifted from the mirror above — update + // DARK_BASE (and re-check the AA gate) rather than muting this test. + for (const key of Object.keys(DARK_BASE)) { + it(`--${key} matches the CSS source of truth`, () => { + expect(effectiveDarkTokens[key], `token --${key}`).toBe(DARK_BASE[key]); + }); + } +}); + +const darkPresets = PRESET_THEMES.filter(t => t.isDark && t.builtIn); + +describe('preset dark themes meet WCAG AA', () => { + it('ships the expected dark presets', () => { + // Guards against a preset being renamed/dropped without updating this gate. + expect(darkPresets.map(t => t.id).sort()).toEqual( + ['dark', 'hal9000', 'matrix', 'ocean-dark', 'sepia-dark'].sort() + ); + }); + + for (const theme of darkPresets) { + describe(`${theme.name} (${theme.id})`, () => { + const t = resolve(theme); + + it('body/muted text ≥ 4.5:1 on every surface state', () => { + for (const surface of SURFACES) { + for (const tier of BODY_TIERS) { + const ratio = channelContrast(t[tier], t[surface]); + expect( + ratio, + `${theme.id}: ${tier} on ${surface} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_TEXT); + } + } + }); + + it('faint/placeholder text ≥ 3:1 on every surface state', () => { + for (const surface of SURFACES) { + const ratio = channelContrast(t['content-faint'], t[surface]); + expect( + ratio, + `${theme.id}: content-faint on ${surface} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_LARGE); + } + }); + + it('primary button label ≥ 4.5:1 on its resting and active fills', () => { + // Button.tsx: bg-primary-500 (rest) / dark:active:bg-primary-600, label + // is text-content-inverted. The transient dark-mode hover fill + // (dark:hover:bg-primary-400) is deliberately NOT gated here: it lightens + // the fill app-wide, so even the untouched historical `dark` preset sits + // at ~2.5:1 white-on-primary-400. That is a shared Button behaviour, not a + // per-theme token, and fixing it needs a Button change, not a palette one. + for (const shade of ['primary-500', 'primary-600'] as const) { + const ratio = channelContrast(t['content-inverted'], t[shade]); + expect( + ratio, + `${theme.id}: content-inverted on ${shade} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_TEXT); + } + }); + + it('accent/link text ≥ 4.5:1 on surface and canvas', () => { + // Dark-mode accent text uses the lighter shades (dark:text-primary-200…400). + for (const shade of ['primary-200', 'primary-300', 'primary-400'] as const) { + for (const surface of ['surface', 'surface-canvas'] as const) { + const ratio = channelContrast(t[shade], t[surface]); + expect( + ratio, + `${theme.id}: ${shade} text on ${surface} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_TEXT); + } + } + }); + + it('primary-500 reads as a UI element ≥ 3:1 on every surface', () => { + // Focus ring (focus-visible:ring-primary-500), button fills, and control + // boundaries can sit on any surface layer, so hold the bar on all of them. + for (const surface of SURFACES) { + const ratio = channelContrast(t['primary-500'], t[surface]); + expect( + ratio, + `${theme.id}: primary-500 vs ${surface} = ${ratio.toFixed(2)}` + ).toBeGreaterThanOrEqual(AA_LARGE); + } + }); + }); + } +}); diff --git a/app/src/lib/theme/presets.ts b/app/src/lib/theme/presets.ts index 0a5b21acd2..5b20b7788a 100644 --- a/app/src/lib/theme/presets.ts +++ b/app/src/lib/theme/presets.ts @@ -118,8 +118,14 @@ const OCEAN_DARK: Theme = { 'line-subtle': '36 48 76', content: '224 232 244', 'content-secondary': '182 196 220', - 'content-muted': '140 156 188', - 'content-faint': '104 118 150', + // Muted/faint raised so they clear AA (body 4.5:1, faint 3:1) even on the + // lightest elevated surfaces (surface-hover/strong `40 52 84`), not just the + // base surface. + 'content-muted': '152 168 200', + 'content-faint': '128 142 172', + // The primary fills here are light blue, so labels over them read as dark — + // a deep navy clears AA on primary-500/600 where white (2.5:1) failed. + 'content-inverted': '8 14 28', 'primary-500': '96 165 250', 'primary-600': '59 130 246', }, @@ -169,8 +175,13 @@ const SEPIA_DARK: Theme = { 'line-subtle': '48 40 30', content: '236 228 214', 'content-secondary': '198 184 162', - 'content-muted': '156 142 120', - 'content-faint': '120 106 86', + // Raised so muted (4.5:1) and faint (3:1) hold on the lighter recessed/hover + // sepia surfaces, not only the base surface. + 'content-muted': '176 160 136', + 'content-faint': '150 134 110', + // Tan primary fills → dark labels. A near-black brown clears AA on + // primary-500/600 where white (2.6:1) failed. + 'content-inverted': '26 22 17', ...BROWN_RAMP, 'primary-500': '200 150 90', 'primary-600': '176 126 70', @@ -198,7 +209,9 @@ const MATRIX_DARK: Theme = { content: '134 255 168', 'content-secondary': '78 210 122', 'content-muted': '58 158 92', - 'content-faint': '44 112 68', + // Raised from `44 112 68` so faint text clears 3:1 even on the brighter + // hover/strong green surfaces (was ~2.6:1); still clearly dimmer than muted. + 'content-faint': '52 132 82', 'content-inverted': '2 8 4', ...GREEN_RAMP, }, @@ -252,6 +265,10 @@ const HAL_DARK: Theme = { 'content-muted': '170 130 130', 'content-faint': '130 96 96', ...RED_RAMP, + // Deepen the resting primary (was `230 40 40`) so the white button label + // clears AA (4.5:1 → 5.2:1). The active fill (primary-600) also clears it, + // and accent text uses the lighter 300/400 shades, so the red identity holds. + 'primary-500': '214 30 30', }, gradient: { canvas: 'radial-gradient(circle at 50% 16%, rgb(84 10 10), rgb(8 4 4) 56%)' }, fonts: {}, diff --git a/app/src/pages/FlowCanvasPage.tsx b/app/src/pages/FlowCanvasPage.tsx index 4123b9d95a..e12d12e8e7 100644 --- a/app/src/pages/FlowCanvasPage.tsx +++ b/app/src/pages/FlowCanvasPage.tsx @@ -158,6 +158,19 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +/** + * True when `title` is "unclaimed" — either blank or exactly the localized + * generic placeholder (`t('flows.page.newWorkflow')`, "New workflow") — i.e. + * nothing user-meaningful has been set yet. Used to decide whether accepting + * a copilot proposal is allowed to adopt `proposal.name` as the flow title: + * it must never clobber a user-chosen or description-derived name. Pure and + * exported for direct unit testing without rendering `FlowEditor`. + */ +export function isPlaceholderTitle(title: string, placeholder: string): boolean { + const trimmed = title.trim(); + return trimmed === '' || trimmed === placeholder.trim(); +} + function BackIcon() { return ( (editorFlow.name); const commitRename = useCallback(async () => { const trimmed = titleDraft.trim(); @@ -311,6 +332,7 @@ function FlowEditor({ log('rename: flow id=%s name=%s', flowId, trimmed); await updateFlow(flowId, { name: trimmed }); setName(trimmed); + persistedNameRef.current = trimmed; } catch (err) { log('rename failed: id=%s err=%o', flowId, err); setTitleDraft(name); @@ -396,6 +418,95 @@ function FlowEditor({ // it survives any number of accept/reject/preview remounts. const persistedGraphRef = useRef(graph); + // Persist the live graph. A saved flow updates in place via `flows_update`; a + // draft is created via `flows_create` (the single persistence gate — an + // agent's `propose_workflow` never reaches this RPC), then we replace into + // the new flow's canonical `/flows/:id` canvas so further saves update it. + // Rejections propagate so the canvas surfaces the failure inline (and leaves + // the draft dirty). + // + // Issue B21: `flows_update` re-validates/normalizes the graph server-side + // (schema migration, id defaults, port normalization, etc.) before + // persisting, so the canonical saved shape can differ from what the client + // sent. Re-sync the canvas draft from the RESPONSE (not just the just-sent + // `next`) and bump `canvasVersion` so the editable canvas re-seeds from the + // canonical persisted graph immediately — matching what a navigate-away- + // and-back remount would show, without requiring one. + // + // Declared ahead of `handleAcceptProposal` (below), which calls it directly + // to persist an accepted proposal immediately. + const handleSave = useCallback( + async (next: WorkflowGraph, overrideName?: string, overrideRequireApproval?: boolean) => { + // `overrideName` covers the copilot-Accept call site: it calls + // `setName(proposal.name)` and `handleSave(...)` in the same handler, + // but `name` in THIS closure is still the pre-update value — React + // state updates don't land until the next render. Falling back to the + // (possibly stale) `name` keeps the normal manual-Save call site + // (which never passes an override) unaffected. + const effectiveName = overrideName ?? name; + // Same stale-closure concern for `requireApproval`: an accepted + // proposal carries its own approval policy (`WorkflowProposal. + // requireApproval`), which must win over the currently-loaded canvas + // policy — otherwise Accept would silently keep the old flow's policy + // instead of the one the agent proposed. A plain manual Save never + // passes an override, so `requireApproval` (the loaded flow's current + // policy) is unaffected. + const effectiveRequireApproval = overrideRequireApproval ?? requireApproval; + if (isDraft) { + log( + 'save: creating draft name=%s nodes=%d edges=%d requireApproval=%s', + effectiveName, + next.nodes.length, + next.edges.length, + effectiveRequireApproval + ); + const created = await createFlow(effectiveName, next, effectiveRequireApproval); + log('save: draft persisted as flow id=%s', created.id); + navigate(`/flows/${created.id}`, { replace: true }); + return; + } + // Only include `name` / `requireApproval` in the update payload when + // they actually diverge from what's already persisted (a manual + // rename already persisted `name` via `commitRename`; a copilot- + // adopted placeholder name or proposal-driven policy has not) — keeps + // the update metadata-safe and avoids needless writes. + const nameChanged = effectiveName !== persistedNameRef.current; + const requireApprovalChanged = overrideRequireApproval !== undefined; + log( + 'save: flow id=%s nodes=%d edges=%d nameChanged=%s requireApprovalChanged=%s', + flowId, + next.nodes.length, + next.edges.length, + nameChanged, + requireApprovalChanged + ); + const updated = await updateFlow(flowId, { + graph: next, + ...(nameChanged ? { name: effectiveName } : {}), + ...(requireApprovalChanged ? { requireApproval: effectiveRequireApproval } : {}), + }); + const persisted = updated.graph as WorkflowGraph; + persistedGraphRef.current = persisted; + persistedNameRef.current = updated.name; + setDraftGraph(persisted); + if (updated.name !== name) { + // Re-sync BOTH title states from the response — leaving `titleDraft` + // stale would show the pre-save value in the input and could + // resubmit it verbatim on a later blur. + setName(updated.name); + setTitleDraft(updated.name); + } + setCanvasVersion(v => v + 1); + log( + 'save: flow id=%s persisted — canvas re-synced from response nodes=%d edges=%d', + flowId, + persisted.nodes.length, + persisted.edges.length + ); + }, + [isDraft, flowId, name, requireApproval, navigate] + ); + const handleGraphChange = useCallback( (next: WorkflowGraph) => { // Freeze the draft while a proposal is under review — the preview graph @@ -422,12 +533,78 @@ function FlowEditor({ [draftGraph] ); - const handleAcceptProposal = useCallback((proposal: WorkflowProposal) => { - log('copilot proposal accepted'); - setDraftGraph(proposal.graph as WorkflowGraph); - setPreview(null); - setCanvasVersion(v => v + 1); - }, []); + // Accept now REVIEWS + SAVES in one step: the canvas copilot's own inline + // proposal card previously only applied the proposal to the local + // `draftGraph` and left persistence to a separate header Save click the + // user rarely noticed — the proposal would look "accepted" while nothing + // was actually saved (confirmed live: a flow persisted as empty + // trigger-only after the user said "looks good"/"save it" and the agent + // had no further action to take). Accept now immediately persists the + // just-applied graph via `handleSave`, matching what a manual Save click + // right after Accept would have done. A failed save is non-fatal: the + // proposal stays applied to the (now dirty) draft and the header Save + // button remains the manual retry — we never crash or revert the draft. + const handleAcceptProposal = useCallback( + async (proposal: WorkflowProposal) => { + log('copilot proposal accepted'); + const proposedGraph = proposal.graph as WorkflowGraph; + setDraftGraph(proposedGraph); + setPreview(null); + setCanvasVersion(v => v + 1); + + // Adopt the proposal's name into the flow title, but ONLY while the + // title is still the generic placeholder — never clobber a user-chosen + // or description-derived meaningful name. + // + // Check the VISIBLE `titleDraft`, not the committed `name` — `name` + // only updates on blur/Enter via `commitRename`, so if the user is + // mid-typing a custom title (or a rename is still in flight) when a + // proposal is accepted, `name` can still read as the stale placeholder + // while `titleDraft` already holds the user's real input. Deciding off + // `name` would silently clobber that in-progress input. Also skip + // entirely while `renaming` is true — an in-flight `commitRename` + // persist must not race with a local proposal-driven rename. + const proposedName = proposal.name?.trim(); + // `handleSave` closes over `name`, which — even after `setName` below — + // won't reflect the adopted name until the NEXT render (stale-closure + // pitfall). Pass it through explicitly as an override instead of + // relying on `name` to have updated in time. + let overrideName: string | undefined; + if ( + proposedName && + !renaming && + isPlaceholderTitle(titleDraft, t('flows.page.newWorkflow')) + ) { + // Log shape, not the user-authored name (no PII in logs). + log( + 'copilot proposal accepted: adopting proposed name into placeholder title, isDraft=%s', + isDraft + ); + setName(proposedName); + setTitleDraft(proposedName); + overrideName = proposedName; + } + + // Persist immediately — this is the actual fix. Do NOT route through + // `canvasRef.current?.save()`: the canvas is mid-remount (the + // `canvasVersion` bump above) so the ref's imperative handle is stale; + // call `handleSave` directly with the known-good proposed graph. + try { + await handleSave(proposedGraph, overrideName, proposal.requireApproval); + log('copilot proposal accepted: persisted'); + } catch (err) { + log('copilot proposal accepted: save failed err=%o', err); + // Rethrow: the draft above is already applied unconditionally, so no + // data is lost by rethrowing. This lets the caller — the copilot + // panel's own `accept` handler — see the failure and skip + // `clearProposal()`, keeping the proposal card visible for retry + // instead of silently vanishing while nothing was actually saved. + // `acceptSaving` there still resets via its own `finally`. + throw err; + } + }, + [titleDraft, renaming, t, isDraft, handleSave] + ); const handleRejectProposal = useCallback(() => { log('copilot proposal rejected'); @@ -453,9 +630,15 @@ function FlowEditor({ () => ({ schema_version: graph.schema_version, id: flowId ?? undefined, name }), [graph.schema_version, flowId, name] ); + // Also dirty when a copilot-adopted proposal name has changed the flow's + // `name` without yet persisting it (`persistedNameRef` only advances on a + // real Save/rename) — a name-only proposal (same graph, new name) must + // still enable Save, or the adopted title can never be persisted. const initialDirty = useMemo( - () => JSON.stringify(editorGraph) !== JSON.stringify(persistedGraphRef.current), - [editorGraph] + () => + JSON.stringify(editorGraph) !== JSON.stringify(persistedGraphRef.current) || + name !== persistedNameRef.current, + [editorGraph, name] ); // Repair seed for the copilot: bind the run context to the CURRENT draft. @@ -475,50 +658,6 @@ function FlowEditor({ [initialCopilotSeed] ); - // Persist the live graph. A saved flow updates in place via `flows_update`; a - // draft is created via `flows_create` (the single persistence gate — an - // agent's `propose_workflow` never reaches this RPC), then we replace into - // the new flow's canonical `/flows/:id` canvas so further saves update it. - // Rejections propagate so the canvas surfaces the failure inline (and leaves - // the draft dirty). - // - // Issue B21: `flows_update` re-validates/normalizes the graph server-side - // (schema migration, id defaults, port normalization, etc.) before - // persisting, so the canonical saved shape can differ from what the client - // sent. Re-sync the canvas draft from the RESPONSE (not just the just-sent - // `next`) and bump `canvasVersion` so the editable canvas re-seeds from the - // canonical persisted graph immediately — matching what a navigate-away- - // and-back remount would show, without requiring one. - const handleSave = useCallback( - async (next: WorkflowGraph) => { - if (isDraft) { - log( - 'save: creating draft name=%s nodes=%d edges=%d', - name, - next.nodes.length, - next.edges.length - ); - const created = await createFlow(name, next, requireApproval); - log('save: draft persisted as flow id=%s', created.id); - navigate(`/flows/${created.id}`, { replace: true }); - return; - } - log('save: flow id=%s nodes=%d edges=%d', flowId, next.nodes.length, next.edges.length); - const updated = await updateFlow(flowId, { graph: next }); - const persisted = updated.graph as WorkflowGraph; - persistedGraphRef.current = persisted; - setDraftGraph(persisted); - setCanvasVersion(v => v + 1); - log( - 'save: flow id=%s persisted — canvas re-synced from response nodes=%d edges=%d', - flowId, - persisted.nodes.length, - persisted.edges.length - ); - }, - [isDraft, flowId, name, requireApproval, navigate] - ); - // Warn on hard tab close / reload while there are unsaved edits. useEffect(() => { if (!dirty) return; diff --git a/app/src/pages/FlowsPage.test.tsx b/app/src/pages/FlowsPage.test.tsx index 4b0fb94f73..0e10805680 100644 --- a/app/src/pages/FlowsPage.test.tsx +++ b/app/src/pages/FlowsPage.test.tsx @@ -131,9 +131,9 @@ describe('FlowsPage', () => { fireEvent.click(screen.getByTestId('flow-toggle-flow-1')); expect(setFlowEnabled).toHaveBeenCalledWith('flow-1', false); - // The toggle is an icon button now; state is conveyed via aria-pressed. + // The toggle is a SettingsSwitch (role=switch) now; state is conveyed via aria-checked. await waitFor(() => - expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-checked', 'false') ); }); @@ -275,6 +275,8 @@ describe('FlowsPage', () => { deleteFlow.mockResolvedValue('flow-1'); renderWithProviders(, { initialEntries: ['/?view=main'] }); + // Delete now lives behind the row's "⋯" overflow menu, alongside + // Export/Duplicate, rather than a standalone icon button. fireEvent.click(await screen.findByTestId('flow-menu-flow-1')); fireEvent.click(await screen.findByTestId('flow-delete-flow-1')); diff --git a/app/src/pages/WorkflowRunsPage.test.tsx b/app/src/pages/WorkflowRunsPage.test.tsx index 6867115507..99ffd273a4 100644 --- a/app/src/pages/WorkflowRunsPage.test.tsx +++ b/app/src/pages/WorkflowRunsPage.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import WorkflowRunsPage from './WorkflowRunsPage'; @@ -17,6 +17,9 @@ vi.mock('../services/api/flowsApi', () => ({ listFlows: (...a: unknown[]) => listFlows(...a), })); +const fetchPendingApprovals = vi.hoisted(() => vi.fn()); +vi.mock('../services/api/approvalApi', () => ({ fetchPendingApprovals })); + // PanelPage + LoadingState pull i18n/redux we don't need — stub to bare markup. vi.mock('../components/layout/PanelPage', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, @@ -31,6 +34,8 @@ describe('WorkflowRunsPage', () => { navigateMock.mockReset(); listAllFlowRuns.mockReset(); listFlows.mockReset(); + fetchPendingApprovals.mockReset(); + fetchPendingApprovals.mockResolvedValue([]); }); it('renders aggregate runs with their workflow name and status', async () => { @@ -75,4 +80,104 @@ describe('WorkflowRunsPage', () => { await waitFor(() => expect(screen.getByText('rpc down')).toBeInTheDocument()); }); + + it('shows "pending approval" for a running run halted at a matching flow approval gate', async () => { + listAllFlowRuns.mockResolvedValue([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + fetchPendingApprovals.mockResolvedValue([ + { + request_id: 'req-1', + tool_name: 'SLACK_SEND_MESSAGE', + action_summary: 'Send Slack message', + args_redacted: {}, + session_id: 'session-1', + created_at: '2026-01-01T00:00:00Z', + expires_at: null, + source_context: { kind: 'flow', flow_id: 'f1', run_id: 'r1' }, + }, + ]); + + render(); + + const row = await screen.findByTestId('workflow-run-r1'); + await waitFor(() => expect(row).toHaveTextContent('pending approval')); + }); + + it('leaves a running run without a matching flow approval labeled "running"', async () => { + listAllFlowRuns.mockResolvedValue([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + fetchPendingApprovals.mockResolvedValue([]); + + render(); + + const row = await screen.findByTestId('workflow-run-r1'); + await waitFor(() => expect(row).toHaveTextContent('running')); + }); + + describe('live refresh (useFlowRunsLiveRefresh integration)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('re-fetches just the runs (not listFlows) on the live-refresh poll while a run is active', async () => { + vi.useFakeTimers(); + listAllFlowRuns + .mockResolvedValueOnce([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]) + .mockResolvedValueOnce([ + { id: 'r1', flow_id: 'f1', status: 'completed', started_at: '2026-01-01T00:00:00Z' }, + ]); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + + render(); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('workflow-runs-list')).toBeInTheDocument(); + expect(listAllFlowRuns).toHaveBeenCalledTimes(1); + expect(listFlows).toHaveBeenCalledTimes(1); + + // The 5s poll fallback fires `refetchRuns` while the one run is still 'running'. + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + // The poll fallback re-fetched just the runs — `listFlows` is not called again. + expect(listAllFlowRuns).toHaveBeenCalledTimes(2); + expect(listFlows).toHaveBeenCalledTimes(1); + }); + + it('does not surface an error banner when a background refetch fails', async () => { + vi.useFakeTimers(); + listAllFlowRuns + .mockResolvedValueOnce([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]) + .mockRejectedValueOnce(new Error('transient rpc blip')); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + + render(); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('workflow-runs-list')).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + expect(listAllFlowRuns).toHaveBeenCalledTimes(2); + // The transient background failure is logged only — the list stays as-is, + // no error banner from the (unrelated) `load` error state. + expect(screen.queryByText('transient rpc blip')).not.toBeInTheDocument(); + expect(screen.getByTestId('workflow-runs-list')).toBeInTheDocument(); + }); + }); }); diff --git a/app/src/pages/WorkflowRunsPage.tsx b/app/src/pages/WorkflowRunsPage.tsx index 84b934e5c8..756985400d 100644 --- a/app/src/pages/WorkflowRunsPage.tsx +++ b/app/src/pages/WorkflowRunsPage.tsx @@ -1,13 +1,22 @@ /** * WorkflowRunsPage — the aggregate "All runs" view: every workflow's runs across * the whole `flows` domain, newest first, backed by the `flows_list_all_runs` - * core RPC. Each row links back to its workflow's canvas. + * core RPC. Each row links back to its workflow's canvas. Stays live via + * {@link useFlowRunsLiveRefresh} while any listed run is still active, via a + * lightweight `refetchRuns` (re-fetches just the runs, not `listFlows()` too) + * so a run doesn't sit on "Running" until the user reloads the page. */ +import debug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import PanelPage from '../components/layout/PanelPage'; import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState'; +import { useFlowRunsLiveRefresh } from '../hooks/useFlowRunsLiveRefresh'; +import { + resolveDisplayStatus, + useRunsPendingApprovalSet, +} from '../hooks/useRunsPendingApprovalSet'; import { useT } from '../lib/i18n/I18nContext'; import { type Flow, @@ -17,6 +26,8 @@ import { listFlows, } from '../services/api/flowsApi'; +const log = debug('app:flows:runs-page'); + const STATUS_CLASS: Record = { running: 'bg-primary-500/15 text-primary-600 dark:text-primary-300', completed: 'bg-sage-500/15 text-sage-700 dark:text-sage-300', @@ -56,6 +67,22 @@ export default function WorkflowRunsPage() { void load(); }, [load]); + // Lighter-weight than `load` — only re-fetches the runs (not `listFlows()` + // too), since flow names rarely change mid-run and re-fetching them on + // every live-refresh tick would be wasted work. + const refetchRuns = useCallback(() => { + listAllFlowRuns() + .then(setRuns) + .catch(err => { + // Best-effort background refresh — a transient failure here shouldn't + // clobber the page's existing error/loading state from `load`. + log('refetchRuns failed: %o', err); + }); + }, []); + + useFlowRunsLiveRefresh(runs, refetchRuns); + const pendingRunIds = useRunsPendingApprovalSet(runs); + const statusLabel = (status: FlowRunStatus) => t(`flows.allRuns.status.${status}`, status.replace(/_/g, ' ')); @@ -79,31 +106,34 @@ export default function WorkflowRunsPage() {
    - {runs.map(run => ( -
  • - - {run.error && ( -

    - {run.error} -

    - )} -
  • - ))} + {runs.map(run => { + const displayStatus = resolveDisplayStatus(run, pendingRunIds); + return ( +
  • + + {run.error && ( +

    + {run.error} +

    + )} +
  • + ); + })}
)}
diff --git a/app/src/pages/__tests__/FlowCanvasPage.test.tsx b/app/src/pages/__tests__/FlowCanvasPage.test.tsx index 0303e31bbd..b46c3bd8b3 100644 --- a/app/src/pages/__tests__/FlowCanvasPage.test.tsx +++ b/app/src/pages/__tests__/FlowCanvasPage.test.tsx @@ -10,10 +10,12 @@ import { createMemoryRouter, MemoryRouter, Route, RouterProvider, Routes } from import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Flow } from '../../services/api/flowsApi'; +import type { WorkflowProposal } from '../../store/chatRuntimeSlice'; import FlowCanvasPage, { asCopilotBuildSeed, asCopilotPrefillSeed, FlowCanvasDraftPage, + isPlaceholderTitle, } from '../FlowCanvasPage'; const getFlow = vi.hoisted(() => vi.fn()); @@ -388,6 +390,367 @@ describe('FlowCanvasPage', () => { }); }); +describe('isPlaceholderTitle', () => { + it('treats an empty or whitespace-only title as a placeholder', () => { + expect(isPlaceholderTitle('', 'New workflow')).toBe(true); + expect(isPlaceholderTitle(' ', 'New workflow')).toBe(true); + }); + + it('treats the localized generic placeholder as a placeholder', () => { + expect(isPlaceholderTitle('New workflow', 'New workflow')).toBe(true); + expect(isPlaceholderTitle(' New workflow ', 'New workflow')).toBe(true); + }); + + it('does not treat a user-chosen or description-derived name as a placeholder', () => { + expect(isPlaceholderTitle('My flow', 'New workflow')).toBe(false); + expect(isPlaceholderTitle('Standup reminder', 'New workflow')).toBe(false); + }); +}); + +// ----------------------------------------------------------------------------- +// Copilot proposal name adoption — accepting a `propose_workflow` proposal +// carries a top-level `name` the canvas previously dropped, leaving the flow +// titled the generic placeholder even when the agent proposed a real name. +// ----------------------------------------------------------------------------- +function makeProposal(overrides: Partial = {}): WorkflowProposal { + return { + name: 'Standup reminder', + graph: { + schema_version: 1, + name: 'Standup reminder', + nodes: [ + { + id: 't', + kind: 'trigger', + name: 'Start', + config: {}, + ports: [], + position: { x: 0, y: 0 }, + }, + { + id: 'a', + kind: 'agent', + name: 'Send reminder', + config: {}, + ports: [], + position: { x: 80, y: 80 }, + }, + ], + edges: [], + }, + requireApproval: false, + summary: { trigger: 'manual', steps: [] }, + ...overrides, + }; +} + +describe('FlowCanvasPage copilot proposal name adoption', () => { + beforeEach(() => { + copilotPanelProps.current = null; + getFlow.mockReset(); + updateFlow.mockReset(); + createFlow.mockReset(); + validateFlow.mockReset(); + listFlowConnections.mockReset(); + validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); + listFlowConnections.mockResolvedValue([]); + // Accept now persists immediately (review + save in one step, see + // `handleAcceptProposal`) — default every test to a successful save so + // tests that only care about the title/name adoption aren't tripped up + // by an unmocked (`undefined`-resolving) `updateFlow`/`createFlow`. + updateFlow.mockResolvedValue(makeFlow()); + createFlow.mockResolvedValue(makeFlow({ id: 'created-id' })); + }); + + function renderEditor(id = 'test-id') { + return render( + + + } /> + Flows list} /> + + + ); + } + + // `handleAcceptProposal` is async (it awaits the persist call) — drive it + // through `act(async () => …)` so React flushes every state update the + // resulting save produces before the test asserts on them. + function acceptProposal(proposal: WorkflowProposal = makeProposal()) { + return act(async () => { + await (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => Promise)( + proposal + ); + }); + } + + it('adopts the proposal name when the title is the generic placeholder', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'New workflow' })); + updateFlow.mockResolvedValue(makeFlow({ name: 'Standup reminder' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('New workflow'); + + await acceptProposal(); + + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder'); + }); + + it('adopts the proposal name when the title is blank', async () => { + getFlow.mockResolvedValue(makeFlow({ name: '' })); + updateFlow.mockResolvedValue(makeFlow({ name: 'Standup reminder' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + await acceptProposal(); + + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder'); + }); + + it('does not clobber a user-set title when accepting a proposal', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'My flow' })); + // The name is unchanged by this accept, so the accept-triggered save's + // response echoes it back unchanged too — matching a real server, which + // only touches `name` when it's part of the update payload. + updateFlow.mockResolvedValue(makeFlow({ name: 'My flow' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + await acceptProposal(); + + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('My flow'); + }); + + // Regression (CodeRabbit on #4886): the committed `name` only updates on + // blur/Enter (`commitRename`), so while the user is still typing a custom + // title the committed `name` can read as the stale placeholder even though + // the visible input already holds real user input. Adoption must check the + // VISIBLE `titleDraft`, or it clobbers in-progress typing. + it('does not clobber an in-progress (uncommitted) title edit when accepting a proposal', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'New workflow' })); + // The uncommitted edit never reaches `name`, so the accept-triggered + // save's payload/response name is unchanged from the loaded flow. + updateFlow.mockResolvedValue(makeFlow({ name: 'New workflow' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + // User is mid-typing a custom title — not yet committed via blur/Enter. + fireEvent.change(screen.getByTestId('flow-canvas-title'), { + target: { value: 'My in-progress title' }, + }); + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('My in-progress title'); + + await acceptProposal(); + + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('My in-progress title'); + }); + + it('Accept on an existing flow fires updateFlow(flowId, { graph, name }) immediately, with no separate Save click', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'New workflow' })); + updateFlow.mockResolvedValue(makeFlow({ name: 'Standup reminder' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + await acceptProposal(); + + expect(updateFlow).toHaveBeenCalledTimes(1); + const [calledId, update] = updateFlow.mock.calls[0]; + expect(calledId).toBe('test-id'); + expect(update.name).toBe('Standup reminder'); + expect(update.graph).toBeDefined(); + // The accept-triggered save re-syncs the canvas from the response and + // clears the dirty baseline — no lingering "Unsaved changes" badge, and + // no manual Save click was fired to get here. + expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument(); + }); + + // Regression (CodeRabbit on #4886): accepting a proposal that changes only + // the top-level `name` (graph unchanged) previously left the editor's dirty + // state false — since the graph-only diff saw no change — so Save stayed + // disabled and the adopted title could never be persisted. Accept now saves + // immediately regardless of dirty tracking, so this asserts the + // accept-triggered save still fires — and carries the adopted name — even + // when the graph itself didn't change. + it('persists a name-only accepted proposal (graph unchanged) via the accept-triggered save', async () => { + const flow = makeFlow({ name: 'New workflow' }); + getFlow.mockResolvedValue(flow); + updateFlow.mockResolvedValue(makeFlow({ name: 'Standup reminder' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + // Clean on load. + expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument(); + + await acceptProposal(makeProposal({ name: 'Standup reminder', graph: flow.graph })); + + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup reminder') + ); + expect(updateFlow).toHaveBeenCalledTimes(1); + const [, update] = updateFlow.mock.calls[0]; + expect(update.name).toBe('Standup reminder'); + }); + + // Regression (CodeRabbit on #4886): when the backend returns a name that + // differs from what was submitted (server-side normalization), the title + // input must re-sync to the persisted value too — not just the committed + // `name` — or the stale draft can be resubmitted verbatim on a later blur. + // Covers F2: `handleSave` re-syncing both `name` and `titleDraft` from the + // server response name. + it('re-syncs titleDraft from the persisted response name on the accept-triggered save', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'New workflow' })); + updateFlow.mockResolvedValue(makeFlow({ name: 'Standup Reminder (normalized)' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + await acceptProposal(); + + // The visible input (`titleDraft`) must reflect the server-normalized + // name, not just the committed `name` — otherwise a later blur would + // resubmit the stale, pre-normalization value. + await waitFor(() => + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Standup Reminder (normalized)') + ); + expect(updateFlow).toHaveBeenCalledTimes(1); + }); + + // Regression for the CodeRabbit finding: the accepted PROPOSAL's own + // `requireApproval` policy must reach `createFlow`, not the draft route's + // pre-existing value — otherwise Accept would silently keep the old canvas + // policy instead of the one the agent proposed. Route state deliberately + // uses the OPPOSITE value from the proposal so a test that reads the + // route's value (the pre-fix bug) fails loudly instead of passing by + // coincidence. + it('Accept on a draft canvas fires createFlow(name, graph, requireApproval) using the PROPOSAL policy and navigates to /flows/', async () => { + createFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' })); + getFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' })); + render( + + + } /> + } /> + + + ); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + expect(screen.getByTestId('flow-canvas-title')).toHaveValue('New workflow'); + + // Proposal policy is TRUE — must be what reaches `createFlow`. + await acceptProposal(makeProposal({ requireApproval: true })); + + expect(createFlow).toHaveBeenCalledTimes(1); + const [name, graph, requireApproval] = createFlow.mock.calls[0]; + expect(name).toBe('Standup reminder'); + expect(graph).toBeDefined(); + expect(requireApproval).toBe(true); + expect(updateFlow).not.toHaveBeenCalled(); + + // `handleSave`'s draft-create path replaces into the new flow's canonical + // route — Accept alone drives that navigation, matching what a manual + // Save click right after Accept used to require. + await waitFor(() => expect(getFlow).toHaveBeenCalledWith('created-id')); + }); + + it('Accept on a saved flow fires updateFlow with the PROPOSAL requireApproval policy', async () => { + // The loaded flow's persisted policy is FALSE; the accepted proposal's + // is TRUE — the update payload must carry the proposal's value, not + // silently keep the flow's current one. + getFlow.mockResolvedValue(makeFlow({ require_approval: false })); + updateFlow.mockResolvedValue(makeFlow({ require_approval: true })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + await acceptProposal(makeProposal({ requireApproval: true })); + + expect(updateFlow).toHaveBeenCalledTimes(1); + expect(updateFlow).toHaveBeenCalledWith( + 'test-id', + expect.objectContaining({ requireApproval: true }) + ); + }); + + // Regression test for the review finding (F1, HIGH): `handleAcceptProposal` + // used to swallow a failed accept-triggered save (log + no rethrow), so + // `onAccept` always resolved — the copilot panel's `clearProposal()` always + // ran, and the proposal card silently vanished on a real save failure with + // no way to retry (its own catch branch was dead code). This test fails + // without the rethrow (the promise resolves instead of rejecting) and + // passes with it. + it('rethrows an accept-triggered save failure so the caller can leave the proposal visible for retry', async () => { + getFlow.mockResolvedValue(makeFlow({ name: 'My flow' })); + updateFlow.mockRejectedValue(new Error('network unreachable')); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument(); + + // Catch INSIDE `act()` (rather than via `expect(...).rejects`, which lets + // the rejection escape the `act()` scope unhandled) so React still + // flushes the synchronous draft/preview updates `handleAcceptProposal` + // makes before the failed `await handleSave(...)` — otherwise the + // assertions below would race an incomplete render. + let caughtErr: unknown; + await act(async () => { + try { + await (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => Promise)( + makeProposal() + ); + } catch (err) { + caughtErr = err; + } + }); + + // This is the fix under test: without the rethrow, `caughtErr` stays + // `undefined` and this assertion fails. + expect(caughtErr).toBeInstanceOf(Error); + expect((caughtErr as Error).message).toBe('network unreachable'); + + // The draft is already applied before `handleSave` is even attempted, so + // rethrowing loses no data: the proposal's graph is still on the canvas + // (2 nodes: the original trigger + the proposal's agent node), dirty, + // with the header Save button enabled as the manual retry — matching + // what `WorkflowCopilotPanel`'s own catch branch (which skips + // `clearProposal()` on rejection) relies on to keep the card visible. + // + // These three assertions land on state derived from the REMOUNTED canvas + // (`handleAcceptProposal` bumps `canvasVersion`, which changes the + // `` and forces a fresh child mount) — its own + // mount-time effects (`onDirtyChange`/`onSaveMetaChange`) can settle on a + // later microtask/effect flush than the outer `act()` above guarantees, + // so poll via `waitFor` instead of asserting immediately (this was + // observed to occasionally race in CI). + await waitFor(() => expect(screen.getAllByTestId('flow-node')).toHaveLength(2)); + await waitFor(() => expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled()); + }); +}); + describe('asCopilotBuildSeed', () => { it('accepts a copilotBuild state with a non-empty description', () => { expect(asCopilotBuildSeed({ copilotBuild: { description: 'digest my Slack' } })).toEqual({ diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index e93b985077..ba137661d4 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -272,30 +272,27 @@ function chatTurnUsagePayload(event: ChatDoneEvent): { * card. */ /** - * Tool names whose successful `output` carries a `workflow_proposal` payload. - * `propose_workflow` (first draft) and `revise_workflow` (iterative refine) - * both return the identical wire shape (see `src/openhuman/flows/builder_tools.rs`), - * so the runtime surfaces a `WorkflowProposalCard` from either. These run inside - * the `workflow_builder` specialist — reached either as the main agent's own - * tool or, in the Flows copilot / prompt-bar flow, as a delegated subagent - * (`build_workflow`) — so BOTH `onToolResult` and `onSubagentToolResult` funnel - * through {@link maybeParseWorkflowProposalTool}. - */ -const WORKFLOW_PROPOSAL_TOOLS = new Set(['propose_workflow', 'revise_workflow']); - -/** - * If a completed tool result is a successful workflow-builder proposal - * (`propose_workflow`/`revise_workflow`), parse it. Returns `null` for anything - * else so callers can cheaply gate on it. Keyed by the tool NAME + success, not - * by agent, so a proposal surfaces whether the tool ran in the main agent or in - * the delegated `workflow_builder` worker. + * Recognition is content-based, not name-based: ANY workflow-builder tool + * (`propose_workflow`, `revise_workflow`, `edit_workflow`, and any future + * addition) whose successful `output` is `{ type: "workflow_proposal", ... }` + * (see `src/openhuman/flows/builder_tools.rs` / `ops::build_builder_proposal`) + * is surfaced as a `WorkflowProposalCard`. This mirrors the Rust-side blocking + * path's `extract_workflow_proposal`, which also scans tool results by + * payload `type` rather than tool name — a name allowlist here can silently + * drop proposals from newly added tools (as happened when `edit_workflow` was + * added without updating this list). `parseWorkflowProposal`'s own + * `obj.type !== 'workflow_proposal'` check is the only gate needed. These + * tools run inside the `workflow_builder` specialist — reached either as the + * main agent's own tool or, in the Flows copilot / prompt-bar flow, as a + * delegated subagent (`build_workflow`) — so BOTH `onToolResult` and + * `onSubagentToolResult` funnel through {@link maybeParseWorkflowProposalTool}. */ function maybeParseWorkflowProposalTool( - toolName: string, + _toolName: string, success: boolean, output: string | undefined ): WorkflowProposal | null { - if (!success || !WORKFLOW_PROPOSAL_TOOLS.has(toolName) || !output) return null; + if (!success || !output) return null; return parseWorkflowProposal(output); } diff --git a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx index 353a349a20..ed25001852 100644 --- a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx +++ b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx @@ -1490,6 +1490,88 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria summary: { trigger: 'signup.created' }, }); }); + + // Regression (#4876 fallout): `edit_workflow` is the prompt-preferred way + // to iterate on an existing draft, and returns the identical + // `{ type: "workflow_proposal", ... }` payload as `propose_workflow` / + // `revise_workflow`. Proposal recognition is content-based (on the + // payload's `type`), not gated on a fixed tool-name allowlist, so this + // must surface a proposal exactly like the other two tools do — a + // name-based allowlist previously dropped it silently. + it('surfaces a workflow proposal from the main-agent edit_workflow tool', () => { + const listeners = renderProvider(); + const threadId = 't-edit-workflow'; + + act(() => { + listeners.onToolCall?.({ + thread_id: threadId, + request_id: 'r1', + round: 0, + tool_name: 'edit_workflow', + skill_id: 'flows', + args: {}, + tool_call_id: 'call-edit-1', + }); + listeners.onToolResult?.({ + thread_id: threadId, + request_id: 'r1', + round: 0, + tool_name: 'edit_workflow', + skill_id: 'flows', + success: true, + output: JSON.stringify({ + type: 'workflow_proposal', + name: 'Digest patch', + graph: { nodes: [], edges: [] }, + require_approval: true, + draft_id: 'draft-42', + summary: { trigger: 'manual', steps: [] }, + }), + tool_call_id: 'call-edit-1', + }); + }); + + const proposal = store.getState().chatRuntime.pendingWorkflowProposalsByThread[threadId]; + expect(proposal).toMatchObject({ name: 'Digest patch', requireApproval: true }); + }); + + // Any future tool that returns `{ type: "workflow_proposal" }` must be + // recognised too — the gate is the payload shape, not a tool-name list. + it('surfaces a workflow proposal from an unrecognised future tool name', () => { + const listeners = renderProvider(); + const threadId = 't-future-tool'; + + act(() => { + listeners.onToolCall?.({ + thread_id: threadId, + request_id: 'r1', + round: 0, + tool_name: 'some_future_builder_tool', + skill_id: 'flows', + args: {}, + tool_call_id: 'call-future-1', + }); + listeners.onToolResult?.({ + thread_id: threadId, + request_id: 'r1', + round: 0, + tool_name: 'some_future_builder_tool', + skill_id: 'flows', + success: true, + output: JSON.stringify({ + type: 'workflow_proposal', + name: 'Future proposal', + graph: { nodes: [], edges: [] }, + require_approval: true, + summary: { trigger: 'manual', steps: [] }, + }), + tool_call_id: 'call-future-1', + }); + }); + + const proposal = store.getState().chatRuntime.pendingWorkflowProposalsByThread[threadId]; + expect(proposal).toMatchObject({ name: 'Future proposal' }); + }); }); // Regression: on Windows users report being "locked out" of the composer diff --git a/app/src/services/api/flowsApi.test.ts b/app/src/services/api/flowsApi.test.ts index ad7af55e1e..9733aa1a96 100644 --- a/app/src/services/api/flowsApi.test.ts +++ b/app/src/services/api/flowsApi.test.ts @@ -340,7 +340,7 @@ describe('flowsApi', () => { expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.flows_discover', params: {}, - timeoutMs: 310_000, + timeoutMs: 610_000, }); expect(result).toEqual([suggestion]); }); @@ -353,7 +353,7 @@ describe('flowsApi', () => { expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.flows_discover', params: { thread_id: 'scout-thread-1' }, - timeoutMs: 310_000, + timeoutMs: 610_000, }); }); diff --git a/app/src/services/api/flowsApi.ts b/app/src/services/api/flowsApi.ts index f23f0eb8a3..c295251b84 100644 --- a/app/src/services/api/flowsApi.ts +++ b/app/src/services/api/flowsApi.ts @@ -799,12 +799,14 @@ export async function promoteDraft(id: string, requireApproval?: boolean): Promi /** * `openhuman.flows_discover` runs the read-only Flow Scout agent, which reasons - * over the user's memory/threads/connections/flows and can take up to ~300s - * server-side (`FLOW_DISCOVER_TIMEOUT_SECS` in `src/openhuman/flows/ops.rs`). - * Give the client a matching budget so a slow discovery run doesn't time out - * client-side while the agent is still thinking. + * over the user's memory/threads/connections/flows and can take up to ~600s + * server-side (`FLOW_DISCOVER_TIMEOUT_SECS` in `src/openhuman/flows/ops.rs`, + * raised to match `FLOW_BUILD_TIMEOUT_SECS` for the same iteration cap). Give + * the client a matching budget (mirrors {@link FLOW_BUILD_TIMEOUT_MS}) so a + * slow discovery run doesn't time out client-side while the agent is still + * thinking. */ -const FLOW_DISCOVER_TIMEOUT_MS = 310_000; +const FLOW_DISCOVER_TIMEOUT_MS = 610_000; /** * Run the Flow Scout discovery agent via `openhuman.flows_discover` and return diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts index b41ff78290..769a8baaae 100644 --- a/app/src/store/chatRuntimeSlice.test.ts +++ b/app/src/store/chatRuntimeSlice.test.ts @@ -17,7 +17,9 @@ import chatRuntimeReducer, { resetSessionTokenUsage, setPendingApprovalForThread, setQueueStatusForThread, + setStreamingAssistantForThread, setToolTimelineForThread, + setWorkflowProposalForThread, } from './chatRuntimeSlice'; function makeRun(id: string, status: AgentRunStatus): AgentRun { @@ -689,6 +691,171 @@ describe('hydrateRuntimeFromSnapshot — live-driver guard', () => { }); }); +// Regression: `fetchAndHydrateTurnState` (via `hydrateRuntimeFromSnapshot`) +// fires on thread rehydration (e.g. the always-open Flows copilot re-opening +// a persisted thread, #4874). A workflow proposal is a client-only flag with +// no server-side record, so a rehydrate must not resurrect a *stale* one from +// a crashed prior session — but it must also not wipe a proposal the +// streaming/blocking path set THIS session, moments before a `completed` +// snapshot for the same settled turn lands. Only `interrupted` (genuine +// crashed-mid-flight cleanup) should clear it. +describe('hydrateRuntimeFromSnapshot — workflow proposal race guard', () => { + function makeProposal(name: string) { + return { + name, + graph: { nodes: [], edges: [] }, + requireApproval: true, + summary: { trigger: 'manual', steps: [] }, + }; + } + + function makeSnapshot( + threadId: string, + lifecycle: PersistedTurnState['lifecycle'] + ): PersistedTurnState { + return { + threadId, + requestId: 'req-1', + lifecycle, + iteration: 3, + maxIterations: 10, + streamingText: '', + thinking: '', + toolTimeline: [], + startedAt: '2026-06-23T00:00:00Z', + updatedAt: '2026-06-23T00:00:00Z', + }; + } + + it('clears a pending proposal on an interrupted (crashed prior-session) snapshot', () => { + const store = makeStore(); + store.dispatch( + setWorkflowProposalForThread({ threadId: 't-crashed', proposal: makeProposal('Stale') }) + ); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeSnapshot('t-crashed', 'interrupted') }) + ); + + expect( + store.getState().chatRuntime.pendingWorkflowProposalsByThread['t-crashed'] + ).toBeUndefined(); + }); + + it('preserves a pending proposal on a completed snapshot from this session', () => { + const store = makeStore(); + // The streaming/blocking path just set this moments before the + // rehydration thunk's `completed` snapshot lands for the same turn. + store.dispatch( + setWorkflowProposalForThread({ threadId: 't-settled', proposal: makeProposal('Fresh') }) + ); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeSnapshot('t-settled', 'completed') }) + ); + + expect(store.getState().chatRuntime.pendingWorkflowProposalsByThread['t-settled']).toEqual( + makeProposal('Fresh') + ); + }); +}); + +// Regression: a `completed` snapshot rehydration must not clobber streaming +// narration / a tool timeline that is fresher than the snapshot itself. This +// happens when the socket-disconnect reconciliation path (ChatRuntimeProvider) +// deliberately preserves `streamingAssistantByThread` across `endInferenceTurn` +// so a partial reply stays visible while the socket reconnects, and a +// `fetchAndHydrateTurnState` rehydration then lands with a `completed` +// snapshot for that same (now-settled) turn. Only `interrupted` (a genuine +// crash — nothing fresher can exist) should unconditionally clobber; a +// `completed` snapshot should only fill in when there is no live state to +// lose (cold boot / new window). +describe('hydrateRuntimeFromSnapshot — streaming/timeline race guard', () => { + function makeTimelineSnapshot( + threadId: string, + lifecycle: PersistedTurnState['lifecycle'] + ): PersistedTurnState { + return { + threadId, + requestId: 'req-snapshot', + lifecycle, + iteration: 3, + maxIterations: 10, + streamingText: '', + thinking: '', + toolTimeline: [{ id: 'c-snapshot', name: 'web_search', round: 1, status: 'success' }], + startedAt: '2026-06-23T00:00:00Z', + updatedAt: '2026-06-23T00:00:00Z', + }; + } + + it('does not wipe a fresher streaming/tool-timeline lane on a completed snapshot', () => { + const store = makeStore(); + // The live driver already has state for this thread — e.g. the + // disconnect-reconciliation path kept the streaming bubble around while + // the socket reconnects. + store.dispatch( + setStreamingAssistantForThread({ + threadId: 't-settled', + streaming: { requestId: 'req-live', content: 'partial reply so far', thinking: '' }, + }) + ); + store.dispatch( + setToolTimelineForThread({ + threadId: 't-settled', + entries: [{ id: 'c-live', name: 'read_file', round: 1, status: 'success' }], + }) + ); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeTimelineSnapshot('t-settled', 'completed') }) + ); + + const state = store.getState().chatRuntime; + // The fresher live streaming bubble survives untouched… + expect(state.streamingAssistantByThread['t-settled']?.content).toBe('partial reply so far'); + // …and so does the live tool timeline, rather than being replaced by the + // (behind) snapshot's single row. + expect(state.toolTimelineByThread['t-settled'].map(e => e.id)).toEqual(['c-live']); + }); + + it('clears the streaming/tool-timeline lanes on an interrupted snapshot (crash cleanup)', () => { + const store = makeStore(); + store.dispatch( + setStreamingAssistantForThread({ + threadId: 't-crashed', + streaming: { requestId: 'req-stale', content: 'stale partial reply', thinking: '' }, + }) + ); + store.dispatch( + setToolTimelineForThread({ + threadId: 't-crashed', + entries: [{ id: 'c-stale', name: 'read_file', round: 1, status: 'running' }], + }) + ); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeTimelineSnapshot('t-crashed', 'interrupted') }) + ); + + const state = store.getState().chatRuntime; + expect(state.streamingAssistantByThread['t-crashed']).toBeUndefined(); + expect(state.toolTimelineByThread['t-crashed'].map(e => e.id)).toEqual(['c-snapshot']); + }); + + it('still hydrates the timeline from a completed snapshot on cold boot (no prior live state)', () => { + const store = makeStore(); + + store.dispatch( + hydrateRuntimeFromSnapshot({ snapshot: makeTimelineSnapshot('t-cold-boot', 'completed') }) + ); + + const state = store.getState().chatRuntime; + expect(state.streamingAssistantByThread['t-cold-boot']).toBeUndefined(); + expect(state.toolTimelineByThread['t-cold-boot'].map(e => e.id)).toEqual(['c-snapshot']); + }); +}); + describe('hydrateRuntimeFromSnapshot — persisted tool result output', () => { it('maps the persisted output onto parent and sub-agent rows as result', () => { const store = makeStore(); diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index 549daf4517..d0cad6d808 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -1969,8 +1969,15 @@ const chatRuntimeSlice = createSlice({ delete state.pendingPlanReviewByThread[threadId]; // Same for a workflow proposal (B4) — it's a client-only "should the // card render" flag with no server-side record, so a rehydrate must - // not resurrect one left over from a previous session. - delete state.pendingWorkflowProposalsByThread[threadId]; + // not resurrect one left over from a previous session. But only clear + // it on a genuinely stale snapshot (`interrupted` = crashed mid-flight + // in a prior process): a `completed` snapshot can be this session's own + // just-settled turn, racing against the streaming/blocking path that + // set the proposal moments ago — clearing unconditionally here would + // wipe a proposal that's still pending the user's Accept/Reject. + if (snapshot.lifecycle === 'interrupted') { + delete state.pendingWorkflowProposalsByThread[threadId]; + } if (snapshot.taskBoard) { state.taskBoardByThread[threadId] = snapshot.taskBoard; } @@ -1982,13 +1989,29 @@ const chatRuntimeSlice = createSlice({ // is still carried so "View processing" replays the full reasoning. if (snapshot.lifecycle === 'interrupted' || snapshot.lifecycle === 'completed') { delete state.inferenceStatusByThread[threadId]; - delete state.streamingAssistantByThread[threadId]; - // Settle any in-flight rows so their agent names stop pulsing - // (no-op for an already-completed snapshot whose rows are terminal). - state.toolTimelineByThread[threadId] = preserveLiveSubagentProse( - state.toolTimelineByThread[threadId], - snapshot.toolTimeline.map(toolTimelineFromPersisted).map(settleOrphanedTimelineEntry) - ); + + // A `completed` snapshot can still lag behind live state this session + // already has for the thread: the socket-disconnect reconciliation + // path (`ChatRuntimeProvider`) deliberately *keeps* + // `streamingAssistantByThread` set across `endInferenceTurn` so a + // partial reply stays visible while the socket reconnects, and a + // `fetchAndHydrateTurnState` rehydration can land moments later. The + // same applies to `toolTimelineByThread`, which the live event + // stream keeps richer/fresher than a flush-boundary persisted copy. + // Only let the snapshot clobber those lanes when it is unambiguously + // the authority: an `interrupted` snapshot (the process that was + // streaming is gone — nothing fresher can exist) or there is no live + // streaming state for this thread to lose (cold boot / new window). + const hasFresherLiveStream = Boolean(state.streamingAssistantByThread[threadId]); + if (snapshot.lifecycle === 'interrupted' || !hasFresherLiveStream) { + delete state.streamingAssistantByThread[threadId]; + // Settle any in-flight rows so their agent names stop pulsing + // (no-op for an already-completed snapshot whose rows are terminal). + state.toolTimelineByThread[threadId] = preserveLiveSubagentProse( + state.toolTimelineByThread[threadId], + snapshot.toolTimeline.map(toolTimelineFromPersisted).map(settleOrphanedTimelineEntry) + ); + } state.processingByThread[threadId] = snapshot.transcript ?? []; return; } diff --git a/package.json b/package.json index 9a7a0fd980..e87ca4f8cc 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "debug": "bash scripts/debug/cli.sh", "test:install-ps1": "pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1", "rust:check": "pnpm --filter openhuman-app rust:check", + "rust:clippy": "cargo clippy -p openhuman -- -D warnings && pnpm --filter openhuman-app rust:clippy", "typecheck": "pnpm --filter openhuman-app compile", "tauri:ios:init": "bash scripts/ios-init.sh", "tauri:ios:dev": "pnpm --filter openhuman-app tauri:ios:dev", diff --git a/src/bin/memory_tree_init_smoke.rs b/src/bin/memory_tree_init_smoke.rs index bddd11e9c2..f2c7f05f9d 100644 --- a/src/bin/memory_tree_init_smoke.rs +++ b/src/bin/memory_tree_init_smoke.rs @@ -57,8 +57,10 @@ fn main() -> ExitCode { } }; - let mut cfg = Config::default(); - cfg.workspace_dir = workspace.clone(); + let cfg = Config { + workspace_dir: workspace.clone(), + ..Config::default() + }; let db_path = workspace.join("memory_tree").join("chunks.db"); let cold = !db_path.exists(); diff --git a/src/bin/slack_backfill.rs b/src/bin/slack_backfill.rs index 732ac3c2e7..e5b77d41fc 100644 --- a/src/bin/slack_backfill.rs +++ b/src/bin/slack_backfill.rs @@ -323,8 +323,8 @@ async fn main() -> Result<()> { enum Outcome { Ok, Ratelimit, - OtherFail(String), - Transport(String), + OtherFail, + Transport, } let mut outcomes: Vec<(u32, std::time::Duration, Outcome)> = Vec::with_capacity(n as usize); let probe_started = Instant::now(); @@ -342,7 +342,10 @@ async fn main() -> Result<()> { .await; let dt = t0.elapsed(); let outcome = match resp { - Err(e) => Outcome::Transport(format!("{e:#}")), + Err(e) => { + log::warn!("[probe-ratelimit] transport error on call {i}: {e:#}"); + Outcome::Transport + } Ok(r) if r.successful => Outcome::Ok, Ok(r) => { let err = r.error.as_deref().unwrap_or("provider failure"); @@ -356,7 +359,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::() / outcomes.len() as u128 @@ -568,3 +575,27 @@ fn component_status(endpoint: &Option, model: &Option) -> String _ => "off".to_string(), } } + +fn sanitize_probe_error(error: &str) -> String { + let single_line = error.split_whitespace().collect::>().join(" "); + openhuman_core::openhuman::inference::provider::ops::sanitize_api_error(&single_line) +} + +#[cfg(test)] +mod tests { + use super::sanitize_probe_error; + + #[test] + fn probe_error_summary_is_single_line_bounded_and_secret_safe() { + let raw = format!( + "provider failed\nwith xoxb-secret-token {}", + "x".repeat(300) + ); + let summary = sanitize_probe_error(&raw); + + assert!(!summary.contains('\n')); + assert!(!summary.contains("xoxb-secret-token")); + assert!(summary.contains("[REDACTED]")); + assert!(summary.chars().count() <= 203); + } +} diff --git a/src/core/cli.rs b/src/core/cli.rs index 014b75c08a..2d6b55be97 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -14,10 +14,6 @@ use crate::core::jsonrpc::{default_state, invoke_method, parse_json_params}; use crate::core::logging::CliLogDefault; use crate::core::{ControllerSchema, TypeSchema}; -/// Debug/e2e agent paths can build deep async poll stacks while assembling -/// prompts, provider requests, and sub-agent tool loops. -const CLI_RUNTIME_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024; - /// The ASCII banner displayed when the CLI starts. const CLI_BANNER: &str = r#" @@ -450,14 +446,6 @@ fn run_namespace_command( Ok(()) } -fn build_cli_runtime() -> Result { - tokio::runtime::Builder::new_multi_thread() - .thread_stack_size(CLI_RUNTIME_THREAD_STACK_SIZE) - .enable_all() - .build() - .map_err(Into::into) -} - /// Parses command-line arguments into a JSON map based on a function's schema. /// /// # Arguments diff --git a/src/openhuman/accessibility/automate.rs b/src/openhuman/accessibility/automate.rs index 51e197d9f7..960b91f648 100644 --- a/src/openhuman/accessibility/automate.rs +++ b/src/openhuman/accessibility/automate.rs @@ -664,7 +664,7 @@ impl AutomateBackend for RealBackend { // `summarization` keeps M1 free of Config-schema churn while still keeping // the chat model out of the loop. use tinyagents::harness::message::Message; - use tinyagents::harness::model::{ChatModel, ModelRequest}; + use tinyagents::harness::model::ModelRequest; let model = crate::openhuman::inference::provider::create_chat_model( "summarization", &self.config, diff --git a/src/openhuman/accessibility/mod.rs b/src/openhuman/accessibility/mod.rs index a7b1de0ab6..a88ba08607 100644 --- a/src/openhuman/accessibility/mod.rs +++ b/src/openhuman/accessibility/mod.rs @@ -28,8 +28,6 @@ mod vision_click; #[cfg(target_os = "windows")] mod uia_interact; -#[cfg(test)] -pub(crate) use automation_state::test_lock as automation_state_test_lock; pub use automation_state::{ clear as clear_automation_denial, mark_system_events_denied, system_events_denied, }; diff --git a/src/openhuman/accessibility/permissions_tests.rs b/src/openhuman/accessibility/permissions_tests.rs index 2009636aac..671cedab3f 100644 --- a/src/openhuman/accessibility/permissions_tests.rs +++ b/src/openhuman/accessibility/permissions_tests.rs @@ -158,8 +158,7 @@ fn permission_state_serde_round_trip() { mod automation_state_stale_cache { use crate::openhuman::accessibility::automation_state; use crate::openhuman::accessibility::{ - automation_state_test_lock, clear_automation_denial, mark_system_events_denied, - system_events_denied, + clear_automation_denial, mark_system_events_denied, system_events_denied, }; #[test] diff --git a/src/openhuman/agent/harness/archivist/mod.rs b/src/openhuman/agent/harness/archivist/mod.rs index 7f9887c7b2..d2943f7ba1 100644 --- a/src/openhuman/agent/harness/archivist/mod.rs +++ b/src/openhuman/agent/harness/archivist/mod.rs @@ -30,8 +30,6 @@ pub use types::ArchivistHook; #[cfg(test)] pub(crate) use crate::openhuman::agent::hooks::PostTurnHook; #[cfg(test)] -pub(crate) use crate::openhuman::config::Config; -#[cfg(test)] pub(crate) use crate::openhuman::memory_store::profile; #[cfg(test)] pub(crate) use helpers::extract_profile_key; diff --git a/src/openhuman/agent/harness/archivist/recap.rs b/src/openhuman/agent/harness/archivist/recap.rs index 2ad95b3ec0..ab7cf2d5bc 100644 --- a/src/openhuman/agent/harness/archivist/recap.rs +++ b/src/openhuman/agent/harness/archivist/recap.rs @@ -64,6 +64,7 @@ impl ArchivistHook { /// - NEVER mutates DB state (no `segment_set_summary`, no embedding). /// - NEVER closes a segment. /// - Safe to call on both open and closed segments. + /// /// Summarize a set of episodic entries into a recap string. /// /// Returns `(text, produced_by_llm)`. `produced_by_llm == false` means the diff --git a/src/openhuman/agent/harness/harness_gap_tests.rs b/src/openhuman/agent/harness/harness_gap_tests.rs index 668f3dbf61..7d624553cb 100644 --- a/src/openhuman/agent/harness/harness_gap_tests.rs +++ b/src/openhuman/agent/harness/harness_gap_tests.rs @@ -23,10 +23,9 @@ //! - `` XML attribute form — the parser does not parse attributes; //! only the tag body (JSON) is used. -use crate::openhuman::agent::error::AgentError; use crate::openhuman::inference::provider::traits::ProviderCapabilities; use crate::openhuman::inference::provider::Provider; -use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, UsageInfo}; +use crate::openhuman::inference::provider::{ChatRequest, ChatResponse}; use crate::openhuman::tool_timeout::parse_tool_timeout_secs; use crate::openhuman::tools::{Tool, ToolResult}; use async_trait::async_trait; diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index e454a80b20..2c02861a2a 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -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, diff --git a/src/openhuman/agent/harness/session/transcript_tests.rs b/src/openhuman/agent/harness/session/transcript_tests.rs index cb35c7f89a..333d1b9777 100644 --- a/src/openhuman/agent/harness/session/transcript_tests.rs +++ b/src/openhuman/agent/harness/session/transcript_tests.rs @@ -871,7 +871,7 @@ fn read_thread_usage_summary_sums_multiple_transcripts() { let raw = raw_session_dir(ws.path()); std::fs::create_dir_all(&raw).unwrap(); - let mut mk = |stem: &str, input: u64, cost: f64| { + let mk = |stem: &str, input: u64, cost: f64| { let mut meta = sample_meta(); meta.thread_id = Some("thr-multi".into()); meta.input_tokens = input; @@ -928,7 +928,7 @@ fn read_thread_usage_summary_groups_subagents_by_archetype() { .unwrap(); // Sub-agent transcripts (stems contain `__`): coder x2 + researcher x1. - let mut sub = |stem: &str, agent: &str, input: u64, output: u64| { + let sub = |stem: &str, agent: &str, input: u64, output: u64| { let mut m = sample_meta(); m.thread_id = Some("thr-sub".into()); m.agent_name = agent.into(); diff --git a/src/openhuman/agent/harness/session/turn/session_io.rs b/src/openhuman/agent/harness/session/turn/session_io.rs index cce7b01509..e98911a9a2 100644 --- a/src/openhuman/agent/harness/session/turn/session_io.rs +++ b/src/openhuman/agent/harness/session/turn/session_io.rs @@ -5,7 +5,9 @@ use super::super::types::Agent; use crate::openhuman::agent::harness; use crate::openhuman::agent::progress::AgentProgress; use crate::openhuman::context::ARCHIVIST_EXTRACTION_PROMPT; -use crate::openhuman::inference::provider::{ChatMessage, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS}; +use crate::openhuman::inference::provider::{ + ChatMessage, ChatResponse, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS, +}; use futures::StreamExt; use tinyagents::harness::model::{ModelRequest, ModelStreamItem}; @@ -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) } diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index ae4afa6870..c837f21c98 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -1,5 +1,4 @@ use super::*; -use crate::core::event_bus::{global, init_global, DomainEvent}; use crate::openhuman::agent::dispatcher::XmlToolDispatcher; use crate::openhuman::agent::hooks::{PostTurnHook, TurnContext}; use crate::openhuman::agent::tool_policy::{ @@ -8,8 +7,7 @@ use crate::openhuman::agent::tool_policy::{ }; use crate::openhuman::agent_memory::memory_loader::MemoryLoader; use crate::openhuman::inference::provider::{ - ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ToolResultMessage, - UsageInfo, + ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, UsageInfo, }; use crate::openhuman::memory::Memory; use crate::openhuman::tools::ToolResult; @@ -20,7 +18,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use tokio::sync::Mutex as AsyncMutex; use tokio::sync::Notify; -use tokio::time::{sleep, timeout, Duration}; +use tokio::time::{timeout, Duration}; struct DummyProvider; @@ -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 = Arc::new(SequenceProvider { + responses: AsyncMutex::new(vec![Ok(ChatResponse { + text: Some("{\"name\":\"echo\",\"arguments\":{}}".into()), + tool_calls: vec![], + usage: Some(UsageInfo { + input_tokens: 13, + output_tokens: 5, + cached_input_tokens: 3, + charged_amount_usd: 0.07, + ..UsageInfo::default() + }), + reasoning_content: None, + })]), + requests: AsyncMutex::new(Vec::new()), + }); + let agent = make_agent_with_builder( + provider, + vec![], + Box::new(FixedMemoryLoader { + context: String::new(), + }), + vec![], + crate::openhuman::config::AgentConfig::default(), + crate::openhuman::config::ContextConfig::default(), + ); + + let (summary, usage) = agent + .summarize_turn_wrapup(&[], "test-model", 1, "write a wrap-up") + .await; + + assert!( + summary.is_empty(), + "prompt-formatted tool calls must trigger the deterministic fallback" + ); + let usage = usage.expect("rejected wrap-up must preserve provider usage"); + assert_eq!(usage.input_tokens, 13); + assert_eq!(usage.output_tokens, 5); + assert_eq!(usage.cached_input_tokens, 3); + assert_eq!(usage.charged_amount_usd, 0.07); +} + #[tokio::test] async fn turn_checkpoint_usage_is_folded_into_transcript_accounting() { // The extra checkpoint provider call costs tokens; those must land in diff --git a/src/openhuman/agent/harness/subagent_runner/ops/mod.rs b/src/openhuman/agent/harness/subagent_runner/ops/mod.rs index c8ba6c102c..9e0ea2692c 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/mod.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/mod.rs @@ -61,9 +61,7 @@ pub(super) use provider::LazyToolkitResolver; pub(super) use super::tool_prep::filter_tool_indices; // Types used by tests that were previously in scope via the flat ops.rs imports. #[cfg(test)] -pub(super) use super::types::{ - SubagentMode, SubagentRunError, SubagentRunOptions, SubagentRunOutcome, -}; +pub(super) use super::types::{SubagentMode, SubagentRunError, SubagentRunOptions}; #[cfg(test)] pub(super) use crate::openhuman::agent::harness::definition::{AgentDefinition, PromptSource}; #[cfg(test)] diff --git a/src/openhuman/agent/harness/tests.rs b/src/openhuman/agent/harness/tests.rs index 472e5463d3..aa73dc0e3b 100644 --- a/src/openhuman/agent/harness/tests.rs +++ b/src/openhuman/agent/harness/tests.rs @@ -5,10 +5,9 @@ use super::parse::{ parse_tool_calls, parse_tool_calls_from_json_value, tools_to_openai_format, }; use crate::openhuman::inference::provider::traits::ProviderCapabilities; -use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, Provider}; -use crate::openhuman::tools::{self, Tool}; +use crate::openhuman::inference::provider::{ChatRequest, ChatResponse, Provider}; +use crate::openhuman::tools; use async_trait::async_trait; -use base64::{engine::general_purpose::STANDARD, Engine as _}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; diff --git a/src/openhuman/agent/harness/tool_filter.rs b/src/openhuman/agent/harness/tool_filter.rs index 7b988c651d..5b375d7f17 100644 --- a/src/openhuman/agent/harness/tool_filter.rs +++ b/src/openhuman/agent/harness/tool_filter.rs @@ -81,7 +81,7 @@ pub fn filter_actions_by_prompt( }) .collect(); - scored.sort_by(|a, b| b.0.cmp(&a.0)); + scored.sort_by_key(|item| std::cmp::Reverse(item.0)); // Only keep positively-scored results. Zero-overlap tools would add noise. scored diff --git a/src/openhuman/agent/progress_tracing.rs b/src/openhuman/agent/progress_tracing.rs index b3d7074b51..87577062fd 100644 --- a/src/openhuman/agent/progress_tracing.rs +++ b/src/openhuman/agent/progress_tracing.rs @@ -774,7 +774,7 @@ impl SpanCollector { ("gen_ai.usage.reasoning_tokens", reasoning_tokens), ("gen_ai.usage.cache_creation_tokens", cache_creation_tokens), ] { - if add == 0 && span.attributes.get(key).is_none() { + if add == 0 && !span.attributes.contains_key(key) { continue; } let prior = span diff --git a/src/openhuman/agent/schemas.rs b/src/openhuman/agent/schemas.rs index bbae99bdec..c095855163 100644 --- a/src/openhuman/agent/schemas.rs +++ b/src/openhuman/agent/schemas.rs @@ -519,7 +519,6 @@ fn local_catalog_models_from_config( fn handle_registry_snapshot(_params: Map) -> ControllerFuture { Box::pin(async { - use crate::openhuman::tools::Tool as _; use tinyagents::registry::{ComponentKind, ComponentMetadata, RegistrySnapshot}; let mut components: Vec = Vec::new(); diff --git a/src/openhuman/agent/task_dispatcher/executor.rs b/src/openhuman/agent/task_dispatcher/executor.rs index f002d8c14a..daabdc7d7a 100644 --- a/src/openhuman/agent/task_dispatcher/executor.rs +++ b/src/openhuman/agent/task_dispatcher/executor.rs @@ -157,6 +157,13 @@ pub(super) async fn run_autonomous( // post-construction override rather than a pre-set on `config` (which // the builder would otherwise clobber). agent.set_max_tool_iterations(TASK_RUN_MAX_ITERATIONS); + tracing::debug!( + agent_id = %executor.agent_id, + run_id = %run_id, + max_tool_iterations = TASK_RUN_MAX_ITERATIONS, + "[task_dispatcher] pinned autonomous task-run iteration budget post-construction \ + (overrides the session builder's per-definition cap)" + ); agent.set_event_context(run_id.to_string(), "task"); agent.set_agent_definition_name(format!( "task-{}-{}", diff --git a/src/openhuman/agent_meetings/summary.rs b/src/openhuman/agent_meetings/summary.rs index 45dfbf862f..bb8470345d 100644 --- a/src/openhuman/agent_meetings/summary.rs +++ b/src/openhuman/agent_meetings/summary.rs @@ -17,7 +17,7 @@ use crate::core::event_bus::BackendMeetTurn; use crate::openhuman::config::{AutoSummarizePolicy, Config}; use crate::openhuman::inference::provider::create_chat_model; use tinyagents::harness::message::Message; -use tinyagents::harness::model::{ChatModel, ModelRequest}; +use tinyagents::harness::model::ModelRequest; use super::types::{ActionItem, ActionItemKind, MeetingSummary}; diff --git a/src/openhuman/agent_memory/tools.rs b/src/openhuman/agent_memory/tools.rs index 42215af356..468ebf8b7d 100644 --- a/src/openhuman/agent_memory/tools.rs +++ b/src/openhuman/agent_memory/tools.rs @@ -116,7 +116,7 @@ impl Tool for CallMemoryAgentTool { let max_turns = args .get("max_turns") .and_then(|v| v.as_u64()) - .map(|v| v.max(1).min(20) as usize); + .map(|v| v.clamp(1, 20) as usize); let is_async = args.get("async").and_then(|v| v.as_bool()).unwrap_or(false); diff --git a/src/openhuman/agent_orchestration/background_delivery.rs b/src/openhuman/agent_orchestration/background_delivery.rs index b1b0e0df21..bae691c788 100644 --- a/src/openhuman/agent_orchestration/background_delivery.rs +++ b/src/openhuman/agent_orchestration/background_delivery.rs @@ -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() + ); } } diff --git a/src/openhuman/agent_orchestration/running_subagents.rs b/src/openhuman/agent_orchestration/running_subagents.rs index 4b26363af6..1319585c74 100644 --- a/src/openhuman/agent_orchestration/running_subagents.rs +++ b/src/openhuman/agent_orchestration/running_subagents.rs @@ -847,7 +847,7 @@ pub(crate) fn task_id_for_session_in_workspace( .into_iter() .filter(|record| record_subagent_session_id(record) == Some(subagent_session_id)) .collect(); - matches.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + matches.sort_by_key(|item| std::cmp::Reverse(item.updated_at)); for record in matches { if record_parent_session(&record) != Some(parent_session) { diff --git a/src/openhuman/agent_orchestration/spawn_parallel_graph.rs b/src/openhuman/agent_orchestration/spawn_parallel_graph.rs index 69f66533e5..f449314226 100644 --- a/src/openhuman/agent_orchestration/spawn_parallel_graph.rs +++ b/src/openhuman/agent_orchestration/spawn_parallel_graph.rs @@ -409,7 +409,7 @@ fn snapshot_agent_definitions( .collect() } -pub(crate) fn prepare_spawn_parallel_tasks_from_defs( +pub(super) fn prepare_spawn_parallel_tasks_from_defs( tasks: Vec, definitions: &HashMap, parent: &ParentExecutionContext, diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index 7fd4eca00d..1a9111fa13 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -1001,18 +1001,20 @@ mod tests { } #[test] - fn workflow_builder_is_registered_worker_with_narrow_propose_or_read_scope() { + fn workflow_builder_is_registered_worker_with_bounded_authoring_scope() { // Phase 5a/5b: the workflow-builder must be a Worker-tier leaf whose - // tool scope is EXACTLY the propose-or-read + Composio discovery/connect - // + confirmed test-run + save-onto-existing belt — no flow creation - // (flows_create/set_enabled), no shell, no file writes, no channel - // sends, and no composio_execute. It can list toolkits/connections, + // tool scope is EXACTLY the bounded authoring/read + Composio + // discovery/connect belt. Creation is limited to `create_workflow` + // and `duplicate_flow`, which always produce disabled flows; the raw + // flows_create/update/set_enabled tools remain unavailable, as do + // shell, file writes, channel sends, and composio_execute. It can list + // toolkits/connections, // raise the inline connect card, `run_flow` a flow the user already // SAVED to test it (a real run the prompt gates behind user // confirmation), and `save_workflow` a built graph onto a flow the host // ALREADY created (the prompt bar's instant-create path) — but it can - // never create/enable a flow or perform an arbitrary raw integration - // action. One narrow, deliberate carve-out (B12): `get_tool_output_sample` + // never enable a flow or perform an arbitrary raw integration action. + // One narrow, deliberate carve-out (B12): `get_tool_output_sample` // DOES make a real Composio call, but only ever a Read-scope one // (hard-refused otherwise, regardless of the user's scope preference) // against an already-connected toolkit — see `builder_tools.rs`'s @@ -1039,19 +1041,41 @@ mod tests { ); match &def.tools { ToolScope::Named(names) => { + // Reconciled against `agent.toml`'s current `[tools].named` + // after the workflow-tools expansion PR widened the belt to + // agent-native editing/creation/run-control (`edit_workflow`, + // `validate_workflow`, `create_workflow`, `duplicate_flow`, + // `list_node_kinds`, `get_node_kind_contract`, + // `get_flow_history`, `list_flow_runs`, `resume_flow_run`, + // `cancel_flow_run`, `list_connectable_toolkits`) — these are + // the agent's own scoped tool surface, not the raw `flows_*` + // controller RPCs banned below, so the "no flow + // creation/enable via the raw controller" invariant still + // holds via the forbidden list. let expected = [ "propose_workflow", "revise_workflow", + "edit_workflow", + "validate_workflow", "save_workflow", "list_flows", "get_flow", + "get_flow_history", "get_flow_run", "list_flow_connections", "search_tool_catalog", "get_tool_contract", "get_tool_output_sample", "list_agent_profiles", + "list_connectable_toolkits", + "list_node_kinds", + "get_node_kind_contract", "dry_run_workflow", + "list_flow_runs", + "resume_flow_run", + "cancel_flow_run", + "create_workflow", + "duplicate_flow", "run_flow", "composio_list_toolkits", "composio_list_connections", @@ -1068,13 +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 creates/enables a flow, - // executes raw integration actions, or touches the host. - // (Persistence onto an EXISTING flow is the deliberate - // `save_workflow` carve-out above; raw `flows_update` — which - // could also rename/re-gate arbitrary flows — stays out.) + // Hard exclusions: no unrestricted flow mutation, raw + // integration actions, or host access. Creation is exposed + // only through the bounded tools above; raw `flows_update` + // could rename or re-gate arbitrary flows, so it stays out. for forbidden in [ "flows_create", "flows_update", @@ -1090,7 +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}`" ); } } diff --git a/src/openhuman/agentbox/store_tests.rs b/src/openhuman/agentbox/store_tests.rs index 052ffc8d75..10ddd51101 100644 --- a/src/openhuman/agentbox/store_tests.rs +++ b/src/openhuman/agentbox/store_tests.rs @@ -1,5 +1,5 @@ use super::store::JobStore; -use super::types::{JobRecord, JobStatus, RunResult}; +use super::types::{JobStatus, RunResult}; use std::time::Duration; #[test] diff --git a/src/openhuman/artifacts/store.rs b/src/openhuman/artifacts/store.rs index f895f97077..9d21de44b1 100644 --- a/src/openhuman/artifacts/store.rs +++ b/src/openhuman/artifacts/store.rs @@ -208,7 +208,7 @@ pub(crate) async fn list_artifacts( } // Sort descending by created_at (newest first) - all.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + all.sort_by_key(|item| std::cmp::Reverse(item.created_at)); // Apply thread filter BEFORE pagination so `total` reflects the // per-thread count the UI surfaces, and so a small page doesn't get diff --git a/src/openhuman/audio_toolkit/ops.rs b/src/openhuman/audio_toolkit/ops.rs index 22ea57ec15..fb77b99a5b 100644 --- a/src/openhuman/audio_toolkit/ops.rs +++ b/src/openhuman/audio_toolkit/ops.rs @@ -211,7 +211,7 @@ pub fn resolve_email_capture_dir(config: &Config) -> Option { } #[cfg(feature = "e2e-test-support")] { - return Some(config.workspace_dir.join(DEFAULT_CAPTURE_DIR)); + Some(config.workspace_dir.join(DEFAULT_CAPTURE_DIR)) } #[cfg(not(feature = "e2e-test-support"))] { diff --git a/src/openhuman/channels/proactive.rs b/src/openhuman/channels/proactive.rs index 54af0190d2..9298fb9dea 100644 --- a/src/openhuman/channels/proactive.rs +++ b/src/openhuman/channels/proactive.rs @@ -112,10 +112,13 @@ impl ProactiveMessageSubscriber { /// unit tests — so [`set_runtime_active_channel`] is a no-op there and never /// leaks across the parallel test suite. The choice is also persisted to /// `config.channels_config.active_channel`, which seeds the handle on next start. -static ACTIVE_CHANNEL_HANDLE: std::sync::OnceLock>>>>> = +type ActiveChannelHandle = Arc>>; +type ActiveChannelHandleSlot = RwLock>; + +static ACTIVE_CHANNEL_HANDLE: std::sync::OnceLock = std::sync::OnceLock::new(); -fn active_channel_handle_slot() -> &'static RwLock>>>> { +fn active_channel_handle_slot() -> &'static ActiveChannelHandleSlot { ACTIVE_CHANNEL_HANDLE.get_or_init(|| RwLock::new(None)) } diff --git a/src/openhuman/composio/tools/direct.rs b/src/openhuman/composio/tools/direct.rs index 1e35a0f2ad..baeddfafb5 100644 --- a/src/openhuman/composio/tools/direct.rs +++ b/src/openhuman/composio/tools/direct.rs @@ -783,10 +783,10 @@ impl Tool for ComposioTool { // tool call itself has no outbound side effect to gate. // `action="execute"` (or anything unknown / missing) is the // write path and routes through the approval gate. - match args.get("action").and_then(|v| v.as_str()) { - Some("list") | Some("connect") => false, - _ => true, - } + !matches!( + args.get("action").and_then(|v| v.as_str()), + Some("list") | Some("connect") + ) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { diff --git a/src/openhuman/config/ops/mod.rs b/src/openhuman/config/ops/mod.rs index 53558d3acd..1a24b834dc 100644 --- a/src/openhuman/config/ops/mod.rs +++ b/src/openhuman/config/ops/mod.rs @@ -33,8 +33,8 @@ pub(crate) use crate::openhuman::config::Config; #[cfg(test)] pub(crate) use loader::{ active_workspace_marker_path, config_openhuman_dir, default_openhuman_dir, env_flag_enabled, - fallback_workspace_dir, reset_local_data_for_paths, reset_local_data_remove_error, - BROWSER_ALLOW_ALL_ENV, BROWSER_ALLOW_ALL_RPC_ENABLE_ENV, + fallback_workspace_dir, reset_local_data_for_paths, BROWSER_ALLOW_ALL_ENV, + BROWSER_ALLOW_ALL_RPC_ENABLE_ENV, }; #[cfg(test)] pub(crate) use std::path::PathBuf; diff --git a/src/openhuman/config/schema/load/mod.rs b/src/openhuman/config/schema/load/mod.rs index 828e5d001c..72e52588d5 100644 --- a/src/openhuman/config/schema/load/mod.rs +++ b/src/openhuman/config/schema/load/mod.rs @@ -32,8 +32,6 @@ pub(crate) use super::Config; #[cfg(test)] pub(crate) use dirs::ACTIVE_USER_STATE_FILE; #[cfg(test)] -pub(crate) use env::ProcessEnvWithoutWorkspace; -#[cfg(test)] pub(crate) use impl_load::parse_config_with_recovery; #[cfg(test)] pub(crate) use migrate::{migrate_cloud_provider_slugs, migrate_legacy_inference_url}; diff --git a/src/openhuman/config/schema/load_tests.rs b/src/openhuman/config/schema/load_tests.rs index 1e16eaa594..9e87902ef2 100644 --- a/src/openhuman/config/schema/load_tests.rs +++ b/src/openhuman/config/schema/load_tests.rs @@ -1360,7 +1360,7 @@ async fn test_save_preserves_backup_file() { let config_path = tmp.path().join("config.toml"); let backup_path = tmp.path().join("config.toml.bak"); - let mut config = Config { + let config = Config { config_path: config_path.clone(), workspace_dir: tmp.path().join("workspace"), action_dir: tmp.path().join("workspace"), @@ -1387,7 +1387,7 @@ async fn test_save_then_corrupt_then_recover() { let tmp = tempfile::tempdir().unwrap(); let config_path = tmp.path().join("config.toml"); - let mut config = Config { + let config = Config { config_path: config_path.clone(), workspace_dir: tmp.path().join("workspace"), action_dir: tmp.path().join("workspace"), diff --git a/src/openhuman/config/schemas/mod.rs b/src/openhuman/config/schemas/mod.rs index 9981018657..aa31c8df4a 100644 --- a/src/openhuman/config/schemas/mod.rs +++ b/src/openhuman/config/schemas/mod.rs @@ -17,18 +17,11 @@ use controllers::{ #[cfg(test)] use helpers::{ deserialize_params, json_output, optional_bool, optional_json, optional_string, - required_string, to_json, ActivityLevelSettingsUpdate, AgentPathsUpdate, AgentSettingsUpdate, - AnalyticsSettingsUpdate, AutonomySettingsUpdate, BrowserSettingsUpdate, - ComposioTriggerSettingsUpdate, DictationSettingsUpdate, LocalAiSettingsUpdate, - MeetSettingsUpdate, MemorySettingsUpdate, MemorySyncSettingsUpdate, ModelSettingsUpdate, - OnboardingCompletedSetParams, RuntimeSettingsUpdate, SandboxSettingsUpdate, - ScreenIntelligenceSettingsUpdate, SearchSettingsUpdate, SetBrowserAllowAllParams, - VoiceServerSettingsUpdate, WorkspaceOnboardingFlagParams, WorkspaceOnboardingFlagSetParams, - DEFAULT_ONBOARDING_FLAG_NAME, + required_string, to_json, AutonomySettingsUpdate, LocalAiSettingsUpdate, MemorySettingsUpdate, + ModelSettingsUpdate, OnboardingCompletedSetParams, SetBrowserAllowAllParams, + WorkspaceOnboardingFlagParams, WorkspaceOnboardingFlagSetParams, DEFAULT_ONBOARDING_FLAG_NAME, }; #[cfg(test)] -use schema_defs::schemas; -#[cfg(test)] use serde_json::{Map, Value}; #[cfg(test)] diff --git a/src/openhuman/credentials/profiles.rs b/src/openhuman/credentials/profiles.rs index 8258f1b9f5..5b6b0e7f77 100644 --- a/src/openhuman/credentials/profiles.rs +++ b/src/openhuman/credentials/profiles.rs @@ -70,6 +70,16 @@ const LOCK_TIMEOUT_MS: u64 = STALE_LOCK_AGE_MS + 5_000; const PERSIST_RETRY_ATTEMPTS: u32 = 6; const PERSIST_RETRY_BASE_MS: u64 = 100; +type EncryptedProfileFields = ( + Option, + Option, + Option, + Option, + Option, + Option, + Option, +); + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AuthProfileKind { @@ -921,18 +931,7 @@ impl AuthProfilesStore { } /// Encrypt a profile's secret fields for JSON storage (keychain-unavailable path). - fn encrypt_for_json( - &self, - profile: &AuthProfile, - ) -> Result<( - Option, - Option, - Option, - Option, - Option, - Option, - Option, - )> { + fn encrypt_for_json(&self, profile: &AuthProfile) -> Result { let (access_token, refresh_token, id_token, expires_at, token_type, scope) = match (&profile.kind, &profile.token_set) { (AuthProfileKind::OAuth, Some(token_set)) => ( diff --git a/src/openhuman/cron/tools/add.rs b/src/openhuman/cron/tools/add.rs index 5f6e76fda0..efe2e75bb1 100644 --- a/src/openhuman/cron/tools/add.rs +++ b/src/openhuman/cron/tools/add.rs @@ -63,7 +63,7 @@ fn validate_delivery(config: &Config, delivery: &DeliveryConfig) -> Result<(), S } match allowed_users_for_channel(config, channel) { - Some(list) if list.is_empty() => Ok(()), + Some([]) => Ok(()), Some(list) => { if list.iter().any(|u| u == to) { Ok(()) diff --git a/src/openhuman/cwd_jail/linux.rs b/src/openhuman/cwd_jail/linux.rs index 8747ce14d4..c7297ec5fa 100644 --- a/src/openhuman/cwd_jail/linux.rs +++ b/src/openhuman/cwd_jail/linux.rs @@ -15,6 +15,12 @@ use super::jail::{Jail, JailBackend}; pub struct LandlockBackend; +impl Default for LandlockBackend { + fn default() -> Self { + Self::new() + } +} + impl LandlockBackend { pub fn new() -> Self { Self @@ -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()) diff --git a/src/openhuman/desktop_companion/pipeline_tests.rs b/src/openhuman/desktop_companion/pipeline_tests.rs index f5b858e184..70cfa13a1a 100644 --- a/src/openhuman/desktop_companion/pipeline_tests.rs +++ b/src/openhuman/desktop_companion/pipeline_tests.rs @@ -8,7 +8,6 @@ use super::*; use crate::openhuman::desktop_companion::pointing::ScreenGeometry; use crate::openhuman::desktop_companion::session; -use crate::openhuman::desktop_companion::types::*; /// Serialize tests that touch the process-global session state. Shared with /// `session_tests` via `session::lock_test_state()` so transitions in one test diff --git a/src/openhuman/desktop_companion/session_tests.rs b/src/openhuman/desktop_companion/session_tests.rs index d4ca0cd2b1..a2e6ea96a5 100644 --- a/src/openhuman/desktop_companion/session_tests.rs +++ b/src/openhuman/desktop_companion/session_tests.rs @@ -1,7 +1,6 @@ //! Tests for companion session lifecycle and state machine. use super::*; -use crate::openhuman::desktop_companion::types::*; /// Serialize tests that mutate the process-global session state. Shared with /// `pipeline_tests` via `lock_test_state()` (defined in `session`) so a diff --git a/src/openhuman/devices/rpc.rs b/src/openhuman/devices/rpc.rs index 11c3d97dc8..427666b26a 100644 --- a/src/openhuman/devices/rpc.rs +++ b/src/openhuman/devices/rpc.rs @@ -334,7 +334,7 @@ mod tests { fn test_config() -> Config { let dir = tempfile::tempdir().expect("tempdir"); let mut config = Config::default(); - config.workspace_dir = dir.into_path(); + config.workspace_dir = dir.keep(); config } diff --git a/src/openhuman/devices/store.rs b/src/openhuman/devices/store.rs index ab4998ec6d..2b89128ff5 100644 --- a/src/openhuman/devices/store.rs +++ b/src/openhuman/devices/store.rs @@ -154,7 +154,7 @@ mod tests { fn test_config() -> Config { let dir = tempfile::tempdir().expect("tempdir"); let mut config = Config::default(); - config.workspace_dir = dir.into_path(); + config.workspace_dir = dir.keep(); config } diff --git a/src/openhuman/embeddings/ollama_adapter.rs b/src/openhuman/embeddings/ollama_adapter.rs index fe48175c60..0b8162fe12 100644 --- a/src/openhuman/embeddings/ollama_adapter.rs +++ b/src/openhuman/embeddings/ollama_adapter.rs @@ -9,6 +9,7 @@ pub use tinyagents::harness::embeddings::{ DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL, DEFAULT_OLLAMA_URL, }; +#[derive(Default)] pub struct OllamaEmbedding { inner: OllamaEmbeddingModel, } @@ -33,14 +34,6 @@ impl OllamaEmbedding { } } -impl Default for OllamaEmbedding { - fn default() -> Self { - Self { - inner: OllamaEmbeddingModel::default(), - } - } -} - #[async_trait] impl EmbeddingProvider for OllamaEmbedding { fn name(&self) -> &str { diff --git a/src/openhuman/file_state/ops.rs b/src/openhuman/file_state/ops.rs index bf8be1133f..1e90d1cdbf 100644 --- a/src/openhuman/file_state/ops.rs +++ b/src/openhuman/file_state/ops.rs @@ -1,6 +1,6 @@ //! Operational API for the file state coordinator. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use std::time::{Instant, SystemTime}; use tokio::sync::{Mutex, OwnedMutexGuard}; @@ -117,10 +117,10 @@ pub fn check_stale_read(agent_id: &str, resolved_path: &PathBuf) -> Option Option { +pub fn check_partial_read(agent_id: &str, resolved_path: &Path) -> Option { let coord = try_global()?; let reads = coord.reads.read(); - let read_key = (agent_id.to_string(), resolved_path.clone()); + let read_key = (agent_id.to_string(), resolved_path.to_path_buf()); let read_stamp = reads.get(&read_key)?; if read_stamp.partial { let display_path = resolved_path.display(); @@ -138,12 +138,12 @@ pub fn check_partial_read(agent_id: &str, resolved_path: &PathBuf) -> Option Option> { +pub async fn acquire_path_lock(resolved_path: &Path) -> Option> { let coord = try_global()?; let mutex = { let mut locks = coord.path_locks.write(); locks - .entry(resolved_path.clone()) + .entry(resolved_path.to_path_buf()) .or_insert_with(|| Arc::new(Mutex::new(()))) .clone() }; diff --git a/src/openhuman/flows/agents/workflow_builder/agent.toml b/src/openhuman/flows/agents/workflow_builder/agent.toml index 159839aee5..c004043381 100644 --- a/src/openhuman/flows/agents/workflow_builder/agent.toml +++ b/src/openhuman/flows/agents/workflow_builder/agent.toml @@ -1,13 +1,14 @@ id = "workflow_builder" display_name = "Workflow Builder" delegate_name = "build_workflow" -when_to_use = "Workflow authoring specialist — owns building tinyflows automation graphs from a natural-language description. Route any request to 'set up a workflow that…', 'automate…', 'when X happens do Y', 'build/create/edit an automation or flow', or to iterate on a proposed workflow here. It grounds nodes in real tool slugs + connections, dry-runs a draft in a sandbox to self-check, and returns a workflow PROPOSAL for review. Persistence stays with the user's Accept + Save; when the user explicitly asks the agent to save (or test-run) onto an existing flow id it may `save_workflow` / `run_flow` — but never on its own, and it can never create a new flow or enable/disable one. Not for running an already-saved flow (that is a direct flows_run) and not for the legacy skill 'workflows'." +when_to_use = "Workflow authoring specialist — owns building tinyflows automation graphs from a natural-language description. Route any request to 'set up a workflow that…', 'automate…', 'when X happens do Y', 'build/create/edit an automation or flow', or to iterate on a proposed workflow here. It grounds nodes in real tool slugs + connections, dry-runs a draft in a sandbox to self-check, and returns a workflow PROPOSAL for review. Persistence stays with the user's Accept + Save; when the user explicitly asks the agent to save (or test-run) onto an existing flow id it may `save_workflow` / `run_flow` — but never on its own. It CAN create a new flow (`create_workflow`) or clone one (`duplicate_flow`) when the user explicitly asks, but every flow it creates is always born DISABLED — it can never enable a flow, or run one without the user's confirmation first. Not for running an already-saved flow (that is a direct flows_run) and not for the legacy skill 'workflows'." temperature = 0.2 max_iterations = 12 iteration_policy = "extended" max_result_chars = 12000 -# No sandbox filter needed: the belt is propose/read/dry-run plus two +# No sandbox filter needed: the belt is propose/read/dry-run plus a handful of # explicitly-bounded writes (save_workflow onto an existing flow; +# create_workflow/duplicate_flow, always force-disabled at creation; # run_flow of a saved flow behind a confirm-first prompt rule), and # dry_run_workflow runs against tinyflows MOCK capabilities (no real effects). sandbox_mode = "none" @@ -28,10 +29,12 @@ hint = "reasoning" # DELIBERATELY NARROW: propose/revise (validate-only) + read (flows, runs, # connections, tool catalog) + sandbox dry-run + Composio discovery/connect + # a confirmed real test-run of a SAVED flow + save_workflow (persist a graph -# onto an EXISTING flow only). NO shell, NO file writes, NO channel sends, NO -# composio_execute, and NO flows_create/set_enabled — the agent can never -# create or enable a flow, or perform an arbitrary real integration action -# directly. Composio access is limited to LISTING toolkits/connections, +# onto an EXISTING flow only) + create_workflow/duplicate_flow (persist a +# BRAND NEW flow — always force-disabled at creation, see +# `builder_tools::CreateWorkflowTool`). NO shell, NO file writes, NO channel +# sends, NO composio_execute, and NO flows_set_enabled — the agent can create +# a flow but can never enable one, or perform an arbitrary real integration +# action directly. Composio access is limited to LISTING toolkits/connections, # raising the inline CONNECT card (an approval-gated OAuth hand-off), and one # narrow carve-out below. # `run_flow` executes a flow the user has ALREADY saved to test it — a real diff --git a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs index 97f7a8f05f..80a6c1f008 100644 --- a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs +++ b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs @@ -335,6 +335,29 @@ mod tests { STANDING_PROMPT.contains("Read-only — you can't change their memory"), "standing prompt must state the memory read-only guarantee, not just mention memory_recall" ); + + // Negative (contract accuracy, issue #6): `create_workflow` and + // `duplicate_flow` are on this agent's belt (see agent.toml's `named` + // tool list), so the prompt must never claim the agent can't create a + // flow at all — only that it can't enable/run one unattended. + for banned in [ + "create a new flow, or enable/disable one", + "It cannot create flows,", + ] { + assert!( + !STANDING_PROMPT.contains(banned), + "standing prompt must not carry the stale \"can never create a flow\" claim \ + `{banned}` — create_workflow/duplicate_flow are on the belt (issue #6)" + ); + } + + // Positive: the accurate contract — the agent CAN create a flow, but + // every flow it creates is always born disabled. + assert!( + STANDING_PROMPT.contains("create_workflow") && STANDING_PROMPT.contains("born"), + "standing prompt must accurately teach that create_workflow exists and that \ + created flows are always born disabled (issue #6)" + ); } #[test] diff --git a/src/openhuman/flows/agents/workflow_builder/prompt.md b/src/openhuman/flows/agents/workflow_builder/prompt.md index 7d8430866f..0ab8b7e5cf 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.md +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -8,23 +8,30 @@ user to review and save. ## The invariants you must never break -You **cannot and must not** create a new flow, or enable/disable one. You have -no tool that does — by design. Your authoring outputs are: +You **can** create a new flow (`create_workflow`) or clone one +(`duplicate_flow`), but only when the user explicitly asks — and every flow +you create is always born **DISABLED**. Enabling a flow is not a tool you +have, by design: you **cannot and must not** enable or disable one, ever. +Your authoring outputs are: - **`propose_workflow`** / **`revise_workflow`** — these *validate* a candidate graph and hand back a proposal summary. They **never** save anything. -- **`dry_run_workflow`** — runs a *draft* in a **sandbox** against mock +- **`dry_run_workflow`** — runs a graph in a **sandbox** against mock capabilities (deterministic echoes). Nothing real happens: no message is sent, - no code runs, no HTTP fires. Treat its output as a wiring check only. + no code runs, no HTTP fires. Treat its output as a wiring check only. Takes the + graph as any of `draft_id` / `flow_id` / an inline `graph` (precedence + `draft_id` > `flow_id` > `graph`). - **`save_workflow`** — the ONE persistence tool you have, and it only writes to - a flow that **already exists** (you need its `flow_id`). See below. + a flow that **already exists** (you need its `flow_id` as the target). Its + source is a `draft_id` (the usual case after iterating with `edit_workflow`) OR + an inline `graph`. See below. Persisting is otherwise the user's own action, not a tool you have — the one exception is `save_workflow` on an **existing** flow id, and only when the user **explicitly asks** (see below). If a user says "just turn it on for me", explain that enabling stays in their hands — you cannot enable a flow. -## Saving your work: `save_workflow` (only on the user's explicit ask) +## Saving your work: `save_workflow` / `create_workflow` (only on the user's explicit ask) Every authoring turn — build, revise, or repair — is **propose-only** by default. Your arc is: @@ -37,19 +44,32 @@ default. Your arc is: card") — never recite every persist path, and never repeat it across turns. -**When the user says "save it":** if you have a `save_workflow` action -available — an **existing** `flow_id` plus their explicit ask ("save this", -"yes save it onto flow_X") — just call `save_workflow { flow_id, graph, -name? }` and confirm in one plain line what you saved (trigger, steps, and — -if the flow is enabled with a schedule/app_event trigger — that it's now -live and will fire on its own). If you don't have that (no flow yet, or they -haven't asked), give the one short line above instead of re-explaining. +**When the user says "save it":** which tool depends on whether the flow +already exists: + +- **Existing flow** — you have a `flow_id` plus their explicit ask ("save + this", "yes save it onto flow_X") — just call `save_workflow { flow_id, + draft_id, name? }` (pass the `draft_id` you've been iterating on; an inline + `graph` also works) and confirm in one plain line what you saved (trigger, + steps, and — if the flow is enabled with a schedule/app_event trigger — + that it's now live and will fire on its own). +- **Brand-new flow** — no `flow_id` yet, but the user explicitly asked you to + create/save it as a new automation ("create this and save it", "make this a + new flow") — call `create_workflow` (or `duplicate_flow` to clone an + existing one) instead; it persists a NEW flow, always born **DISABLED**, + and confirm what you created plus that it's off until they enable it. +- **Neither** (no flow yet and no explicit save/create ask, or they haven't + asked at all) — give the one short line from step 2 above instead of + re-explaining. **Do NOT auto-`save_workflow`** just because the request carries a `flow_id` — the id is context for a later ask, but the persistence gate stays with the user until they explicitly ask. Never `save_workflow` onto a -flow the user did NOT ask you to build/update. It cannot create flows, and -it never changes `enabled` or the approval gate. +flow the user did NOT ask you to build/update. It only writes onto a flow +that already exists (creating one is `create_workflow`'s job, not +`save_workflow`'s) and it never touches the approval gate — but it CAN +auto-disable the flow if the graph's trigger just transitioned from manual +to automatic on an already-enabled flow; say so if it happens. ## Testing a saved flow: `run_flow` (ask first!) @@ -91,13 +111,19 @@ rather than a general context recall), use `memory_hybrid_search` in its - `search_tool_catalog { query, toolkit? }` → real Composio action **slugs** from the FULL LIVE catalog for ANY named app — connected or not, curated or not (curated matches come back `featured: true` and are - ranked first). **Never hallucinate a slug** — if the catalog has no - match for the app, prefer an `http_request` node or tell the user the - integration isn't available. Each match also carries `required_args` / - `output_fields` / `primary_array_path` — but call `get_tool_contract - { slug }` before you actually WIRE a match: it hands back the exact - required args, the full input/output schema, and the array path a - `split_out` should use (see `tool_call` below). `propose_workflow` / + ranked first; a match may also carry `runtime_gated: true`, meaning that + action is blocked on real runs — prefer a `featured` one instead). + **Prefer ONE short keyword** (e.g. `gmail`, `send email`) for the widest + listing; a multi-word query that finds nothing no longer dead-ends — it + falls back to the nearest per-keyword matches with an explanatory `note`, + so read that note rather than assuming the app is missing. **Never + hallucinate a slug** — if the catalog genuinely has no match, prefer an + `http_request` node or tell the user the integration isn't available. Each + match also carries `required_args` / `output_fields` / `primary_array_path` + — but call `get_tool_contract { slug }` before you actually WIRE a match: it + hands back the exact required args, the full input/output schema, and the + array path a `split_out` should use (see `tool_call` below). + `propose_workflow` / `revise_workflow` / `save_workflow` HARD-REJECT a `tool_call` whose slug isn't real in the live catalog, or that's missing one of its real required args — so grounding here isn't optional polish, it's what @@ -117,13 +143,29 @@ You have a machine-readable belt; use it instead of relying on memory: gotchas. Consult these instead of guessing config shapes (this is the source of truth; the summary below is just orientation). - **Iterate cheaply:** once a draft exists, prefer `edit_workflow { draft_id | - flow_id | graph, ops[] }` (add_node / update_node_config[merge-patch] / - rename_node / add_edge / remove_edge / …) over re-emitting the whole graph - with `revise_workflow` — it's fewer tokens and won't drop a node or mangle an - edge. Edits to a `draft_id` are written back to the shared draft. -- **Check without proposing:** `validate_workflow { graph | flow_id }` runs the - same structural + hard-gate stack and returns every problem at once, so you - can self-verify mid-build without emitting a proposal card. + flow_id | graph, ops[] }` over re-emitting the whole graph with + `revise_workflow` — it's fewer tokens and won't drop a node or mangle an edge. + The op shapes (each is `{ "op": , … }`; `id` also accepts the alias + `node_id`, and `rename_node`'s `new_id` accepts `new_node_id`): + `add_node {node}` · `update_node_config {id, config}` (a JSON merge-patch — a + `null` value deletes that config key) · `set_node_name {id, name}` · + `rename_node {id, new_id}` (rewires edges) · `remove_node {id}` (drops its + edges) · `add_edge {edge}` · `remove_edge {from_node, to_node, from_port?, + to_port?}` · `set_node_position {id, position}`. Ops apply **strictly in array + order**, so to replace a node put its `remove_node` BEFORE the `add_node` (or + just `update_node_config` in place) — an "id already exists" error is almost + always that ordering slip. A bad op's error names the failing op index and the + exact shape that op wanted; fix and call again. + **Persistence:** `edit_workflow` NEVER saves. Editing a `flow_id` **seeds a new + draft** from that flow (the flow itself is untouched) and returns its + `draft_id`; editing a `draft_id` writes back to that same draft. The result + always carries `persisted: false` plus a `next` hint — keep iterating by + passing the returned `draft_id` to `edit_workflow` / `dry_run_workflow`, and + persist only on the user's explicit ask with `save_workflow { flow_id, + draft_id }`. A proposal is never a save. +- **Check without proposing:** `validate_workflow { draft_id | flow_id | graph }` + runs the same structural + hard-gate stack and returns every problem at once, + so you can self-verify mid-build without emitting a proposal card. - **Steer connections:** `list_connectable_toolkits` flags which toolkits are already connected — prefer those; the proposal's `required_connections` enumerates what still needs linking. @@ -300,6 +342,20 @@ A `WorkflowGraph` is `{ name?, nodes: [...], edges: [...] }`. slug isn't a real action in the live Composio catalog for its toolkit — a hallucinated or typo'd slug never makes it past validation, so always ground `config.slug` in a `search_tool_catalog` result first. + - **The `connection_ref` is enforced against the RIGHT toolkit.** + `config.connection_ref` must read `composio::` where + `` matches the slug's toolkit AND `` is one of the user's real + connections **for that toolkit** — get each ref verbatim from + `list_flow_connections`. Copying an id from a DIFFERENT toolkit (e.g. a + TikTok connection id onto a Gmail node) is HARD-REJECTED at + `propose_workflow`/`revise_workflow`/`save_workflow`, naming the correct + ref — so never reuse an id across toolkits. + - **`get_tool_contract` may return a top-level `runtime_gate` warning.** For + an uncurated action of a toolkit that ships a curated catalog, the real + runtime tool gate allows curated actions only, so that action is REJECTED + on every real run. Treat a `runtime_gate` warning as a **hard stop**: go + back to `search_tool_catalog` and pick a `featured: true` action instead of + wiring the gated one. - **Wiring a DOWNSTREAM node off THIS tool's output?** Don't guess the field name (e.g. assuming `GMAIL_FETCH_EMAILS` returns `.messages`) — `get_tool_contract`'s `output_fields` names the action's REAL top-level @@ -585,7 +641,11 @@ names, `dry_run_workflow` again, and repeat until it comes back clean. Only then call `propose_workflow` / `save_workflow`. Don't hand back a proposal you haven't verified just because the turn has run long — the user would rather wait one more tool call than review a graph that silently does -nothing. +nothing. **One exception:** a `null_resolutions` entry flagged `unverifiable: +true` (or an `unverifiable_bindings` list) is a Composio-upstream binding the +sandbox genuinely can't check — confirm it with `get_tool_contract` rather +than re-wiring, and don't loop on it (see "Interpreting dry-run results +honestly" below). ### Interpreting dry-run results honestly @@ -613,13 +673,27 @@ values appear: entry and the dry run returns `ok: false`. **These are real bugs — never dismiss them.** Fix every one before proposing. +3. **Unverifiable Composio-upstream bindings** — a `null_resolutions` entry + may carry `unverifiable: true` and an `upstream_tool_call` when the required + arg binds to the OUTPUT of a Composio `tool_call` node (an early-abort dry + run surfaces the same class as `unverifiable_bindings`). The echo sandbox + can never produce a Composio tool's real output fields, so this null does + **NOT** prove the binding wrong — it is genuinely unknowable here. Do **not** + thrash re-wiring it. Confirm the path against `get_tool_contract`'s + `output_fields` / `primary_array_path` (remember Composio results nest under + `.item.json.data.`), or `get_tool_output_sample { slug, args }` for the real + shape; it's a bug only if the path doesn't match the action's actual output. + The propose/save gate no longer blocks on this class, so a graph whose only + flag is `unverifiable` bindings you've confirmed is fine to propose. + #### Sandbox mock behavior per node type (authoritative — do NOT probe) | Node kind | Sandbox output | Enveloped? | What resolves downstream | |-----------|----------------|------------|---------------------------| | `trigger` | Passthrough — echoes the `input` value (default `{}`) | No | Whatever was passed as `input` | -| `agent` (with `output_parser.schema`) | Typed placeholder per schema property (`string`→`""`, `number`/`integer`→`0`, `boolean`→`false`, `object`→`{}`, `array`→`[]`, `enum`→its first listed value) | Yes | `=nodes..item.json.` → the placeholder (non-null) | -| `agent` (no schema) | `{ "agent": "", "request": {...}, "connection": ... }` | Yes | Only `.json.agent` / `.json.request` / `.json.connection` resolve; any other `.json.` → null | +| `agent` (with `output_parser.schema`) | Typed placeholder per schema property (`string`→`""`, `number`/`integer`→`0`, `boolean`→`false`, `object`→`{}`, `array`→`[]`, `enum`→its first listed value). Applies to **every** agent node — plain or with an `agent_ref` | Yes | `=nodes..item.json.` → the placeholder (non-null) | +| `agent` (no schema, plain — no `agent_ref`) | `{ "completion": , "connection": ... }` (the mock LLM echo) | Yes | Only `.json.completion` / `.json.connection` resolve; any other `.json.` → null | +| `agent` (no schema, with `agent_ref`) | `{ "agent": "", "request": {...}, "connection": ... }` | Yes | Only `.json.agent` / `.json.request` / `.json.connection` resolve; any other `.json.` → null | | `tool_call` | Required Composio args are preflight-checked first (missing/null → dry run fails before the mock even runs), then echoes `{ "tool": "", "args": {...}, "connection": ... }` — NOT a real API response | Yes | `.json.tool` / `.json.args` / `.json.connection` resolve; a response-shaped field (e.g. `.json.data.` for a real Composio call — see "the envelope" above) does **not**, because the mock echo carries no `data` wrapper. That is a mock-shape gap, not a wiring bug — don't "fix" a correctly-wired `.json.data.` binding just because the dry run can't resolve it | | `http_request` | `{ "status": 200, "request": {...}, "connection": ... }` | Yes | `.json.status` → `200`; response-body fields → null | | `code` | `{ "result": }` — the real `source` is NOT executed | **No** | `.item.result` resolves directly (no `.json.` — `code` does not envelope) | diff --git a/src/openhuman/flows/builder_tools.rs b/src/openhuman/flows/builder_tools.rs index 599d90b0ab..60495aca0b 100644 --- a/src/openhuman/flows/builder_tools.rs +++ b/src/openhuman/flows/builder_tools.rs @@ -64,6 +64,30 @@ use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; /// capabilities are non-blocking echoes, so this is a generous safety net. const DRY_RUN_TIMEOUT_SECS: u64 = 30; +/// Comma list of the valid `op` tag values, for the missing-/unknown-`op` +/// parse errors surfaced by [`EditWorkflowTool`]. +const VALID_OP_TYPES: &str = "add_node, update_node_config, set_node_name, rename_node, \ + remove_node, add_edge, remove_edge, set_node_position"; + +/// The expected field shape for a given `op` tag, used in `edit_workflow`'s +/// per-op parse diagnostics so a failing op tells the agent exactly what that +/// op type wants. Returns `None` for an unrecognized tag. +fn edit_op_shape(op: &str) -> Option<&'static str> { + Some(match op { + "add_node" => "{ op, node: { id, kind, name, config? } }", + "update_node_config" => { + "{ op, id, config } (id also accepts alias `node_id`; config is a JSON merge-patch)" + } + "set_node_name" => "{ op, id, name } (id also accepts alias `node_id`)", + "rename_node" => "{ op, id, new_id } (also accept aliases `node_id` / `new_node_id`)", + "remove_node" => "{ op, id } (id also accepts alias `node_id`)", + "add_edge" => "{ op, edge: { from_node, to_node, from_port?, to_port? } }", + "remove_edge" => "{ op, from_node, to_node, from_port?, to_port? }", + "set_node_position" => "{ op, id, position: { x, y } } (id also accepts alias `node_id`)", + _ => return None, + }) +} + // ───────────────────────────────────────────────────────────────────────────── // revise_workflow — iterative refine of an existing draft (proposal only) // ───────────────────────────────────────────────────────────────────────────── @@ -191,6 +215,10 @@ impl Tool for ReviseWorkflowTool { require_approval, true, instruction, + // revise_workflow takes only an inline graph — no draft/flow handle + // to echo. The payload still carries persisted:false unconditionally. + None, + None, ) .await { @@ -240,10 +268,14 @@ impl Tool for EditWorkflowTool { {id, config} (JSON merge-patch — a null value deletes that config key), set_node_name \ {id, name}, rename_node {id, new_id} (rewires edges), remove_node {id} (drops its edges), \ add_edge {edge}, remove_edge {from_node, to_node, from_port?, to_port?}, set_node_position \ - {id, position}. Like propose/revise_workflow this ONLY VALIDATES and returns a proposal \ - for the user to review — it never creates, updates, or enables the flow. If an op fails or \ - the resulting graph is invalid, the error names the failing op / node; fix it and call \ - edit_workflow again." + {id, position}. PERSISTENCE: the applied edit is written to a DRAFT, never onto the saved \ + flow — this tool NEVER saves. Editing a flow_id SEEDS A NEW DRAFT from that flow's graph \ + and returns its `draft_id`; editing a draft_id writes back to that same draft. The result \ + carries `draft_id`, `flow_id` (if any), `persisted: false`, and a `next` hint. To keep \ + iterating pass that `draft_id` (to edit_workflow / dry_run_workflow); to persist, call \ + save_workflow { flow_id, draft_id } when the user asks. If an op fails or the resulting \ + graph is invalid, the error names the failing op / node; fix it and call edit_workflow \ + again." } fn parameters_schema(&self) -> Value { @@ -314,9 +346,16 @@ impl Tool for EditWorkflowTool { .filter(|s| !s.is_empty()); let inline_graph = args.get("graph").filter(|v| !v.is_null()); - // When editing a draft, remember its id so the applied edit is written - // back — the draft is the durable working copy across turns/reloads. + // The applied edit is always written back to a durable DRAFT (the shared + // working copy across turns/reloads). `write_back_draft` is the draft id + // it lands on; `edited_from_flow` is the saved flow this edit derives + // from / would persist onto, if any. The core WS2 fix: editing a bare + // `flow_id` used to persist NOTHING and return NO handle — the edit was + // unreachable and read as "written onto the flow". Now a `flow_id` base + // seeds a NEW draft, so the edit is durable, addressable, and clearly + // NOT the saved flow. let mut write_back_draft: Option = None; + let mut edited_from_flow: Option = None; let (base_graph, default_name) = match (draft_id, flow_id, inline_graph) { (Some(id), _, _) => match ops::flows_draft_get(&self.config, id) { @@ -325,6 +364,9 @@ impl Tool for EditWorkflowTool { match ops::migrate_and_deserialize_graph(draft.graph.clone()) { Ok(graph) => { write_back_draft = Some(draft.id.clone()); + // A draft may already be linked to a saved flow — + // carry that through so the proposal echoes it. + edited_from_flow = draft.flow_id.clone(); (graph, draft.name) } Err(e) => { @@ -341,7 +383,47 @@ impl Tool for EditWorkflowTool { } }, (None, Some(id), _) => match ops::flows_get(&self.config, id).await { - Ok(outcome) => (outcome.value.graph, outcome.value.name), + Ok(outcome) => { + let flow = outcome.value; + // Seed a NEW draft from the saved flow's graph so the edit is + // durable and reachable (the RPC/canvas path uses the same + // `flows_draft_create` op). Linking the draft to `flow.id` + // means a later save_workflow { flow_id, draft_id } knows its + // target. + let graph_json = match serde_json::to_value(&flow.graph) { + Ok(v) => v, + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not serialize flow '{id}' to seed a draft: {e}" + ))); + } + }; + match ops::flows_draft_create( + &self.config, + Some(flow.id.clone()), + flow.name.clone(), + graph_json, + crate::openhuman::flows::DraftOrigin::Chat, + ) { + Ok(created) => { + let new_draft_id = created.value.id.clone(); + tracing::debug!( + target: "flows", + draft_id = %new_draft_id, + flow_id = %flow.id, + "[flows] edit_workflow: seeded a new draft from saved flow (edits live on the draft, NOT the flow)" + ); + write_back_draft = Some(new_draft_id); + edited_from_flow = Some(flow.id.clone()); + (flow.graph, flow.name) + } + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not create a draft to edit flow '{id}': {e}" + ))); + } + } + } Err(e) => { return Ok(ToolResult::error(format!( "Could not load flow '{id}' to edit: {e}" @@ -370,31 +452,46 @@ impl Tool for EditWorkflowTool { } }; - // Parse the ops list. - let ops_value = match args.get("ops") { - Some(v) if v.is_array() => v.clone(), + // Parse the ops list element-by-element so a bad op reports its index, + // its `op` tag, the serde error, AND the expected field shape for THAT + // op type — instead of a bare aggregate "missing field `id`" that names + // neither the failing op nor what it wanted (audit WS4). + let ops_array = match args.get("ops") { + Some(Value::Array(items)) => items.clone(), _ => { return Ok(ToolResult::error( "Missing 'ops' parameter (a non-empty array of structured edits).".to_string(), )); } }; - let graph_ops: Vec = match serde_json::from_value(ops_value) - { - Ok(ops) => ops, - Err(e) => { - return Ok(ToolResult::error(format!( - "Could not parse `ops`: {e}. Each op is {{ \"op\": , ... }} — valid \ - types: add_node, update_node_config, set_node_name, rename_node, \ - remove_node, add_edge, remove_edge, set_node_position." - ))); - } - }; - if graph_ops.is_empty() { + if ops_array.is_empty() { return Ok(ToolResult::error( "`ops` is empty — provide at least one edit.".to_string(), )); } + let mut graph_ops: Vec = Vec::with_capacity(ops_array.len()); + for (index, item) in ops_array.into_iter().enumerate() { + let op_tag = item.get("op").and_then(Value::as_str).map(str::to_string); + match serde_json::from_value::(item) { + Ok(op) => graph_ops.push(op), + Err(e) => { + let shape = match op_tag.as_deref() { + Some(tag) => match edit_op_shape(tag) { + Some(shape) => format!("op `{tag}` expects {shape}"), + None => { + format!("unknown op type `{tag}` — valid types: {VALID_OP_TYPES}") + } + }, + None => format!("missing `op` field — valid types: {VALID_OP_TYPES}"), + }; + tracing::debug!(target: "flows", index, ?op_tag, error = %e, "[flows] edit_workflow: op failed to parse"); + return Ok(ToolResult::error(format!( + "Could not parse op {index}: {e}. Expected {shape}. Each op is \ + {{ \"op\": , ... }}. Fix the ops and call edit_workflow again." + ))); + } + } + } let name = args .get("name") @@ -430,8 +527,22 @@ impl Tool for EditWorkflowTool { Ok(graph) => graph, Err(e) => { tracing::debug!(target: "flows", %name, error = %e, "[flows] edit_workflow: an op failed to apply"); + // Ops apply strictly in array order, so an add_node for an id + // that already exists is almost always an ordering mistake + // (adding before removing the old node). Point at the fix — this + // is the exact 2nd wasted call the WS4 audit caught. + let hint = match (e.op, &e.kind) { + ("add_node", tinyflows::graph_ops::GraphOpErrorKind::NodeIdExists(id)) => { + format!( + "\n\nOps apply strictly in array order. To replace node `{id}`, put a \ + remove_node op for it BEFORE the add_node, or use update_node_config \ + to patch it in place." + ) + } + _ => String::new(), + }; return Ok(ToolResult::error(format!( - "{e}\n\nFix the ops and call edit_workflow again." + "{e}{hint}\n\nFix the ops and call edit_workflow again." ))); } }; @@ -469,6 +580,8 @@ impl Tool for EditWorkflowTool { } // Full builder hard-gate stack + proposal payload (shared with revise). + // Thread the persistence-state handles so the payload carries draft_id / + // flow_id / persisted:false and can't be misread as a save. match ops::build_builder_proposal( &self.config, "edit_workflow", @@ -477,12 +590,32 @@ impl Tool for EditWorkflowTool { require_approval, true, instruction, + write_back_draft.clone(), + edited_from_flow.clone(), ) .await { Ok(mut payload) => { - if let Some(draft_id) = write_back_draft { - payload["draft_id"] = json!(draft_id); + // A prominent, one-line pointer at where the edit actually lives + // (the draft) vs. where it does NOT (the saved flow) — the exact + // confusion the WS2 audit caught. Only meaningful when the edit + // landed on a draft (inline-graph edits have no durable handle). + if let Some(draft_id) = write_back_draft.as_deref() { + let next = match edited_from_flow.as_deref() { + Some(flow_id) => format!( + "Edits live on draft {draft_id}, NOT on flow {flow_id}. Iterate with \ + edit_workflow/dry_run_workflow {{ draft_id: \"{draft_id}\" }}, then \ + persist with save_workflow {{ flow_id: \"{flow_id}\", draft_id: \ + \"{draft_id}\" }} when the user asks." + ), + None => format!( + "Edits live on draft {draft_id} (not yet linked to a saved flow). \ + Iterate with edit_workflow/dry_run_workflow {{ draft_id: \ + \"{draft_id}\" }}, then persist with create_workflow, or save_workflow \ + {{ flow_id, draft_id: \"{draft_id}\" }} once a flow exists." + ), + }; + payload["next"] = json!(next); } Ok(ToolResult::success(serde_json::to_string_pretty(&payload)?)) } @@ -524,25 +657,31 @@ impl Tool for ValidateWorkflowTool { fn description(&self) -> &str { "Check a workflow graph WITHOUT proposing or saving it — the same validation the \ propose/revise/edit/save tools run, surfaced on its own so you can verify a draft mid-\ - build. Provide the graph to check (inline `graph`, or `flow_id` for a saved flow). \ - Returns { ok, structurally_valid, errors, error_details:[{code, message, node_id}], \ - gate_errors, warnings }: `errors` lists EVERY structural problem at once; `gate_errors` \ - lists the hard author-gate failures (unresolvable bindings, unreal tool slugs, unwired \ - required args) checked only once the graph is structurally valid; `warnings` are \ - non-fatal. `ok` is true only when there are no errors and no gate_errors. Read-only." + build. Provide the graph to check as exactly one of `draft_id` (a working draft), \ + `flow_id` (a saved flow), or inline `graph` (if several are given, draft_id wins, then \ + flow_id). Returns { ok, structurally_valid, errors, error_details:[{code, message, \ + node_id}], gate_errors, warnings }: `errors` lists EVERY structural problem at once; \ + `gate_errors` lists the hard author-gate failures (unresolvable bindings, unreal tool \ + slugs, unwired required args) checked only once the graph is structurally valid; \ + `warnings` are non-fatal. `ok` is true only when there are no errors and no gate_errors. \ + Read-only." } fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": { + "draft_id": { + "type": "string", + "description": "A working draft to validate. Provide one of draft_id / flow_id / graph (draft_id wins)." + }, "flow_id": { "type": "string", - "description": "A saved flow to validate. Provide this OR `graph`." + "description": "A saved flow to validate. Provide one of draft_id / flow_id / graph." }, "graph": { "type": "object", - "description": "An inline tinyflows WorkflowGraph to validate. Provide this OR `flow_id`.", + "description": "An inline tinyflows WorkflowGraph to validate. Provide one of draft_id / flow_id / graph.", "properties": { "nodes": { "type": "array" }, "edges": { "type": "array" } @@ -561,7 +700,14 @@ impl Tool for ValidateWorkflowTool { } async fn execute(&self, args: Value) -> anyhow::Result { - // Resolve the graph to check from either a saved flow or an inline graph. + // Resolve the graph to check from exactly one of a working draft, a + // saved flow, or an inline graph — same precedence (draft_id > flow_id > + // graph) as edit_workflow, so the sibling tools accept the same handles. + let draft_id = args + .get("draft_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); let flow_id = args .get("flow_id") .and_then(Value::as_str) @@ -569,8 +715,16 @@ impl Tool for ValidateWorkflowTool { .filter(|s| !s.is_empty()); let inline_graph = args.get("graph").filter(|v| !v.is_null()); - let graph_json = match (flow_id, inline_graph) { - (Some(id), _) => match ops::load_flow_graph(&self.config, id) { + let graph_json = match (draft_id, flow_id, inline_graph) { + (Some(id), _, _) => match ops::flows_draft_get(&self.config, id) { + Ok(outcome) => outcome.value.graph, + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not load draft '{id}' to validate: {e}" + ))); + } + }, + (None, Some(id), _) => match ops::load_flow_graph(&self.config, id) { Ok(Some(graph)) => serde_json::to_value(&graph)?, Ok(None) => { return Ok(ToolResult::error(format!("flow '{id}' not found"))); @@ -581,11 +735,11 @@ impl Tool for ValidateWorkflowTool { ))); } }, - (None, Some(graph)) => graph.clone(), - (None, None) => { + (None, None, Some(graph)) => graph.clone(), + (None, None, None) => { return Ok(ToolResult::error( - "Provide either `flow_id` (a saved flow) or `graph` (an inline graph) to \ - validate." + "Provide one of `draft_id` (a working draft), `flow_id` (a saved flow), or \ + `graph` (an inline graph) to validate." .to_string(), )); } @@ -593,6 +747,7 @@ impl Tool for ValidateWorkflowTool { tracing::debug!( target: "flows", + from_draft = draft_id.is_some(), from_flow = flow_id.is_some(), "[flows] validate_workflow: checking graph (read-only)" ); @@ -1458,6 +1613,75 @@ pub(crate) async fn search_live_catalog( toolkit_filter: Option<&str>, limit: usize, ) -> Vec { + search_catalog(config, query, toolkit_filter, limit) + .await + .results +} + +/// Cap on fallback (per-keyword) matches — a near-miss query must not flood the +/// agent's context with the whole toolkit, so the OR-scored fallback returns at +/// most this many rows regardless of the primary `limit`. +const MAX_FALLBACK_RESULTS: usize = 10; + +/// Outcome of a catalog search: the shaped rows, whether the per-keyword +/// fallback pass fired, and an optional advisory `note` the tool surfaces so an +/// agent never misreads a keyword miss as "the action doesn't exist". +pub(crate) struct CatalogSearchOutcome { + pub results: Vec, + /// True when the per-token OR fallback pass ran (primary AND match was + /// empty for a multi-word query). + pub fallback: bool, + /// Advisory note explaining a near-miss / keyword-based search, if any. + pub note: Option, +} + +/// Shape one live-catalog [`ToolContract`](crate::openhuman::tinyflows::caps::ToolContract) +/// into a search-result row. The SINGLE row-construction site shared by both +/// the primary AND-match path and the per-keyword fallback path, so every row +/// carries the same fields — including WS3's `runtime_gated: true` on an +/// uncurated action of a toolkit that ships a curated-only allowlist. +fn shape_catalog_row( + tool: &crate::openhuman::tinyflows::caps::ToolContract, + toolkit: &str, + toolkit_curated: bool, +) -> Value { + let mut row = json!({ + "slug": tool.slug, + "toolkit": toolkit, + "description": tool.description, + "required_args": tool.required_args, + "output_fields": tool.output_fields, + "primary_array_path": tool.primary_array_path, + "featured": tool.is_curated, + }); + // Compact: only present when true. + if !tool.is_curated && toolkit_curated { + if let Some(obj) = row.as_object_mut() { + obj.insert("runtime_gated".to_string(), Value::Bool(true)); + } + } + row +} + +/// Search the FULL LIVE Composio catalog and return a [`CatalogSearchOutcome`]. +/// +/// Primary pass: case-insensitive AND — an action matches only if EVERY +/// whitespace-separated term substring-matches its slug, toolkit name, or +/// description (curated matches ranked first, stable sort preserves fetch +/// order). When that yields zero rows for a MULTI-WORD query, a per-keyword OR +/// fallback runs: each action is scored by how many query tokens match its +/// slug/toolkit/description, and the top [`MAX_FALLBACK_RESULTS`] (ranked by +/// hit-count desc, then curated first) are returned with an advisory `note`. +/// This is what keeps a natural-language query like "twitter tweet replies +/// lookup" from returning a bare `count: 0` even though `TWITTER_*` actions +/// exist — the agent gets the nearest keyword matches instead of falsely +/// concluding the action is missing. +pub(crate) async fn search_catalog( + config: &Config, + query: &str, + toolkit_filter: Option<&str>, + limit: usize, +) -> CatalogSearchOutcome { use crate::openhuman::memory_sync::composio::providers::agent_ready_toolkits; use crate::openhuman::tinyflows::caps::fetch_live_toolkit_catalog; @@ -1487,10 +1711,26 @@ pub(crate) async fn search_live_catalog( })) .await; + // Drop toolkits whose fetch failed (no backend session / network error) — + // they contribute zero results rather than erroring the whole search. + let fetched: Vec<(String, Vec)> = fetched + .into_iter() + .filter_map(|(tk, catalog)| catalog.map(|c| (tk, c))) + .collect(); + + // Does the scanned scope hold ANY actions at all? Distinguishes "keyword + // miss" (has actions, none matched) from "nothing to search" (empty scope). + let any_actions = fetched.iter().any(|(_, catalog)| !catalog.is_empty()); + + // ── Primary pass: case-insensitive AND across every term ── let mut matches: Vec<(bool, Value)> = Vec::new(); - for (toolkit, catalog) in fetched { - let Some(catalog) = catalog else { continue }; - for tool in &catalog { + for (toolkit, catalog) in &fetched { + // WS3 — a toolkit that ships a curated catalog is a hard curated-only + // allowlist at RUNTIME, so any `featured: false` action of it is + // rejected on every real run. Compute once per toolkit and flag those + // rows so the blocker is visible at search time (transcript failure #2). + let toolkit_curated = ops::toolkit_has_curated_catalog(toolkit); + for tool in catalog { let slug_lc = tool.slug.to_ascii_lowercase(); let desc_lc = tool .description @@ -1505,15 +1745,7 @@ pub(crate) async fn search_live_catalog( } matches.push(( tool.is_curated, - json!({ - "slug": tool.slug, - "toolkit": toolkit, - "description": tool.description, - "required_args": tool.required_args, - "output_fields": tool.output_fields, - "primary_array_path": tool.primary_array_path, - "featured": tool.is_curated, - }), + shape_catalog_row(tool, toolkit, toolkit_curated), )); } } @@ -1522,7 +1754,104 @@ pub(crate) async fn search_live_catalog( // within each group. matches.sort_by_key(|(is_curated, _)| std::cmp::Reverse(*is_curated)); matches.truncate(limit); - matches.into_iter().map(|(_, v)| v).collect() + let primary: Vec = matches.into_iter().map(|(_, v)| v).collect(); + + if !primary.is_empty() { + return CatalogSearchOutcome { + results: primary, + fallback: false, + note: None, + }; + } + + // ── Zero primary hits ── + // Single-token queries keep today's behavior exactly; only attach a light + // advisory note so a lone keyword miss still explains the search is + // keyword-based (task WS5.4, optional). + if terms.len() <= 1 { + let note = if any_actions { + Some(format!( + "No actions matched '{query}'. This search is keyword-based (matches action \ + slug/name/description) — try a different single keyword (e.g. 'gmail' or \ + 'tweets')." + )) + } else { + None + }; + return CatalogSearchOutcome { + results: Vec::new(), + fallback: false, + note, + }; + } + + // ── Fallback pass (multi-word, zero primary hits): per-token OR scoring ── + // Score each action by how many DISTINCT query tokens match its + // slug/toolkit/description; keep the primary path's curated boost as the + // tiebreak. Rows go through the SAME `shape_catalog_row` path as primary. + let mut scored: Vec<(usize, bool, Value)> = Vec::new(); + for (toolkit, catalog) in &fetched { + let toolkit_curated = ops::toolkit_has_curated_catalog(toolkit); + for tool in catalog { + let slug_lc = tool.slug.to_ascii_lowercase(); + let desc_lc = tool + .description + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase(); + let hits = terms + .iter() + .filter(|term| { + slug_lc.contains(*term) || toolkit.contains(*term) || desc_lc.contains(*term) + }) + .count(); + if hits == 0 { + continue; + } + scored.push(( + hits, + tool.is_curated, + shape_catalog_row(tool, toolkit, toolkit_curated), + )); + } + } + + // Most keyword hits first, then curated first; stable sort preserves fetch + // order within a (hits, curated) group. + scored.sort_by_key(|(hits, is_curated, _)| std::cmp::Reverse((*hits, *is_curated))); + scored.truncate(limit.min(MAX_FALLBACK_RESULTS)); + let results: Vec = scored.into_iter().map(|(_, _, v)| v).collect(); + + tracing::debug!( + target: "flows", + query, + fallback = true, + hits = results.len(), + "[flows] search_tool_catalog: primary AND-match empty for a multi-word query — ran per-keyword OR fallback" + ); + + if results.is_empty() { + // Literally zero tokens matched anything: no rows, but a note so the + // agent doesn't read `count: 0` as "action doesn't exist" (task WS5.3). + return CatalogSearchOutcome { + results, + fallback: true, + note: Some(format!( + "No actions matched any keyword in '{query}'. This search is keyword-based \ + (matches action slug/name/description) — retry with a single keyword (e.g. one \ + word like 'gmail' or 'tweets') for a full listing." + )), + }; + } + + CatalogSearchOutcome { + results, + fallback: true, + note: Some(format!( + "No exact match for '{query}'. Showing the nearest per-keyword matches — retry with a \ + single keyword (e.g. one word like 'gmail' or 'tweets') for a full listing." + )), + } } #[async_trait] @@ -1553,7 +1882,7 @@ impl Tool for SearchToolCatalogTool { "properties": { "query": { "type": "string", - "description": "Keywords to match against tool slugs/descriptions (case-insensitive; all terms must match)." + "description": "Keywords to match against tool slugs/descriptions (case-insensitive). All terms must match for an exact hit; a multi-word query with no exact match falls back to the nearest per-keyword matches. For the widest listing, prefer ONE keyword (e.g. 'gmail' or 'tweets')." }, "toolkit": { "type": "string", @@ -1585,12 +1914,24 @@ impl Tool for SearchToolCatalogTool { toolkit = toolkit.unwrap_or("(any)"), "[flows] search_tool_catalog: searching the FULL LIVE Composio catalog (read-only)" ); - let results = search_live_catalog(&self.config, &query, toolkit, MAX_CATALOG_RESULTS).await; - Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ - "query": query, - "count": results.len(), - "results": results, - }))?)) + let outcome = search_catalog(&self.config, &query, toolkit, MAX_CATALOG_RESULTS).await; + // Build with `note` first so an agent reading top-down sees the + // near-miss / keyword-based advisory before the (possibly zero) rows. + // `count` is always the number of returned rows, never a stand-in for + // "no such action" — a fallback carries a non-zero count. + let mut obj = serde_json::Map::new(); + if let Some(note) = outcome.note { + obj.insert("note".to_string(), Value::String(note)); + } + obj.insert("query".to_string(), Value::String(query)); + obj.insert( + "count".to_string(), + Value::Number(outcome.results.len().into()), + ); + obj.insert("results".to_string(), Value::Array(outcome.results)); + Ok(ToolResult::success(serde_json::to_string_pretty( + &Value::Object(obj), + )?)) } } @@ -1705,6 +2046,37 @@ impl Tool for GetToolContractTool { // permanently `None`. let contract = crate::openhuman::tinyflows::caps::apply_probe_override(contract.clone()); + + // WS3 — EARLY runtime-gate warning (transcript failure #2): a + // real-but-uncurated action of a toolkit that ships a curated + // catalog is a hard curated-only allowlist at RUNTIME, so it is + // REJECTED on every real run. The late `validate_workflow` gate + // catches it, but only ~15 tool calls after the agent has built + // and wired the node. Surface the blocker HERE, at contract-fetch + // time (and first in the payload), so the agent never wires it. + if !contract.is_curated && ops::toolkit_has_curated_catalog(&toolkit) { + tracing::debug!( + target: "flows", + %slug, + %toolkit, + "[flows] get_tool_contract: uncurated action of a curated toolkit — attaching runtime_gate warning" + ); + #[derive(serde::Serialize)] + struct ContractWithRuntimeGate { + runtime_gate: &'static str, + #[serde(flatten)] + contract: crate::openhuman::tinyflows::caps::ToolContract, + } + let payload = ContractWithRuntimeGate { + runtime_gate: "This action will be REJECTED on every real run — the \ + runtime tool gate only allows curated actions for this \ + toolkit. Pick a `featured: true` result from \ + search_tool_catalog instead.", + contract, + }; + return Ok(ToolResult::success(serde_json::to_string_pretty(&payload)?)); + } + Ok(ToolResult::success(serde_json::to_string_pretty( &contract, )?)) @@ -2170,6 +2542,79 @@ impl Tool for GetNodeKindContractTool { /// (an unexercised branch can be entirely intentional), and is surfaced on /// both the `ok: true` and `ok: false` result shapes so the caller can /// double-check that node's wiring by hand. +/// Builds one `null_resolutions` diagnostic entry for a `tool_call` node's +/// null-resolved `args.*` config expression. +/// +/// The common case reports `{ node_id, location, expression }` — a wiring +/// mistake the agent should fix. But when the null-resolved expression binds to +/// the output of an upstream Composio `tool_call` node +/// ([`ops::composio_tool_call_upstream_ref`]), the entry is instead marked +/// `unverifiable: true` and carries an honest `suggestion`: the echo sandbox +/// can NEVER produce a Composio tool's real output fields, so this particular +/// null is expected here and does NOT prove the binding wrong (WS6 — the +/// transcript audit where the agent re-wired an already-correct binding three +/// times chasing this exact false negative). The message points at +/// `get_tool_contract` / `get_tool_output_sample` as the real disambiguators. +fn build_null_resolution_entry( + node_id: &str, + diag: &tinyflows::expr::NullResolution, + graph: &WorkflowGraph, +) -> Value { + if let Some(upstream) = crate::openhuman::flows::ops::composio_tool_call_upstream_ref( + &diag.expression, + graph, + node_id, + ) { + let field = diag.location.strip_prefix("args.").unwrap_or("args"); + return json!({ + "node_id": node_id, + "location": diag.location, + "expression": diag.expression, + "unverifiable": true, + "upstream_tool_call": upstream, + "suggestion": format!( + "required arg `{field}` binds to the output of Composio tool_call node \ + `{upstream}` — the SANDBOX only echoes tool calls and can never produce \ + their real output fields, so this binding is UNVERIFIABLE here (not \ + necessarily wrong). Confirm the path against get_tool_contract {{ slug }}'s \ + output_fields / primary_array_path (remember Composio results nest under \ + `.item.json.data.`), or get_tool_output_sample {{ slug, args }} for the \ + real shape. It is a real bug only if the path doesn't match the action's \ + actual output." + ), + }); + } + json!({ + "node_id": node_id, + "location": diag.location, + "expression": diag.expression, + }) +} + +/// Every null-resolved `args.*` config expression that landed on a `tool_call` +/// node, as `null_resolutions` diagnostic entries (see +/// [`build_null_resolution_entry`] for the shape, including the WS6 +/// `unverifiable` Composio-upstream variant). Shared by the settled-run path +/// (which fails the dry run on these) and the errored-run path (which surfaces +/// only the `unverifiable` ones so a stop-policy preflight abort explains +/// itself honestly instead of via the generic required-arg text). +fn tool_call_arg_null_entries( + steps: &[tinyflows::observability::ExecutionStep], + graph: &WorkflowGraph, + tool_call_node_ids: &std::collections::HashSet<&str>, +) -> Vec { + steps + .iter() + .filter(|step| tool_call_node_ids.contains(step.node_id.as_str())) + .flat_map(|step| { + step.diagnostics + .iter() + .filter(|&diag| diag.location == "args" || diag.location.starts_with("args.")) + .map(|diag| build_null_resolution_entry(&step.node_id, diag, graph)) + }) + .collect() +} + pub struct DryRunWorkflowTool { security: Arc, config: Arc, @@ -2188,13 +2633,15 @@ impl Tool for DryRunWorkflowTool { } fn description(&self) -> &str { - "Dry-run a DRAFT workflow graph in a SANDBOX to self-verify it before \ + "Dry-run a workflow graph in a SANDBOX to self-verify it before \ proposing. Compiles the graph and executes it against MOCK capabilities \ — every LLM / tool_call / http_request / code node returns a deterministic \ echo, so NOTHING real happens (no messages sent, no code run). Returns the \ simulated per-node output labeled as sandbox output. Use it to catch \ - wiring/routing mistakes; it does NOT prove real integrations work. Pass \ - the same graph shape as propose_workflow, plus an optional `input`." + wiring/routing mistakes; it does NOT prove real integrations work. Provide \ + the graph as exactly one of `draft_id` (a working draft), `flow_id` (a saved \ + flow), or inline `graph` (draft_id wins, then flow_id), plus an optional \ + `input`." } fn parameters_schema(&self) -> Value { @@ -2203,11 +2650,15 @@ impl Tool for DryRunWorkflowTool { "properties": { "draft_id": { "type": "string", - "description": "A working draft to simulate. Provide this OR `graph`." + "description": "A working draft to simulate. Provide one of draft_id / flow_id / graph (draft_id wins)." + }, + "flow_id": { + "type": "string", + "description": "A saved flow to simulate. Provide one of draft_id / flow_id / graph." }, "graph": { "type": "object", - "description": "The DRAFT tinyflows WorkflowGraph to simulate: { nodes: [...], edges: [...] }. Provide this OR `draft_id`.", + "description": "An inline tinyflows WorkflowGraph to simulate: { nodes: [...], edges: [...] }. Provide one of draft_id / flow_id / graph.", "properties": { "nodes": { "type": "array" }, "edges": { "type": "array" } @@ -2235,31 +2686,48 @@ impl Tool for DryRunWorkflowTool { } async fn execute(&self, args: Value) -> anyhow::Result { - // Graph source: a working draft (draft_id) or an inline graph. - let graph_json = if let Some(draft_id) = args + // Graph source: exactly one of a working draft, a saved flow, or an + // inline graph — same precedence (draft_id > flow_id > graph) as the + // sibling validate/edit tools, so they all accept the same handles. + let draft_id = args .get("draft_id") .and_then(Value::as_str) .map(str::trim) - .filter(|s| !s.is_empty()) - { - match ops::flows_draft_get(&self.config, draft_id) { + .filter(|s| !s.is_empty()); + let flow_id = args + .get("flow_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); + let inline_graph = args.get("graph").filter(|v| !v.is_null()); + + let graph_json = match (draft_id, flow_id, inline_graph) { + (Some(id), _, _) => match ops::flows_draft_get(&self.config, id) { Ok(outcome) => outcome.value.graph, Err(e) => { return Ok(ToolResult::error(format!( - "Could not load draft '{draft_id}' to dry-run: {e}" + "Could not load draft '{id}' to dry-run: {e}" ))); } - } - } else { - match args.get("graph") { - Some(v) if !v.is_null() => v.clone(), - _ => { - return Ok(ToolResult::error( - "Provide `graph` (an inline graph) or `draft_id` (a working draft) to \ - dry-run." - .to_string(), - )); + }, + (None, Some(id), _) => match ops::load_flow_graph(&self.config, id) { + Ok(Some(graph)) => serde_json::to_value(&graph)?, + Ok(None) => { + return Ok(ToolResult::error(format!("flow '{id}' not found"))); + } + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not load flow '{id}' to dry-run: {e}" + ))); } + }, + (None, None, Some(v)) => v.clone(), + (None, None, None) => { + return Ok(ToolResult::error( + "Provide one of `draft_id` (a working draft), `flow_id` (a saved flow), or \ + `graph` (an inline graph) to dry-run." + .to_string(), + )); } }; let input = args.get("input").cloned().unwrap_or_else(|| json!({})); @@ -2299,6 +2767,12 @@ impl Tool for DryRunWorkflowTool { let mut caps = tinyflows::caps::mock::mock_capabilities_with_agent( crate::openhuman::tinyflows::caps::SchemaAwareMockAgentRunner, ); + // Plain agent nodes (no `agent_ref`) never reach the runner above — + // the vendored `agent` node routes them to the `llm` slot instead (see + // `SchemaAwareMockLlm`'s doc). Swap the vendored `MockLlm` echo for the + // schema-aware mock so their `output_parser.schema` is honored too, + // instead of the echo shape failing the sub-port's validation. + caps.llm = std::sync::Arc::new(crate::openhuman::tinyflows::caps::SchemaAwareMockLlm); // Wiring preflight over the echo mocks (see the struct doc): required // Composio args must be present and non-null even in the sandbox. caps.tools = std::sync::Arc::new(crate::openhuman::tinyflows::caps::PreflightToolInvoker { @@ -2344,6 +2818,46 @@ impl Tool for DryRunWorkflowTool { { Ok(Ok(outcome)) => outcome, Ok(Err(e)) => { + // A `stop`-policy `tool_call` whose required arg resolved null + // aborts the WHOLE run here (via `PreflightToolInvoker`), so + // the honest per-field diagnostic never reaches the settled-run + // `null_resolutions` path below. Recover it from the observer: + // if the abort was caused by a required arg bound to an upstream + // Composio `tool_call`'s output, the echo mock simply CAN'T + // produce that field — so surface it as `unverifiable` rather + // than letting the generic "required arg missing/null" text + // (which sent the transcript agent re-wiring a correct binding + // three times) stand alone. WS6. + let unverifiable_bindings: Vec = + tool_call_arg_null_entries(&observer.steps(), &graph, &tool_call_node_ids) + .into_iter() + .filter(|entry| { + entry.get("unverifiable").and_then(Value::as_bool) == Some(true) + }) + .collect(); + if !unverifiable_bindings.is_empty() { + tracing::debug!( + target: "flows", + error = %e, + unverifiable_count = unverifiable_bindings.len(), + "[flows] dry_run_workflow: sandbox run aborted on a Composio-upstream \ + binding the echo mock cannot verify — surfacing it honestly" + ); + return Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ + "sandbox": true, + "ok": false, + "error": e.to_string(), + "unverifiable_bindings": unverifiable_bindings, + "note": "SANDBOX (mock) output — a tool_call node aborted because a \ + required arg binds to the output of an upstream Composio tool_call, \ + which the sandbox can only ECHO (it never produces real tool output \ + fields). See unverifiable_bindings: each MAY already be wired \ + correctly — confirm the path with get_tool_contract {{ slug }} \ + (output_fields / primary_array_path; Composio results nest under \ + .item.json.data.) or get_tool_output_sample {{ slug, args }} instead \ + of re-wiring blindly. No real side effects occurred.", + }))?)); + } tracing::debug!(target: "flows", error = %e, "[flows] dry_run_workflow: sandbox run errored"); return Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ "sandbox": true, @@ -2363,23 +2877,12 @@ impl Tool for DryRunWorkflowTool { // `tool_call` node's `args.*` config path — the class of binding // mistake that "builds" (compiles, dry-runs against echo mocks) but // does nothing at runtime because the wired field never had a value. - let null_resolutions: Vec = observer - .steps() - .iter() - .filter(|step| tool_call_node_ids.contains(step.node_id.as_str())) - .flat_map(|step| { - step.diagnostics - .iter() - .filter(|&diag| diag.location == "args" || diag.location.starts_with("args.")) - .map(|diag| { - json!({ - "node_id": step.node_id, - "location": diag.location, - "expression": diag.expression, - }) - }) - }) - .collect(); + // Each entry is honest about WHY it resolved null: a binding to an + // upstream Composio `tool_call`'s output is flagged `unverifiable` + // (the echo mock can't produce real tool output fields) rather than + // reported as a plain wiring mistake — see [`build_null_resolution_entry`]. + let null_resolutions: Vec = + tool_call_arg_null_entries(&observer.steps(), &graph, &tool_call_node_ids); // Collect every null-resolved `agent`-node `prompt` — execution- // breaking in the same way a null `tool_call` arg is: `prompt` is the @@ -2723,12 +3226,19 @@ impl Tool for SaveWorkflowTool { fn description(&self) -> &str { "Save a workflow graph onto an EXISTING saved flow (by `flow_id`), persisting it. \ - Use this after the user asked you to build/update a workflow and you have \ - dry-run-verified the graph: it validates and writes the graph (and optional new \ - `name`) to that flow. It can NOT create a new flow, and it never changes the \ - flow's enabled state or its approval gate. NOTE: if the flow is enabled and the \ - graph has a schedule/app_event trigger, saving arms it — it will start firing on \ - its own. Always tell the user what you saved. Params: { flow_id, graph, name? }." + This is the ONLY builder tool that writes onto a saved flow — edit/validate/dry_run \ + never do. Use it after the user asked you to build/update a workflow and you have \ + dry-run-verified the graph. The graph source is either `draft_id` (a working draft — \ + the usual case after editing with edit_workflow; draft_id wins if both are given) or \ + an inline `graph`; `flow_id` is always required as the persistence TARGET. It \ + validates and writes the graph (and optional new `name`) to that flow. It can NOT \ + create a new flow, and it never touches the approval gate — but it CAN \ + auto-disable the flow when the trigger transitions from manual to automatic \ + (schedule/webhook/app_event), so a save never silently arms a trigger that wasn't \ + already live; the returned `warnings` will explain it when that happens. NOTE: if \ + the flow was ALREADY enabled with an automatic trigger and stays automatic, saving \ + re-arms it live — it will start firing on its own. Always tell the user what you \ + saved (including any auto-disable). Params: { flow_id, draft_id? | graph?, name? }." } fn parameters_schema(&self) -> Value { @@ -2737,11 +3247,15 @@ impl Tool for SaveWorkflowTool { "properties": { "flow_id": { "type": "string", - "description": "Id of the EXISTING saved flow to write the graph to." + "description": "Id of the EXISTING saved flow to write the graph to (the persistence target — always required)." + }, + "draft_id": { + "type": "string", + "description": "A working draft whose graph to persist onto the flow. Provide this OR inline `graph`; if both are given, draft_id wins." }, "graph": { "type": "object", - "description": "The full tinyflows WorkflowGraph to persist: { name?, nodes: [...], edges: [...] }. Same shape as propose_workflow.", + "description": "The full tinyflows WorkflowGraph to persist: { name?, nodes: [...], edges: [...] }. Provide this OR `draft_id`. Same shape as propose_workflow.", "properties": { "nodes": { "type": "array" }, "edges": { "type": "array" } @@ -2753,7 +3267,7 @@ impl Tool for SaveWorkflowTool { "description": "Optional new human-readable name for the flow." } }, - "required": ["flow_id", "graph"], + "required": ["flow_id"], "additionalProperties": false }) } @@ -2781,10 +3295,36 @@ impl Tool for SaveWorkflowTool { )) } }; - let graph_json = match args.get("graph") { - Some(v) if !v.is_null() => v.clone(), - _ => return Ok(ToolResult::error("Missing 'graph' parameter".to_string())), - }; + // Graph source: a working draft (the usual post-edit_workflow handle) or + // an inline graph. `flow_id` above is the persistence TARGET, always + // required; the draft only supplies the graph to write. If both a + // draft_id and an inline graph are given, the draft wins (it is the + // durable working copy the agent just iterated on). + let draft_id = args + .get("draft_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); + let graph_json = + if let Some(id) = draft_id { + match ops::flows_draft_get(&self.config, id) { + Ok(outcome) => outcome.value.graph, + Err(e) => { + return Ok(ToolResult::error(format!( + "Could not load draft '{id}' to save: {e}" + ))); + } + } + } else { + match args.get("graph") { + Some(v) if !v.is_null() => v.clone(), + _ => return Ok(ToolResult::error( + "Provide `draft_id` (a working draft) or inline `graph` to save onto the \ + flow." + .to_string(), + )), + } + }; let name = args .get("name") .and_then(Value::as_str) @@ -2848,12 +3388,29 @@ impl Tool for SaveWorkflowTool { enabled = flow.enabled, "[flows] save_workflow: persisted" ); + // Surface any explanatory logs `flows_update` produced — most + // notably the manual→automatic auto-disarm message (#4889) — + // to the agent. Skip the boilerplate "flow updated: " line, + // which just duplicates the `persisted`/`flow_id` fields this + // response already carries. + let flow_updated_boilerplate = format!("flow updated: {flow_id}"); + warnings.extend( + outcome + .logs + .into_iter() + .filter(|log| *log != flow_updated_boilerplate), + ); // Issue B29 (save/enable safety), Rule 3: `flows_create` only // gates the FIRST creation of a flow — an agent `save_workflow` - // targets an EXISTING flow via `flows_update`, which preserves - // whatever `enabled` state the flow already had. If the user - // already armed this flow (enabled it) and it has an automatic - // trigger, saving a new graph onto it re-arms it live with no + // targets an EXISTING flow via `flows_update`, which (since + // #4889) force-disables the flow whenever the trigger + // transitions from manual to automatic (schedule/webhook/ + // app_event) — so a save can never silently arm a trigger that + // wasn't already live (see the `warnings.extend` above for the + // explanatory log). Short of that transition, `flows_update` + // preserves whatever `enabled` state the flow already had: if + // it was ALREADY enabled with an automatic trigger and stays + // automatic, saving a new graph onto it re-arms it live with no // further confirmation. Surface that loudly so the copilot // relays it to the user instead of staying silent. if flow.enabled && ops::trigger_is_automatic(&flow.graph) { @@ -2878,6 +3435,9 @@ impl Tool for SaveWorkflowTool { } Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ "type": "workflow_saved", + // Explicit counterpart to a proposal's persisted:false — this + // graph IS now written onto the saved flow. + "persisted": true, "flow_id": flow.id, "name": flow.name, "enabled": flow.enabled, diff --git a/src/openhuman/flows/builder_tools_tests.rs b/src/openhuman/flows/builder_tools_tests.rs index fc207c73fe..da11ca8972 100644 --- a/src/openhuman/flows/builder_tools_tests.rs +++ b/src/openhuman/flows/builder_tools_tests.rs @@ -211,6 +211,26 @@ fn seeded_gmail_send_contract() -> ToolContract { } } +/// A minimal seeded contract with NO required args, for WS6 dry-run tests: seeds +/// a bespoke toolkit so the required-arg preflight always passes and the sandbox +/// run settles into the `null_resolutions` path (rather than aborting), letting +/// the test assert the honest Composio-upstream diagnostic deterministically — +/// independent of whatever gmail/slack contracts other tests seed into the +/// process-global cache. +fn seeded_ws6_contract(slug: &str, toolkit: &str) -> ToolContract { + ToolContract { + slug: slug.to_string(), + toolkit: toolkit.to_string(), + description: Some("ws6 test action".to_string()), + required_args: vec![], + input_schema: Some(json!({ "type": "object", "additionalProperties": true })), + output_fields: vec![], + output_schema: None, + primary_array_path: None, + is_curated: true, + } +} + #[tokio::test] async fn search_live_catalog_finds_a_seeded_real_gmail_slug() { seed_live_catalog_cache("gmail", vec![seeded_gmail_send_contract()]); @@ -403,6 +423,279 @@ async fn get_tool_contract_rejects_a_hallucinated_slug() { assert!(result.output().contains("not a real action")); } +// ── WS3: early runtime-gate warnings on uncurated actions ──────────────────── +// +// Transcript failure #2: `get_tool_contract { slug: "TWITTER_USER_LOOKUP_ME" }` +// returned `is_curated: false` with no other signal; the agent built and wired +// the node and only ~15 tool calls later did `validate_workflow` reject it. A +// real-but-uncurated action of a toolkit that ships a curated catalog is a hard +// curated-only allowlist at RUNTIME, so surface the blocker at contract-fetch / +// search time. Uses `spotify` / `telegram` (real curated toolkits unused by +// other tests) so these seeds can't race with the shared `gmail`/`slack` keys. + +fn spotify_curated_action() -> ToolContract { + ToolContract { + slug: "SPOTIFY_START_PLAYBACK".to_string(), + toolkit: "spotify".to_string(), + description: Some("Start playback".to_string()), + required_args: vec![], + input_schema: Some(json!({ "type": "object" })), + output_fields: vec![], + output_schema: None, + primary_array_path: None, + is_curated: true, + } +} + +#[tokio::test] +async fn get_tool_contract_warns_on_an_uncurated_action_of_a_curated_toolkit() { + let uncurated = ToolContract { + slug: "SPOTIFY_OBSCURE_ACTION".to_string(), + is_curated: false, + ..spotify_curated_action() + }; + seed_live_catalog_cache("spotify", vec![spotify_curated_action(), uncurated]); + let tmp = TempDir::new().unwrap(); + let tool = GetToolContractTool::new(test_config(&tmp)); + + // Uncurated action → runtime_gate present, FIRST in the payload, contract intact. + let result = tool + .execute(json!({ "slug": "SPOTIFY_OBSCURE_ACTION" })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let out = result.output(); + assert!(out.contains("runtime_gate"), "{out}"); + assert!(out.contains("REJECTED on every real run"), "{out}"); + let gate_pos = out.find("runtime_gate").expect("runtime_gate key"); + let slug_pos = out.find("\"slug\"").expect("slug key"); + assert!( + gate_pos < slug_pos, + "runtime_gate must serialize first (agents read top-down): {out}" + ); + let parsed: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["slug"], "SPOTIFY_OBSCURE_ACTION"); + assert_eq!(parsed["is_curated"], false); + + // Curated action of the same toolkit → NO runtime_gate. + let result = tool + .execute(json!({ "slug": "SPOTIFY_START_PLAYBACK" })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert!( + !result.output().contains("runtime_gate"), + "{}", + result.output() + ); +} + +#[tokio::test] +async fn search_tool_catalog_flags_runtime_gated_uncurated_rows() { + let curated = ToolContract { + slug: "TELEGRAM_SEND_MESSAGE".to_string(), + toolkit: "telegram".to_string(), + description: Some("Send a message".to_string()), + required_args: vec![], + input_schema: None, + output_fields: vec![], + output_schema: None, + primary_array_path: None, + is_curated: true, + }; + let uncurated = ToolContract { + slug: "TELEGRAM_OBSCURE_SEND".to_string(), + is_curated: false, + ..curated.clone() + }; + seed_live_catalog_cache("telegram", vec![curated, uncurated]); + + let config = Config::default(); + let results = search_live_catalog(&config, "send", Some("telegram"), 40).await; + assert_eq!(results.len(), 2, "{results:?}"); + // Curated row: no `runtime_gated` key (only present when true). + let curated_row = results.iter().find(|r| r["featured"] == true).unwrap(); + assert!(curated_row.get("runtime_gated").is_none(), "{curated_row}"); + // Uncurated row of a curated toolkit: `runtime_gated: true`. + let uncurated_row = results.iter().find(|r| r["featured"] == false).unwrap(); + assert_eq!(uncurated_row["runtime_gated"], true); +} + +// ── WS5: per-token fallback ranking for zero-result multi-word queries ─────── +// +// Transcript failure: `search_tool_catalog` behaved like near-exact matching — +// multi-word natural-language queries ("twitter tweet replies lookup") returned +// `count: 0` even though the toolkit HAS matching actions, so the agent falsely +// concluded the action didn't exist. The primary pass is a strict case- +// insensitive AND (every token must match); when that misses for a multi-word +// query, a per-keyword OR fallback now returns the nearest matches + a note. + +fn twt_lookup() -> ToolContract { + ToolContract { + slug: "TWTFALLBACKTEST_TWEET_LOOKUP".to_string(), + toolkit: "twtfallbacktest".to_string(), + description: Some("Look up a tweet".to_string()), + required_args: vec!["id".to_string()], + input_schema: None, + output_fields: vec!["text".to_string()], + output_schema: None, + primary_array_path: None, + is_curated: true, + } +} + +fn twt_replies() -> ToolContract { + ToolContract { + slug: "TWTFALLBACKTEST_LIST_REPLIES".to_string(), + toolkit: "twtfallbacktest".to_string(), + description: Some("List replies to a tweet".to_string()), + required_args: vec!["tweet_id".to_string()], + input_schema: None, + output_fields: vec!["replies".to_string()], + output_schema: None, + primary_array_path: None, + is_curated: true, + } +} + +#[tokio::test] +async fn search_catalog_multiword_miss_falls_back_to_per_keyword() { + seed_live_catalog_cache("twtfallbacktest", vec![twt_lookup(), twt_replies()]); + let config = Config::default(); + // Strict AND misses ("twitter"/"timeline" match nothing) but individual + // tokens ("tweet", "replies", "lookup") hit — so the fallback fires. + let outcome = search_catalog( + &config, + "twitter tweet replies lookup timeline", + Some("twtfallbacktest"), + 40, + ) + .await; + assert!( + outcome.fallback, + "multi-word AND-miss must run the fallback" + ); + assert_eq!(outcome.results.len(), 2, "{:?}", outcome.results); + let note = outcome.note.expect("fallback carries an advisory note"); + assert!( + note.contains("nearest per-keyword"), + "note should explain the near-miss + single-keyword retry: {note}" + ); + // Fallback rows carry the SAME shape as primary rows. + for r in &outcome.results { + assert_eq!(r["toolkit"], "twtfallbacktest"); + assert_eq!(r["featured"], true); + assert!(r["required_args"].is_array()); + } +} + +#[tokio::test] +async fn search_tool_catalog_tool_surfaces_fallback_note_with_nonzero_count() { + seed_live_catalog_cache("twtfallbacktest", vec![twt_lookup(), twt_replies()]); + let tmp = TempDir::new().unwrap(); + let tool = SearchToolCatalogTool::new(test_config(&tmp)); + let result = tool + .execute(json!({ + "query": "twitter tweet replies lookup timeline", + "toolkit": "twtfallbacktest" + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + // `count` reflects the returned rows (non-zero) so an agent never reads a + // fallback as "no such action". + assert_eq!(parsed["count"], 2); + assert!(parsed["results"].as_array().unwrap().len() == 2); + assert!(parsed["note"].as_str().unwrap().contains("No exact match")); +} + +#[tokio::test] +async fn search_catalog_single_word_behavior_unchanged() { + seed_live_catalog_cache("onewordtest", vec![twt_lookup()]); + let config = Config::default(); + // A hit: single-word query returns the primary match, no fallback, no note. + let hit = search_catalog(&config, "tweet", Some("onewordtest"), 40).await; + assert!(!hit.fallback); + assert!(hit.note.is_none()); + assert_eq!(hit.results.len(), 1); + // A miss: single-word query stays empty and does NOT run the fallback. + let miss = search_catalog(&config, "zzznomatchzzz", Some("onewordtest"), 40).await; + assert!( + !miss.fallback, + "single-token miss must not trigger fallback" + ); + assert!(miss.results.is_empty()); +} + +#[tokio::test] +async fn search_catalog_multiword_zero_token_match_returns_note() { + seed_live_catalog_cache("zerotoktest", vec![twt_lookup()]); + let config = Config::default(); + // Multi-word query where NO token matches anything: still a note (not a bare + // count: 0), but zero rows. + let outcome = search_catalog(&config, "qqq www eeeeee", Some("zerotoktest"), 40).await; + assert!(outcome.fallback, "multi-word miss ran the fallback pass"); + assert!(outcome.results.is_empty()); + let note = outcome + .note + .expect("zero-token multi-word miss still gets a note"); + assert!( + note.contains("keyword-based"), + "note should explain the keyword-based search: {note}" + ); +} + +#[tokio::test] +async fn search_catalog_fallback_rows_flag_runtime_gated() { + // Reuse the exact telegram seed of the runtime_gated primary test so a + // concurrent run over the shared cache stays self-consistent; telegram is a + // real curated toolkit, so its uncurated action is `runtime_gated`. + let curated = ToolContract { + slug: "TELEGRAM_SEND_MESSAGE".to_string(), + toolkit: "telegram".to_string(), + description: Some("Send a message".to_string()), + required_args: vec![], + input_schema: None, + output_fields: vec![], + output_schema: None, + primary_array_path: None, + is_curated: true, + }; + let uncurated = ToolContract { + slug: "TELEGRAM_OBSCURE_SEND".to_string(), + is_curated: false, + ..curated.clone() + }; + seed_live_catalog_cache("telegram", vec![curated, uncurated]); + + let config = Config::default(); + // "obscure" hits only the uncurated slug; "lookup"/"replies" hit nothing; + // "telegram" matches the toolkit of both — so strict AND misses and the + // fallback ranks the OBSCURE row first (2 hits) over SEND_MESSAGE (1 hit). + let outcome = search_catalog( + &config, + "telegram obscure lookup replies", + Some("telegram"), + 40, + ) + .await; + assert!(outcome.fallback); + assert_eq!(outcome.results.len(), 2, "{:?}", outcome.results); + let gated = outcome + .results + .iter() + .find(|r| r["featured"] == false) + .expect("uncurated row present"); + assert_eq!(gated["runtime_gated"], true); + let curated_row = outcome + .results + .iter() + .find(|r| r["featured"] == true) + .expect("curated row present"); + assert!(curated_row.get("runtime_gated").is_none()); +} + /// B12: a cached real-output probe overrides `get_tool_contract`'s /// schema-derived `primary_array_path`/`output_fields` — most relevant for a /// slug whose live listing (like every GitHub action, verified live) has NO @@ -587,6 +880,78 @@ async fn dry_run_exercises_agent_ref_node_via_mock_agent_runner() { ); } +#[tokio::test] +async fn dry_run_plain_agent_with_output_parser_schema_is_green() { + // Regression for the transcript false-failure: a builder-generated `agent` + // node carries NO `agent_ref`, so the vendored engine routes it to the + // `llm` slot (not the `AgentRunner`). Before `SchemaAwareMockLlm` the plain + // `MockLlm` echo (`{ completion, connection }`) failed the node's + // `output_parser.schema` sub-port with `output_parser: value failed schema + // validation after auto-fix: missing required property ...`, sinking a + // correctly-built graph. Now the mock LLM synthesizes a schema-valid object, + // and a downstream node binds the typed placeholders (non-null). + let tool = DryRunWorkflowTool::new( + policy(AutonomyLevel::Supervised), + test_config(&TempDir::new().unwrap()), + ); + let graph = json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Schedule", + "config": { "trigger_kind": "schedule" } }, + { "id": "a", "kind": "agent", "name": "Extract", + "config": { "prompt": "extract the fields", + "output_parser": { "schema": { "type": "object", + "required": ["subject", "priority", "recipients"], + "properties": { + "subject": { "type": "string" }, + "priority": { "type": "integer" }, + "recipients": { "type": "array" } + } } } } }, + // Downstream node binds the schema'd agent fields: proves the + // placeholders are addressable and resolve to typed (non-null) + // values, not the vendored echo's opaque `{ completion, ... }`. + { "id": "down", "kind": "transform", "name": "Route", + "config": { "set": { + "subject": "=nodes.a.item.json.subject", + "priority": "=nodes.a.item.json.priority", + "recipients": "=nodes.a.item.json.recipients" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "a" }, + { "from_node": "a", "to_node": "down" } + ] + }); + let result = tool + .execute(json!({ "graph": graph, "input": { "topic": "launch" } })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let out = result.output(); + assert!( + !out.to_lowercase().contains("schema validation"), + "plain agent with a valid schema must not hit the output_parser failure: {out}" + ); + let parsed: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(parsed["sandbox"], true); + assert_eq!( + parsed["ok"], true, + "plain-agent-with-schema dry-run must be green: {parsed}" + ); + // The agent envelope's `json` carries the schema-synthesized placeholders. + // (In the run OUTPUT each Item serializes as `{ json: }`, and the + // agent's value is the `{json,text,raw}` envelope — hence the double hop.) + let agent_json = &parsed["output"]["nodes"]["a"]["items"][0]["json"]["json"]; + assert_eq!(agent_json["subject"], "", "{parsed}"); + assert_eq!(agent_json["priority"], 0, "{parsed}"); + assert_eq!(agent_json["recipients"], json!([]), "{parsed}"); + // The downstream node's bindings resolved to those typed placeholders — + // none of them null. + let down_json = &parsed["output"]["nodes"]["down"]["items"][0]["json"]; + assert!(!down_json["subject"].is_null(), "{parsed}"); + assert_eq!(down_json["priority"], 0, "{parsed}"); + assert_eq!(down_json["recipients"], json!([]), "{parsed}"); +} + #[tokio::test] async fn dry_run_invalid_graph_is_error() { let tool = DryRunWorkflowTool::new( @@ -711,6 +1076,112 @@ async fn dry_run_flags_tool_call_arg_null_resolved_from_unschemad_agent() { ); } +#[tokio::test] +async fn dry_run_flags_composio_upstream_binding_as_unverifiable_not_a_wiring_bug() { + // WS6: `post`'s `body` binds to the OUTPUT of an upstream Composio + // `tool_call` (`get_me`). The echo sandbox renders `get_me` as + // `{tool, args, connection}` and can NEVER produce `.item.json.data.username`, + // so the binding resolves `null` here even when it's wired correctly. The + // dry run still fails (`ok: false` — a null could hide a typo), but the + // diagnostic must be HONEST: mark it `unverifiable` and point at + // get_tool_contract / get_tool_output_sample rather than telling the agent + // its (possibly-correct) wiring is broken — the exact false negative that + // sent the transcript agent re-wiring an already-correct binding 3 times. + // Seed bespoke toolkits (no other test touches `ws6up`/`ws6dl`) with NO + // required args, so the required-arg preflight passes and the run settles + // into the `null_resolutions` path deterministically — independent of the + // process-global catalog cache other tests seed for gmail/slack/etc. + seed_live_catalog_cache("ws6up", vec![seeded_ws6_contract("WS6UP_LOOKUP", "ws6up")]); + seed_live_catalog_cache("ws6dl", vec![seeded_ws6_contract("WS6DL_SEND", "ws6dl")]); + let tool = DryRunWorkflowTool::new( + policy(AutonomyLevel::Supervised), + test_config(&TempDir::new().unwrap()), + ); + let graph = json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "get_me", "kind": "tool_call", "name": "Who am I", + "config": { "slug": "WS6UP_LOOKUP", "args": {} } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "WS6DL_SEND", + "args": { "recipient_email": "a@b.com", "subject": "hi", + "body": "=nodes.get_me.item.json.data.username" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "get_me" }, + { "from_node": "get_me", "to_node": "post" } + ] + }); + let result = tool.execute(json!({ "graph": graph })).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["ok"], false, "{parsed}"); + let null_resolutions = parsed["null_resolutions"] + .as_array() + .expect("null_resolutions array"); + let entry = null_resolutions + .iter() + .find(|e| e["node_id"] == "post" && e["location"] == "args.body") + .unwrap_or_else(|| panic!("expected a post.body null resolution: {parsed}")); + assert_eq!(entry["unverifiable"], true, "{parsed}"); + assert_eq!(entry["upstream_tool_call"], "get_me", "{parsed}"); + let suggestion = entry["suggestion"].as_str().expect("suggestion string"); + assert!(suggestion.contains("UNVERIFIABLE"), "{suggestion}"); + assert!(suggestion.contains("get_tool_contract"), "{suggestion}"); + assert!( + suggestion.contains("get_tool_output_sample"), + "{suggestion}" + ); +} + +#[tokio::test] +async fn dry_run_keeps_generic_null_text_for_a_non_tool_call_upstream_binding() { + // WS6 contrast: `post`'s arg binds to a `transform` node's output (whose + // real output the echo sandbox DOES produce), and the transform never sets + // the referenced field, so the null IS a genuine wiring bug. This entry must + // stay the plain `{ node_id, location, expression }` shape — no + // `unverifiable` flag — so the honest-uncertainty treatment doesn't leak + // onto real mistakes. + seed_live_catalog_cache("ws6dl", vec![seeded_ws6_contract("WS6DL_SEND", "ws6dl")]); + let tool = DryRunWorkflowTool::new( + policy(AutonomyLevel::Supervised), + test_config(&TempDir::new().unwrap()), + ); + let graph = json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "build", "kind": "transform", "name": "Build", + "config": { "set": { "unrelated": "x" } } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "WS6DL_SEND", + "args": { "recipient_email": "a@b.com", "subject": "hi", + "body": "=nodes.build.item.json.missing" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "build" }, + { "from_node": "build", "to_node": "post" } + ] + }); + let result = tool.execute(json!({ "graph": graph })).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["ok"], false, "{parsed}"); + let entry = parsed["null_resolutions"] + .as_array() + .expect("null_resolutions array") + .iter() + .find(|e| e["node_id"] == "post" && e["location"] == "args.body") + .unwrap_or_else(|| panic!("expected a post.body null resolution: {parsed}")); + assert!( + entry.get("unverifiable").is_none(), + "a non-tool_call upstream must keep the generic diagnostic: {parsed}" + ); + assert!( + entry.get("suggestion").is_none(), + "generic entry carries no unverifiable suggestion: {parsed}" + ); +} + #[tokio::test] async fn dry_run_passes_when_agent_schema_matches_tool_call_binding() { // The FALSE-POSITIVE-PREVENTION case: `summarize` DOES declare a schema @@ -1288,6 +1759,76 @@ async fn save_workflow_rejects_invalid_graph_and_leaves_flow_intact() { ); } +/// A single-node graph with an automatic (schedule) trigger — enough to +/// exercise the manual→automatic transition without tripping any of +/// `run_builder_gates`' binding/connection/contract checks (no other nodes, +/// nothing to bind). +fn schedule_trigger_graph() -> Value { + json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Trigger", + "config": { "trigger_kind": "schedule", "schedule": "0 8 * * *" } } + ], + "edges": [] + }) +} + +#[tokio::test] +async fn save_workflow_surfaces_auto_disarm_warning_on_manual_to_automatic_transition() { + // Regression for #4889 + the stale-docs issue that motivated this test: + // `flows_update` auto-disables a flow whenever its trigger transitions + // from manual to automatic on an already-enabled flow, but `save_workflow` + // used to drop `flows_update`'s explanatory `RpcOutcome.logs` entirely — + // the agent had no way to relay the disarm to the user. Assert both the + // disarm itself and that its log now surfaces in `save_workflow`'s + // `warnings`. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow_id = seed_flow(&config, "Manual flow").await; + let seeded = ops::flows_get(&config, &flow_id).await.unwrap().value; + assert!( + seeded.enabled, + "precondition: a manual-trigger flow persists enabled from create" + ); + + let tool = SaveWorkflowTool::new(config.clone()); + let result = tool + .execute(json!({ + "flow_id": flow_id, + "graph": schedule_trigger_graph(), + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!( + parsed["enabled"], false, + "manual→automatic transition on an enabled flow must auto-disable it: {parsed}" + ); + let warnings = parsed["warnings"] + .as_array() + .expect("warnings must be an array"); + assert!( + warnings + .iter() + .any(|w| w.as_str().unwrap_or("").contains("auto-disabled")), + "save_workflow must surface flows_update's disarm log as a warning, got: {parsed}" + ); + let flow_updated_boilerplate = format!("flow updated: {flow_id}"); + assert!( + warnings + .iter() + .all(|w| w.as_str().unwrap_or("") != flow_updated_boilerplate), + "save_workflow must exclude the redundant \"flow updated: \" boilerplate \ + from warnings, got: {parsed}" + ); + + // Persisted, not just returned in-memory. + let reloaded = ops::flows_get(&config, &flow_id).await.unwrap().value; + assert!(!reloaded.enabled); +} + // ── save_workflow: enforcing binding-resolvability gate ───────────────────── /// The proven live-failure shape (same as @@ -1501,6 +2042,98 @@ async fn edit_workflow_reports_failing_op_with_guidance() { assert!(out.contains("edit_workflow again"), "{out}"); } +#[tokio::test] +async fn edit_workflow_bad_op_reports_index_type_and_shape() { + let tmp = TempDir::new().unwrap(); + let tool = EditWorkflowTool::new(test_config(&tmp)); + // ops 0 and 1 are well-formed; op 2 is an add_node missing its `node`. + let result = tool + .execute(json!({ + "graph": valid_graph(), + "ops": [ + { "op": "set_node_name", "id": "a", "name": "One" }, + { "op": "set_node_name", "id": "a", "name": "Two" }, + { "op": "add_node", "id": "b" } + ] + })) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + let out = result.output(); + // Names the failing op index, its op type, and the expected shape for it. + assert!(out.contains("op 2"), "{out}"); + assert!(out.contains("add_node"), "{out}"); + assert!(out.contains("node:"), "expected add_node shape in: {out}"); + assert!(out.contains("edit_workflow again"), "{out}"); +} + +#[tokio::test] +async fn edit_workflow_missing_op_field_lists_valid_types() { + let tmp = TempDir::new().unwrap(); + let tool = EditWorkflowTool::new(test_config(&tmp)); + let result = tool + .execute(json!({ + "graph": valid_graph(), + "ops": [ { "id": "a", "name": "No op tag" } ] + })) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + let out = result.output(); + assert!(out.contains("op 0"), "{out}"); + assert!(out.contains("missing `op` field"), "{out}"); + assert!(out.contains("update_node_config"), "{out}"); +} + +#[tokio::test] +async fn edit_workflow_add_node_exists_carries_ordering_hint() { + let tmp = TempDir::new().unwrap(); + let tool = EditWorkflowTool::new(test_config(&tmp)); + // Re-adding an existing node id fails in-order; the hint should point at the + // remove-first / patch-in-place fix. + let result = tool + .execute(json!({ + "graph": valid_graph(), + "ops": [ + { "op": "add_node", "node": { "id": "a", "kind": "merge", "name": "Dup" } } + ] + })) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + let out = result.output(); + assert!(out.contains("already exists"), "{out}"); + assert!(out.contains("array order"), "{out}"); + assert!(out.contains("remove_node"), "{out}"); + assert!(out.contains("update_node_config"), "{out}"); +} + +#[tokio::test] +async fn edit_workflow_accepts_node_id_aliases_end_to_end() { + let tmp = TempDir::new().unwrap(); + let tool = EditWorkflowTool::new(test_config(&tmp)); + // A valid ops array using the `node_id` alias (the natural agent guess) + // applies cleanly through edit_workflow. + let result = tool + .execute(json!({ + "graph": valid_graph(), + "name": "Aliased edit", + "ops": [ + { "op": "update_node_config", "node_id": "a", "config": { "prompt": "aliased" } }, + { "op": "set_node_name", "node_id": "a", "name": "Aliased step" } + ] + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["type"], "workflow_proposal"); + let nodes = parsed["graph"]["nodes"].as_array().unwrap(); + let agent = nodes.iter().find(|n| n["id"] == "a").unwrap(); + assert_eq!(agent["config"]["prompt"], "aliased"); + assert_eq!(agent["name"], "Aliased step"); +} + #[tokio::test] async fn edit_workflow_rejects_a_result_that_is_structurally_invalid() { let tmp = TempDir::new().unwrap(); @@ -1724,3 +2357,147 @@ fn phase4_write_tools_have_the_right_permissions() { PermissionLevel::None ); } + +// ── WS2: unified draft_id|flow_id|graph handles + explicit persistence state ── + +#[tokio::test] +async fn edit_workflow_by_flow_id_seeds_a_retrievable_draft_and_marks_unpersisted() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + // A saved flow to edit — editing it must NOT write onto the flow (the WS2 + // bug: a flow_id edit used to persist nothing and return no handle). + let flow = ops::flows_create(&config, "Base flow".to_string(), valid_graph(), false) + .await + .unwrap() + .value; + + let tool = EditWorkflowTool::new(config.clone()); + let result = tool + .execute(json!({ + "flow_id": flow.id, + "ops": [ { "op": "set_node_name", "id": "a", "name": "Renamed step" } ] + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + + // The edit lives on a NEW draft, is explicitly NOT persisted, and echoes the + // flow it derives from plus a `next` hint naming the draft. + assert_eq!(parsed["persisted"], false); + assert_eq!(parsed["flow_id"], flow.id.as_str()); + let draft_id = parsed["draft_id"] + .as_str() + .expect("edit_workflow by flow_id returns a draft_id") + .to_string(); + assert!(parsed["next"].as_str().unwrap().contains(&draft_id)); + + // The draft is retrievable via ops::flows_draft_get and holds the EDITED + // graph, linked back to the source flow. + let draft = ops::flows_draft_get(&config, &draft_id).unwrap().value; + assert_eq!(draft.flow_id.as_deref(), Some(flow.id.as_str())); + let agent = draft.graph["nodes"] + .as_array() + .unwrap() + .iter() + .find(|n| n["id"] == "a") + .unwrap(); + assert_eq!(agent["name"], "Renamed step"); + + // The SAVED flow is untouched — the whole point of WS2. + let saved = ops::flows_get(&config, &flow.id).await.unwrap().value; + let saved_graph = serde_json::to_value(&saved.graph).unwrap(); + let saved_agent = saved_graph["nodes"] + .as_array() + .unwrap() + .iter() + .find(|n| n["id"] == "a") + .unwrap(); + assert_eq!( + saved_agent["name"], "Summarize", + "the flow must not be edited" + ); +} + +#[tokio::test] +async fn dry_run_workflow_by_flow_id_runs_the_saved_flow_graph() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = ops::flows_create(&config, "Runnable".to_string(), valid_graph(), false) + .await + .unwrap() + .value; + let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Supervised), config.clone()); + let result = tool.execute(json!({ "flow_id": flow.id })).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["sandbox"], true); + assert_eq!(parsed["ok"], true); +} + +#[tokio::test] +async fn validate_workflow_by_draft_id_checks_the_draft_graph() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let draft = ops::flows_draft_create( + &config, + None, + "Draft".to_string(), + valid_graph(), + crate::openhuman::flows::DraftOrigin::Chat, + ) + .unwrap() + .value; + let tool = ValidateWorkflowTool::new(config.clone()); + let result = tool.execute(json!({ "draft_id": draft.id })).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["ok"], true); + assert_eq!(parsed["structurally_valid"], true); +} + +#[tokio::test] +async fn save_workflow_by_draft_id_persists_the_draft_graph_onto_the_flow() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + // A flow seeded with a bare 1-node graph. + let flow_id = seed_flow(&config, "Blank flow").await; + // A draft holding the richer 2-node valid graph, linked to that flow. + let draft = ops::flows_draft_create( + &config, + Some(flow_id.clone()), + "Draft".to_string(), + valid_graph(), + crate::openhuman::flows::DraftOrigin::Chat, + ) + .unwrap() + .value; + + let tool = SaveWorkflowTool::new(config.clone()); + let result = tool + .execute(json!({ "flow_id": flow_id, "draft_id": draft.id })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["type"], "workflow_saved"); + assert_eq!(parsed["persisted"], true); + assert_eq!(parsed["node_count"], 2); + + // The draft's graph really landed on the flow. + let saved = ops::flows_get(&config, &flow_id).await.unwrap().value; + assert_eq!(saved.graph.nodes.len(), 2); +} + +#[tokio::test] +async fn revise_workflow_proposal_is_marked_unpersisted() { + let tmp = TempDir::new().unwrap(); + let tool = ReviseWorkflowTool::new(test_config(&tmp)); + let result = tool + .execute(json!({ "name": "R", "graph": valid_graph() })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let parsed: Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["persisted"], false); +} diff --git a/src/openhuman/flows/ops.rs b/src/openhuman/flows/ops.rs index 44b73438a9..72de2d8eae 100644 --- a/src/openhuman/flows/ops.rs +++ b/src/openhuman/flows/ops.rs @@ -142,6 +142,15 @@ pub(crate) async fn run_builder_gates(config: &Config, graph: &WorkflowGraph) -> if !binding_errors.is_empty() { return binding_errors; } + // Async, live connection list: a tool_call whose `connection_ref` names the + // wrong toolkit for its slug, or a connection id the user doesn't actually + // have (WS3 — the transcript bug where a TIKTOK connection id was wired onto + // Twitter/Gmail nodes and every author-time gate returned ok). Cheap: + // one connection-list fetch, no per-node catalog round trips. + let connection_ref_errors = validate_connection_refs(config, graph).await; + if !connection_ref_errors.is_empty() { + return connection_ref_errors; + } // Async, live catalog: a tool_call whose slug isn't a real Composio action // or whose real required args aren't all wired. let contract_errors = validate_tool_contracts(config, graph).await; @@ -192,11 +201,20 @@ pub(crate) async fn strict_gate(config: &Config, graph_json: &Value) -> Result<( /// the tool in the "fix … and call `` again" guidance so each caller's /// error text points the agent back at the right tool. /// +/// `draft_id` / `flow_id` are OPTIONAL persistence-state context echoed onto +/// the payload (the draft this proposal's edit lives on, and the saved flow it +/// derives from / targets). The payload ALWAYS carries `"persisted": false` so +/// a proposal can never be mistaken for a save confirmation — the exact false +/// belief the WS2 audit caught (an agent read a proposal as "written onto the +/// saved flow"). Actual persistence only happens via `save_workflow` / +/// `create_workflow` / `flows_draft_promote`. +/// /// Returns `Ok(payload)` on success, or `Err(message)` with a /// model-consumable, fix-and-retry error when a gate rejects the graph. The /// caller is responsible for structural validation (`validate_and_migrate_graph` /// / `validate_all`) *before* calling this — these gates assume a compilable /// graph. +#[allow(clippy::too_many_arguments)] pub(crate) async fn build_builder_proposal( config: &Config, retry_tool: &str, @@ -205,6 +223,8 @@ pub(crate) async fn build_builder_proposal( require_approval: bool, revision: bool, instruction: Option, + draft_id: Option, + flow_id: Option, ) -> Result { // The full builder hard-gate stack, run through the single canonical // runner so every proposal/save/strict-RPC path gates identically (F3). @@ -238,6 +258,10 @@ pub(crate) async fn build_builder_proposal( let mut payload = json!({ "type": "workflow_proposal", "revision": revision, + // A proposal is NEVER a persisted flow — it is a candidate the user + // still has to accept/save. Stamp this unconditionally so the payload + // can't be misread as a save confirmation (WS2 audit). + "persisted": false, "name": name, "graph": graph_value, "require_approval": require_approval, @@ -248,6 +272,14 @@ pub(crate) async fn build_builder_proposal( if let Some(instruction) = instruction { payload["instruction"] = json!(instruction); } + // Echo the persistence-state handles so the agent can iterate/persist + // against the right ids (the draft the edit lives on; the flow it targets). + if let Some(draft_id) = draft_id { + payload["draft_id"] = json!(draft_id); + } + if let Some(flow_id) = flow_id { + payload["flow_id"] = json!(flow_id); + } Ok(payload) } @@ -334,6 +366,49 @@ pub(crate) fn graph_has_outbound_side_effect(graph: &WorkflowGraph) -> bool { }) } +/// Whether `graph` has anything for [`flows_run`] to actually *do* — i.e. at +/// least one non-`trigger` node **reachable from the trigger** by following +/// directed edges. A graph made of nothing but a bare `trigger` node (or a +/// `trigger` plus unreachable/disconnected nodes — even ones wired to each +/// other by their own edges, just not to the trigger) can compile and "run" +/// cleanly while producing no work whatsoever — the exact live finding this +/// guards: a trigger-only flow reported `status="completed" +/// pending_approvals=0` having done nothing, which reads as a successful +/// automation to anyone not staring at the node count. Used by `flows_run` +/// to attach a human-readable note to an otherwise-silent "success". +/// +/// Deliberately a reachability walk rather than "any edge at all exists": +/// `nodes.len() > 1 && !edges.is_empty()` would count a disconnected +/// component's internal edges as actionable even though nothing downstream +/// of the trigger ever runs. +pub(crate) fn graph_has_actionable_nodes(graph: &WorkflowGraph) -> bool { + let Some(trigger) = graph.trigger() else { + // No single resolvable trigger to walk from — fall back to the + // coarse "any non-trigger node wired up by an edge" check so a + // malformed/ambiguous-trigger graph doesn't spuriously suppress the + // empty-flow note. + return graph.nodes.iter().any(|n| n.kind != NodeKind::Trigger) && !graph.edges.is_empty(); + }; + + let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut stack = vec![trigger.id.as_str()]; + while let Some(current) = stack.pop() { + if !visited.insert(current) { + continue; + } + for next in graph.successors(current) { + if !visited.contains(next) { + stack.push(next); + } + } + } + + visited + .into_iter() + .filter_map(|id| graph.node(id)) + .any(|n| n.kind != NodeKind::Trigger) +} + /// Produces host-side, **non-fatal** validation warnings for a graph — today /// exactly one: "this trigger kind does not fire automatically yet". Returns /// an empty vec when the trigger fires (`manual`/`schedule`/`app_event`), when @@ -1102,10 +1177,23 @@ pub(crate) fn validate_binding_resolvability(graph: &WorkflowGraph) -> Vec bool { + use crate::openhuman::memory_sync::composio::providers::{catalog_for_toolkit, get_provider}; + get_provider(toolkit) + .and_then(|p| p.curated_tools()) + .or_else(|| catalog_for_toolkit(toolkit)) + .is_some() +} + pub(crate) async fn validate_tool_contracts(config: &Config, graph: &WorkflowGraph) -> Vec { - use crate::openhuman::memory_sync::composio::providers::{ - catalog_for_toolkit, get_provider, toolkit_from_slug, - }; + use crate::openhuman::memory_sync::composio::providers::toolkit_from_slug; use crate::openhuman::tinyflows::caps::{ fetch_live_toolkit_catalog, missing_required_args, unsupported_arg_names, }; @@ -1166,10 +1254,7 @@ pub(crate) async fn validate_tool_contracts(config: &Config, graph: &WorkflowGra // action on a curated toolkit and then fail every run with "tool // not permitted". Hold authoring to the same bar the runtime gate // enforces instead of loosening the runtime gate. - let has_static_catalog = get_provider(&toolkit) - .and_then(|p| p.curated_tools()) - .or_else(|| catalog_for_toolkit(&toolkit)) - .is_some(); + let has_static_catalog = toolkit_has_curated_catalog(&toolkit); if has_static_catalog && !contract.is_curated { tracing::warn!( target: "flows", @@ -1273,6 +1358,248 @@ pub(crate) async fn validate_tool_contracts(config: &Config, graph: &WorkflowGra errors } +// ───────────────────────────────────────────────────────────────────────────── +// Connection-ref gate (WS3): a Composio tool_call's `connection_ref` must name +// a real connected account of the RIGHT toolkit +// ───────────────────────────────────────────────────────────────────────────── +// +// Transcript audit: the user's connections were `twitter → +// composio:twitter:ca_JX6QU88UfSk4`, `gmail → composio:gmail:ca_vX_WA8FsqNmE`, +// `tiktok → composio:tiktok:ca_LPCp3WQpaDma`. The agent wired +// `composio:twitter:ca_LPCp3WQpaDma` and `composio:gmail:ca_LPCp3WQpaDma` (the +// TIKTOK id) onto the Twitter and Gmail tool_call nodes. dry_run / validate / +// propose all returned ok:true — nothing cross-checked the id against the user's +// real connections, nor the ref's toolkit segment against the slug — and it +// would fail on the first real run. This gate closes that gap: it parses the +// ref, enforces the toolkit segment matches the slug (needs no I/O), and — when +// the live connection list is reachable — that the id names a real connected +// account of that toolkit, naming the correct ref when it can. + +/// Parses a `composio::` connection_ref into its `(toolkit, id)` +/// segments. Mirrors [`crate::openhuman::tinyflows::caps::composio_connection_id`]'s +/// rsplit for the id (everything after the LAST `:`), taking everything between +/// the `composio:` prefix and that last `:` as the toolkit. Returns `None` for +/// anything that isn't this shape (missing `composio:` prefix, no `:` after it, +/// or an empty toolkit/id segment). +fn parse_composio_connection_ref(conn_ref: &str) -> Option<(&str, &str)> { + let rest = conn_ref.strip_prefix("composio:")?; + let (toolkit, id) = rest.rsplit_once(':')?; + if toolkit.trim().is_empty() || id.trim().is_empty() { + return None; + } + Some((toolkit.trim(), id.trim())) +} + +/// First connected account `connection_ref` for `toolkit` (case-insensitive) +/// from `conns`, used to name the correct ref in a rejection's "did you mean" +/// hint. `None` when the toolkit has no connection at all. +fn first_connection_ref_for_toolkit(conns: &[FlowConnection], toolkit: &str) -> Option { + conns + .iter() + .find(|c| { + c.toolkit + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case(toolkit)) + }) + .map(|c| c.connection_ref.clone()) +} + +/// Hard gate: for every Composio `tool_call` node carrying a `connection_ref`, +/// prove the ref names a real connected account of the SAME toolkit as the +/// slug. Fetches the live connection list once (same source +/// [`flows_list_connections`] reads) and delegates the pure matching to +/// [`validate_connection_refs_against`]. +/// +/// Fail-open on I/O: if the Composio connection list is unreachable (backend +/// outage), the id-existence check is SKIPPED (a `tracing::debug!` records it) +/// so a real connection is never false-rejected during an outage — but the +/// toolkit-mismatch check, which needs no I/O, still runs. +pub(crate) async fn validate_connection_refs( + config: &Config, + graph: &WorkflowGraph, +) -> Vec { + let connections: Option> = + match crate::openhuman::composio::ops::composio_list_connections(config).await { + Ok(outcome) => Some(build_flow_connections( + outcome.value.connections, + Vec::new(), + )), + Err(e) => { + tracing::debug!( + target: "flows", + error = %e, + "[flows] connection-ref check: composio connection list unavailable — \ + skipping id-existence check (fail-open); toolkit-mismatch check still runs" + ); + None + } + }; + validate_connection_refs_against(graph, connections.as_deref()) +} + +/// Pure connection-ref validator (no I/O) so the gate's decision logic is +/// unit-testable without a live Composio backend. `connections` is `Some(list)` +/// when the live connection list was fetched (possibly empty — a genuine "no +/// connections" state), or `None` when it was unavailable (fail-open: the +/// id-existence check is skipped, only the toolkit-mismatch check runs). +fn validate_connection_refs_against( + graph: &WorkflowGraph, + connections: Option<&[FlowConnection]>, +) -> Vec { + use crate::openhuman::memory_sync::composio::providers::toolkit_from_slug; + + let mut errors = Vec::new(); + for node in &graph.nodes { + if node.kind != NodeKind::ToolCall { + continue; + } + let Some(slug) = node.config.get("slug").and_then(Value::as_str) else { + continue; + }; + // `=`-derived slugs resolve at runtime; native `oh:` tools have no + // Composio connection to name. + if slug.starts_with('=') || slug.starts_with("oh:") { + continue; + } + // A MISSING `connection_ref` stays allowed (unchanged): a Composio + // tool_call with no ref runs against the ambient signed-in account and + // the flow prompts for a connection at first run. + let Some(conn_ref) = node.config.get("connection_ref").and_then(Value::as_str) else { + continue; + }; + if conn_ref.trim().is_empty() { + continue; + } + let Some(slug_toolkit) = toolkit_from_slug(slug) else { + continue; + }; + + let Some((ref_toolkit, ref_id)) = parse_composio_connection_ref(conn_ref) else { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %conn_ref, + matched = false, + "[flows] connection-ref check: malformed ref — rejecting" + ); + errors.push(format!( + "Node '{}': `connection_ref` `{conn_ref}` is malformed — a Composio account ref \ + must look like `composio::` (e.g. \ + `composio:{slug_toolkit}:`). Call list_flow_connections and copy a \ + `connection_ref` value verbatim.", + node.id + )); + continue; + }; + + // Toolkit segment vs the slug's toolkit — needs no I/O. + if !ref_toolkit.eq_ignore_ascii_case(&slug_toolkit) { + let suggestion = connections + .and_then(|conns| first_connection_ref_for_toolkit(conns, &slug_toolkit)); + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_toolkit, + %ref_id, + matched = false, + "[flows] connection-ref check: toolkit segment does not match the slug's toolkit — rejecting" + ); + let hint = match suggestion { + Some(r) => format!(" — did you mean `{r}`?"), + None => format!( + " — no `{slug_toolkit}` account is connected; connect one with \ + composio_connect (or ask the user to), then use its `connection_ref`" + ), + }; + errors.push(format!( + "Node '{}': `connection_ref` `{conn_ref}` names the `{ref_toolkit}` toolkit but the \ + tool_call slug `{slug}` is a `{slug_toolkit}` action{hint}.", + node.id + )); + continue; + } + + // Existence check: the id must name a real connected account of this + // toolkit. Skipped (fail-open) when the connection list is unavailable. + let Some(conns) = connections else { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_id, + "[flows] connection-ref check: toolkit matches; id-existence check skipped (connections unavailable)" + ); + continue; + }; + // The id must belong to a connection OF THIS TOOLKIT — not merely + // exist somewhere. The transcript bug was a real TIKTOK connection id + // stamped onto a `composio:twitter:` ref: the id exists globally, but + // it is not a Twitter account, so it must still be rejected. + let id_exists = conns.iter().any(|c| { + c.toolkit + .as_deref() + .is_some_and(|t| t.eq_ignore_ascii_case(&slug_toolkit)) + && parse_composio_connection_ref(&c.connection_ref) + .is_some_and(|(_, cid)| cid.eq_ignore_ascii_case(ref_id)) + }); + if id_exists { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_id, + matched = true, + "[flows] connection-ref check: ref resolves to a real connected account — ok" + ); + continue; + } + // Unknown id. Name the right ref for this toolkit if one exists. + match first_connection_ref_for_toolkit(conns, &slug_toolkit) { + Some(r) => { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_id, + matched = false, + "[flows] connection-ref check: unknown id; toolkit has a different connected account — rejecting" + ); + errors.push(format!( + "Node '{}': `connection_ref` `{conn_ref}` does not match any connected \ + `{slug_toolkit}` account — did you mean `{r}`? Call list_flow_connections and \ + copy a `connection_ref` value verbatim.", + node.id + )); + } + None => { + tracing::debug!( + target: "flows", + node = %node.id, + %slug, + toolkit = %slug_toolkit, + %ref_id, + matched = false, + "[flows] connection-ref check: no connected account for this toolkit — rejecting" + ); + errors.push(format!( + "Node '{}': `connection_ref` `{conn_ref}` names a `{slug_toolkit}` account, but \ + no `{slug_toolkit}` account is connected — connect one with composio_connect \ + (or ask the user to), then use its `connection_ref`.", + node.id + )); + } + } + } + errors +} + // ───────────────────────────────────────────────────────────────────────────── // Required-arg resolvability gate (issue B18) // ───────────────────────────────────────────────────────────────────────────── @@ -1348,13 +1675,19 @@ const REQUIRED_ARG_NULL_CHECK_TIMEOUT_SECS: u64 = 15; /// check only ever adds a diagnostic the sandbox actually observed. pub(crate) async fn validate_required_arg_resolvability(graph: &WorkflowGraph) -> Vec { use crate::openhuman::flows::builder_tools::CapturingObserver; - use crate::openhuman::tinyflows::caps::SchemaAwareMockAgentRunner; + use crate::openhuman::tinyflows::caps::{SchemaAwareMockAgentRunner, SchemaAwareMockLlm}; let Ok(compiled) = tinyflows::compiler::compile(graph) else { return Vec::new(); }; - let caps = tinyflows::caps::mock::mock_capabilities_with_agent(SchemaAwareMockAgentRunner); + let mut caps = tinyflows::caps::mock::mock_capabilities_with_agent(SchemaAwareMockAgentRunner); + // Same fix as `DryRunWorkflowTool`: a plain agent node (no `agent_ref`) + // routes to the `llm` slot, not the runner above, so the vendored `MockLlm` + // echo would fail its `output_parser.schema` sub-port and make this gate + // reject a correct graph (which is why `propose_workflow` was rejecting + // valid graphs). The schema-aware mock LLM honors the schema instead. + caps.llm = Arc::new(SchemaAwareMockLlm); let observer = Arc::new(CapturingObserver::default()); let observer_dyn: Arc = observer.clone(); @@ -1426,6 +1759,34 @@ pub(crate) async fn validate_required_arg_resolvability(graph: &WorkflowGraph) - ); continue; } + // A null bound to the OUTPUT of an upstream Composio `tool_call` + // node is UNVERIFIABLE in this echo sandbox — the mock renders a + // Composio `tool_call` as `{tool, args, connection}` and can NEVER + // produce its real output fields (`.item.json.data.`), so a + // downstream binding to one resolves `null` here even when the + // wiring is perfectly correct. Hard-rejecting it (WS6) would block + // a possibly-correct graph from ever being proposed — the exact + // false-negative the transcript audit caught. Downgrade to a + // debug-logged skip; `dry_run_workflow` remains the surface that + // reports it (as an `unverifiable` diagnostic the agent can act on + // via get_tool_contract / get_tool_output_sample). + if let Some(upstream) = + composio_tool_call_upstream_ref(&diag.expression, graph, &step.node_id) + { + tracing::debug!( + target: "flows", + node = %step.node_id, + %slug, + %field, + upstream = %upstream, + expression = %diag.expression, + "[flows] required-arg resolvability check: arg binds to a Composio \ + tool_call's output — UNVERIFIABLE in the echo sandbox (the mock cannot \ + produce real tool output fields), not rejecting; dry_run_workflow \ + reports it instead" + ); + continue; + } tracing::warn!( target: "flows", node = %step.node_id, @@ -1541,6 +1902,72 @@ fn is_trigger_scoped_expression( predecessors.peek().is_some() && predecessors.all(|e| e.from_node == trigger_id) } +/// If a null-resolved config expression on `node_id` is bound to the OUTPUT of +/// an upstream **Composio `tool_call`** node (a `tool_call` whose `slug` is a +/// real Composio action — not `=`-derived, not native `oh:`), returns that +/// upstream node's id; otherwise `None`. +/// +/// The dry-run / gate sandbox renders a Composio `tool_call` as a deterministic +/// echo (`{tool, args, connection}`) and can NEVER produce its real output +/// fields, so a downstream binding to `.item.json.data.` off such a node +/// resolves `null` in the sandbox **even when the wiring is correct** — the +/// binding is UNVERIFIABLE here, not necessarily broken. Callers use this to +/// tell that honest-uncertainty case apart from a genuinely broken binding +/// (one wired to an `agent` / `transform` / `code` / trigger upstream, whose +/// real output the sandbox DOES produce, so a null there IS a real bug). +/// +/// Handles both addressing forms the engine can trace: +/// - explicit `=nodes....` / `=.nodes[""]...` (parsed via +/// [`explicit_nodes_ref`]), and +/// - implicit `=item...` / `=items...`, resolved against `node_id`'s direct +/// predecessor — but only when there is exactly ONE incoming edge, so an +/// ambiguous fan-in is never mis-attributed to a single upstream node. +/// +/// Anything else (a `=run...` trigger reference, a jq expression not rooted at +/// one of the above, or a reference to a non-`tool_call` / native / dynamic +/// node) returns `None`. +pub(crate) fn composio_tool_call_upstream_ref<'a>( + expr: &str, + graph: &'a WorkflowGraph, + node_id: &str, +) -> Option<&'a str> { + let referenced_id: String = if let Some(id) = explicit_nodes_ref(expr) { + id.to_string() + } else { + let body = expr.strip_prefix('=').unwrap_or(expr).trim(); + let body = body.strip_prefix('.').unwrap_or(body); + let is_item_scoped = body == "item" + || body.starts_with("item.") + || body.starts_with("item[") + || body == "items" + || body.starts_with("items.") + || body.starts_with("items["); + if !is_item_scoped { + return None; + } + let mut preds = graph + .edges + .iter() + .filter(|e| e.to_node == node_id) + .map(|e| e.from_node.as_str()); + let first = preds.next()?; + if preds.next().is_some() { + // Ambiguous fan-in — cannot attribute the null to one upstream node. + return None; + } + first.to_string() + }; + let node = graph.nodes.iter().find(|n| n.id == referenced_id)?; + if node.kind != NodeKind::ToolCall { + return None; + } + let slug = node.config.get("slug").and_then(Value::as_str)?; + if slug.starts_with('=') || slug.starts_with("oh:") { + return None; + } + Some(node.id.as_str()) +} + /// Validates a candidate graph without persisting it — the same /// migrate/validate path `flows_create` and `ProposeWorkflowTool` use — and /// reports structural errors alongside non-fatal trigger warnings @@ -2078,6 +2505,40 @@ fn map_flow_update_error(e: store::FlowUpdateError) -> String { /// old cadence, or a newly-added schedule would never get bound at all. /// Skipped entirely for a name/`require_approval`-only update (no /// `graph_json` supplied), since the trigger definitely didn't change. +/// +/// **B29 Rule 1 analogue for saves** (save/enable safety — same issue +/// `flows_create` guards at creation time, see its doc): `flows_create` +/// refuses to persist an automatic-trigger graph (`schedule` / `app_event` / +/// `webhook`, see [`trigger_is_automatic`]) as `enabled`, but that guard only +/// runs once, at creation. Without an equivalent here, a flow created +/// `enabled: true` with a manual/no-op trigger could later have an +/// automatic-trigger graph saved onto it — via the `save_workflow` agent +/// tool, the canvas Save button, a proposal apply, or any other +/// `flows_update` caller — and go LIVE immediately with no user review +/// (confirmed live: a flow started firing on an unreviewed 8am schedule). +/// So: when the *new* graph's trigger is automatic and the *previous* +/// graph's trigger was NOT automatic (a manual/none → automatic +/// transition), this forces the persisted `enabled` back to `false` in the +/// same store write — the user must explicitly re-arm via +/// `flows_set_enabled` after reviewing the new trigger. An automatic → +/// automatic re-edit (e.g. tweaking a cron expression) is left alone — the +/// user already opted in once, and re-disarming on every edit would just be +/// friction. +/// +/// The override is applied **unconditionally** on a manual/none → automatic +/// transition — it does *not* gate on whether the flow *looked* enabled in +/// the `existing` read above. That read is a snapshot taken before +/// `store::update_flow_graph`'s own guarded UPDATE re-reads the row; a +/// concurrent `flows_set_enabled(id, true)` landing in the gap would leave +/// this snapshot stale while the row is actually enabled by the time the +/// guarded UPDATE runs — and since `set_enabled` bumps `updated_at` too, +/// such a race wouldn't even trip the optimistic-concurrency conflict, it +/// would just silently persist the automatic graph as enabled (the exact +/// bug this rule exists to close). Gating on the stale `existing.enabled` +/// re-opens that race; forcing the override on every transition, enabled-or- +/// not, is exactly as safe as Rule 1's at-create version — a transition on +/// an already-disabled flow is just a no-op write of `enabled=false` over +/// `enabled=false`. pub async fn flows_update( config: &Config, id: &str, @@ -2101,17 +2562,54 @@ pub async fn flows_update( } }; + // B29 Rule 1 analogue: disarm every manual/none → automatic trigger + // transition, unconditionally — see the doc comment above for why this + // must NOT gate on the (possibly stale) `existing.enabled` read. + let was_auto = trigger_is_automatic(&existing.graph); + let now_auto = trigger_is_automatic(&graph); + let is_manual_to_auto_transition = now_auto && !was_auto; + let enabled_override = is_manual_to_auto_transition.then_some(false); + // Best-effort flag for the info log / result message below: whether the + // flow *appeared* live going into this update. Not used for the + // override decision itself (that's unconditional, see above) — only to + // avoid telling the user "flow was auto-disabled" when it was already + // disabled going in. + let should_disarm = is_manual_to_auto_transition && existing.enabled; + tracing::debug!( + target: "flows", + flow_id = %id, + was_auto, + now_auto, + currently_enabled = existing.enabled, + is_manual_to_auto_transition, + should_disarm, + "[flows] flows_update: auto-trigger disarm decision inputs" + ); + tracing::debug!(target: "flows", flow_id = %id, has_expected = expected_version.is_some(), "[flows] flows_update: persisting changes"); + // `enabled_override` is threaded into the same guarded UPDATE as the + // graph/name/require_approval write (see `store::update_flow_graph`) + // rather than a follow-up `flows_set_enabled` call, so the disarm can + // never race a concurrent read/write of `enabled`. let updated = store::update_flow_graph( config, id, new_name, graph, new_require_approval, + enabled_override, expected_version.as_deref(), ) .map_err(map_flow_update_error)?; + if should_disarm { + tracing::info!( + target: "flows", + flow_id = %id, + "[flows] flows_update: auto-disabled — graph changed manual→automatic trigger on an enabled flow" + ); + } + if graph_changed && updated.enabled { let trigger_unchanged = bus::extract_trigger_kind(&existing) == bus::extract_trigger_kind(&updated) @@ -2124,10 +2622,16 @@ pub async fn flows_update( } publish_flow_changed(id, "updated", "system"); - Ok(RpcOutcome::single_log( - updated, - format!("flow updated: {id}"), - )) + let mut logs = vec![format!("flow updated: {id}")]; + if should_disarm { + logs.push( + "Flow was auto-disabled because its trigger changed from manual to automatic \ + (schedule / app_event / webhook). Enable it explicitly (flows_set_enabled) once \ + you've reviewed the new trigger." + .to_string(), + ); + } + Ok(RpcOutcome::new(updated, logs)) } /// Lists a flow's revision history (prior graph snapshots), newest first, @@ -2472,6 +2976,24 @@ pub async fn flows_run( .map_err(|e| e.to_string())? .ok_or_else(|| format!("flow '{flow_id}' not found"))?; + // Live finding: a graph with no actionable nodes (only a `trigger`, or a + // `trigger` plus nodes with no edges wiring them up) compiles and "runs" + // cleanly but does nothing — and previously reported + // `status="completed" pending_approvals=0` indistinguishably from a real + // run, reading as "triggered but nothing happened" was actually a + // success. Surface it loudly instead of letting it pass silently: warn + // now (independent of how the run below turns out), and attach a + // human-readable note to the returned outcome so the UI can show + // "nothing to run" rather than a bare "completed". + let no_actionable_nodes = !graph_has_actionable_nodes(&flow.graph); + if no_actionable_nodes { + tracing::warn!( + target: "flows", + flow_id = %flow_id, + "[flows] flows_run: flow has no actionable nodes — nothing to execute" + ); + } + // `store::get_flow` already ran the stored `graph_json` through // `tinyflows::migrate::migrate` before deserializing, so `flow.graph` is // always on the current schema here. @@ -2627,17 +3149,26 @@ pub async fn flows_run( flow_id = %flow_id, status, pending_approvals = outcome.pending_approvals.len(), + no_actionable_nodes, "[flows] flows_run: finished" ); - Ok(RpcOutcome::single_log( - json!({ - "output": outcome.output, - "pending_approvals": outcome.pending_approvals, - "thread_id": thread_id, - }), - format!("flow run {status}"), - )) + const NO_ACTIONABLE_NODES_NOTE: &str = "This flow's graph has no actionable nodes beyond \ + its trigger (no downstream action nodes, or no edges connecting them) — the run \ + completed without doing anything. Add and wire up at least one action node."; + + let mut result = json!({ + "output": outcome.output, + "pending_approvals": outcome.pending_approvals, + "thread_id": thread_id, + }); + let mut logs = vec![format!("flow run {status}")]; + if no_actionable_nodes { + result["note"] = json!(NO_ACTIONABLE_NODES_NOTE); + logs.push(NO_ACTIONABLE_NODES_NOTE.to_string()); + } + + Ok(RpcOutcome::new(result, logs)) } /// Resumes a `flows_run` that paused at a human-in-the-loop approval gate, @@ -3030,7 +3561,6 @@ pub async fn flows_cancel_run(config: &Config, run_id: &str) -> Result match checkpointer.delete_thread(thread_id).await { Ok(()) => { @@ -3297,7 +3827,14 @@ fn notify_pending_approval(flow: &Flow, thread_id: &str, pending_approvals: &[St /// reasons read-only over the user's data and ends by emitting /// `suggest_workflows`; its own `max_iterations` caps the loop, but a hung /// LLM/tool call must never let the RPC block indefinitely. -const FLOW_DISCOVER_TIMEOUT_SECS: u64 = 300; +/// +/// Matches [`FLOW_BUILD_TIMEOUT_SECS`] (600s): the session builder applies the +/// `flow_discovery` definition's `effective_max_iterations()` (50, not the +/// global default of 10) to this path (issue #4868), so a worst-case run at +/// ~10s/iteration can take up to ~500s — the old 300s bound could clip a +/// legitimate long discovery run before the iteration cap ever got a chance +/// to (post-merge Codex P2 finding). +const FLOW_DISCOVER_TIMEOUT_SECS: u64 = 600; /// The canned brief handed to the `flow_discovery` agent. The agent's own /// archetype prompt teaches the read → correlate → ground → emit loop; this is @@ -3558,7 +4095,9 @@ pub async fn flows_discover( const FLOW_BUILD_TIMEOUT_SECS: u64 = 600; /// Tools stripped from the `workflow_builder` belt on the direct `flows_build` -/// RPC path (issue #4593). +/// RPC path (issue #4593; widened for `resume_flow_run`/`cancel_flow_run` +/// alongside issue #4881, which added both to the belt without extending +/// this list). /// /// `flows_build` runs the builder under [`AgentTurnOrigin::Cli`] so the approval /// gate does not fail-closed in a headless/streamed run — but that same origin @@ -3578,22 +4117,46 @@ const FLOW_BUILD_TIMEOUT_SECS: u64 = 600; /// name (now the unrelated harness spawn tool) is listed too as belt-and-braces /// against a re-rename or the name ever leaking back onto this belt; /// `hide_tools` no-ops on a name that isn't present. -const FLOWS_BUILD_HIDDEN_TOOLS: &[&str] = &["run_workflow", "run_flow"]; +/// +/// `resume_flow_run` ([`builder_tools::ResumeFlowRunTool`]) is the exact same +/// concern as `run_flow`, one hop later: it is `external_effect() == true` +/// (its own description says "This ADVANCES A REAL RUN — approved outbound +/// nodes will fire") and would be auto-allowed by the same `Cli`-origin gate +/// bypass, letting an authoring turn (or a confused/prompt-injected model) +/// approve a live run's parked Slack/Gmail/HTTP node with zero human +/// confirmation — the exact HITL hole #4593 closed, reopened by #4881 +/// widening the belt. +/// +/// `cancel_flow_run` fires no new outbound effect +/// (`external_effect() == false`), so it isn't a gate-bypass concern the same +/// way — but an authoring turn still has no business tearing down a run the +/// *user* started, so it is hidden alongside the two above out of caution. +/// +/// `create_workflow` / `duplicate_flow` are deliberately **left visible**: +/// both are hard-forced **born disabled** (see [`builder_tools::CreateWorkflowTool`] +/// / [`builder_tools::DuplicateFlowTool`]), so even an unattended call can't +/// leave anything live — lower risk than the run/resume/cancel trio above. +const FLOWS_BUILD_HIDDEN_TOOLS: &[&str] = &[ + "run_workflow", + "run_flow", + "resume_flow_run", + "cancel_flow_run", +]; -/// Strip the live-run tool(s) in [`FLOWS_BUILD_HIDDEN_TOOLS`] from `agent`'s -/// callable set for the direct `flows_build` RPC path. +/// Strip the live-run / resume / cancel tool(s) in [`FLOWS_BUILD_HIDDEN_TOOLS`] +/// from `agent`'s callable set for the direct `flows_build` RPC path. /// /// Delegates to [`crate::openhuman::agent::Agent::hide_tools`], which removes /// the names from the builder's (already narrow) visible belt and rebuilds the /// session's `ToolPolicySession` so they resolve to `Deny` at the tool-call /// boundary — a hard execution guarantee even if the model requests the tool. -/// The authoring tools (`propose`/`revise`/`save`/`dry_run`/reads) are all -/// `external_effect() == false` and untouched, so the turn never fail-closes. +/// The authoring tools (`propose`/`revise`/`save`/`dry_run`/reads/`create_workflow`/ +/// `duplicate_flow`) stay visible and untouched, so the turn never fail-closes. fn restrict_builder_toolset(agent: &mut crate::openhuman::agent::Agent) { tracing::debug!( target: "flows", hidden = ?FLOWS_BUILD_HIDDEN_TOOLS, - "[flows] flows_build: hiding live-run tools from builder belt" + "[flows] flows_build: hiding live-run/resume/cancel tools from builder belt" ); agent.hide_tools(FLOWS_BUILD_HIDDEN_TOOLS); } @@ -3704,26 +4267,25 @@ pub async fn flows_build( } }; - // Emit the terminal chat event so a client viewing the copilot thread stops - // "processing" and finalizes the assistant bubble (the bridge streams only - // intermediate deltas). Success delivers `chat_done`; a run error delivers - // `chat_error`. The blocking return below is unchanged. - if let Some(target) = &stream { - let terminal: Result = match &run_error { - None => Ok(assistant_text.clone()), - Some(err) => Err(err.clone()), - }; - finalize_flow_stream(target, &terminal, &prompt).await; - } - // Capture the proposal from the run's tool history (propose/revise/save all // emit the same self-describing `{ type: "workflow_proposal", … }` payload). + // Extracted BEFORE the stream is finalized below (issue: builder + // convergence): the trail-off backstop needs `proposal`/`capped` to decide + // whether to override `assistant_text`, and the streamed copilot-pane chat + // bubble must render the SAME (possibly-overridden) text as the RPC + // response — the frontend renders from the stream, not the return value, + // so patching only the latter would still leave an interactive user + // staring at the original silent/status-only text. let proposal = extract_workflow_proposal(agent.history()); // A run that both errored AND produced no proposal is a hard failure; a run // that proposed before erroring still returns the proposal for review. if proposal.is_none() { if let Some(err) = &run_error { + if let Some(target) = &stream { + let terminal: Result = Err(err.clone()); + finalize_flow_stream(target, &terminal, &prompt).await; + } return Err(format!("workflow_builder produced no proposal: {err}")); } } @@ -3741,11 +4303,51 @@ pub async fn flows_build( let hit_cap = agent.last_turn_hit_cap(); let capped = hit_cap && proposal.is_none(); + // Terminal-state guarantee (builder convergence fix): a turn can end + // "naturally" (no more tool calls, not capped, no run error) yet still + // produce neither a proposal nor a real question — the model ran out of + // steam mid-build and left a status dump ("Done so far: checked + // connections…") as its final reply. `prompt.md` tells the model to + // always end a building turn in a proposal or a question, but a prompt + // rule can be silently ignored; this is the fail-closed backend backstop + // that makes it a hard invariant regardless of model behavior — the user + // is NEVER left with silence or an unanswerable status note. + let trail_off = !capped && proposal.is_none() && run_error.is_none(); + let assistant_text = if trail_off && !text_looks_like_question(&assistant_text) { + let fallback = build_trail_off_fallback(agent.history()); + tracing::warn!( + target: "flows", + flow_id = req.flow_id.as_deref().unwrap_or(""), + original_len = assistant_text.len(), + fallback_len = fallback.len(), + "[flows] flows_build: trail-off detected (no proposal, no cap, no question) — \ + guaranteeing a fallback question instead of silence" + ); + fallback + } else { + assistant_text + }; + + // Emit the terminal chat event so a client viewing the copilot thread stops + // "processing" and finalizes the assistant bubble (the bridge streams only + // intermediate deltas). Success delivers `chat_done`; a run error delivers + // `chat_error`. The blocking return below is unchanged. Uses the + // (possibly trail-off-overridden) `assistant_text` above. + if let Some(target) = &stream { + let terminal: Result = match &run_error { + None => Ok(assistant_text.clone()), + Some(err) => Err(err.clone()), + }; + finalize_flow_stream(target, &terminal, &prompt).await; + } + tracing::info!( target: "flows", + flow_id = req.flow_id.as_deref().unwrap_or(""), has_proposal = proposal.is_some(), hit_cap, capped, + trail_off, "[flows] flows_build: workflow_builder turn complete" ); Ok(RpcOutcome::single_log( @@ -3754,11 +4356,154 @@ pub async fn flows_build( "assistant_text": assistant_text, "error": run_error, "capped": capped, + "trail_off": trail_off, }), "workflow builder turn complete", )) } +/// Heuristic: does `text` already end with a clear, answerable question? +/// Conservative by design (issue: builder convergence) — a false negative (an +/// actual question this misses) just wraps it in the trail-off fallback, +/// which still includes the blocker context, so the safe failure mode is +/// "over-wrap", never "under-detect and stay silent". +fn text_looks_like_question(text: &str) -> bool { + let trimmed = text + .trim() + .trim_end_matches(['"', '\'', ')', ']', '*', '_', '`', '.']) + .trim_end(); + if trimmed.is_empty() { + return false; + } + if trimmed.ends_with('?') { + return true; + } + // The question may not be the literal last character (trailing markdown + // like a closing code fence or list marker on its own line) — fall back + // to the last non-blank line. This does NOT catch a question followed by + // a further trailing sentence ("...channel?\n\nLet me know!") — that's + // an accepted false negative: the turn still ends in a real (if + // over-eagerly replaced) question, never in silence, which is the + // invariant this function exists to protect. + trimmed + .lines() + .rfind(|line| !line.trim().is_empty()) + .is_some_and(|last_line| last_line.trim_end().ends_with('?')) +} + +/// Builder-authoring tools whose result body can explain a trail-off — the +/// authoring belt `dry_run_workflow`/`validate_workflow`/`propose_workflow`/ +/// `revise_workflow`/`edit_workflow`/`save_workflow` all report either a hard +/// gate rejection (`ToolResult::error`) or a self-reported broken-graph +/// result (`"ok": false` in a successful body), so a plain-text read-only +/// tool's output is never misattributed as the blocker. +const TRAIL_OFF_BLOCKER_TOOLS: &[&str] = &[ + "dry_run_workflow", + "validate_workflow", + "propose_workflow", + "revise_workflow", + "edit_workflow", + "save_workflow", +]; + +/// Synthesizes a guaranteed, user-facing fallback for a trail-off turn (no +/// proposal, not capped, no run error, and the model's own text isn't a +/// question). Scans the run's tool history for the last builder-tool result +/// that looks like a blocker (a hard-gate rejection, or a `dry_run_workflow`/ +/// `validate_workflow` report with `"ok": false`) and asks the user about it; +/// falls back to a generic "what should I focus on" question when no such +/// blocker is found (the model may have simply stopped with nothing to point +/// to). +fn build_trail_off_fallback( + history: &[crate::openhuman::inference::provider::ConversationMessage], +) -> String { + match last_builder_tool_blocker(history) { + Some(blocker) => format!( + "I wasn't able to finish building this workflow. Here's where I got stuck:\n\n{blocker}\n\n\ + Could you tell me how you'd like me to resolve that, or share more detail about what's needed here?" + ), + None => "I wasn't able to finish building this workflow in this turn. Could you describe \ + what you'd like in more detail, or tell me which part to focus on?" + .to_string(), + } +} + +/// Scans `history` in reverse for the last result from a +/// [`TRAIL_OFF_BLOCKER_TOOLS`] call that reads as a failure — a plain-text +/// error message (gate rejection), or a JSON body with `"ok": false` — and +/// returns a truncated, human-readable description of it. Tool names are +/// resolved by correlating each `ToolResults` entry's `tool_call_id` back to +/// the `AssistantToolCalls` message that issued it, so this never +/// misattributes an unrelated read-only tool's plain-text output as a +/// blocker. +fn last_builder_tool_blocker( + history: &[crate::openhuman::inference::provider::ConversationMessage], +) -> Option { + use crate::openhuman::inference::provider::ConversationMessage; + + let mut call_names: std::collections::HashMap = + std::collections::HashMap::new(); + for message in history { + if let ConversationMessage::AssistantToolCalls { tool_calls, .. } = message { + for call in tool_calls { + call_names.insert(call.id.clone(), call.name.clone()); + } + } + } + + for message in history.iter().rev() { + let ConversationMessage::ToolResults(results) = message else { + continue; + }; + for result in results.iter().rev() { + let Some(name) = call_names.get(&result.tool_call_id) else { + continue; + }; + if !TRAIL_OFF_BLOCKER_TOOLS.contains(&name.as_str()) { + continue; + } + // This is the MOST RECENT authoring-belt tool result in the + // turn (results are scanned newest-first). Whatever it reads as + // is authoritative: a success/progress result here means any + // earlier failure from the same tool was already resolved + // within this turn, so we must stop at this result rather than + // keep walking backward and surfacing a stale, already-fixed + // blocker (see review discussion on this PR). + return describe_tool_result_blocker(&result.content) + .map(|desc| crate::openhuman::util::truncate_with_ellipsis(&desc, 500)); + } + } + None +} + +/// Reads one builder tool result's content as a failure description, or +/// `None` when it reads as success/progress (a `workflow_proposal` payload, +/// or an `"ok": true` report). The whole body is the description, never one +/// hardcoded field, so this stays correct regardless of which fields a given +/// tool uses to explain its failure. +fn describe_tool_result_blocker(content: &str) -> Option { + let trimmed = content.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(value) = serde_json::from_str::(trimmed) { + if value.get("type").and_then(Value::as_str) == Some("workflow_proposal") { + return None; // Success: a proposal was emitted. + } + if let Some(ok) = value.get("ok").and_then(Value::as_bool) { + return if ok { None } else { Some(value.to_string()) }; + } + // Some other structured payload with no `ok`/`type` marker this + // function recognises — not confidently a blocker, skip it. + return None; + } + // Non-JSON content: a hard-gate rejection (`ToolResult::error`) puts the + // plain error message straight into the content — since every builder + // tool's SUCCESS shape is JSON (a proposal or a `{ ok, ... }` report), a + // bare string here is, by elimination, an error message. + Some(trimmed.to_string()) +} + /// Scans an agent run's conversation history for the workflow proposal a builder /// tool emitted. `propose_workflow` / `revise_workflow` / `save_workflow` all /// return a self-describing `{ "type": "workflow_proposal", … }` JSON string as diff --git a/src/openhuman/flows/ops_tests.rs b/src/openhuman/flows/ops_tests.rs index 4349f0523f..448b35fb12 100644 --- a/src/openhuman/flows/ops_tests.rs +++ b/src/openhuman/flows/ops_tests.rs @@ -228,6 +228,119 @@ async fn flows_run_completes_trigger_only_graph() { assert!(reloaded.value.last_run_at.is_some()); } +/// Live finding: a trigger-only graph (no downstream action nodes at all) +/// used to report `status="completed" pending_approvals=0` from `flows_run` +/// completely indistinguishably from a run that actually did something — +/// "triggered but nothing happened" read as a plain success. This asserts +/// the run still completes (running an empty flow isn't an error), but now +/// carries a human-readable `note` in the result so the UI can show +/// "nothing to run" instead of a bare "completed". +#[tokio::test] +async fn flows_run_on_trigger_only_graph_surfaces_no_actionable_nodes_note() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let created = flows_create(&config, "empty".to_string(), trigger_only_graph(), false) + .await + .unwrap(); + + let outcome = flows_run(&config, &created.value.id, json!({}), FlowRunTrigger::Rpc) + .await + .unwrap(); + + let note = outcome.value["note"] + .as_str() + .expect("trigger-only run must carry a human-readable 'note' field"); + assert!( + note.contains("no actionable nodes") || note.to_lowercase().contains("nothing"), + "note should explain that nothing ran, got: {note}" + ); + assert!( + outcome.logs.iter().any(|l| l.contains("no actionable")), + "the note should also surface via the RpcOutcome logs, got: {:?}", + outcome.logs + ); + + // Still a completed run, not an error — an empty flow isn't a failure, + // just a no-op that must not masquerade as having done real work. + let reloaded = flows_get(&config, &created.value.id).await.unwrap(); + assert_eq!(reloaded.value.last_status.as_deref(), Some("completed")); +} + +/// A graph with a real downstream node, wired up by an edge, must NOT carry +/// the "nothing to run" note — only a graph with no actionable nodes at all. +/// Uses `output_parser` nodes (like the approval-gated fixture above) rather +/// than an `agent`/`tool_call` node so the run completes deterministically +/// without needing a configured LLM provider or network access. +#[tokio::test] +async fn flows_run_on_graph_with_actionable_nodes_has_no_empty_flow_note() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let graph = json!({ + "name": "has-work", + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Trigger" }, + { "id": "downstream", "kind": "output_parser", "name": "Downstream" } + ], + "edges": [ + { "from_node": "t", "to_node": "downstream" } + ] + }); + let created = flows_create(&config, "has-work".to_string(), graph, false) + .await + .unwrap(); + + let outcome = flows_run(&config, &created.value.id, json!({}), FlowRunTrigger::Rpc) + .await + .unwrap(); + + assert!( + outcome.value.get("note").is_none(), + "a graph with real downstream nodes must not get the empty-flow note, got: {:?}", + outcome.value.get("note") + ); +} + +/// `graph_has_actionable_nodes` must walk from the trigger, not merely check +/// "any non-trigger node plus any edge". A component with edges of its own, +/// but no path back to the trigger, is unreachable and must still surface +/// the "nothing to run" note — a naive count-based check would have missed +/// this and wrongly suppressed the note. +#[tokio::test] +async fn flows_run_on_graph_with_disconnected_component_still_surfaces_empty_flow_note() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let graph = json!({ + "name": "disconnected", + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Trigger" }, + { "id": "a", "kind": "output_parser", "name": "Orphan A" }, + { "id": "b", "kind": "output_parser", "name": "Orphan B" } + ], + "edges": [ + // "a" -> "b" is wired up, but neither is reachable from "t" — the + // trigger has no outgoing edges at all. + { "from_node": "a", "to_node": "b" } + ] + }); + let created = flows_create(&config, "disconnected".to_string(), graph, false) + .await + .unwrap(); + + let outcome = flows_run(&config, &created.value.id, json!({}), FlowRunTrigger::Rpc) + .await + .unwrap(); + + let note = outcome.value["note"] + .as_str() + .expect("a component disconnected from the trigger must still surface the empty-flow note"); + assert!( + note.contains("no actionable nodes") || note.to_lowercase().contains("nothing"), + "note should explain that nothing ran, got: {note}" + ); +} + #[tokio::test] async fn flows_run_reports_pending_approval_and_blocks_downstream() { let tmp = TempDir::new().unwrap(); @@ -559,6 +672,191 @@ async fn flows_update_does_not_rebind_when_graph_is_not_supplied() { assert_eq!(job.expression, old_job.expression); } +// ── flows_update B29 Rule 1 analogue (save/enable safety on update) ─────── +// +// `flows_create` already refuses to persist an automatic-trigger graph as +// `enabled` (Rule 1, above). Live finding: `flows_update` had no equivalent +// — a flow created `enabled: true` with a manual trigger could later have an +// automatic-trigger graph (schedule / app_event / webhook) saved onto it via +// `flows_update` and go LIVE immediately with no user review. These tests +// cover the manual→automatic transition (must disarm), automatic→automatic +// re-edit (must NOT disarm — the user already opted in), and manual→manual +// (never touched). + +#[tokio::test] +async fn flows_update_disables_on_manual_to_automatic_trigger_transition_when_enabled() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + // A manual-trigger flow persists enabled straight from create (Rule 1 + // only gates automatic triggers). + let created = flows_create( + &config, + "manual-then-scheduled".to_string(), + manual_trigger_graph(), + false, + ) + .await + .unwrap(); + assert!(created.value.enabled, "manual-trigger flows create enabled"); + + // Saving an automatic-trigger graph onto that enabled flow must disarm + // it — not go live unattended. + let updated = flows_update( + &config, + &created.value.id, + None, + Some(schedule_trigger_graph("0 8 * * *")), + None, + None, + ) + .await + .unwrap(); + + assert!( + !updated.value.enabled, + "an enabled flow whose trigger just changed from manual to automatic must be \ + auto-disabled, not armed live" + ); + assert!( + updated.logs.iter().any(|l| l.contains("auto-disabled")), + "the disarm must be surfaced in the outcome logs, got: {:?}", + updated.logs + ); + + // Persisted, not just returned in-memory. + let reloaded = flows_get(&config, &created.value.id).await.unwrap(); + assert!(!reloaded.value.enabled); + + // And no cron job was left bound — the flow never actually went live. + assert!( + crate::openhuman::cron::find_flow_schedule_job(&config, &created.value.id) + .unwrap() + .is_none(), + "an auto-disabled flow must not have its schedule cron job bound" + ); +} + +/// Regression: the manual→automatic disarm must apply unconditionally, not +/// only when `flows_update`'s own `existing` read observes `enabled: true`. +/// A live race (Codex, this PR) could leave that read stale — a concurrent +/// `flows_set_enabled(id, true)` landing between the read and the guarded +/// write would previously compute `should_disarm = false` from the stale +/// snapshot and let the automatic graph persist enabled. This test pins the +/// non-racy half of that contract directly at the `flows_update` level: even +/// starting from an *observed* `enabled: false`, a manual→automatic +/// transition still writes the override (a no-op here since the flow was +/// already disabled) rather than skipping it — see +/// `store::update_flow_graph_override_wins_over_concurrently_enabled_row` +/// (store_tests.rs) for the deterministic proof that this override also wins +/// a genuine concurrent-enable race. +#[tokio::test] +async fn flows_update_disarms_manual_to_automatic_transition_even_when_already_disabled() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let created = flows_create( + &config, + "manual-then-scheduled".to_string(), + manual_trigger_graph(), + false, + ) + .await + .unwrap(); + flows_set_enabled(&config, &created.value.id, false) + .await + .unwrap(); + + let updated = flows_update( + &config, + &created.value.id, + None, + Some(schedule_trigger_graph("0 8 * * *")), + None, + None, + ) + .await + .unwrap(); + + assert!( + !updated.value.enabled, + "a manual→automatic transition must never leave the flow enabled, regardless of \ + whether it looked enabled going in" + ); + let reloaded = flows_get(&config, &created.value.id).await.unwrap(); + assert!(!reloaded.value.enabled); +} + +#[tokio::test] +async fn flows_update_preserves_enabled_when_already_automatic() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + // Rule 1 creates an automatic-trigger flow disabled; the user arms it + // explicitly — this IS the "already reviewed and opted in" state. + let created = flows_create( + &config, + "scheduled".to_string(), + schedule_trigger_graph("0 9 * * *"), + false, + ) + .await + .unwrap(); + assert!(!created.value.enabled); + flows_set_enabled(&config, &created.value.id, true) + .await + .unwrap(); + + // A legitimate re-edit (still an automatic trigger, just a new cron + // expression) must NOT be treated as a fresh unattended arm. + let updated = flows_update( + &config, + &created.value.id, + None, + Some(schedule_trigger_graph("30 8 * * *")), + None, + None, + ) + .await + .unwrap(); + + assert!( + updated.value.enabled, + "re-editing an already-enabled automatic-trigger flow must not disarm it — the \ + user already opted in once" + ); + assert!(!updated.logs.iter().any(|l| l.contains("auto-disabled"))); +} + +#[tokio::test] +async fn flows_update_preserves_enabled_for_manual_target() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + + let created = flows_create(&config, "manual".to_string(), manual_trigger_graph(), false) + .await + .unwrap(); + assert!(created.value.enabled); + + // manual → manual: no automatic trigger ever enters the picture, so + // `enabled` must be left completely untouched. + let mut new_graph = manual_trigger_graph(); + new_graph["name"] = json!("manual-renamed"); + let updated = flows_update( + &config, + &created.value.id, + None, + Some(new_graph), + None, + None, + ) + .await + .unwrap(); + + assert!(updated.value.enabled); + assert!(!updated.logs.iter().any(|l| l.contains("auto-disabled"))); +} + // ── flows_resume (issue B2) ─────────────────────────────────────────────── fn approval_gated_graph() -> Value { @@ -2008,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" } ] })); @@ -2059,6 +2357,162 @@ async fn validate_tool_contracts_passes_a_fully_wired_real_slug() { assert!(errors.is_empty(), "{errors:?}"); } +// ── validate_connection_refs (WS3) ────────────────────────────────────────── +// +// The transcript bug: the user's connections were twitter → +// `composio:twitter:ca_JX6QU88UfSk4`, gmail → `composio:gmail:ca_vX_WA8FsqNmE`, +// tiktok → `composio:tiktok:ca_LPCp3WQpaDma`. The agent wired +// `composio:twitter:ca_LPCp3WQpaDma` (the TIKTOK id) onto a Twitter node and +// every author-time gate returned ok. These tests exercise the pure matcher so +// no live Composio backend is touched. + +/// Build a composio `FlowConnection` fixture (the exact shape +/// `build_flow_connections` produces). +fn ws3_flow_conn(toolkit: &str, id: &str) -> FlowConnection { + FlowConnection { + connection_ref: format!("composio:{toolkit}:{id}"), + kind: "composio".to_string(), + display: toolkit.to_string(), + toolkit: Some(toolkit.to_string()), + scheme: None, + } +} + +/// The user's real connected set from the transcript. +fn ws3_transcript_connections() -> Vec { + vec![ + ws3_flow_conn("twitter", "ca_JX6QU88UfSk4"), + ws3_flow_conn("gmail", "ca_vX_WA8FsqNmE"), + ws3_flow_conn("tiktok", "ca_LPCp3WQpaDma"), + ] +} + +/// A single tool_call node graph with `slug` + optional `connection_ref`. +fn ws3_tool_call_graph(slug: &str, connection_ref: Option<&str>) -> WorkflowGraph { + let mut config = json!({ "slug": slug, "args": {} }); + if let Some(cr) = connection_ref { + config["connection_ref"] = json!(cr); + } + graph(json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "act", "kind": "tool_call", "name": "Act", "config": config } + ], + "edges": [ { "from_node": "t", "to_node": "act" } ] + })) +} + +#[test] +fn connection_refs_reject_the_transcript_wrong_id_naming_the_right_ref() { + // Twitter node carrying the TIKTOK connection id: toolkit segment matches + // (twitter == twitter) but the id belongs to no Twitter account. + let g = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:twitter:ca_LPCp3WQpaDma"), + ); + let conns = ws3_transcript_connections(); + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("act"), "{}", errors[0]); + assert!( + errors[0].contains("composio:twitter:ca_JX6QU88UfSk4"), + "must name the correct ref verbatim: {}", + errors[0] + ); + assert!(errors[0].contains("did you mean"), "{}", errors[0]); +} + +#[test] +fn connection_refs_reject_a_toolkit_mismatch_naming_the_right_ref() { + // A literal `composio:tiktok:...` ref stamped onto a Twitter node. + let g = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:tiktok:ca_LPCp3WQpaDma"), + ); + let conns = ws3_transcript_connections(); + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("tiktok"), "{}", errors[0]); + assert!( + errors[0].contains("composio:twitter:ca_JX6QU88UfSk4"), + "{}", + errors[0] + ); +} + +#[test] +fn connection_refs_reject_an_unknown_id_when_the_toolkit_has_no_connection() { + // Gmail slug, but no gmail account connected at all → point at composio_connect. + let g = ws3_tool_call_graph("GMAIL_SEND_EMAIL", Some("composio:gmail:ca_missing")); + let conns = vec![ws3_flow_conn("twitter", "ca_JX6QU88UfSk4")]; + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("composio_connect"), "{}", errors[0]); + assert!(!errors[0].contains("did you mean"), "{}", errors[0]); +} + +#[test] +fn connection_refs_pass_the_correct_ref() { + let g = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:twitter:ca_JX6QU88UfSk4"), + ); + let conns = ws3_transcript_connections(); + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert!(errors.is_empty(), "{errors:?}"); +} + +#[test] +fn connection_refs_reject_a_malformed_ref() { + let g = ws3_tool_call_graph("GMAIL_SEND_EMAIL", Some("gmail-ca_vX_WA8FsqNmE")); + let conns = ws3_transcript_connections(); + let errors = validate_connection_refs_against(&g, Some(&conns)); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("malformed"), "{}", errors[0]); +} + +#[test] +fn connection_refs_skip_oh_and_refless_and_expression_nodes() { + // Native oh: tool with a ref → skipped. + let g_oh = ws3_tool_call_graph("oh:memory_search", Some("composio:twitter:whatever")); + assert!( + validate_connection_refs_against(&g_oh, Some(&ws3_transcript_connections())).is_empty() + ); + // Composio tool_call with NO connection_ref stays allowed (prompts at run). + let g_refless = ws3_tool_call_graph("TWITTER_CREATION_OF_A_POST", None); + assert!( + validate_connection_refs_against(&g_refless, Some(&ws3_transcript_connections())) + .is_empty() + ); + // `=`-derived slug → skipped. + let g_expr = ws3_tool_call_graph("=item.slug", Some("composio:twitter:ca_LPCp3WQpaDma")); + assert!( + validate_connection_refs_against(&g_expr, Some(&ws3_transcript_connections())).is_empty() + ); +} + +#[test] +fn connection_refs_fail_open_on_unavailable_connections_but_keep_mismatch() { + // Connections unavailable (None): the id-existence check is SKIPPED — a + // toolkit-matched ref with an unknown id passes rather than false-reject. + let g_ok = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:twitter:ca_anything"), + ); + assert!( + validate_connection_refs_against(&g_ok, None).is_empty(), + "unknown id must be skipped when connections are unavailable" + ); + // ...but the toolkit-mismatch check needs no I/O and still fires. + let g_mismatch = ws3_tool_call_graph( + "TWITTER_CREATION_OF_A_POST", + Some("composio:tiktok:ca_LPCp3WQpaDma"), + ); + let errors = validate_connection_refs_against(&g_mismatch, None); + assert_eq!(errors.len(), 1, "{errors:?}"); + assert!(errors[0].contains("tiktok"), "{}", errors[0]); +} + // ── validate_required_arg_resolvability (issue B18) ───────────────────────── // // `validate_tool_contracts`'s `missing_required_args` only proves an arg is @@ -2190,6 +2644,95 @@ async fn validate_required_arg_resolvability_rejects_an_explicit_nodes_reference assert!(errors[0].contains("nodes.build_body"), "{}", errors[0]); } +/// A required tool arg wired to a PLAIN agent node's (`no agent_ref`) +/// `output_parser.schema` field must pass this sandbox gate: the schema-aware +/// mock LLM (wired above via `caps.llm = SchemaAwareMockLlm`) synthesizes a +/// schema-valid completion, so the agent's output-parser sub-port succeeds and +/// the downstream `=nodes..item.json.` binding resolves to a typed +/// placeholder (non-null) instead of the run aborting on a schema-validation +/// failure. Without the mock LLM this gate would sink `propose_workflow`/`save` +/// on a correctly-built graph (the vendored `MockLlm` echo fails the sub-port). +#[tokio::test] +async fn validate_required_arg_resolvability_accepts_a_schema_agent_field_binding() { + let g = graph(json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "summarize", "kind": "agent", "name": "Summarize", + "config": { "prompt": "summarize the thread", + "output_parser": { "schema": { "type": "object", + "required": ["channel"], + "properties": { "channel": { "type": "string" } } } } } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "SLACK_SEND_MESSAGE", + "args": { "channel": "=nodes.summarize.item.json.channel" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "summarize" }, + { "from_node": "summarize", "to_node": "post" } + ] + })); + let errors = validate_required_arg_resolvability(&g).await; + assert!(errors.is_empty(), "{errors:?}"); +} + +/// WS6: a required arg wired to the OUTPUT of an upstream Composio `tool_call` +/// must NOT be hard-rejected by this gate. The echo sandbox renders a Composio +/// `tool_call` as `{tool, args, connection}` and can never produce its real +/// output fields, so `=nodes..item.json.data.` resolves `null` +/// here even when the wiring is perfectly correct — rejecting it would block a +/// possibly-correct graph from ever being proposed (the transcript false +/// negative). Contrast `..._rejects_an_explicit_nodes_reference` above, where +/// the same explicit-`nodes` form addresses a `code` node (whose real output +/// the sandbox DOES produce) and stays a hard reject. +#[tokio::test] +async fn validate_required_arg_resolvability_downgrades_a_composio_tool_call_upstream_binding() { + let g = graph(json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "get_me", "kind": "tool_call", "name": "Who am I", + "config": { "slug": "TWITTER_USER_LOOKUP_ME", "args": {} } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "GMAIL_SEND_EMAIL", + "args": { "recipient_email": "a@b.com", "subject": "hi", + "body": "=nodes.get_me.item.json.data.username" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "get_me" }, + { "from_node": "get_me", "to_node": "post" } + ] + })); + let errors = validate_required_arg_resolvability(&g).await; + assert!( + errors.is_empty(), + "a binding to a Composio tool_call's output is UNVERIFIABLE, not a hard reject: {errors:?}" + ); +} + +/// WS6 companion: the implicit `=item...` form of the same case — `post`'s only +/// predecessor is a Composio `tool_call`, so `=item.json.data.username` +/// addresses that node's (echo-only) output and is likewise unverifiable, not a +/// reject. +#[tokio::test] +async fn validate_required_arg_resolvability_downgrades_an_item_scoped_composio_upstream_binding() { + let g = graph(json!({ + "nodes": [ + { "id": "t", "kind": "trigger", "name": "Manual" }, + { "id": "get_me", "kind": "tool_call", "name": "Who am I", + "config": { "slug": "TWITTER_USER_LOOKUP_ME", "args": {} } }, + { "id": "post", "kind": "tool_call", "name": "Post", + "config": { "slug": "GMAIL_SEND_EMAIL", + "args": { "recipient_email": "a@b.com", "subject": "hi", + "body": "=item.json.data.username" } } } + ], + "edges": [ + { "from_node": "t", "to_node": "get_me" }, + { "from_node": "get_me", "to_node": "post" } + ] + })); + let errors = validate_required_arg_resolvability(&g).await; + assert!(errors.is_empty(), "{errors:?}"); +} + /// (Codex feedback on this PR) `notion` ships a static curated catalog /// (`catalog_for_toolkit`), so at RUNTIME `flow_tool_allowed`'s Path A /// hard-rejects any slug `find_curated` doesn't recognize — even a real, @@ -3209,21 +3752,25 @@ fn finalize_terminal_status_no_error_when_clean() { assert_eq!(error, None); } -/// Regression for issue #4593: the `flows_build` builder turn runs under -/// `AgentTurnOrigin::Cli`, which makes the `ApprovalGate` auto-allow every -/// `external_effect` tool. The flows live-runner executes a *live* saved flow, -/// so it must be unreachable on this path — `restrict_builder_toolset` drops it -/// from the builder's callable belt while leaving the authoring tools in place -/// so the turn still functions (never fail-closes). +/// Regression for issue #4593 (widened for #4881's `resume_flow_run`/ +/// `cancel_flow_run` addition to the belt): the `flows_build` builder turn +/// runs under `AgentTurnOrigin::Cli`, which makes the `ApprovalGate` +/// auto-allow every `external_effect` tool. The flows live-runner (`run_flow`) +/// and the run-resume tool (`resume_flow_run`) both execute/advance a *live* +/// saved flow's real outbound effects, so both must be unreachable on this +/// path — `restrict_builder_toolset` drops them (plus `cancel_flow_run`, out +/// of caution) from the builder's callable belt while leaving the authoring +/// tools in place so the turn still functions (never fail-closes). #[tokio::test] async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); - // Document WHY the live-runner must be hidden: running a saved flow fires - // real Slack/Gmail/HTTP/code effects, so it is an external-effect tool. This - // pins that invariant independently of belt name-resolution so the - // hide-list can't silently stop covering a live-run tool. + // Document WHY each run-advancing tool must be hidden: running or + // resuming a saved flow fires real Slack/Gmail/HTTP/code effects, so both + // are external-effect tools. This pins that invariant independently of + // belt name-resolution so the hide-list can't silently stop covering a + // live-run/resume tool. use crate::openhuman::tools::Tool as _; let live_runner = crate::openhuman::flows::tools::RunFlowTool::new(std::sync::Arc::new(config.clone())); @@ -3231,6 +3778,14 @@ async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { live_runner.external_effect(), "the flows live-runner must be external-effect for the #4593 concern to apply" ); + let resumer = crate::openhuman::flows::builder_tools::ResumeFlowRunTool::new( + std::sync::Arc::new(config.clone()), + ); + assert!( + resumer.external_effect(), + "resume_flow_run advances a real run's outbound effects, so it must be \ + external-effect for the same #4593/#4881 concern to apply" + ); crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global(&config.workspace_dir) .expect("agent registry init"); @@ -3239,27 +3794,36 @@ async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { .expect("build workflow_builder agent"); agent.set_agent_definition_name("workflow_builder".to_string()); - // Precondition: the builder advertises the live-run tool (`run_flow`) on its - // belt before restriction — the exact tool #4593 is about. - assert!( - agent.visible_tool_names_for_test().contains("run_flow"), - "precondition: workflow_builder belt should advertise the live-run tool `run_flow`; \ - visible = {:?}", - agent.visible_tool_names_for_test() - ); + // Precondition: the builder advertises all four run-advancing tools on its + // belt before restriction — the exact set #4593/#4881 are about. + let visible_before = agent.visible_tool_names_for_test(); + for present in ["run_flow", "resume_flow_run", "cancel_flow_run"] { + assert!( + visible_before.contains(present), + "precondition: workflow_builder belt should advertise `{present}`; visible = \ + {visible_before:?}" + ); + } restrict_builder_toolset(&mut agent); - // After restriction neither the current name nor the post-rename name is - // callable on the flows_build path — the hide-list covers both (#4593). + // After restriction none of the run-advancing tools are callable on the + // flows_build path — the hide-list covers all of them (#4593 + #4881). let visible = agent.visible_tool_names_for_test(); - for hidden in ["run_workflow", "run_flow"] { + for hidden in [ + "run_workflow", + "run_flow", + "resume_flow_run", + "cancel_flow_run", + ] { assert!( !visible.contains(hidden), - "live-run tool `{hidden}` must be hidden on the flows_build path; visible = {visible:?}" + "run-advancing tool `{hidden}` must be hidden on the flows_build path; visible = \ + {visible:?}" ); } - // Authoring / read tools stay reachable so the builder turn still works + // Authoring / read tools — including the born-disabled `create_workflow` + // and `duplicate_flow` — stay reachable so the builder turn still works // headlessly under the CLI origin (no fail-close). for keep in [ "propose_workflow", @@ -3267,6 +3831,8 @@ async fn flows_build_hides_the_live_run_tool_from_the_builder_belt() { "save_workflow", "dry_run_workflow", "list_flows", + "create_workflow", + "duplicate_flow", ] { assert!( visible.contains(keep), @@ -3995,3 +4561,284 @@ async fn compute_required_connections_skips_native_and_http_nodes() { "native oh: and http_request need no connection: {required:?}" ); } + +// ── extract_workflow_proposal: survives large, tabulation-eligible graphs ───── +// +// Regression coverage for the "blank canvas on ≥4-node graphs" bug: tinyjuice's +// JSON compressor tabulates any uniform object-array of >= 3 rows over ~512 +// bytes, which strips the `"type": "workflow_proposal"` marker this extractor +// keys on. The fix lives in `tinyagents::middleware::ToolOutputMiddleware` +// (COMPACTION_EXEMPT_TOOLS), which keeps proposal-tool results out of +// tokenjuice entirely — so by the time a payload reaches `agent.history()` +// here, it must still be the untabulated, structurally-intact JSON. + +#[test] +fn extract_workflow_proposal_survives_large_graph() { + use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage}; + + // 6 nodes, several columns each — comfortably over tinyjuice's MIN_ROWS (3) + // and ~512-byte tabulation thresholds, so an unprotected payload would get + // compacted into a `[json table: …]` marker and lose the `"type"` field. + let nodes: Vec = (0..6) + .map(|i| { + json!({ + "id": format!("node-{i}"), + "kind": if i == 0 { "trigger" } else { "tool_call" }, + "name": format!("Step {i}"), + "config": { + "slug": format!("oh:placeholder_action_{i}"), + "args": { "input": format!("value-{i}"), "note": "generic placeholder payload for size padding" } + } + }) + }) + .collect(); + let edges: Vec = (0..5) + .map(|i| json!({ "from_node": format!("node-{i}"), "to_node": format!("node-{}", i + 1) })) + .collect(); + let proposal_payload = json!({ + "type": "workflow_proposal", + "flow_id": "flow-large-graph", + "graph": { "nodes": nodes, "edges": edges }, + }); + let payload_str = serde_json::to_string(&proposal_payload).unwrap(); + assert!( + payload_str.len() > 512, + "test payload must exceed tinyjuice's tabulation byte threshold: {} bytes", + payload_str.len() + ); + + let history = vec![ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "call-1".to_string(), + content: payload_str, + }])]; + + let proposal = extract_workflow_proposal(&history).expect("proposal should be extractable"); + assert_eq!( + proposal.get("type").and_then(serde_json::Value::as_str), + Some("workflow_proposal") + ); + assert_eq!( + proposal["graph"]["nodes"].as_array().unwrap().len(), + 6, + "all 6 nodes must survive intact: {proposal}" + ); +} + +#[test] +fn extract_workflow_proposal_returns_the_latest_of_multiple_results() { + use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage}; + + let first = json!({ "type": "workflow_proposal", "flow_id": "first" }); + let second = json!({ "type": "workflow_proposal", "flow_id": "second" }); + let history = vec![ + ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "call-1".to_string(), + content: first.to_string(), + }]), + ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "call-2".to_string(), + content: second.to_string(), + }]), + ]; + + let proposal = extract_workflow_proposal(&history).expect("proposal should be extractable"); + assert_eq!(proposal["flow_id"], "second"); +} + +#[test] +fn extract_workflow_proposal_ignores_non_proposal_tool_results() { + use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage}; + + let history = vec![ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: "call-1".to_string(), + content: json!({ "type": "search_results", "items": [] }).to_string(), + }])]; + + assert!(extract_workflow_proposal(&history).is_none()); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Builder convergence fix — trail-off backstop (`flows_build`'s terminal-state +// guarantee: every turn ends in a proposal or a real question, never silence). +// ───────────────────────────────────────────────────────────────────────────── + +fn builder_tool_call( + id: &str, + name: &str, +) -> crate::openhuman::inference::provider::ConversationMessage { + use crate::openhuman::inference::provider::{ConversationMessage, ToolCall}; + ConversationMessage::AssistantToolCalls { + text: None, + tool_calls: vec![ToolCall { + id: id.to_string(), + name: name.to_string(), + arguments: "{}".to_string(), + extra_content: None, + }], + reasoning_content: None, + extra_metadata: None, + } +} + +fn builder_tool_result( + call_id: &str, + content: &str, +) -> crate::openhuman::inference::provider::ConversationMessage { + use crate::openhuman::inference::provider::{ConversationMessage, ToolResultMessage}; + ConversationMessage::ToolResults(vec![ToolResultMessage { + tool_call_id: call_id.to_string(), + content: content.to_string(), + }]) +} + +#[test] +fn text_looks_like_question_detects_trailing_question_mark() { + assert!(text_looks_like_question( + "Which Slack channel should I post to?" + )); + assert!(text_looks_like_question("Which channel?\n")); + // Trailing markdown/punctuation noise after the '?' shouldn't defeat it. + assert!(text_looks_like_question("Which channel should I use?\"")); + // A trailing blank line after the question is still detected (the last + // NON-BLANK line is what's checked). + assert!(text_looks_like_question( + "Which channel should I post to?\n\n" + )); +} + +/// A question followed by a further trailing sentence on its own line +/// ("...channel?\n\nLet me know!") is an accepted false negative — the +/// heuristic is deliberately conservative (see the function doc). Pin that +/// this case is NOT detected so a future "improvement" doesn't silently +/// change the accepted trade-off without a matching design review. +#[test] +fn text_looks_like_question_accepts_false_negative_on_trailing_pleasantry() { + assert!(!text_looks_like_question( + "Which channel should I post to?\n\nLet me know!" + )); +} + +#[test] +fn text_looks_like_question_rejects_status_dumps_and_silence() { + assert!(!text_looks_like_question( + "## Done so far\n- Checked connections\n- Verified contracts" + )); + assert!(!text_looks_like_question("")); + assert!(!text_looks_like_question(" ")); + assert!(!text_looks_like_question("I'll continue working on this.")); +} + +/// The terminal-state guarantee's core invariant: whatever `build_trail_off_fallback` +/// returns, it must ALWAYS read as a question — the user is never left with +/// silence, regardless of what (if anything) the tool history contains. +#[test] +fn build_trail_off_fallback_always_yields_a_question() { + let fallback = build_trail_off_fallback(&[]); + assert!( + text_looks_like_question(&fallback), + "fallback with no tool history must still be a question: {fallback}" + ); + assert!(!fallback.trim().is_empty()); +} + +#[test] +fn build_trail_off_fallback_surfaces_last_dry_run_blocker() { + let history = vec![ + builder_tool_call("call_1", "dry_run_workflow"), + builder_tool_result( + "call_1", + r#"{"ok": false, "null_resolutions": [{"node_id": "send", "path": "args.channel"}]}"#, + ), + ]; + let fallback = build_trail_off_fallback(&history); + assert!( + text_looks_like_question(&fallback), + "blocker fallback must still end in a question: {fallback}" + ); + assert!( + fallback.contains("null_resolutions"), + "fallback should surface the actual dry-run blocker, got: {fallback}" + ); +} + +#[test] +fn build_trail_off_fallback_surfaces_gate_rejection_error_text() { + let history = vec![ + builder_tool_call("call_1", "propose_workflow"), + builder_tool_result( + "call_1", + "propose_workflow rejected: tool slug 'slack:not_a_real_action' does not exist", + ), + ]; + let fallback = build_trail_off_fallback(&history); + assert!(text_looks_like_question(&fallback)); + assert!(fallback.contains("does not exist")); +} + +#[test] +fn build_trail_off_fallback_ignores_unrelated_read_tool_output() { + // A plain-text result from a tool OUTSIDE the builder authoring belt (e.g. + // a read-only history lookup) must never be misattributed as the blocker + // — this stays tool-agnostic within the authoring belt, not "any tool". + let history = vec![ + builder_tool_call("call_1", "get_flow_history"), + builder_tool_result("call_1", "no prior revisions found"), + ]; + let fallback = build_trail_off_fallback(&history); + assert!(text_looks_like_question(&fallback)); + assert!( + !fallback.contains("no prior revisions found"), + "must not surface an unrelated read-tool's output as the blocker: {fallback}" + ); +} + +#[test] +fn build_trail_off_fallback_ignores_a_successful_proposal_payload() { + let history = vec![ + builder_tool_call("call_1", "propose_workflow"), + builder_tool_result( + "call_1", + r#"{"type": "workflow_proposal", "name": "demo", "graph": {}}"#, + ), + ]; + let fallback = build_trail_off_fallback(&history); + assert!(text_looks_like_question(&fallback)); + assert!(!fallback.contains("workflow_proposal")); +} + +#[test] +fn build_trail_off_fallback_picks_the_most_recent_blocker() { + // Two dry-run failures in the history: the fallback should describe the + // LAST one (the one the agent was still stuck on), not the first. + let history = vec![ + builder_tool_call("call_1", "dry_run_workflow"), + builder_tool_result("call_1", r#"{"ok": false, "errors": ["first issue"]}"#), + builder_tool_call("call_2", "dry_run_workflow"), + builder_tool_result("call_2", r#"{"ok": false, "errors": ["second issue"]}"#), + ]; + let fallback = build_trail_off_fallback(&history); + assert!(fallback.contains("second issue")); + assert!(!fallback.contains("first issue")); +} + +/// Regression for review feedback (chatgpt-codex-connector, PR #4887): a +/// dry-run failure that the agent goes on to FIX later in the same turn +/// (a later `{"ok": true}` from the same authoring belt) must not be +/// resurfaced as "here's where I got stuck" — that failure is already +/// resolved. The scan must stop at the most recent authoring-belt result, +/// not keep walking backward past a success to an older, stale blocker. +#[test] +fn build_trail_off_fallback_does_not_resurface_a_resolved_blocker() { + let history = vec![ + builder_tool_call("call_1", "dry_run_workflow"), + builder_tool_result("call_1", r#"{"ok": false, "errors": ["first issue"]}"#), + builder_tool_call("call_2", "dry_run_workflow"), + builder_tool_result("call_2", r#"{"ok": true, "warnings": []}"#), + ]; + let fallback = build_trail_off_fallback(&history); + assert!( + !fallback.contains("first issue"), + "must not surface an already-resolved blocker: {fallback}" + ); + assert!(text_looks_like_question(&fallback)); +} diff --git a/src/openhuman/flows/schemas.rs b/src/openhuman/flows/schemas.rs index dac1f64167..feee1c1a7c 100644 --- a/src/openhuman/flows/schemas.rs +++ b/src/openhuman/flows/schemas.rs @@ -1694,6 +1694,7 @@ mod tests { "resume", "cancel_run", "list_runs", + "list_all_runs", "get_run", "prune_runs", "build", @@ -1719,7 +1720,7 @@ mod tests { #[test] fn all_registered_controllers_has_handler_per_schema() { let controllers = all_registered_controllers(); - assert_eq!(controllers.len(), 32); + assert_eq!(controllers.len(), 33); let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect(); assert_eq!( names, @@ -1738,6 +1739,7 @@ mod tests { "resume", "cancel_run", "list_runs", + "list_all_runs", "get_run", "prune_runs", "build", diff --git a/src/openhuman/flows/store.rs b/src/openhuman/flows/store.rs index 9d9b95f990..3eb7e0c1bf 100644 --- a/src/openhuman/flows/store.rs +++ b/src/openhuman/flows/store.rs @@ -374,12 +374,22 @@ impl std::fmt::Display for FlowUpdateError { /// flow's `updated_at` no longer matches — so an agent save and a concurrent /// canvas save can't silently clobber each other. `None` keeps the prior /// last-write-wins behaviour for callers that don't track a version. +/// +/// `enabled_override`, when `Some`, forces the persisted `enabled` flag to +/// that value in the *same* guarded `UPDATE` as the graph/name/ +/// `require_approval` write — used by `ops::flows_update`'s B29 Rule 1 +/// analogue (auto-disarming a flow whose trigger just changed from manual to +/// automatic) so the disarm can never race a concurrent read/write of +/// `enabled` (a separate `set_enabled` call after this one would leave a +/// TOCTOU window). `None` leaves `enabled` untouched, matching the previous +/// behaviour for every other caller. pub fn update_flow_graph( config: &Config, id: &str, name: String, graph: tinyflows::model::WorkflowGraph, require_approval: bool, + enabled_override: Option, expected_updated_at: Option<&str>, ) -> std::result::Result { let current = get_flow(config, id) @@ -400,21 +410,25 @@ pub fn update_flow_graph( let prior_graph_json = serde_json::to_string(¤t.graph).unwrap_or_else(|_| "null".to_string()); let now = Utc::now().to_rfc3339(); + let new_enabled = enabled_override.unwrap_or(current.enabled); with_connection(config, |conn| { // Guarded UPDATE keyed on the observed updated_at (race-safe even // without an explicit expected version) — a concurrent writer that // moved updated_at makes this match 0 rows. Targeted columns only, so a - // concurrent set_enabled/record_run isn't clobbered. + // concurrent set_enabled/record_run isn't clobbered (unless this call + // itself carries an `enabled_override`, in which case `enabled` is + // one of the targeted columns by design). let changed = conn .execute( "UPDATE flow_definitions SET name = ?1, graph_json = ?2, updated_at = ?3, \ - require_approval = ?4 WHERE id = ?5 AND updated_at = ?6", + require_approval = ?4, enabled = ?5 WHERE id = ?6 AND updated_at = ?7", params![ name, graph_json, now, if require_approval { 1 } else { 0 }, + if new_enabled { 1 } else { 0 }, id, current.updated_at, ], diff --git a/src/openhuman/flows/store_tests.rs b/src/openhuman/flows/store_tests.rs index 8192cb0e73..cd679fae42 100644 --- a/src/openhuman/flows/store_tests.rs +++ b/src/openhuman/flows/store_tests.rs @@ -98,6 +98,7 @@ fn update_flow_graph_bumps_updated_at_and_preserves_created_at() { new_graph, false, None, + None, ) .unwrap(); @@ -106,6 +107,110 @@ fn update_flow_graph_bumps_updated_at_and_preserves_created_at() { assert_eq!(updated.graph.name, "renamed-graph"); } +/// `enabled_override: None` must leave the persisted `enabled` column +/// exactly as it was — `update_flow_graph` re-reads the current row and +/// falls back to `current.enabled`, not to whatever the caller might have +/// observed earlier. +#[test] +fn update_flow_graph_with_none_override_preserves_current_enabled_column() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false, true).unwrap(); + assert!(flow.enabled, "flow created enabled"); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + trigger_graph(), + false, + None, // enabled_override + None, + ) + .unwrap(); + + assert!( + updated.enabled, + "a None override must preserve the row's current enabled state" + ); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(reloaded.enabled); +} + +/// `enabled_override: Some(false)` must force-persist `enabled=false` +/// regardless of what the row's `enabled` column currently holds — this is +/// the mechanism `flows_update`'s B29 Rule 1 analogue relies on to disarm a +/// manual→automatic trigger transition in the same guarded write. +#[test] +fn update_flow_graph_with_some_false_override_forces_disabled() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false, true).unwrap(); + assert!(flow.enabled, "flow created enabled"); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + trigger_graph(), + false, + Some(false), // enabled_override + None, + ) + .unwrap(); + + assert!( + !updated.enabled, + "a Some(false) override must force enabled=false even though the row was enabled" + ); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(!reloaded.enabled); +} + +/// Regression for the silent live-arming race Codex flagged on this PR: +/// `flows_update` (ops.rs) makes its manual→automatic disarm decision from +/// an *outer* `existing` read taken before `update_flow_graph`'s own guarded +/// UPDATE re-reads the row. If a concurrent `flows_set_enabled(id, true)` +/// landed in that gap — which bumps `updated_at`, so it would NOT trip the +/// optimistic-concurrency conflict — the outer read would be stale while the +/// row is actually enabled by write time. This proves the mechanism the fix +/// relies on to close that race: an `enabled_override` of `Some(false)` +/// (what `flows_update` now passes unconditionally on a manual→automatic +/// transition, never gated on the stale outer read) always wins over +/// whatever the row's `enabled` column was concurrently flipped to, +/// simulated here by flipping it with `set_enabled` between the two calls. +#[test] +fn update_flow_graph_override_wins_over_concurrently_enabled_row() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let flow = create_flow(&config, "demo".to_string(), trigger_graph(), false, false).unwrap(); + assert!(!flow.enabled, "flow created disabled"); + + // Simulates a concurrent `flows_set_enabled(id, true)` racing in after + // `flows_update`'s outer `existing` read observed `enabled: false`, but + // before its guarded `update_flow_graph` write below. + let raced = set_enabled(&config, &flow.id, true).unwrap(); + assert!(raced.enabled); + + let updated = update_flow_graph( + &config, + &flow.id, + flow.name.clone(), + trigger_graph(), + false, + Some(false), // the unconditional disarm override + None, + ) + .unwrap(); + + assert!( + !updated.enabled, + "the disarm override must win over a concurrently-enabled row, not the reverse" + ); + let reloaded = get_flow(&config, &flow.id).unwrap().unwrap(); + assert!(!reloaded.enabled); +} + #[test] fn record_run_sets_last_run_fields() { let tmp = TempDir::new().unwrap(); @@ -204,6 +309,7 @@ fn update_flow_graph_can_change_require_approval() { trigger_graph(), true, None, + None, ) .unwrap(); assert!(updated.require_approval); diff --git a/src/openhuman/flows/tools.rs b/src/openhuman/flows/tools.rs index 8a36cf5e99..20eb1278d8 100644 --- a/src/openhuman/flows/tools.rs +++ b/src/openhuman/flows/tools.rs @@ -261,6 +261,10 @@ impl Tool for ProposeWorkflowTool { Ok(ToolResult::success(serde_json::to_string_pretty(&json!({ "type": "workflow_proposal", + // A proposal is never a persisted flow — stamp it so the payload + // can't be misread as a save confirmation (WS2 audit). Matches + // `ops::build_builder_proposal`'s unconditional persisted:false. + "persisted": false, "name": name, "graph": graph_value, "require_approval": require_approval, diff --git a/src/openhuman/flows/tools_tests.rs b/src/openhuman/flows/tools_tests.rs index 3ce228532e..59912f2fcf 100644 --- a/src/openhuman/flows/tools_tests.rs +++ b/src/openhuman/flows/tools_tests.rs @@ -58,6 +58,9 @@ async fn valid_graph_returns_workflow_proposal_success() { assert_eq!(parsed["type"], "workflow_proposal"); assert_eq!(parsed["name"], "Daily standup summary"); assert_eq!(parsed["graph"]["nodes"].as_array().unwrap().len(), 3); + // A proposal is never a persisted flow — the payload must say so (WS2) so + // an agent can't misread it as a save confirmation. + assert_eq!(parsed["persisted"], false); } #[tokio::test] diff --git a/src/openhuman/inference/local/install.rs b/src/openhuman/inference/local/install.rs index d6d8c6de6f..ae5ca6b8f3 100644 --- a/src/openhuman/inference/local/install.rs +++ b/src/openhuman/inference/local/install.rs @@ -339,7 +339,6 @@ mod tests { /// (OLLAMA_BIN, PATH) with other local-AI tests that also read these /// variables. Without this, cargo's test runner can interleave set/remove /// calls and cause flakes. - fn env_lock() -> std::sync::MutexGuard<'static, ()> { crate::openhuman::inference::inference_test_guard() } diff --git a/src/openhuman/inference/local/mod.rs b/src/openhuman/inference/local/mod.rs index a643700692..472eba8cdd 100644 --- a/src/openhuman/inference/local/mod.rs +++ b/src/openhuman/inference/local/mod.rs @@ -52,5 +52,6 @@ pub use schemas::{ all_controller_schemas as all_local_inference_controller_schemas, all_registered_controllers as all_local_inference_registered_controllers, }; +#[cfg(feature = "voice")] pub(crate) use service::whisper_engine; pub use service::LocalAiService; diff --git a/src/openhuman/inference/local/ollama.rs b/src/openhuman/inference/local/ollama.rs index e2d8462ab0..486c48e823 100644 --- a/src/openhuman/inference/local/ollama.rs +++ b/src/openhuman/inference/local/ollama.rs @@ -386,6 +386,7 @@ impl OllamaShowResponse { /// (unknown): /// * empty / absent capabilities (older Ollama, or an `/api/show` miss); /// * a tag set we don't recognise (e.g. `["insert"]` only). +/// /// Callers treat `None` as "keep visible" — fail-open, never hide a model /// that might be usable for chat. Mirrors the non-rejecting `Unknown` arm of /// [`super::model_requirements::ContextEligibility`]. See Sentry TAURI-RUST-4P6. diff --git a/src/openhuman/inference/provider/claude_code/driver.rs b/src/openhuman/inference/provider/claude_code/driver.rs index eca47e2fec..f2eccb414f 100644 --- a/src/openhuman/inference/provider/claude_code/driver.rs +++ b/src/openhuman/inference/provider/claude_code/driver.rs @@ -40,6 +40,7 @@ const DISALLOWED_CC_BUILTINS: &[&str] = &[ /// Whether the user opted into FULL access for Claude Code (`bypassPermissions` /// + full native toolset incl. Bash/network). Default is **off** → the safer +/// /// `acceptEdits` posture (file edits only). This is a deliberate user choice, /// not the default — enabling Claude Code alone does not grant shell/network /// power. diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 58c83858f1..b074a831c0 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -1605,10 +1605,10 @@ pub(crate) fn create_turn_chat_model_from_string_with_native_tools( /// `make_omlx_provider` / `make_local_openai_provider` — they share the endpoint /// helpers but each builds its own client, so an endpoint/auth change must touch /// both until the `Provider` path is deleted. -fn try_create_local_runtime_chat_model( - role: &str, - config: &Config, -) -> Option>, String)>> { +type ResolvedChatModel = (Arc>, String); +type OptionalChatModelResult = Option>; + +fn try_create_local_runtime_chat_model(role: &str, config: &Config) -> OptionalChatModelResult { let resolved = provider_for_role(role, config); try_create_local_runtime_chat_model_from_string(role, &resolved, config, true) } @@ -1618,7 +1618,7 @@ fn try_create_local_runtime_chat_model_from_string( provider: &str, config: &Config, require_session: bool, -) -> Option>, String)>> { +) -> OptionalChatModelResult { use crate::openhuman::inference::local::profile::{ LOCAL_OPENAI_PROFILE, MLX_PROFILE, OMLX_PROFILE, }; @@ -2514,10 +2514,7 @@ fn make_cloud_provider_by_slug( /// `verify_session_active`) runs before building. Temperature rides the per-call /// `ModelRequest` (managed/local parity; the `@` suffix still bakes a fixed /// override). -fn try_create_cloud_slug_chat_model( - role: &str, - config: &Config, -) -> Option>, String)>> { +fn try_create_cloud_slug_chat_model(role: &str, config: &Config) -> OptionalChatModelResult { try_create_cloud_slug_chat_model_with_native_tools(role, config, true) } @@ -2525,7 +2522,7 @@ fn try_create_cloud_slug_chat_model_with_native_tools( role: &str, config: &Config, native_tool_calling: bool, -) -> Option>, String)>> { +) -> OptionalChatModelResult { // Resolve the role's provider string, expanding the empty / "cloud" sentinel // to the primary cloud target (mirroring create_chat_provider_from_string). let mut resolved = provider_for_role(role, config); @@ -2544,7 +2541,7 @@ fn try_create_cloud_slug_chat_model_from_string( role: &str, provider: &str, config: &Config, -) -> Option>, String)>> { +) -> OptionalChatModelResult { try_create_cloud_slug_chat_model_from_string_with_native_tools(role, provider, config, true) } @@ -2553,7 +2550,7 @@ fn try_create_cloud_slug_chat_model_from_string_with_native_tools( provider: &str, config: &Config, native_tool_calling: bool, -) -> Option>, String)>> { +) -> OptionalChatModelResult { let p = provider.trim().to_string(); // Only the ":[@temp]" cloud form routes here. The managed diff --git a/src/openhuman/inference/provider/factory_tests.rs b/src/openhuman/inference/provider/factory_tests.rs index 7be21342d8..d98cd1919c 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -927,7 +927,7 @@ fn verify_session_active_rejects_when_no_session_token() { #[test] fn verify_session_active_rejects_when_token_is_empty() { let tmp = TempDir::new().expect("tempdir"); - let mut config = config_in_tempdir(&tmp); + let config = config_in_tempdir(&tmp); let auth = AuthService::new(tmp.path(), config.secrets.encrypt); auth.store_provider_token("app-session", "default", "", Default::default(), false) .expect("store empty token"); @@ -941,7 +941,7 @@ fn verify_session_active_rejects_when_token_is_empty() { #[test] fn verify_session_active_passes_when_session_token_present() { let tmp = TempDir::new().expect("tempdir"); - let mut config = config_in_tempdir(&tmp); + let config = config_in_tempdir(&tmp); let auth = AuthService::new(tmp.path(), config.secrets.encrypt); auth.store_provider_token( "app-session", @@ -2780,7 +2780,7 @@ async fn create_chat_model_wraps_provider_and_round_trips() { use async_trait::async_trait; use std::sync::Arc; use tinyagents::harness::message::Message; - use tinyagents::harness::model::{ChatModel, ModelRequest}; + use tinyagents::harness::model::ModelRequest; let _guard = crate::openhuman::inference::inference_test_guard(); @@ -2834,8 +2834,6 @@ fn resolves_to_managed_backend_for_default_config_but_not_for_local() { #[test] fn create_chat_model_routes_managed_backend_to_crate_native() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); // No test-provider override installed → the managed short-circuit engages. let config = Config::default(); @@ -2851,8 +2849,6 @@ fn create_chat_model_routes_managed_backend_to_crate_native() { #[test] fn create_chat_model_routes_local_runtime_to_crate_native() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); let mut config = Config::default(); config.chat_provider = Some("ollama:qwen2.5".to_string()); @@ -2917,8 +2913,6 @@ fn deepseek_entry(id: &str) -> CloudProviderCreds { #[test] fn create_chat_model_routes_plain_bearer_cloud_slug_to_crate_native() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); // DeepSeek is a built-in chat-completions-only Bearer provider: no // `/v1/responses` fallback and no codex-oauth, so it is wire-equivalent and @@ -2961,8 +2955,6 @@ fn explicit_cloud_provider_string_routes_to_crate_native_model() { #[test] fn create_chat_model_routes_anthropic_auth_cloud_slug_to_crate_native() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); // Anthropic-auth cloud slugs are always wire-equivalent (their endpoints have // no `/v1/responses`, so the host's dormant fallback is behavior-neutral). @@ -2982,8 +2974,6 @@ fn create_chat_model_routes_anthropic_auth_cloud_slug_to_crate_native() { #[test] fn try_create_cloud_slug_flips_openai_but_declines_non_cloud() { - use tinyagents::harness::model::ChatModel; - let _guard = crate::openhuman::inference::inference_test_guard(); // `openai` (API-key Bearer, no codex OAuth) now flips crate-native on Chat // Completions — the legacy `/v1/responses` fallback is not replicated. diff --git a/src/openhuman/learning/extract/heuristics.rs b/src/openhuman/learning/extract/heuristics.rs index ac44cf6fe0..7cb749bb58 100644 --- a/src/openhuman/learning/extract/heuristics.rs +++ b/src/openhuman/learning/extract/heuristics.rs @@ -413,8 +413,6 @@ mod heuristics_tests { #[test] fn length_ratio_emits_compressed_when_user_msgs_shrink() { let session = fresh_session_id(); - let buf = Buffer::new(1024); - // First 15 turns: high ratio (user talks a lot). let now = now_secs(); for i in 0..15 { diff --git a/src/openhuman/learning/reflection.rs b/src/openhuman/learning/reflection.rs index a46a0e240f..f7a248a05c 100644 --- a/src/openhuman/learning/reflection.rs +++ b/src/openhuman/learning/reflection.rs @@ -60,7 +60,7 @@ async fn invoke_cloud_reflection( prompt: &str, ) -> anyhow::Result { use tinyagents::harness::message::Message; - use tinyagents::harness::model::{ChatModel, ModelRequest}; + use tinyagents::harness::model::ModelRequest; Ok(provider .invoke( &(), diff --git a/src/openhuman/learning/stability_detector.rs b/src/openhuman/learning/stability_detector.rs index 231fb6886b..67f040e4de 100644 --- a/src/openhuman/learning/stability_detector.rs +++ b/src/openhuman/learning/stability_detector.rs @@ -288,7 +288,7 @@ impl StabilityDetector { facet_type: FacetType::Preference, key: full_key.clone(), value, - confidence: (agg_score).min(1.0).max(0.0), + confidence: agg_score.clamp(0.0, 1.0), evidence_count: new_evidence_count, source_segment_ids: existing.and_then(|f| f.source_segment_ids.clone()), first_seen_at: first_seen, @@ -551,9 +551,7 @@ fn state_from_stability(score: f64, user_state: UserState) -> FacetState { return FacetState::Dropped; } - if score.is_infinite() { - FacetState::Active - } else if score >= TAU_PROMOTE { + if score.is_infinite() || score >= TAU_PROMOTE { FacetState::Active } else if score >= TAU_PROVISIONAL { FacetState::Provisional diff --git a/src/openhuman/mcp_registry/connections.rs b/src/openhuman/mcp_registry/connections.rs index fdc964593c..6e41e84cf3 100644 --- a/src/openhuman/mcp_registry/connections.rs +++ b/src/openhuman/mcp_registry/connections.rs @@ -595,6 +595,7 @@ pub async fn call_tool( /// and the re-auth affordance keyed off the status alone. /// - other recorded connect failure in `LAST_ERRORS` → `Error` + message. /// - otherwise → `Disconnected`. +/// /// Pure status decision for one installed server, factored out of /// [`all_status`] so the priority order is unit-testable without a live /// connection registry or store. Inputs: diff --git a/src/openhuman/mcp_registry/registries/mcp_official.rs b/src/openhuman/mcp_registry/registries/mcp_official.rs index 269aad977c..453d4002c9 100644 --- a/src/openhuman/mcp_registry/registries/mcp_official.rs +++ b/src/openhuman/mcp_registry/registries/mcp_official.rs @@ -75,8 +75,11 @@ const MAX_CURSOR_WALK_PAGES: u32 = 50; /// `parking_lot::Mutex` matches the rest of the memory subsystem and keeps /// the critical section synchronous — every access is a `HashMap` op, no /// `.await` while the lock is held. -fn cursor_cache() -> &'static Mutex> { - static CACHE: OnceLock>> = OnceLock::new(); +type CursorCacheKey = (String, u32, u32); +type CursorCache = Mutex>; + +fn cursor_cache() -> &'static CursorCache { + static CACHE: OnceLock = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) } diff --git a/src/openhuman/mcp_server/write_dispatch.rs b/src/openhuman/mcp_server/write_dispatch.rs index ae4f1568a8..43c32d0009 100644 --- a/src/openhuman/mcp_server/write_dispatch.rs +++ b/src/openhuman/mcp_server/write_dispatch.rs @@ -142,11 +142,11 @@ pub(super) async fn dispatch_write_tool( fn audit_write(config: &Config, record: NewMcpWriteRecord) { let config = config.clone(); if let Ok(handle) = tokio::runtime::Handle::try_current() { - let _ = handle.spawn_blocking(move || { + std::mem::drop(handle.spawn_blocking(move || { if let Err(err) = mcp_audit::record_write(&config, record) { log::warn!("[mcp_server] mcp write audit insert failed: {err}"); } - }); + })); } else { let _ = std::thread::spawn(move || { if let Err(err) = mcp_audit::record_write(&config, record) { @@ -203,7 +203,7 @@ pub(super) fn audit_write_rejection_without_config( let args_summary = summarize_write_args(&tool_name, audit_arguments); match tokio::runtime::Handle::try_current() { Ok(handle) => { - let _ = handle.spawn(async move { + std::mem::drop(handle.spawn(async move { match config_rpc::load_config_with_timeout().await { Ok(config) => audit_write( &config, @@ -223,7 +223,7 @@ pub(super) fn audit_write_rejection_without_config( err ), } - }); + })); } Err(err) => log::warn!( "[mcp_server] write rejection audit skipped tool={} runtime unavailable error={}", diff --git a/src/openhuman/meet_agent/brain/llm.rs b/src/openhuman/meet_agent/brain/llm.rs index f0fd583743..5d54872bf8 100644 --- a/src/openhuman/meet_agent/brain/llm.rs +++ b/src/openhuman/meet_agent/brain/llm.rs @@ -15,7 +15,7 @@ use crate::openhuman::agent::harness::session::Agent; /// One rolling-history entry handed to the LLM. #[derive(Debug, Clone)] -pub(super) struct ConversationTurn { +pub(crate) struct ConversationTurn { pub role: &'static str, pub content: String, } diff --git a/src/openhuman/meet_agent/brain/speech.rs b/src/openhuman/meet_agent/brain/speech.rs index 88eb70f53b..ac84b5f6ea 100644 --- a/src/openhuman/meet_agent/brain/speech.rs +++ b/src/openhuman/meet_agent/brain/speech.rs @@ -58,7 +58,6 @@ pub(super) async fn tts(text: &str, voice_id: Option<&str>) -> Result, // Per-mascot voice for speaker alternation. `None` preserves the // backend's default-voice pick (single-mascot behavior). voice_id: voice_id.map(str::to_owned), - ..Default::default() }; let outcome = synthesize_reply(&config, text, &opts).await?; let result = outcome.value; diff --git a/src/openhuman/meet_agent/session.rs b/src/openhuman/meet_agent/session.rs index edfff41e78..334b5dce53 100644 --- a/src/openhuman/meet_agent/session.rs +++ b/src/openhuman/meet_agent/session.rs @@ -330,6 +330,7 @@ impl MeetAgentSession { /// * none present → `advance_speaker` yields `None` and the /// reply-speech backend keeps picking its own default voice /// (exact previous behavior). + /// /// The active slot starts at 0 so an idle session reports the primary /// mascot; [`Self::advance_speaker`] keeps the first reply there and /// rotates thereafter. diff --git a/src/openhuman/meet_agent/store.rs b/src/openhuman/meet_agent/store.rs index 600f9223f9..4bf527431f 100644 --- a/src/openhuman/meet_agent/store.rs +++ b/src/openhuman/meet_agent/store.rs @@ -163,7 +163,7 @@ async fn read_recent_from(path: &Path, limit: usize) -> Result Utc::now().timestamp_millis(), "deferred job should be rescheduled into the future" ); + let defer_reason = job.last_error.as_deref().unwrap_or(""); assert!( - job.last_error - .as_deref() - .unwrap_or("") - .contains("re-embed backfill"), - "defer reason should be recorded for visibility" + defer_reason.contains("re-embed backfill") + || defer_reason.contains("llm concurrency gate busy"), + "defer reason should identify the backfill or the shared gate: {defer_reason:?}" ); assert_eq!(count_by_status(&cfg, JobStatus::Ready).unwrap(), 1); } diff --git a/src/openhuman/memory_store/chunks/connection.rs b/src/openhuman/memory_store/chunks/connection.rs index 87fe5a2e4a..f21278718a 100644 --- a/src/openhuman/memory_store/chunks/connection.rs +++ b/src/openhuman/memory_store/chunks/connection.rs @@ -18,6 +18,3 @@ pub(crate) fn recover_corrupt_db(config: &Config) -> Result { log::warn!("[memory:chunks] checking corrupt database recovery"); tinycortex::memory::chunks::recover_corrupt_db(&engine_config(config)) } - -#[cfg(test)] -pub(crate) use tinycortex::memory::chunks::{is_transient_cold_start, try_cleanup_stale_files}; diff --git a/src/openhuman/memory_store/chunks/embeddings.rs b/src/openhuman/memory_store/chunks/embeddings.rs index 703c541a76..beedf1733e 100644 --- a/src/openhuman/memory_store/chunks/embeddings.rs +++ b/src/openhuman/memory_store/chunks/embeddings.rs @@ -7,9 +7,6 @@ use rusqlite::{Connection, Transaction}; use crate::openhuman::config::Config; -#[cfg(test)] -pub use tinycortex::memory::chunks::{embedding_to_blob, REEMBED_SKIP_KEY_MAX_LEN}; - fn engine_config(config: &Config) -> tinycortex::memory::MemoryConfig { crate::openhuman::tinycortex::memory_config_from(config, config.workspace_dir.clone()) } diff --git a/src/openhuman/memory_tools/capture.rs b/src/openhuman/memory_tools/capture.rs index b9934378a8..b885f86ed5 100644 --- a/src/openhuman/memory_tools/capture.rs +++ b/src/openhuman/memory_tools/capture.rs @@ -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(); } diff --git a/src/openhuman/memory_tree/tree/factory.rs b/src/openhuman/memory_tree/tree/factory.rs index e0ba2cc183..2b3f4daf43 100644 --- a/src/openhuman/memory_tree/tree/factory.rs +++ b/src/openhuman/memory_tree/tree/factory.rs @@ -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) } diff --git a/src/openhuman/memory_tree/tree/registry.rs b/src/openhuman/memory_tree/tree/registry.rs index ec6b7386a8..d11ff6e943 100644 --- a/src/openhuman/memory_tree/tree/registry.rs +++ b/src/openhuman/memory_tree/tree/registry.rs @@ -76,10 +76,10 @@ pub fn get_or_create_tree(config: &Config, kind: TreeKind, scope: &str) -> Resul /// Matches both the anyhow-wrapped rusqlite error text and the raw SQLite /// error codes in case the wrapping chain is shorter. pub fn is_unique_violation(err: &anyhow::Error) -> bool { - if let Some(rusqlite_err) = err.downcast_ref::() { - if let rusqlite::Error::SqliteFailure(sqlite_err, _) = rusqlite_err { - return sqlite_err.code == rusqlite::ErrorCode::ConstraintViolation; - } + if let Some(rusqlite::Error::SqliteFailure(sqlite_err, _)) = + err.downcast_ref::() + { + return sqlite_err.code == rusqlite::ErrorCode::ConstraintViolation; } // Fallback for chained/wrapped errors: scan the rendered message. let msg = format!("{err:#}"); diff --git a/src/openhuman/memory_tree/tree/rpc.rs b/src/openhuman/memory_tree/tree/rpc.rs index 88301c201a..c277a850f4 100644 --- a/src/openhuman/memory_tree/tree/rpc.rs +++ b/src/openhuman/memory_tree/tree/rpc.rs @@ -832,10 +832,9 @@ pub async fn set_enabled_rpc( mod tests { use super::*; use crate::openhuman::memory_queue as jobs; - use crate::openhuman::memory_queue::store::count_total; use crate::openhuman::memory_store::chunks::types::SourceKind; use crate::openhuman::memory_sync::canonicalize::document::DocumentInput; - use chrono::{Duration as ChronoDuration, Utc}; + use chrono::Utc; use serde_json::json; use tempfile::TempDir; diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 74f8442046..601a569cfc 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -57,6 +57,7 @@ pub mod harness_init; pub mod health; pub mod heartbeat; pub mod http_host; +#[cfg(feature = "media")] pub mod image; pub mod inference; pub mod integrations; @@ -68,6 +69,7 @@ pub mod mcp_audit; pub mod mcp_client; pub mod mcp_registry; pub mod mcp_server; +#[cfg(feature = "media")] pub mod media_generation; pub mod meet; pub mod meet_agent; diff --git a/src/openhuman/people/store.rs b/src/openhuman/people/store.rs index b9f9fc8beb..d6ec23372d 100644 --- a/src/openhuman/people/store.rs +++ b/src/openhuman/people/store.rs @@ -16,6 +16,14 @@ use crate::openhuman::people::migrations; use crate::openhuman::people::types::{Handle, Interaction, Person, PersonId}; pub type ConnHandle = Arc>; +type PersonRow = ( + String, + Option, + Option, + Option, + i64, + i64, +); /// Process-global handle to the `PeopleStore`, tagged with the workspace it is /// bound to. Controller handlers are free functions with no `&self`, so they @@ -326,7 +334,7 @@ impl PeopleStore { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || -> SqlResult> { let guard = conn.blocking_lock(); - let row: Option<(String, Option, Option, Option, i64, i64)> = + let row: Option = guard .query_row( "SELECT id, display_name, primary_email, primary_phone, created_at, updated_at \ diff --git a/src/openhuman/runtime_python/bootstrap.rs b/src/openhuman/runtime_python/bootstrap.rs index cfffb2a0a5..3bb729d077 100644 --- a/src/openhuman/runtime_python/bootstrap.rs +++ b/src/openhuman/runtime_python/bootstrap.rs @@ -276,6 +276,7 @@ async fn acquire_install_lock(install_dir: &Path) -> Result { let file = OpenOptions::new() .create(true) + .truncate(false) .read(true) .write(true) .open(&lock_path_for_task) diff --git a/src/openhuman/security/pii/rules.rs b/src/openhuman/security/pii/rules.rs index f3671f15b3..b4b6d3d45f 100644 --- a/src/openhuman/security/pii/rules.rs +++ b/src/openhuman/security/pii/rules.rs @@ -71,7 +71,7 @@ pub(crate) fn is_luhn_valid(raw: &str) -> bool { } sum += v; } - sum % 10 == 0 + sum.is_multiple_of(10) } /// Validate that a dotted-quad match is a real IPv4 address (each octet 0–255). diff --git a/src/openhuman/skill_runtime/run_machinery.rs b/src/openhuman/skill_runtime/run_machinery.rs index 8d5dee3a72..f69bba0f1b 100644 --- a/src/openhuman/skill_runtime/run_machinery.rs +++ b/src/openhuman/skill_runtime/run_machinery.rs @@ -202,6 +202,12 @@ pub async fn spawn_workflow_run_background( // a post-construction override rather than a pre-set on `config` // (which the builder would otherwise clobber). agent.set_max_tool_iterations(WORKFLOW_RUN_MAX_ITERATIONS); + tracing::debug!( + run_id = %run_id, + max_tool_iterations = WORKFLOW_RUN_MAX_ITERATIONS, + "[skills] workflow_run: pinned iteration budget post-construction (overrides \ + the session builder's orchestrator definition cap)" + ); agent.set_event_context(run_id.clone(), "skill"); agent.set_agent_definition_name(format!( "orchestrator-skill-{}", diff --git a/src/openhuman/skills/e2e_plumbing_tests.rs b/src/openhuman/skills/e2e_plumbing_tests.rs index 6281524eb5..ee35174c1e 100644 --- a/src/openhuman/skills/e2e_plumbing_tests.rs +++ b/src/openhuman/skills/e2e_plumbing_tests.rs @@ -39,7 +39,6 @@ use crate::openhuman::skills::ops_create::{ use crate::openhuman::skills::ops_types::WorkflowScope; use crate::openhuman::skills::registry::get_workflow; use crate::openhuman::skills::run_log; -use crate::openhuman::tools::policy::DefaultToolPolicy; use crate::openhuman::tools::traits::Tool; // ── Mock LLM ───────────────────────────────────────────────────────────── diff --git a/src/openhuman/skills/e2e_run_tests.rs b/src/openhuman/skills/e2e_run_tests.rs index a7ff46bc66..03349f3e2c 100644 --- a/src/openhuman/skills/e2e_run_tests.rs +++ b/src/openhuman/skills/e2e_run_tests.rs @@ -42,7 +42,6 @@ use crate::openhuman::skill_runtime::{await_run_outcome, spawn_workflow_run_back use crate::openhuman::skills::schemas::resolve_workspace_dir; use crate::openhuman::todos::ops as board_ops; use crate::openhuman::todos::ops::{BoardLocation, CardPatch}; -use crate::openhuman::tools::policy::DefaultToolPolicy; use crate::openhuman::tools::traits::Tool; /// Serialize this module's tests (each touches process-global state). diff --git a/src/openhuman/skills/ops_tests.rs b/src/openhuman/skills/ops_tests.rs index d49ccfba77..75e2deb6bc 100644 --- a/src/openhuman/skills/ops_tests.rs +++ b/src/openhuman/skills/ops_tests.rs @@ -1678,7 +1678,6 @@ fn uninstall_resolves_agents_skills_legacy_root() { // discover_workflows surfaces ~/.agents/skills/, so uninstall must reach it // too — otherwise a listed workflow can never be deleted via this API. let home = tempfile::tempdir().unwrap(); - let ws = tempfile::tempdir().unwrap(); let dir = home.path().join(".agents").join("skills").join("agenty"); write( &dir.join(SKILL_MD), diff --git a/src/openhuman/skills/registry.rs b/src/openhuman/skills/registry.rs index 580d27b7ca..dc374b71c4 100644 --- a/src/openhuman/skills/registry.rs +++ b/src/openhuman/skills/registry.rs @@ -148,8 +148,9 @@ pub fn prune_legacy_default_workflows(workspace_dir: &Path) { /// `run_workflow`) returned "unknown workflow" for anything created via the UI. /// /// Per dir: `skill.toml` (id / `when_to_use` / `[[inputs]]` / `[github]`) -/// + the `SKILL.md` body as the inline system prompt; or, when there's no -/// `skill.toml`, a synthesized SKILL.md-only definition so a bare workflow is +/// + the `SKILL.md` body as the inline system prompt. +/// +/// Without `skill.toml`, a synthesized SKILL.md-only definition means a bare workflow is /// still runnable. A bad `skill.toml` falls back to the SKILL.md-only form. pub fn load_workflows(workspace_dir: &Path) -> Vec { // Prune any legacy bundled skills an older build left behind so discover's diff --git a/src/openhuman/socket/medulla/mod.rs b/src/openhuman/socket/medulla/mod.rs index 1a961e4c44..0656761ca4 100644 --- a/src/openhuman/socket/medulla/mod.rs +++ b/src/openhuman/socket/medulla/mod.rs @@ -373,10 +373,9 @@ async fn drain_steer(steer_rx: &mut mpsc::UnboundedReceiver) -> Option msg, - Err(_) => None, - } + tokio::time::timeout(STEER_DRAIN_GRACE, steer_rx.recv()) + .await + .unwrap_or_default() } /// Build (or resume) an agent session for a medulla task. diff --git a/src/openhuman/subconscious/profiles/memory.rs b/src/openhuman/subconscious/profiles/memory.rs index c9f6ef7ae9..49f818066c 100644 --- a/src/openhuman/subconscious/profiles/memory.rs +++ b/src/openhuman/subconscious/profiles/memory.rs @@ -166,6 +166,13 @@ impl MemoryProfile { warn!("[subconscious:memory] agent init failed: {e}"); format!("agent init: {e}") })?; + // Stable per-tick correlation id — minted once and reused below for + // the pin log, the agent's event context, and the turn origin's + // `job_id`, so `mode`/`max_tool_iterations` (which repeat across + // ticks) don't leave concurrent/successive subconscious ticks + // indistinguishable in logs. + let tick_id = format!("subconscious:tick:{}", now_secs() as u64); + // Issue #4868 — `Agent::from_config` builds as the `orchestrator` // definition (max_iterations=15, strict), so the session builder // would stamp orchestrator's cap onto this agent regardless of mode @@ -173,12 +180,16 @@ impl MemoryProfile { // 30-iteration budget set above to 15. Re-apply the mode-specific // cap post-construction so this tick keeps its previous behavior. agent.set_max_tool_iterations(mode_iteration_cap); - - agent.set_event_context( - format!("subconscious:tick:{}", now_secs() as u64), - "subconscious", + debug!( + tick_id = %tick_id, + "[subconscious:memory] pinned mode-specific iteration budget post-construction: \ + mode={:?} max_tool_iterations={} (overrides the session builder's orchestrator \ + definition cap)", + self.mode, mode_iteration_cap ); + agent.set_event_context(tick_id.clone(), "subconscious"); + let mode_guidance = match self.mode { SubconsciousMode::Aggressive | SubconsciousMode::EventDriven => { "\n\nYou may delegate deeper work with `spawn_async_subagent` (e.g. research \ @@ -201,10 +212,10 @@ impl MemoryProfile { ticks. Do not invent busywork.{mode_guidance}", ); - debug!("[subconscious:memory] spawning decision agent"); + debug!(tick_id = %tick_id, "[subconscious:memory] spawning decision agent"); let source = tick_origin_source(has_external_content); let origin = crate::openhuman::agent::turn_origin::AgentTurnOrigin::TrustedAutomation { - job_id: format!("subconscious:tick:{}", now_secs() as u64), + job_id: tick_id.clone(), source, }; let response = crate::openhuman::agent::turn_origin::with_origin( diff --git a/src/openhuman/subconscious_triggers/runtime.rs b/src/openhuman/subconscious_triggers/runtime.rs index 941b1024a0..5ed4ac6e28 100644 --- a/src/openhuman/subconscious_triggers/runtime.rs +++ b/src/openhuman/subconscious_triggers/runtime.rs @@ -132,7 +132,8 @@ impl TriggerOrchestrator { /// Non-blocking ingestion entry point for the bus subscriber. Normalizes /// + admits synchronously, then spawns the gate task for admitted - /// triggers so event dispatch is never blocked on an LLM call. + /// + /// Triggers ensure event dispatch is never blocked on an LLM call. pub fn ingest(self: &Arc, event: &DomainEvent) { let now = now_secs(); let Some(trigger) = normalize(event, now) else { diff --git a/src/openhuman/test_support/introspect.rs b/src/openhuman/test_support/introspect.rs index 4bc75daa76..8198deb2c2 100644 --- a/src/openhuman/test_support/introspect.rs +++ b/src/openhuman/test_support/introspect.rs @@ -144,7 +144,7 @@ async fn walk_dir( size: if is_dir { 0 } else { meta.len() }, is_dir, }); - if is_dir && depth + 1 <= max_depth { + if is_dir && depth < max_depth { stack.push((path, depth + 1)); } } @@ -168,7 +168,7 @@ pub async fn list_workspace_files( Ok(RpcOutcome::single_log( ListResult { root: root.display().to_string(), - entries: entries.iter().cloned().collect(), + entries: entries.to_vec(), truncated, }, format!( diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index 559c029734..5cc4f3a7aa 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -718,6 +718,65 @@ impl Middleware<()> for PromptCacheSegmentMiddleware { } } +/// Tools whose results are self-describing JSON payloads that downstream +/// extractors and the frontend canvas parse structurally (the `type` marker +/// must survive). Compacting/summarizing them destroys the contract and +/// serves no purpose — the model doesn't benefit from a tabulated graph and +/// the payload is the turn's final output, not intermediate context. +/// +/// These tools are exempt from *every* content-rewriting stage below — +/// tokenjuice compaction (steps 1+2) **and** the per-tool char cap / shared +/// byte-budget backstop (steps 3+4, see [`is_truncation_exempt`]). Both +/// `flows::ops::extract_workflow_proposal` and the frontend's +/// `parseWorkflowProposal` parse this content as a single whole-string JSON +/// document; a byte-cap truncation at a UTF-8 boundary produces invalid JSON +/// just as surely as tokenjuice tabulation strips the `"type"` marker — both +/// end in a silent `proposal: None` and a blank canvas. A ≥10-node graph +/// routinely clears the ~16 KiB shared budget, so the truncation exemption +/// matters just as much as the compaction one. +const COMPACTION_EXEMPT_TOOLS: &[&str] = &[ + "propose_workflow", + "revise_workflow", + "edit_workflow", + "save_workflow", + "create_workflow", +]; + +/// Tools whose results the model reads to derive an exact schema (e.g. +/// `primary_array_path` / `output_fields`) from a *real* sampled tool +/// response, per the B12 output-probe contract (`flows::builder_tools`). +/// TokenJuice's array-elision tabulation defeats their purpose outright — a +/// tabulated sample hides the very array shape the model is calling the tool +/// to observe, so it derives a wrong or nonexistent `split_out.path` from the +/// summary instead of the real response. They're compaction-exempt +/// ([`is_compaction_exempt`]) for that reason. +/// +/// Unlike [`COMPACTION_EXEMPT_TOOLS`], their payload is intermediate context +/// the model reasons over — not the turn's final machine-parsed output — and +/// samples can be genuinely large (a full API response body). So they stay +/// subject to the per-tool char cap / shared byte-budget backstop +/// ([`is_truncation_exempt`] returns `false` for them): a truncated-but-not- +/// tabulated sample is still a usable (if partial) real response, and the +/// backstop keeps these calls from blowing the context budget. +const SAMPLING_TOOLS: &[&str] = &["get_tool_output_sample", "get_tool_contract"]; + +/// Steps 1 (payload summarizer) + 2 (tokenjuice compaction) exemption: +/// proposal tools (final-output contract, see [`COMPACTION_EXEMPT_TOOLS`]) +/// plus sampling tools (tabulation would corrupt the schema they exist to +/// reveal, see [`SAMPLING_TOOLS`]). +fn is_compaction_exempt(name: &str) -> bool { + COMPACTION_EXEMPT_TOOLS.contains(&name) || SAMPLING_TOOLS.contains(&name) +} + +/// Steps 3 (per-tool char cap) + 4 (shared byte-budget backstop) exemption: +/// proposal tools only. Their JSON is parsed as a single whole-string +/// document downstream, so any truncation — not just tokenjuice tabulation — +/// breaks the parse. Sampling tools are deliberately *not* in this set: see +/// [`SAMPLING_TOOLS`] for why the byte cap stays in force for them. +fn is_truncation_exempt(name: &str) -> bool { + COMPACTION_EXEMPT_TOOLS.contains(&name) +} + /// `after_tool`: apply the semantic payload summarizer (when configured) and /// then the hard per-tool-result byte cap to each tool result's model-facing /// content, before it enters the transcript. The graph analogue of the byte cap @@ -758,75 +817,120 @@ impl Middleware<()> for ToolOutputMiddleware { _state: &(), result: &mut TaToolResult, ) -> TaResult<()> { + // Proposal-/persistence-emitting workflow tools return a self-describing + // `{ "type": "workflow_proposal", … }` JSON payload that `flows::ops`' + // `extract_workflow_proposal` (and the frontend's content-based + // recognition) parse structurally. Sampling tools (`get_tool_contract` / + // `get_tool_output_sample`) return a real API response the model reads + // to derive an exact array path/schema. All four stages below are + // content-*rewriting*: tokenjuice (steps 1+2) tabulates any uniform + // object-array of ≥3 rows over ~512 bytes into a `[json table: …]` + // marker (stripping the `"type"` field on graphs with enough nodes, or + // eliding the array a sample exists to reveal); the char cap and shared + // byte-budget backstop (steps 3+4) truncate at a UTF-8 boundary, which + // breaks the whole-string JSON parse both proposal consumers do. See + // [`is_compaction_exempt`]/[`is_truncation_exempt`] for which stages + // each tool family skips and why. + let compaction_exempt = is_compaction_exempt(&result.name); + let truncation_exempt = is_truncation_exempt(&result.name); + if compaction_exempt { + tracing::debug!( + tool = %result.name, + bytes = result.content.len(), + "[tinyagents::mw] compaction-exempt: skipping payload summarizer + tokenjuice" + ); + } + if truncation_exempt { + tracing::debug!( + tool = %result.name, + bytes = result.content.len(), + "[tinyagents::mw] truncation-exempt: skipping per-tool char cap + shared byte-budget backstop" + ); + } + // 1. Semantic summarization (progressive disclosure) — swap the raw // payload for a compressed summary when the summarizer opts in. // Failures never break the tool call (the trait swallows them). - if let Some(ps) = &self.payload_summarizer { - if let Ok(Some(payload)) = ps - .maybe_summarize_in_parent(ctx, &result.name, None, &result.content) - .await - { - tracing::info!( - tool = %result.name, - from_bytes = payload.original_bytes, - to_bytes = payload.summary_bytes, - "[tinyagents::mw] payload_summarizer compressed tool output" - ); + if !compaction_exempt { + if let Some(ps) = &self.payload_summarizer { + if let Ok(Some(payload)) = ps + .maybe_summarize_in_parent(ctx, &result.name, None, &result.content) + .await + { + tracing::info!( + tool = %result.name, + from_bytes = payload.original_bytes, + to_bytes = payload.summary_bytes, + "[tinyagents::mw] payload_summarizer compressed tool output" + ); + ctx.emit(AgentEvent::Compressed { + from_tokens: estimate_output_tokens(payload.original_bytes), + to_tokens: estimate_output_tokens(payload.summary_bytes), + }); + result.content = payload.summary; + } + } + + // 2. TokenJuice content-aware compaction. This mirrors the legacy + // `agent_tool_exec` stage that ran after semantic summarization and + // before the hard output caps. + let before_tokenjuice_bytes = result.content.len(); + let compacted = crate::openhuman::tokenjuice::compact_output_with_policy( + std::mem::take(&mut result.content), + &result.name, + self.tokenjuice_compaction_enabled, + self.tokenjuice_compression, + ) + .await; + result.content = compacted; + let after_tokenjuice_bytes = result.content.len(); + if after_tokenjuice_bytes < before_tokenjuice_bytes { ctx.emit(AgentEvent::Compressed { - from_tokens: estimate_output_tokens(payload.original_bytes), - to_tokens: estimate_output_tokens(payload.summary_bytes), + from_tokens: estimate_output_tokens(before_tokenjuice_bytes), + to_tokens: estimate_output_tokens(after_tokenjuice_bytes), }); - result.content = payload.summary; } } - // 2. TokenJuice content-aware compaction. This mirrors the legacy - // `agent_tool_exec` stage that ran after semantic summarization and - // before the hard output caps. - let before_tokenjuice_bytes = result.content.len(); - let compacted = crate::openhuman::tokenjuice::compact_output_with_policy( - std::mem::take(&mut result.content), - &result.name, - self.tokenjuice_compaction_enabled, - self.tokenjuice_compression, - ) - .await; - result.content = compacted; - let after_tokenjuice_bytes = result.content.len(); - if after_tokenjuice_bytes < before_tokenjuice_bytes { - ctx.emit(AgentEvent::Compressed { - from_tokens: estimate_output_tokens(before_tokenjuice_bytes), - to_tokens: estimate_output_tokens(after_tokenjuice_bytes), - }); - } - // 3. Per-tool **char** cap — a tool that declares `max_result_size_chars` // caps its own output in characters, with the tool-cap marker the model // was taught to read (legacy engine parity). Distinct from the generic - // byte budget below: the tool cap is the tool's own contract. + // byte budget below: the tool cap is the tool's own contract. Skipped + // for truncation-exempt tools (see [`is_truncation_exempt`]) — the tool + // cap is still *computed* below (step 4's "no cap of its own" check + // reads it), just not applied to `result.content`. let tool_cap = self.tool_char_cap(&result.name); - if let Some(cap) = tool_cap { - let char_count = result.content.chars().count(); - if char_count > cap { - let truncated: String = result.content.chars().take(cap).collect(); - let dropped = char_count - cap; - tracing::debug!( - tool = %result.name, - cap, - char_count, - dropped, - "[tinyagents::mw] per-tool char cap applied" - ); - result.content = format!( - "{truncated}\n\n[truncated by tool cap: {dropped} more chars not shown]" - ); + if !truncation_exempt { + if let Some(cap) = tool_cap { + let char_count = result.content.chars().count(); + if char_count > cap { + let truncated: String = result.content.chars().take(cap).collect(); + let dropped = char_count - cap; + tracing::debug!( + tool = %result.name, + cap, + char_count, + dropped, + "[tinyagents::mw] per-tool char cap applied" + ); + result.content = format!( + "{truncated}\n\n[truncated by tool cap: {dropped} more chars not shown]" + ); + } } } // 4. Shared byte-cap backstop — truncate at a UTF-8 boundary with a marker. // Only for tools with no cap of their own (a capped tool already bounded - // itself above; stacking the two markers would double-truncate). - if tool_cap.is_none() && self.budget_bytes > 0 { + // itself above; stacking the two markers would double-truncate), and + // never for truncation-exempt tools. This is a per-result cap only — + // `apply_per_result_persistence` takes a single `content: String` and a + // fixed `self.budget_bytes`, with no shared/global accumulator across + // tool calls (the aggregate-spill variant, `spill_aggregate_tool_results`, + // is a separate legacy code path not wired into this middleware) — so + // exempting these tools' own contribution here cannot perturb any other + // tool's budget accounting. + if !truncation_exempt && tool_cap.is_none() && self.budget_bytes > 0 { let (capped, outcome) = apply_per_result_persistence( std::mem::take(&mut result.content), self.artifact_store.as_ref(), @@ -2275,6 +2379,31 @@ fn args_fingerprint(arguments: &serde_json::Value) -> String { format!("{:x}", hasher.finish()) } +/// Detect a **body-level** failure from `validate_workflow` / `dry_run_workflow` +/// (issue: flows breaker doesn't see repeated invalid-graph loops). Both tools +/// report an invalid graph / aborted sandbox run via `ToolResult::success` with +/// a JSON body carrying top-level `"ok": false` +/// (`src/openhuman/flows/builder_tools.rs`) rather than `ToolResult::error` — so +/// `result.error` stays `None` and the no-progress breaker below never counts +/// the repeat as a failure, letting a graph the model can't fix burn the whole +/// iteration budget instead of tripping the same nudge/halt ladder. +/// +/// Scoped to exactly these two tool names: a generic `"ok": false` in some other +/// tool's JSON body may be legitimate data (not a failure signal), so this must +/// not reinterpret arbitrary tool output. Tolerant of non-JSON or missing `ok` +/// content — returns `false` rather than guessing. +fn is_body_level_failure(name: &str, content: &str) -> bool { + if name != "validate_workflow" && name != "dry_run_workflow" { + return false; + } + match serde_json::from_str::(content) { + Ok(serde_json::Value::Object(map)) => { + matches!(map.get("ok"), Some(serde_json::Value::Bool(false))) + } + _ => false, + } +} + #[async_trait] impl Middleware<()> for RepeatedToolFailureMiddleware { fn name(&self) -> &str { @@ -2309,11 +2438,20 @@ impl Middleware<()> for RepeatedToolFailureMiddleware { .unwrap_or_default(); let step = self.step.fetch_add(1, Ordering::SeqCst) + 1; + // Body-level failure signal: `validate_workflow` / `dry_run_workflow` + // report an invalid graph via a `success` result whose JSON body carries + // `"ok": false` — see `is_body_level_failure`. Only meaningful when + // `result.error` is `None`; when both are set, `result.error` already + // drives every check below, so this never double-counts one failure. + let body_level_failure = + result.error.is_none() && is_body_level_failure(&result.name, &result.content); + // Combined failure text for classification: the model-facing content plus // the (redundant but authoritative) error field. Both are scanned for the // policy / terminal-inference / recoverable markers below. let failure_text = match result.error.as_deref() { Some(err) => format!("{}\n{}", result.content, err), + None if body_level_failure => result.content.clone(), None => String::new(), }; @@ -2395,10 +2533,20 @@ impl Middleware<()> for RepeatedToolFailureMiddleware { // handles the deterministic 3/6 + hard-reject-2 path below. self.reset_recoverable_streak(); + // Union the body-level `ok:false` signal with the existing `error.is_some()` + // predicate so the crate tracker (which reads `attempt.error` as its sole + // success/failure signal — `None` means "progress was made, reset every + // counter") sees the repeat as a failure and feeds it into the same + // nudge/halt ladder as a real tool error. + let attempt_error: Option<&str> = match result.error.as_deref() { + Some(err) => Some(err), + None if body_level_failure => Some(failure_text.as_str()), + None => None, + }; let attempt = ToolAttempt { tool: &result.name, arg_fingerprint: &arg_fp, - error: result.error.as_deref(), + error: attempt_error, hard_reject, // The unknown-tool recovery sentinel is a C3 concern; today every // failure feeds the generic backstop exactly as the legacy ladder did. @@ -3599,6 +3747,317 @@ mod tests { ); } + // ── ToolOutputMiddleware: COMPACTION_EXEMPT_TOOLS (workflow proposals) ─── + + /// A `workflow_proposal` payload with enough uniform-object rows to clear + /// tinyjuice's `MIN_ROWS` (3) and default ~512-byte tabulation floor — + /// i.e. exactly the shape that used to get its `"type"` marker stripped by + /// the `[json table: …]` rewrite before the middleware exemption existed. + fn large_workflow_proposal_json() -> String { + let nodes: Vec = (0..6) + .map(|i| { + json!({ + "id": format!("node-{i}"), + "kind": if i == 0 { "trigger" } else { "tool_call" }, + "name": format!("Step {i}"), + "config": { + "slug": format!("oh:placeholder_action_{i}"), + "args": { "input": format!("value-{i}"), "note": "generic placeholder payload for size padding" } + } + }) + }) + .collect(); + serde_json::to_string(&json!({ + "type": "workflow_proposal", + "flow_id": "flow-large-graph", + "graph": { "nodes": nodes, "edges": [] }, + })) + .unwrap() + } + + fn compaction_enabled_mw() -> ToolOutputMiddleware { + ToolOutputMiddleware { + budget_bytes: 1_000_000, + payload_summarizer: None, + artifact_store: None, + tokenjuice_compaction_enabled: true, + tokenjuice_compression: AgentTokenjuiceCompression::Full, + tool_policies: HashMap::new(), + } + } + + #[test] + fn compaction_exempt_tools_contains_every_proposal_tool() { + for tool in [ + "propose_workflow", + "revise_workflow", + "edit_workflow", + "save_workflow", + "create_workflow", + ] { + assert!( + COMPACTION_EXEMPT_TOOLS.contains(&tool), + "{tool} must be exempt from tokenjuice/summarizer compaction" + ); + } + } + + #[tokio::test] + async fn tool_output_tabulates_a_large_graph_for_a_non_exempt_tool() { + // Sanity baseline proving this test's payload actually exercises real + // tinyjuice tabulation (and isn't just below-threshold): a tool name + // NOT in COMPACTION_EXEMPT_TOOLS loses the `"type"` marker. + let mw = compaction_enabled_mw(); + let payload = large_workflow_proposal_json(); + let mut result = tool_result("some_other_tool", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_ne!( + result.content, payload, + "a non-exempt tool's large uniform-array payload should be rewritten by tokenjuice" + ); + let reparsed: Result = serde_json::from_str(&result.content); + let marker_survived = reparsed + .ok() + .and_then(|v| v.get("type").and_then(|t| t.as_str().map(str::to_string))) + == Some("workflow_proposal".to_string()); + assert!( + !marker_survived, + "baseline expectation: tabulation strips the type marker for non-exempt tools" + ); + } + + #[tokio::test] + async fn tool_output_leaves_propose_workflow_byte_for_byte_intact() { + let mw = compaction_enabled_mw(); + let payload = large_workflow_proposal_json(); + let mut result = tool_result("propose_workflow", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "propose_workflow results must pass through compaction untouched" + ); + let reparsed: serde_json::Value = serde_json::from_str(&result.content).unwrap(); + assert_eq!(reparsed["type"], "workflow_proposal"); + assert_eq!(reparsed["graph"]["nodes"].as_array().unwrap().len(), 6); + } + + #[tokio::test] + async fn tool_output_leaves_every_exempt_tool_name_intact() { + let mw = compaction_enabled_mw(); + let payload = large_workflow_proposal_json(); + for tool in COMPACTION_EXEMPT_TOOLS { + let mut result = tool_result(tool, &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "{tool}'s result must pass through compaction untouched" + ); + } + } + + // ── ToolOutputMiddleware: truncation exemption (#4888 follow-up, gap 1) ── + + /// A `workflow_proposal` payload with `node_count` nodes, each padded with + /// a 500-byte `note`, so the caller can force the serialized size past the + /// ~16 KiB shared byte-budget backstop (`DEFAULT_TOOL_RESULT_BUDGET_BYTES`) + /// — the size class a real ≥10-node graph proposal routinely reaches, and + /// exactly what used to get UTF-8-boundary-truncated into unparseable JSON + /// before the truncation exemption existed. + fn oversized_workflow_proposal_json(node_count: usize) -> String { + let nodes: Vec = (0..node_count) + .map(|i| { + json!({ + "id": format!("node-{i}"), + "kind": if i == 0 { "trigger" } else { "tool_call" }, + "name": format!("Step {i}"), + "config": { + "slug": format!("oh:placeholder_action_{i}"), + "args": { "input": format!("value-{i}"), "note": "a".repeat(500) } + } + }) + }) + .collect(); + serde_json::to_string(&json!({ + "type": "workflow_proposal", + "flow_id": "flow-oversized-graph", + "graph": { "nodes": nodes, "edges": [] }, + })) + .unwrap() + } + + /// Middleware config isolating the byte-cap stages (3+4): tokenjuice off, + /// no tool-declared char cap, the real `DEFAULT_TOOL_RESULT_BUDGET_BYTES` + /// (~16 KiB) as the shared backstop, and no artifact store (so an + /// over-budget non-exempt tool falls straight to inline truncation instead + /// of being persisted — deterministic to assert on). + fn truncation_probe_mw() -> ToolOutputMiddleware { + ToolOutputMiddleware { + budget_bytes: DEFAULT_TOOL_RESULT_BUDGET_BYTES, + payload_summarizer: None, + artifact_store: None, + tokenjuice_compaction_enabled: false, + tokenjuice_compression: AgentTokenjuiceCompression::Off, + tool_policies: HashMap::new(), + } + } + + #[tokio::test] + async fn tool_output_leaves_an_oversized_propose_workflow_byte_for_byte_intact() { + // Gap 1: a ≥10-node proposal routinely exceeds the ~16 KiB shared + // byte-budget backstop. Before the truncation exemption, step 4 + // truncated it at a UTF-8 boundary — invalid JSON, so both + // `flows::ops::extract_workflow_proposal` and the frontend's + // `parseWorkflowProposal` silently fell back to `proposal: None` and a + // blank canvas. This must survive byte-for-byte regardless of size. + let mw = truncation_probe_mw(); + let payload = oversized_workflow_proposal_json(30); + assert!( + payload.len() > DEFAULT_TOOL_RESULT_BUDGET_BYTES, + "test payload must exceed the shared byte budget to exercise step 4: {} bytes", + payload.len() + ); + let mut result = tool_result("propose_workflow", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "an oversized propose_workflow result must not be truncated by the shared byte-budget backstop" + ); + let reparsed: serde_json::Value = serde_json::from_str(&result.content) + .expect("must still be valid JSON after passing through after_tool"); + assert_eq!(reparsed["type"], "workflow_proposal"); + assert_eq!(reparsed["graph"]["nodes"].as_array().unwrap().len(), 30); + } + + #[tokio::test] + async fn tool_output_truncates_the_same_oversized_payload_for_a_non_exempt_tool() { + // Baseline pairing with the test above: proves the identical oversized + // payload IS truncated (and consequently unparseable) for a tool that + // is NOT truncation-exempt, so the exemption test isn't vacuously true + // because the payload never actually crossed the budget. + let mw = truncation_probe_mw(); + let payload = oversized_workflow_proposal_json(30); + let mut result = tool_result("some_other_tool", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_ne!( + result.content, payload, + "a non-exempt tool's oversized payload should be truncated by the shared byte-budget backstop" + ); + assert!( + result.content.contains("truncated by tool_result_budget"), + "expected the byte-budget truncation marker: {}", + result.content + ); + assert!( + serde_json::from_str::(&result.content).is_err(), + "truncated JSON should no longer parse as a whole document" + ); + } + + // ── ToolOutputMiddleware: sampling tools (#4888 follow-up, gap 2) ──────── + + /// A large uniform-array JSON payload shaped like a real sampled tool + /// response (no `workflow_proposal` envelope) — what `get_tool_output_sample` + /// / `get_tool_contract` actually return so the model can derive an exact + /// `primary_array_path`/`output_fields` from the real shape. `row_count` + /// rows of ≥3 clear tinyjuice's tabulation threshold. + fn large_sample_response_json(row_count: usize) -> String { + let rows: Vec = (0..row_count) + .map(|i| { + json!({ + "id": i, + "title": format!("Issue {i}"), + "state": "open", + "body": "padding padding padding padding padding padding", + }) + }) + .collect(); + serde_json::to_string(&json!({ "items": rows })).unwrap() + } + + #[tokio::test] + async fn get_tool_output_sample_is_compaction_exempt() { + // Gap 2: tokenjuice tabulation elides the very array the model calls + // this tool to observe, so it would derive a wrong or nonexistent + // `split_out.path` from the tabulated summary instead of the real + // response shape. The sample must reach the model untabulated. + let mw = compaction_enabled_mw(); + let payload = large_sample_response_json(10); + let mut result = tool_result("get_tool_output_sample", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "get_tool_output_sample's response must not be tokenjuice-tabulated" + ); + } + + #[tokio::test] + async fn get_tool_contract_is_compaction_exempt() { + let mw = compaction_enabled_mw(); + let payload = large_sample_response_json(10); + let mut result = tool_result("get_tool_contract", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!( + result.content, payload, + "get_tool_contract's response must not be tokenjuice-tabulated" + ); + } + + #[tokio::test] + async fn sampling_tool_output_still_hits_the_byte_budget_backstop() { + // Unlike the proposal tools, sampling tools are deliberately NOT + // truncation-exempt: a truncated-but-untabulated sample is still a + // usable (if partial) real response, and these calls can be genuinely + // large, so the shared byte-budget backstop keeps protecting the + // context budget for them. + let mw = truncation_probe_mw(); + let payload = large_sample_response_json(400); + assert!( + payload.len() > DEFAULT_TOOL_RESULT_BUDGET_BYTES, + "test payload must exceed the shared byte budget: {} bytes", + payload.len() + ); + let mut result = tool_result("get_tool_output_sample", &payload); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_ne!( + result.content, payload, + "get_tool_output_sample must still be subject to the shared byte-budget backstop" + ); + assert!( + result.content.contains("truncated by tool_result_budget"), + "expected the byte-budget truncation marker: {}", + result.content + ); + } + + #[test] + fn compaction_and_truncation_exempt_sets_are_distinct() { + // Proposal tools: exempt from both compaction and truncation. + for tool in COMPACTION_EXEMPT_TOOLS { + assert!( + is_compaction_exempt(tool), + "{tool} must be compaction-exempt" + ); + assert!( + is_truncation_exempt(tool), + "{tool} must be truncation-exempt" + ); + } + // Sampling tools: exempt from compaction only. + for tool in SAMPLING_TOOLS { + assert!( + is_compaction_exempt(tool), + "{tool} must be compaction-exempt" + ); + assert!( + !is_truncation_exempt(tool), + "{tool} must remain subject to the char cap / shared byte-budget backstop" + ); + } + // An arbitrary non-listed tool: exempt from neither. + assert!(!is_compaction_exempt("some_other_tool")); + assert!(!is_truncation_exempt("some_other_tool")); + } + // ── CostBudgetMiddleware ──────────────────────────────────────────────── #[tokio::test] @@ -3891,6 +4350,169 @@ mod tests { ); } + // ── RepeatedToolFailureMiddleware body-level ok:false (flows breaker) ──── + + /// A `ToolResult::success` (no `error`) whose JSON body carries a top-level + /// `"ok": false` — the shape `validate_workflow` / `dry_run_workflow` return + /// for an invalid graph / aborted sandbox run. + fn body_failure_result(name: &str, extra: serde_json::Value) -> TaToolResult { + let mut body = json!({ "ok": false }); + if let serde_json::Value::Object(map) = extra { + body.as_object_mut().unwrap().extend(map); + } + tool_result(name, &serde_json::to_string_pretty(&body).unwrap()) + } + + #[test] + fn is_body_level_failure_detects_validate_and_dry_run_only() { + assert!(is_body_level_failure( + "validate_workflow", + r#"{"ok": false, "errors": ["bad node"]}"#, + )); + assert!(is_body_level_failure( + "dry_run_workflow", + r#"{"sandbox": true, "ok": false, "error": "aborted"}"#, + )); + // ok:true never counts as a failure. + assert!(!is_body_level_failure( + "validate_workflow", + r#"{"ok": true}"#, + )); + // A different tool's ok:false is not reinterpreted as a failure — it may + // be legitimate data. + assert!(!is_body_level_failure( + "some_other_tool", + r#"{"ok": false}"#, + )); + // Tolerant of non-JSON / missing `ok`: never guess. + assert!(!is_body_level_failure("validate_workflow", "not json")); + assert!(!is_body_level_failure("validate_workflow", r#"{}"#)); + } + + #[tokio::test] + async fn repeated_validate_workflow_ok_false_trips_the_breaker() { + // The bug: `validate_workflow` reports an invalid graph via a `success` + // result body-level `"ok": false`, never `result.error` — so the breaker + // must synthesize a failure signal from the body or it burns the whole + // iteration budget on a graph it can never fix. + let handle = SteeringHandle::allow_all(); + let mw = RepeatedToolFailureMiddleware::new( + handle.clone(), + 3, + std::sync::Arc::new(std::sync::Mutex::new(None)), + ); + let mut halted = false; + // Same invalid graph re-validated repeatedly (same content each time, no + // `error` field): well within the varied-failure any-failure backstop + // (halts at 6 consecutive) even before the identical-repeat threshold. + for _ in 0..8 { + let mut r = body_failure_result( + "validate_workflow", + json!({ "errors": ["node 'x' has no outgoing edge"] }), + ); + assert!(r.error.is_none(), "the tool call itself did not error"); + mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + if drain_pause_count(&handle) > 0 { + halted = true; + break; + } + } + assert!( + halted, + "repeated validate_workflow ok:false must trip the no-progress breaker" + ); + } + + #[tokio::test] + async fn single_or_unrelated_ok_false_does_not_falsely_trip_the_breaker() { + let handle = SteeringHandle::allow_all(); + let mw = RepeatedToolFailureMiddleware::new( + handle.clone(), + 3, + std::sync::Arc::new(std::sync::Mutex::new(None)), + ); + // A single validate_workflow ok:false is not a loop. + let mut r = body_failure_result("validate_workflow", json!({})); + mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + assert_eq!( + drain_pause_count(&handle), + 0, + "a single body-level failure must not halt" + ); + + // An unrelated tool's ok:false, repeated, must never be reinterpreted as + // a failure signal — it may be legitimate data from that tool. + for _ in 0..8 { + let mut r = body_failure_result("some_other_tool", json!({ "count": 0 })); + mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + } + assert_eq!( + drain_pause_count(&handle), + 0, + "an unrelated tool's ok:false must not trip the breaker" + ); + assert!( + handle.drain().is_empty(), + "an unrelated tool's ok:false must not even nudge the run" + ); + } + + #[tokio::test] + async fn existing_error_is_some_behavior_is_unchanged_by_body_level_check() { + // Regression guard: a real `result.error` (no body-level ok:false at all) + // must still drive the breaker exactly as before — three identical + // failures halt, matching `repeated_tool_failure_pauses_only_after_the_threshold`. + let handle = SteeringHandle::allow_all(); + let mw = RepeatedToolFailureMiddleware::new( + handle.clone(), + 3, + std::sync::Arc::new(std::sync::Mutex::new(None)), + ); + for _ in 0..2 { + let mut r = failing_result("flaky", "boom"); + mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + } + assert_eq!( + drain_pause_count(&handle), + 0, + "no halt before the threshold" + ); + let mut r = failing_result("flaky", "boom"); + mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + assert_eq!( + drain_pause_count(&handle), + 1, + "error.is_some() behavior must be unchanged by the body-level check" + ); + + // A tool result with BOTH `error` set AND a body-level ok:false must not + // be double-counted — it is still exactly one failed attempt per call. + let handle2 = SteeringHandle::allow_all(); + let mw2 = RepeatedToolFailureMiddleware::new( + handle2.clone(), + 3, + std::sync::Arc::new(std::sync::Mutex::new(None)), + ); + for _ in 0..2 { + let mut r = body_failure_result("validate_workflow", json!({})); + r.error = Some("validation failed".to_string()); + mw2.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + } + assert_eq!( + drain_pause_count(&handle2), + 0, + "two identical error+ok:false results are one repeat each, not two — below the halt threshold" + ); + let mut r = body_failure_result("validate_workflow", json!({})); + r.error = Some("validation failed".to_string()); + mw2.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + assert_eq!( + drain_pause_count(&handle2), + 1, + "the third identical error+ok:false result halts, same as a plain error" + ); + } + // ── ApprovalSecurityMiddleware ────────────────────────────────────────── #[test] diff --git a/src/openhuman/tinyagents/mod.rs b/src/openhuman/tinyagents/mod.rs index 4ba7386b55..4412ae314f 100644 --- a/src/openhuman/tinyagents/mod.rs +++ b/src/openhuman/tinyagents/mod.rs @@ -79,7 +79,7 @@ pub(crate) use embeddings::ProviderEmbeddingModel; pub(crate) use middleware::{ HandoffConfig, SuperContextConfig, TranscriptSnapshotSink, TurnContextMiddleware, }; -use model::ProviderModel; +use model::{BuiltTurnModels, ProviderModel, TierRoutes, TurnChatModel}; pub(crate) use observability::SubagentScope; use observability::{ CapPauser, IterationCursor, OpenhumanEventBridge, ProviderUsageCarry, ToolFailureMap, @@ -1172,13 +1172,13 @@ fn tinyagents_depth_error( /// `Provider` → `ChatModel` adaptation is confined to `build_turn_models`. pub(crate) struct TurnModels { /// The turn's effective/primary model (registry default + dispatch target). - primary: Arc>, + primary: TurnChatModel, /// Additive workload-tier routes (registry name → model), excluding the /// primary; the crate registry resolves fallback/selection across them. - routes: Vec<(String, Arc>)>, + routes: TierRoutes, /// A model for the context-window summarizer (a distinct adapter instance so /// its provider errors don't touch the turn's `error_slot`). - summarizer: Arc>, + summarizer: TurnChatModel, /// Recovers the primary's original (downcastable) provider error on failure. error_slot: crate::openhuman::tinyagents::model::ProviderErrorSlot, /// Provider telemetry id (`{provider_id}.{model}` in Langfuse), captured from @@ -1307,26 +1307,25 @@ fn build_turn_models_crate( // The primary honours an explicit provider-string override when the producer's // effective provider differs from `provider_for_role(role)` (triage #1257). - let build_primary = - |m: &str| -> anyhow::Result>> { - match primary_override { - Some(ps) => factory::create_turn_chat_model_from_string_with_native_tools( - role, - ps, - config, - m, - temperature, - !force_text_mode, - ), - None => factory::create_turn_chat_model_with_native_tools( - role, - config, - m, - temperature, - !force_text_mode, - ), - } - }; + let build_primary = |m: &str| -> anyhow::Result { + match primary_override { + Some(ps) => factory::create_turn_chat_model_from_string_with_native_tools( + role, + ps, + config, + m, + temperature, + !force_text_mode, + ), + None => factory::create_turn_chat_model_with_native_tools( + role, + config, + m, + temperature, + !force_text_mode, + ), + } + }; // Build the primary, every workload-tier route, and the summarizer under one // per-turn egress-dedup ledger: each managed construction resolves through the @@ -1334,44 +1333,39 @@ fn build_turn_models_crate( // separate `ExternalTransferPending` for the same logical destination (codex // P2, PR #4812). `dedup_turn_scope` collapses same-destination repeats to one // disclosure per turn while still surfacing each distinct tier model. - #[allow(clippy::type_complexity)] - let (primary, routes, summarizer): ( - Arc>, - Vec<(String, Arc>)>, - Arc>, - ) = crate::openhuman::security::egress::dedup_turn_scope(|| { - let primary = build_primary(model)?; - - // Additive workload-tier routes: one crate-native model per tier (skipping the - // turn's own model, which is registered as the default primary), each pinned to - // the tier alias so the crate registry resolves cross-route fallback across them. - let mut routes: Vec<(String, Arc>)> = - Vec::new(); - for &tier in routes::WORKLOAD_ROUTE_TIERS { - if tier == model { - continue; - } - let tier_role = factory::role_for_model_tier(tier); - match factory::create_turn_chat_model(tier_role, config, tier, temperature) { - Ok(route_model) => routes.push((tier.to_string(), route_model)), - Err(e) => { - // A route that can't be built (e.g. an unconfigured BYOK tier) is - // skipped, not fatal — the primary still dispatches (parity with the - // `Provider` path, where an unresolved tier simply isn't registered). - tracing::debug!( - route = tier, - error = %e, - "[models] skipping crate-native workload route that failed to build" - ); + let (primary, routes, summarizer): BuiltTurnModels = + crate::openhuman::security::egress::dedup_turn_scope(|| { + let primary = build_primary(model)?; + + // Additive workload-tier routes: one crate-native model per tier (skipping the + // turn's own model, which is registered as the default primary), each pinned to + // the tier alias so the crate registry resolves cross-route fallback across them. + let mut routes: TierRoutes = Vec::new(); + for &tier in routes::WORKLOAD_ROUTE_TIERS { + if tier == model { + continue; + } + let tier_role = factory::role_for_model_tier(tier); + match factory::create_turn_chat_model(tier_role, config, tier, temperature) { + Ok(route_model) => routes.push((tier.to_string(), route_model)), + Err(e) => { + // A route that can't be built (e.g. an unconfigured BYOK tier) is + // skipped, not fatal — the primary still dispatches (parity with the + // `Provider` path, where an unresolved tier simply isn't registered). + tracing::debug!( + route = tier, + error = %e, + "[models] skipping crate-native workload route that failed to build" + ); + } } } - } - // The summarizer is a distinct adapter instance (own empty error slot). - let summarizer = build_primary(model)?; + // The summarizer is a distinct adapter instance (own empty error slot). + let summarizer = build_primary(model)?; - anyhow::Ok((primary, routes, summarizer)) - })?; + anyhow::Ok((primary, routes, summarizer)) + })?; Ok(TurnModels { primary, @@ -1671,7 +1665,8 @@ struct AssembledTurnHarness { /// Shared `call_id → (success, failure, elapsed_ms, output_chars)` /// side-channel: the tool-outcome capture middleware classifies each outcome /// + records its duration/output size; the event bridge reads it to project - /// real success + a user-facing failure + timing onto `ToolCallCompleted`. + /// + /// Real success + a user-facing failure + timing onto `ToolCallCompleted`. failure_map: ToolFailureMap, /// Shared FIFO carry of per-call provider `UsageInfo` (charged USD + context /// window): the model adapter pushes, the event bridge pops when recording diff --git a/src/openhuman/tinyagents/model.rs b/src/openhuman/tinyagents/model.rs index dc8807c505..f6ca880061 100644 --- a/src/openhuman/tinyagents/model.rs +++ b/src/openhuman/tinyagents/model.rs @@ -26,6 +26,10 @@ use crate::openhuman::inference::provider::{ }; use crate::openhuman::tools::ToolSpec; +pub(super) type TurnChatModel = Arc>; +pub(super) type TierRoutes = Vec<(String, TurnChatModel)>; +pub(super) type BuiltTurnModels = (TurnChatModel, TierRoutes, TurnChatModel); + /// Translate a harness [`ModelRequest`] into openhuman's message list + tool /// specs (shared by the buffered and streaming paths). fn build_chat_inputs( diff --git a/src/openhuman/tinyagents/observability.rs b/src/openhuman/tinyagents/observability.rs index c38685a983..8c7bdeea25 100644 --- a/src/openhuman/tinyagents/observability.rs +++ b/src/openhuman/tinyagents/observability.rs @@ -52,6 +52,7 @@ pub(crate) type ToolNameMap = Arc) -> Retriever mod tests { use super::*; use async_trait::async_trait; - use tinyagents::harness::events::{EventListener, EventRecord, RecordingListener}; + use tinyagents::harness::events::{EventListener, EventRecord}; use crate::openhuman::memory::{MemoryCategory, MemoryEntry, NamespaceSummary}; diff --git a/src/openhuman/tinyagents/tests.rs b/src/openhuman/tinyagents/tests.rs index 8c54cf9e24..bce571eeeb 100644 --- a/src/openhuman/tinyagents/tests.rs +++ b/src/openhuman/tinyagents/tests.rs @@ -584,7 +584,6 @@ fn adapter_inventory_registers_model_tools_and_middleware() { // Capability profile (issue #4249, Phase 2): derived from the wrapped // provider plus the runner-threaded token limits. - use tinyagents::harness::model::ChatModel; let registered = assembled .harness .models() diff --git a/src/openhuman/tinycortex/embeddings.rs b/src/openhuman/tinycortex/embeddings.rs index c34c7f8439..76f1557a68 100644 --- a/src/openhuman/tinycortex/embeddings.rs +++ b/src/openhuman/tinycortex/embeddings.rs @@ -4,6 +4,7 @@ //! OpenHuman owns the concrete providers (voyage / openai / cohere / ollama / //! cloud / noop) and the `embeddings/factory.rs` construction policy (rate-limit //! + retry). TinyCortex "never makes a network call" — it takes compute through +//! //! [`EmbeddingBackend`] (the vector store) and [`Embedder`] (retrieval / seal //! scoring). This adapter wraps one `Arc` and re-exposes //! it as both, so the engine drives OpenHuman embeddings without cloning any diff --git a/src/openhuman/tinycortex/sync.rs b/src/openhuman/tinycortex/sync.rs index 37967d7b87..482bc63922 100644 --- a/src/openhuman/tinycortex/sync.rs +++ b/src/openhuman/tinycortex/sync.rs @@ -116,7 +116,7 @@ fn source_sync_context(memory: MemoryClientRef, config: &Config, local: bool) -> documents: adapter.clone(), state: adapter.clone(), local_documents: local.then(|| adapter.clone() as std::sync::Arc), - external_sources: local.then(|| adapter as std::sync::Arc), + external_sources: local.then_some(adapter as std::sync::Arc), summariser: local.then(|| { std::sync::Arc::new(super::HostSummariser::new(config.clone())) as std::sync::Arc diff --git a/src/openhuman/tinyflows/caps.rs b/src/openhuman/tinyflows/caps.rs index c3db0c133e..6e2470b06d 100644 --- a/src/openhuman/tinyflows/caps.rs +++ b/src/openhuman/tinyflows/caps.rs @@ -591,6 +591,7 @@ impl LlmProvider for OpenHumanLlm { /// the node builds a real session agent /// ([`Agent::from_config_for_agent`](crate::openhuman::agent::Agent::from_config_for_agent) /// + `set_agent_definition_name`) and drives one full turn via +/// /// [`Agent::run_single`](crate::openhuman::agent::Agent::run_single) — the /// complete tool loop. The definition's `ToolScope` / `sandbox_mode` / /// `max_iterations` govern the turn, so an agent node gains its curated @@ -680,6 +681,35 @@ pub(crate) fn scale_timeout_for_iteration_cap( } } +/// Resolves the actual wall-clock timeout for one agent-node harness turn, +/// combining [`clamp_run_timeout_secs`] and [`scale_timeout_for_iteration_cap`] +/// per the post-merge Codex P2 finding on issue #4868's iteration-cap timeout +/// scaling: **an explicit `timeout_secs` the flow author set on the node must +/// never be scaled up.** +/// +/// A node's `timeout_secs` can be an intentional fast-fail/SLA bound (e.g. +/// `timeout_secs: 120` to bound a health-check-style agent call) — scaling +/// that up to match a 50-iteration-cap agent would silently defeat the +/// author's explicit choice. So the iteration-cap scaling only ever widens +/// the *default* (no `timeout_secs` supplied) 240s bound; an explicit value is +/// clamped to `10..=600` (as it always was) and returned as-is. +/// +/// `requested_timeout_secs` is the raw `request["timeout_secs"]` (before +/// clamping) so this function can distinguish "caller supplied a value" from +/// "caller supplied nothing" — [`clamp_run_timeout_secs`] alone collapses that +/// distinction into a plain `u64`. +pub(crate) fn resolve_run_timeout_secs( + requested_timeout_secs: Option, + effective_iteration_cap: usize, +) -> u64 { + let base_timeout_secs = clamp_run_timeout_secs(requested_timeout_secs); + if requested_timeout_secs.is_some() { + base_timeout_secs + } else { + scale_timeout_for_iteration_cap(base_timeout_secs, effective_iteration_cap) + } +} + /// Renders an agent-node completion `request` into the single user message /// [`Agent::run_single`](crate::openhuman::agent::Agent::run_single) takes: the /// `prompt` string when present and non-empty, else the `messages` array @@ -923,19 +953,24 @@ impl OpenHumanAgentRunner { let prompt = build_harness_run_prompt(&request); - let base_timeout_secs = - clamp_run_timeout_secs(request.get("timeout_secs").and_then(Value::as_u64)); + let requested_timeout_secs = request.get("timeout_secs").and_then(Value::as_u64); + let base_timeout_secs = clamp_run_timeout_secs(requested_timeout_secs); // Issue #4868 — the session builder now stamps `agent_ref`'s own // `effective_max_iterations()` onto the agent (instead of the global // default of 10), so `code_executor`/`tools_agent`/etc. can run up to // 50 iterations here. Read the cap actually applied to `agent` // (reflects the definition cap or the global fallback, whichever the - // builder resolved) and scale the timeout accordingly — see + // builder resolved) and scale the DEFAULT timeout accordingly — see // `scale_timeout_for_iteration_cap`. + // + // Post-merge Codex P2 finding: an EXPLICIT `timeout_secs` the node + // config supplied is a caller-chosen bound (e.g. a fast-fail/SLA of + // 120s) and must be honored as-is, never scaled up just because the + // agent's iteration cap is high — see `resolve_run_timeout_secs`. let effective_iteration_cap = agent.agent_config().max_tool_iterations; let timeout_secs = - scale_timeout_for_iteration_cap(base_timeout_secs, effective_iteration_cap); + resolve_run_timeout_secs(requested_timeout_secs, effective_iteration_cap); tracing::debug!( target: "flows", @@ -943,6 +978,7 @@ impl OpenHumanAgentRunner { node_model = node_model.as_deref().unwrap_or(""), default_model = effective.default_model.as_deref().unwrap_or(""), effective_iteration_cap, + explicit_timeout_secs = requested_timeout_secs.is_some(), base_timeout_secs, timeout_secs, prompt_len = prompt.len(), @@ -1165,6 +1201,71 @@ impl AgentRunner for SchemaAwareMockAgentRunner { } } +/// A **dry-run-only** [`LlmProvider`] mock that, unlike the vendored crate's +/// `tinyflows::caps::mock::MockLlm`, respects an `agent` node's +/// `config.output_parser.schema` when synthesizing its completion. +/// +/// This closes the OTHER half of the same gap [`SchemaAwareMockAgentRunner`] +/// closes. The vendored `agent` node only routes to an [`AgentRunner`] when the +/// node carries a **non-empty `agent_ref`** AND the host wired an agent registry +/// (`vendor/tinyflows/src/nodes/integration/agent.rs`, `run_turn`: +/// `(Some(agent_ref), Some(runner)) => runner.run_agent(...)`); **every other +/// case** — and builder-generated agent nodes carry NO `agent_ref` — falls back +/// to `ctx.caps.llm.complete(cfg.clone(), conn)`. So in the sandbox those plain +/// agent nodes never reach `SchemaAwareMockAgentRunner` at all: they hit the +/// `llm` slot, which (with the vendored `MockLlm`) echoes +/// `{ "completion": , "connection": }`. The agent node's +/// output-parser sub-port then validates that echo against the declared schema +/// (`schema::parse_and_validate` — it validates the WHOLE completion value, not +/// a `.text` field), no field matches, and it falls to a one-shot LLM auto-fix +/// that the same `MockLlm` also can't satisfy — so the dry run errors with +/// `output_parser: value failed schema validation after auto-fix: missing +/// required property ...` even for a workflow a real run would execute cleanly. +/// This false-failure burned many dry-run cycles for correctly-built graphs. +/// +/// When `request` (the node config the node hands to `complete` — see the +/// `_ => ctx.caps.llm.complete(cfg.clone(), conn)` arm above) carries a non-null +/// `output_parser.schema`, this returns [`placeholder_for_schema`] DIRECTLY. +/// The sub-port receives that already-schema-valid object as its `value` +/// (`validate` returns no errors), so it returns `Ok` WITHOUT ever invoking the +/// auto-fix LLM path — exactly the shape the vendored validator's +/// `type`/`required`/`enum` checks accept, with no real model call. With no +/// schema, it mirrors the vendored `MockLlm` echo shape byte-for-byte +/// (`{ "completion": request, "connection": conn }`) so schema-less agent +/// dry-run behavior — and downstream `=nodes..item.json.completion...` +/// bindings — stay identical to today. +#[derive(Debug, Default, Clone)] +pub struct SchemaAwareMockLlm; + +#[async_trait] +impl LlmProvider for SchemaAwareMockLlm { + async fn complete(&self, request: Value, conn: Option<&str>) -> Result { + let schema = request + .get("output_parser") + .and_then(|parser| parser.get("schema")) + .filter(|schema| !schema.is_null()); + match schema { + Some(schema) => { + let placeholder = placeholder_for_schema(schema); + tracing::debug!( + target: "flows", + "[flows] dry_run: schema-aware mock LLM synthesized a placeholder \ + matching output_parser.schema (plain agent node, no agent_ref)" + ); + Ok(placeholder) + } + None => { + tracing::debug!( + target: "flows", + "[flows] dry_run: schema-aware mock LLM has no output_parser.schema — \ + mirroring the vendored MockLlm echo shape" + ); + Ok(json!({ "completion": request, "connection": conn })) + } + } + } +} + /// Builds a placeholder JSON value satisfying `schema`'s `properties`/`type` /// constraints, for [`SchemaAwareMockAgentRunner`]. Only the shallow, top-level /// `properties` map is populated — enough for the minimal validator in @@ -3543,6 +3644,62 @@ mod tests { assert_eq!(out["request"], request); } + // ── SchemaAwareMockLlm ─────────────────────────────────────────────────── + + #[tokio::test] + async fn schema_aware_mock_llm_mirrors_vendored_echo_without_a_schema() { + // No `output_parser.schema`: byte-identical to the vendored `MockLlm` + // so schema-less agent dry runs (which route to the `llm` slot, not the + // runner) keep today's `{ completion, connection }` shape. + let llm = SchemaAwareMockLlm; + let request = json!({ "prompt": "hi" }); + let out = llm + .complete(request.clone(), Some("conn_1")) + .await + .expect("complete"); + assert_eq!(out["completion"], request); + assert_eq!(out["connection"], "conn_1"); + + let without_conn = llm.complete(request, None).await.expect("complete"); + assert!(without_conn["connection"].is_null()); + } + + #[tokio::test] + async fn schema_aware_mock_llm_synthesizes_a_schema_valid_completion() { + // A plain agent node (no `agent_ref`) hands its config to the `llm` + // slot; the returned object must pass the output-parser sub-port's + // validator directly (no auto-fix hop) for every declared type. + let llm = SchemaAwareMockLlm; + let request = json!({ + "prompt": "extract", + "output_parser": { "schema": { "type": "object", + "required": ["email", "count", "active", "meta", "tags"], + "properties": { + "email": { "type": "string" }, + "count": { "type": "integer" }, + "active": { "type": "boolean" }, + "meta": { "type": "object" }, + "tags": { "type": "array" } + } } } + }); + let out = llm.complete(request, None).await.expect("complete"); + assert_eq!(out["email"], ""); + assert_eq!(out["count"], 0); + assert_eq!(out["active"], false); + assert_eq!(out["meta"], json!({})); + assert_eq!(out["tags"], json!([])); + } + + #[tokio::test] + async fn schema_aware_mock_llm_ignores_null_schema() { + // `output_parser: { schema: null }` is treated as "no schema" — the + // vendored echo shape, same as the runner's null-schema handling. + let llm = SchemaAwareMockLlm; + let request = json!({ "prompt": "hi", "output_parser": { "schema": null } }); + let out = llm.complete(request.clone(), None).await.expect("complete"); + assert_eq!(out["completion"], request); + } + #[test] fn placeholder_for_schema_falls_back_to_type_without_properties() { assert_eq!( @@ -4165,6 +4322,29 @@ mod tests { assert_eq!(scale_timeout_for_iteration_cap(240, 200), 600); } + /// Post-merge Codex P2 finding on issue #4868: an explicit `timeout_secs` + /// the node config supplied (a caller-chosen fast-fail/SLA bound) must be + /// honored as-is — never scaled up just because the agent's iteration cap + /// is high — while the absence of one still gets the iteration-cap + /// scaling so a 50-iteration agent isn't killed by the 240s default. + #[test] + fn resolve_run_timeout_secs_preserves_an_explicit_request_even_for_a_high_cap_agent() { + assert_eq!(resolve_run_timeout_secs(Some(120), 50), 120); + } + + #[test] + fn resolve_run_timeout_secs_scales_the_default_up_for_a_high_cap_agent() { + // No explicit timeout_secs (None) -> default 240s, scaled by the + // 50-iteration cap to min(50*12, 600) = 600. + assert_eq!(resolve_run_timeout_secs(None, 50), 600); + } + + #[test] + fn resolve_run_timeout_secs_leaves_low_cap_agents_unscaled_either_way() { + assert_eq!(resolve_run_timeout_secs(None, 10), 240); + assert_eq!(resolve_run_timeout_secs(Some(120), 10), 120); + } + /// Regression for issue #4868: the agent-node runtime path /// (`OpenHumanAgentRunner::run_via_harness`) must build an `Agent` that /// carries `agent_ref`'s definition's effective cap (50 for an diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index 574e27f5fa..5a6a488d2b 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -2668,7 +2668,6 @@ use std::sync::Arc; use tinyplace::signal::session::SignalSession; use tinyplace::signal::store::SessionStore; -use tinyplace::signal::store::SessionStore as _; /// Get the `Arc` from the client or fail with a clear message. /// diff --git a/src/openhuman/todos/graph_shadow.rs b/src/openhuman/todos/graph_shadow.rs index f74be76068..81b2af4f6d 100644 --- a/src/openhuman/todos/graph_shadow.rs +++ b/src/openhuman/todos/graph_shadow.rs @@ -4,8 +4,9 @@ //! //! ADAPTER-FIRST / SHADOW ONLY — nothing in this module changes product //! behavior. The legacy [`TaskBoardStore`](crate::openhuman::agent::task_board) -//! + [`todos::ops`](crate::openhuman::todos::ops) remain the single source of -//! truth. This module (C2b first slice) mirrors post-mutation card snapshots +//! + [`todos::ops`](crate::openhuman::todos::ops) remain the single source of truth. +//! +//! This module (C2b first slice) mirrors post-mutation card snapshots //! into a crate `Store` and shadow-runs the crate `claim_card` CAS purely to //! prove parity ahead of the C2 cutover, logging any divergence. All work is //! best-effort and fire-and-forget: a mirror/claim failure is logged and diff --git a/src/openhuman/tools/impl/filesystem/glob_search.rs b/src/openhuman/tools/impl/filesystem/glob_search.rs index 982c5474f2..c73f15d3cc 100644 --- a/src/openhuman/tools/impl/filesystem/glob_search.rs +++ b/src/openhuman/tools/impl/filesystem/glob_search.rs @@ -301,7 +301,7 @@ fn collect_matches( } // Newest first. - hits.sort_by(|a, b| b.0.cmp(&a.0)); + hits.sort_by_key(|item| std::cmp::Reverse(item.0)); let truncated = hits.len() > max_results; let paths: Vec = hits.into_iter().take(max_results).map(|(_, p)| p).collect(); (paths, truncated) diff --git a/src/openhuman/tools/impl/network/polymarket.rs b/src/openhuman/tools/impl/network/polymarket.rs index c0b3218f9e..0da511fc46 100644 --- a/src/openhuman/tools/impl/network/polymarket.rs +++ b/src/openhuman/tools/impl/network/polymarket.rs @@ -1192,10 +1192,10 @@ impl Tool for PolymarketTool { // rejected by the CLOB. Credential derivation is similarly // single-flight (see ensure_clob_credentials OnceCell). Reads remain // concurrency-safe. - match args.get("action").and_then(Value::as_str) { - Some("place_order") | Some("cancel_order") => false, - _ => true, - } + !matches!( + args.get("action").and_then(Value::as_str), + Some("place_order") | Some("cancel_order") + ) } async fn execute(&self, args: Value) -> Result { diff --git a/src/openhuman/tools/impl/system/schedule.rs b/src/openhuman/tools/impl/system/schedule.rs index 712bc86050..50501839ca 100644 --- a/src/openhuman/tools/impl/system/schedule.rs +++ b/src/openhuman/tools/impl/system/schedule.rs @@ -90,10 +90,10 @@ impl Tool for ScheduleTool { // (create/add/once/cancel/remove/pause/resume) persist or remove a // scheduled job and must go through the ApprovalGate // (GHSA-f46p-6vf9-64mm). - match args.get("action").and_then(|v| v.as_str()) { - Some("list") | Some("get") => false, - _ => true, - } + !matches!( + args.get("action").and_then(|v| v.as_str()), + Some("list") | Some("get") + ) } async fn execute(&self, args: serde_json::Value) -> Result { diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 95fb2736bb..d9203d79b5 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -875,6 +875,9 @@ pub fn all_tools_with_runtime( // Media generation (image/video via GMI through the backend). Skipped when // no integration client is configured; artifacts land under `action_dir`. + // Gated by the `media` compile-time feature (#4804); absent from slim + // builds. Runtime `DomainSet::media` (#4796) still gates it when compiled. + #[cfg(feature = "media")] tools.extend(crate::openhuman::media_generation::build_media_tools( root_config, action_dir, diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 3e1e0d5b7e..9ddb2cfe28 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -260,6 +260,40 @@ fn all_tools_always_registers_curl() { ); } +// Compile-time `media` feature gate (#4804). The media-generation agent tools +// (`media_generate_*`) are present only when the `media` feature is compiled +// in AND an integration client is configured. The disabled build proves the +// module + its single call site drop out entirely (leaf gate, no stub facade). +#[cfg(feature = "media")] +#[test] +fn media_tools_registered_when_feature_on() { + let tmp = TempDir::new().unwrap(); + let cfg = integration_test_config(&tmp, "http://127.0.0.1:1"); + store_test_session_token(&cfg); + let tools = integration_tools_for_config(&tmp, &cfg); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!( + names.contains(&"media_generate_image"), + "media tools must register with the `media` feature on + an integration \ + client; got: {names:?}" + ); +} + +#[cfg(not(feature = "media"))] +#[test] +fn media_tools_absent_when_feature_off() { + let tmp = TempDir::new().unwrap(); + let cfg = integration_test_config(&tmp, "http://127.0.0.1:1"); + store_test_session_token(&cfg); + let tools = integration_tools_for_config(&tmp, &cfg); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!( + !names.iter().any(|n| n.starts_with("media_")), + "no `media_*` tools may be registered when the `media` feature is off; \ + got: {names:?}" + ); +} + #[test] fn all_tools_registers_gitbooks_when_enabled() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/voice/compile_status.rs b/src/openhuman/voice/compile_status.rs new file mode 100644 index 0000000000..c4973cd244 --- /dev/null +++ b/src/openhuman/voice/compile_status.rs @@ -0,0 +1,49 @@ +//! Compile-time visibility into the `voice` gate. +//! +//! Deliberately **ungated**: unlike the rest of the domain, this module is +//! compiled in both feature states, because its whole purpose is to report +//! which state the binary ended up in. It is part of the always-compiled facade +//! described in [`super`], alongside `stub`. + +/// Whether the real voice domain was compiled into this binary. +/// +/// Cargo features are per-crate and invisible to dependents' `#[cfg]`, so a +/// consumer that *requires* voice (the desktop shell) has no other way to detect +/// that it silently got the stubbed build — which is exactly how #4901 shipped: +/// `app/src-tauri/Cargo.toml` set `default-features = false`, dropping the +/// default-ON `voice` feature, so every `openhuman.voice_*` controller went +/// unregistered and answered "unknown method" at runtime. +/// +/// The shell asserts this at compile time (`const _: () = assert!(...)` in +/// `app/src-tauri/src/lib.rs`), turning that silent runtime failure into a build +/// failure. +pub const VOICE_COMPILED_IN: bool = cfg!(feature = "voice"); + +#[cfg(test)] +mod tests { + use super::VOICE_COMPILED_IN; + + /// Pins the constant to the gate rather than to a hardcoded value: the + /// assertion inverts with the feature, so it holds for both the default + /// build and the slim (`--no-default-features`) build. + #[test] + fn reports_the_compiled_gate_state() { + assert_eq!(VOICE_COMPILED_IN, cfg!(feature = "voice")); + } + + /// The default build ships voice; this is the state the desktop app + /// requires (#4901). Skipped when the slim build is under test. + #[test] + #[cfg(feature = "voice")] + fn is_true_when_the_voice_feature_is_on() { + assert!(VOICE_COMPILED_IN); + } + + /// The slim build must report honestly, otherwise the shell's const assert + /// would pass against a stubbed core and #4901 could ship again. + #[test] + #[cfg(not(feature = "voice"))] + fn is_false_when_the_voice_feature_is_off() { + assert!(!VOICE_COMPILED_IN); + } +} diff --git a/src/openhuman/voice/factory/tests.rs b/src/openhuman/voice/factory/tests.rs index a5884ff09c..7ad2d2fa6d 100644 --- a/src/openhuman/voice/factory/tests.rs +++ b/src/openhuman/voice/factory/tests.rs @@ -2,14 +2,12 @@ use super::entry::{ create_stt_provider, create_tts_provider, default_stt_provider, default_tts_provider, - DEFAULT_WHISPER_MODEL, WHISPER_MODEL_PRESETS, + WHISPER_MODEL_PRESETS, }; use super::helpers::{effective_stt_provider, effective_tts_provider, split_slug_model}; use super::stt_providers::WhisperSttProvider; use super::traits::SttProvider; -use crate::openhuman::config::schema::voice_providers::{ - SttApiStyle, TtsApiStyle, VoiceCapability, -}; +use crate::openhuman::config::schema::voice_providers::{SttApiStyle, VoiceCapability}; use crate::openhuman::config::Config; fn cfg() -> Config { diff --git a/src/openhuman/voice/mod.rs b/src/openhuman/voice/mod.rs index 993dca1479..50b3508d5d 100644 --- a/src/openhuman/voice/mod.rs +++ b/src/openhuman/voice/mod.rs @@ -23,6 +23,12 @@ //! (`cargo check --no-default-features --features ""`): any //! signature drift fails that build. +// Ungated — part of the always-compiled facade (see the module docs above): it +// reports which side of the gate this binary landed on, so it must exist in +// both states. +pub mod compile_status; +pub use compile_status::VOICE_COMPILED_IN; + #[cfg(feature = "voice")] pub mod always_on; #[cfg(feature = "voice")] diff --git a/src/openhuman/voice/schemas/mod.rs b/src/openhuman/voice/schemas/mod.rs index e4900a2bdc..034276fba0 100644 --- a/src/openhuman/voice/schemas/mod.rs +++ b/src/openhuman/voice/schemas/mod.rs @@ -23,9 +23,8 @@ use helpers::{ }; #[cfg(test)] use params::{ - OverlaySttNotifyParams, OverlaySttState, ReplySynthesizeParams, SetProvidersParams, - SttDispatchParams, TranscribeBytesParams, TranscribeParams, TtsDispatchParams, TtsParams, - VoiceListModelsParams, VoiceProviderCredUpdate, VoiceTestProviderParams, + SetProvidersParams, SttDispatchParams, TranscribeBytesParams, TranscribeParams, + TtsDispatchParams, TtsParams, VoiceListModelsParams, VoiceTestProviderParams, VoiceUpdateProviderSettingsParams, }; diff --git a/src/openhuman/voice/server.rs b/src/openhuman/voice/server.rs index eaa354f84e..d6cc73118d 100644 --- a/src/openhuman/voice/server.rs +++ b/src/openhuman/voice/server.rs @@ -537,7 +537,7 @@ impl HotkeyListenerKind { fn start_hotkey_listener( hotkey_str: &str, mode: hotkey::ActivationMode, - server_cancel: &CancellationToken, + _server_cancel: &CancellationToken, ) -> Result< ( HotkeyListenerKind, @@ -548,7 +548,7 @@ fn start_hotkey_listener( #[cfg(target_os = "macos")] { if hotkey_str.trim().eq_ignore_ascii_case("fn") { - return start_globe_hotkey_listener(mode, server_cancel); + return start_globe_hotkey_listener(mode, _server_cancel); } // rdev calls TSMGetInputSourceProperty off the main thread; macOS 26 // enforces main-queue-only access and crashes the process. Only the diff --git a/src/openhuman/wallet/chains/btc.rs b/src/openhuman/wallet/chains/btc.rs index 1a5d117bce..49f8b50f77 100644 --- a/src/openhuman/wallet/chains/btc.rs +++ b/src/openhuman/wallet/chains/btc.rs @@ -167,7 +167,7 @@ fn select_utxos( fee_sats: u64, ) -> Result<(Vec, u64), String> { let mut sorted = utxos.to_vec(); - sorted.sort_by(|a, b| b.value.cmp(&a.value)); + sorted.sort_by_key(|item| std::cmp::Reverse(item.value)); let target = amount_sats .checked_add(fee_sats) .ok_or_else(|| "amount + fee overflow".to_string())?; diff --git a/src/openhuman/webview_accounts/ops.rs b/src/openhuman/webview_accounts/ops.rs index 7b45682b76..67b9db7309 100644 --- a/src/openhuman/webview_accounts/ops.rs +++ b/src/openhuman/webview_accounts/ops.rs @@ -34,7 +34,7 @@ struct Provider { /// Providers tracked in the webview-account snapshot. Keep this list aligned /// with the webview accounts system in `app/src-tauri/src/webview_accounts/`. -pub(crate) const PROVIDERS: &[Provider] = &[ +const PROVIDERS: &[Provider] = &[ Provider { key: "gmail", host_suffixes: &[".google.com"], diff --git a/src/openhuman/webview_apis/client.rs b/src/openhuman/webview_apis/client.rs index a5bc26ad5c..8435dcf39a 100644 --- a/src/openhuman/webview_apis/client.rs +++ b/src/openhuman/webview_apis/client.rs @@ -34,6 +34,8 @@ pub const PORT_ENV: &str = "OPENHUMAN_WEBVIEW_APIS_PORT"; const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); static CLIENT: OnceLock = OnceLock::new(); +type PendingResponse = oneshot::Sender>; +type PendingRequests = Arc>>; fn client() -> &'static Client { CLIENT.get_or_init(Client::new) @@ -75,7 +77,7 @@ where struct Client { next_id: AtomicU64, - pending: Arc>>>>, + pending: PendingRequests, sink: Arc>>>, } diff --git a/src/openhuman/x402/x402_tests.rs b/src/openhuman/x402/x402_tests.rs index 9cfc74cbc1..d1b8746294 100644 --- a/src/openhuman/x402/x402_tests.rs +++ b/src/openhuman/x402/x402_tests.rs @@ -1,8 +1,6 @@ -use base64::engine::{general_purpose::STANDARD as B64, Engine as _}; -use serde_json::json; - use super::ops::{build_evm_payment_with_signer, eip3009_struct_hash, eip712_domain_separator}; use super::types::*; +use base64::engine::{general_purpose::STANDARD as B64, Engine as _}; #[test] fn parse_payment_required_round_trips() { diff --git a/tests/agent_harness_e2e.rs b/tests/agent_harness_e2e.rs index 9f854b7694..8bbd69760a 100644 --- a/tests/agent_harness_e2e.rs +++ b/tests/agent_harness_e2e.rs @@ -1861,7 +1861,8 @@ async fn approval_gate_timeout_inner() { // ─── Task 7: Max iterations + empty provider response ──────────────────────── // // max_iterations_exceeded: -// Default max_tool_iterations = 10 (tool_loop.rs:15). +// The orchestrator's effective max_tool_iterations comes from its agent +// definition (currently 15), rather than the global default of 10. // Circuit breakers (REPEAT_FAILURE_THRESHOLD=3 on failing calls, // NO_PROGRESS_FAILURE_THRESHOLD=6 on consecutive fails) only fire on // success=false outcomes. We must pick a tool that: @@ -1879,9 +1880,9 @@ async fn approval_gate_timeout_inner() { // resolve_time IS in ops.rs (line 192) and in orchestrator named (agent.toml:173). // It's a pure chrono calculation, no I/O, always succeeds. Varying the // `expression` arg (format!("{i}m ago")) gives a different hash each iteration, -// preventing REPEAT_OUTPUT_THRESHOLD from firing. Queuing 12 calls trips the -// max_tool_iterations cap at 10 (DEFAULT_MAX_TOOL_ITERATIONS, tool_loop.rs:15). -// AgentError::MaxIterationsExceeded → "Agent exceeded maximum tool iterations (10)" +// preventing REPEAT_OUTPUT_THRESHOLD from firing. Queuing beyond the +// definition-derived cap trips max_tool_iterations. AgentError::MaxIterationsExceeded → +// "Agent exceeded maximum tool iterations (N)" // (error.rs:89-90; MAX_ITERATIONS_ERROR_PREFIX at error.rs:176). // // empty_provider_response: @@ -1910,11 +1911,19 @@ fn max_iterations_exceeded() { async fn max_iterations_exceeded_inner() { let _lock = env_lock(); - // 12 tool calls > default max_tool_iterations (10). Each uses a unique + init_agent_def_registry(); + let max_iterations = AgentDefinitionRegistry::global() + .and_then(|registry| registry.get("orchestrator")) + .map(|definition| definition.effective_max_iterations()) + .expect("built-in orchestrator definition must exist"); + + // Queue beyond the definition-derived cap. Deriving this count from the + // same definition used by the session builder keeps the regression valid + // when the orchestrator's policy changes. Each call uses a unique // expression to prevent REPEAT_OUTPUT_THRESHOLD from firing first. // resolve_time is a pure computation tool (no I/O) that always succeeds. // The required parameter name is "expr" (resolve_time.rs schema). - let responses: Vec = (0..12) + let responses: Vec = (0..max_iterations + 5) .map(|i| tool_call_completion("resolve_time", json!({ "expr": format!("{}m ago", i + 1) }))) .collect(); reset_script(responses); diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 90668c3263..bcd7c0e0d6 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -485,12 +485,6 @@ fn mock_upstream_router() -> Router { let checkout_url = "http://127.0.0.1/mock-checkout"; let session_id = "cs_mock_abc"; - if checkout_url.is_empty() || session_id.is_empty() { - return Err(error_json( - StatusCode::BAD_REQUEST, - "missing checkoutUrl or sessionId", - )); - } Ok(Json(json!({ "success": true, @@ -501,9 +495,6 @@ fn mock_upstream_router() -> Router { async fn stripe_portal(headers: HeaderMap) -> Result, (StatusCode, Json)> { require_bearer(&headers, BILLING_TOKEN)?; let portal_url = "http://127.0.0.1/mock-portal"; - if portal_url.is_empty() { - return Err(error_json(StatusCode::BAD_REQUEST, "missing portalUrl")); - } Ok(Json(json!({ "success": true, @@ -1141,7 +1132,7 @@ async fn json_rpc_config_update_browser_settings_persists_backend() { let updated = post_json_rpc( &rpc_base, - 4124_1, + 41_241, "openhuman.config_update_browser_settings", json!({ "enabled": true, @@ -1167,7 +1158,7 @@ async fn json_rpc_config_update_browser_settings_persists_backend() { "browser backend should persist in update response: {updated_snapshot}" ); - let get = post_json_rpc(&rpc_base, 4124_2, "openhuman.config_get", json!({})).await; + let get = post_json_rpc(&rpc_base, 41_242, "openhuman.config_get", json!({})).await; let get_result = assert_no_jsonrpc_error(&get, "config_get"); let snapshot = peel_logs_envelope(get_result); assert_eq!( @@ -1180,7 +1171,7 @@ async fn json_rpc_config_update_browser_settings_persists_backend() { let invalid = post_json_rpc( &rpc_base, - 4124_3, + 41_243, "openhuman.config_update_browser_settings", json!({ "backend": "netscape" @@ -1201,7 +1192,7 @@ async fn json_rpc_tokenjuice_detect_and_cache_stats() { // detect: a JSON array of objects classifies as `json`. let detect = post_json_rpc( &rpc_base, - 1860_1, + 18_601, "openhuman.tokenjuice_detect", json!({ "content": r#"[{"a":1,"b":2},{"a":3,"b":4}]"# }), ) @@ -1215,7 +1206,7 @@ async fn json_rpc_tokenjuice_detect_and_cache_stats() { // cache_stats: returns numeric occupancy fields. let stats = post_json_rpc( &rpc_base, - 1860_2, + 18_602, "openhuman.tokenjuice_cache_stats", json!({}), ) @@ -1240,7 +1231,7 @@ async fn json_rpc_tokenjuice_settings_and_savings() { // CCR token threshold (default 500) and the router master switch. let get = post_json_rpc( &rpc_base, - 1861_1, + 18_611, "openhuman.tokenjuice_settings_get", json!({}), ) @@ -1261,7 +1252,7 @@ async fn json_rpc_tokenjuice_settings_and_savings() { // settings_update: flip the CCR token threshold and confirm it round-trips. let updated = post_json_rpc( &rpc_base, - 1861_2, + 18_612, "openhuman.tokenjuice_settings_update", json!({ "patch": { "ccr_min_tokens": 750 } }), ) @@ -1278,7 +1269,7 @@ async fn json_rpc_tokenjuice_settings_and_savings() { // savings_stats: returns the aggregate shape (total + attribution model + cache). let savings = post_json_rpc( &rpc_base, - 1861_3, + 18_613, "openhuman.tokenjuice_savings_stats", json!({}), ) @@ -1295,7 +1286,7 @@ async fn json_rpc_tokenjuice_settings_and_savings() { // savings_reset: zeroes the totals. let reset = post_json_rpc( &rpc_base, - 1861_4, + 18_614, "openhuman.tokenjuice_savings_reset", json!({}), ) @@ -1312,7 +1303,7 @@ async fn json_rpc_tool_registry_lists_and_gets_entries() { let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; let rpc_base = format!("http://{rpc_addr}"); - let list = post_json_rpc(&rpc_base, 1848_1, "openhuman.tool_registry_list", json!({})).await; + let list = post_json_rpc(&rpc_base, 18_481, "openhuman.tool_registry_list", json!({})).await; let list_result = assert_no_jsonrpc_error(&list, "tool_registry_list"); let tools = list_result .get("tools") @@ -1359,7 +1350,7 @@ async fn json_rpc_tool_registry_lists_and_gets_entries() { let get = post_json_rpc( &rpc_base, - 1848_2, + 18_482, "openhuman.tool_registry_get", json!({ "tool_id": "tools.web_search" }), ) @@ -1381,7 +1372,7 @@ async fn json_rpc_tool_registry_lists_and_gets_entries() { let missing = post_json_rpc( &rpc_base, - 1848_3, + 18_483, "openhuman.tool_registry_get", json!({ "tool_id": "missing.tool" }), ) @@ -1405,7 +1396,7 @@ async fn json_rpc_monitor_list_and_read_surface() { let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; let rpc_base = format!("http://{rpc_addr}"); - let list = post_json_rpc(&rpc_base, 3371_1, "openhuman.monitor_list", json!({})).await; + let list = post_json_rpc(&rpc_base, 33_711, "openhuman.monitor_list", json!({})).await; let list_result = assert_no_jsonrpc_error(&list, "monitor_list"); let monitors = list_result .get("monitors") @@ -1418,7 +1409,7 @@ async fn json_rpc_monitor_list_and_read_surface() { let missing = post_json_rpc( &rpc_base, - 3371_2, + 33_712, "openhuman.monitor_read", json!({ "monitor_id": "mon_missing" }), ) @@ -1447,7 +1438,7 @@ async fn json_rpc_harness_init_status_returns_snapshot_envelope() { // attempt real Python/Node/spaCy downloads). let resp = post_json_rpc( &rpc_base, - 4471_1, + 44_711, "openhuman.harness_init_status", json!({}), ) @@ -1500,7 +1491,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let list = post_json_rpc( &rpc_base, - 2862_1, + 28_621, "openhuman.agent_registry_list", json!({ "include_disabled": true }), ) @@ -1525,7 +1516,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let definitions = post_json_rpc( &rpc_base, - 2862_1_1, + 286_211, "openhuman.agent_list_definitions", json!({}), ) @@ -1560,7 +1551,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let missing = post_json_rpc( &rpc_base, - 2862_10, + 286_210, "openhuman.agent_registry_get", json!({ "id": "does_not_exist" }), ) @@ -1572,7 +1563,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let update_default = post_json_rpc( &rpc_base, - 2862_11, + 286_211, "openhuman.agent_registry_update", json!({ "id": "researcher", @@ -1604,7 +1595,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let update_missing = post_json_rpc( &rpc_base, - 2862_12, + 286_212, "openhuman.agent_registry_update", json!({ "id": "missing_agent", "enabled": false }), ) @@ -1622,7 +1613,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let disabled = post_json_rpc( &rpc_base, - 2862_2, + 28_622, "openhuman.agent_registry_set_enabled", json!({ "id": "code_executor", "enabled": false }), ) @@ -1645,7 +1636,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let visible = post_json_rpc( &rpc_base, - 2862_3, + 28_623, "openhuman.agent_registry_list", json!({}), ) @@ -1664,7 +1655,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let all_after_disable = post_json_rpc( &rpc_base, - 2862_13, + 286_213, "openhuman.agent_registry_list", json!({ "include_disabled": true }), ) @@ -1689,7 +1680,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let reenabled = post_json_rpc( &rpc_base, - 2862_14, + 286_214, "openhuman.agent_registry_set_enabled", json!({ "id": "code_executor", "enabled": true }), ) @@ -1704,7 +1695,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let disabled_orchestrator = post_json_rpc( &rpc_base, - 2862_31, + 286_231, "openhuman.agent_registry_set_enabled", json!({ "id": "orchestrator", "enabled": false }), ) @@ -1724,7 +1715,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let update_orchestrator_disabled = post_json_rpc( &rpc_base, - 2862_32, + 286_232, "openhuman.agent_registry_update", json!({ "id": "orchestrator", "enabled": false }), ) @@ -1744,7 +1735,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let created = post_json_rpc( &rpc_base, - 2862_4, + 28_624, "openhuman.agent_registry_create_custom", json!({ "id": "custom_writer", @@ -1780,7 +1771,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let get_custom = post_json_rpc( &rpc_base, - 2862_5, + 28_625, "openhuman.agent_registry_get", json!({ "id": "custom_writer" }), ) @@ -1797,7 +1788,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let updated_custom = post_json_rpc( &rpc_base, - 2862_15, + 286_215, "openhuman.agent_registry_update", json!({ "id": "custom_writer", @@ -1838,7 +1829,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let reenabled_custom = post_json_rpc( &rpc_base, - 2862_16, + 286_216, "openhuman.agent_registry_set_enabled", json!({ "id": "custom_writer", "enabled": true }), ) @@ -1853,7 +1844,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let full_upsert = post_json_rpc( &rpc_base, - 2862_17, + 286_217, "openhuman.agent_registry_upsert_custom", json!({ "agent": { @@ -1901,7 +1892,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let visible_after_custom_disable = post_json_rpc( &rpc_base, - 2862_18, + 286_218, "openhuman.agent_registry_list", json!({}), ) @@ -1922,7 +1913,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let default_collision = post_json_rpc( &rpc_base, - 2862_6, + 28_626, "openhuman.agent_registry_create_custom", json!({ "id": "orchestrator", @@ -1946,7 +1937,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let removed_reviewer = post_json_rpc( &rpc_base, - 2862_19, + 286_219, "openhuman.agent_registry_remove", json!({ "id": "custom_reviewer" }), ) @@ -1960,7 +1951,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let removed_custom = post_json_rpc( &rpc_base, - 2862_7, + 28_627, "openhuman.agent_registry_remove", json!({ "id": "custom_writer" }), ) @@ -1976,7 +1967,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let removed_missing = post_json_rpc( &rpc_base, - 2862_20, + 286_220, "openhuman.agent_registry_remove", json!({ "id": "missing_agent" }), ) @@ -1990,7 +1981,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let reset_default = post_json_rpc( &rpc_base, - 2862_21, + 286_221, "openhuman.agent_registry_remove", json!({ "id": "researcher" }), ) @@ -2004,7 +1995,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let reset_code_executor = post_json_rpc( &rpc_base, - 2862_22, + 286_222, "openhuman.agent_registry_remove", json!({ "id": "code_executor" }), ) @@ -2021,7 +2012,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { let code_executor = post_json_rpc( &rpc_base, - 2862_23, + 286_223, "openhuman.agent_registry_get", json!({ "id": "code_executor" }), ) @@ -2038,7 +2029,7 @@ async fn json_rpc_agent_registry_manages_defaults_and_custom_agents() { // a {name, description} pair whose name is a valid tool_allowlist value. let available_tools = post_json_rpc( &rpc_base, - 2862_24, + 286_224, "openhuman.agent_registry_available_tools", json!({}), ) @@ -2201,12 +2192,11 @@ async fn json_rpc_protocol_auth_and_agent_hello_inner() { Some(thread_id) ); assert!( - sse_event + !sse_event .get("full_response") .and_then(Value::as_str) .unwrap_or_default() - .len() - > 0, + .is_empty(), "expected non-empty chat_done response payload: {sse_event}" ); @@ -3011,7 +3001,7 @@ async fn json_rpc_thread_goal_lifecycle() { // Pull the `{ goal }` payload out of either response shape: a bare // `{ goal }` (get) or the `{ result: { goal }, logs }` CLI envelope // (mutations). - fn goal_of<'a>(result: &'a Value) -> Option<&'a Value> { + fn goal_of(result: &Value) -> Option<&Value> { let envelope = result.get("result").unwrap_or(result); envelope.get("goal").filter(|g| !g.is_null()) } @@ -5535,9 +5525,9 @@ async fn json_rpc_web_chat_custom_chat_provider_with_auth_none_omits_auth_header ) .await; assert_no_jsonrpc_error(&update, "update_model_settings"); - let cfg = post_json_rpc(&rpc_base, 6102_1, "openhuman.config_get", json!({})).await; + let cfg = post_json_rpc(&rpc_base, 61_021, "openhuman.config_get", json!({})).await; let cfg_outer = assert_no_jsonrpc_error(&cfg, "config_get auth-none"); - let cfg_payload = cfg_outer.get("result").unwrap_or(&cfg_outer); + let cfg_payload = cfg_outer.get("result").unwrap_or(cfg_outer); let config = cfg_payload.get("config").unwrap_or(cfg_payload); assert_eq!( config.get("chat_provider").and_then(Value::as_str), @@ -6079,7 +6069,7 @@ async fn json_rpc_app_state_update_local_state_round_trips_into_snapshot() { ) .await; let update_result = assert_no_jsonrpc_error(&update, "app_state_update_local_state"); - let updated_state = update_result.get("result").unwrap_or(&update_result); + let updated_state = update_result.get("result").unwrap_or(update_result); assert_eq!( updated_state.get("encryptionKey").and_then(Value::as_str), Some("secret-key") @@ -6087,7 +6077,7 @@ async fn json_rpc_app_state_update_local_state_round_trips_into_snapshot() { let snapshot = post_json_rpc(&rpc_base, 10042, "openhuman.app_state_snapshot", json!({})).await; let snapshot_result = assert_no_jsonrpc_error(&snapshot, "app_state_snapshot after update"); - let body = snapshot_result.get("result").unwrap_or(&snapshot_result); + let body = snapshot_result.get("result").unwrap_or(snapshot_result); let local_state = body .get("localState") .and_then(Value::as_object) @@ -6275,7 +6265,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { ) .await; let body = assert_no_jsonrpc_error(&assets, "wallet_supported_assets"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); let list = result.as_array().expect("supported_assets array"); // Pin the actual expected multi-chain catalog (not just a lower bound) so a // regression that silently drops a network is caught. @@ -6320,7 +6310,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { // chain_status: every chain is configured, so the provider row is ready. let cs = post_json_rpc(&rpc_base, 2003, "openhuman.wallet_chain_status", json!({})).await; let body = assert_no_jsonrpc_error(&cs, "wallet_chain_status"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); let rows = result.as_array().expect("chain_status array"); // 6 EVM rows (one per L2 / mainnet, incl. BNB Chain) + 3 non-EVM chains. assert_eq!(rows.len(), 9); @@ -6335,7 +6325,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { // BTC + Solana + Tron = 6. let balances = post_json_rpc(&rpc_base, 2004, "openhuman.wallet_balances", json!({})).await; let body = assert_no_jsonrpc_error(&balances, "wallet_balances"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); let rows = result.as_array().expect("balances array"); assert_eq!(rows.len(), 6); assert_eq!( @@ -6369,7 +6359,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { ) .await; let body = assert_no_jsonrpc_error(&prep, "wallet_prepare_transfer"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); let quote_id = result .get("quoteId") .and_then(Value::as_str) @@ -6406,7 +6396,7 @@ async fn json_rpc_wallet_execution_surface_round_trips() { ) .await; let body = assert_no_jsonrpc_error(&exec, "wallet_execute_prepared"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); assert_eq!( result.get("status").and_then(Value::as_str), Some("broadcasted"), @@ -6482,7 +6472,7 @@ async fn json_rpc_wallet_tx_reads_and_web3_gates_round_trip() { ) .await; let body = assert_no_jsonrpc_error(&status, "wallet_tx_status"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); assert_eq!( result.get("state").and_then(Value::as_str), Some("confirmed") @@ -6497,7 +6487,7 @@ async fn json_rpc_wallet_tx_reads_and_web3_gates_round_trip() { ) .await; let body = assert_no_jsonrpc_error(&receipt, "wallet_tx_receipt"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); assert_eq!(result.get("success").and_then(Value::as_bool), Some(true)); assert_eq!(result.get("gasUsed").and_then(Value::as_str), Some("21000")); @@ -6510,7 +6500,7 @@ async fn json_rpc_wallet_tx_reads_and_web3_gates_round_trip() { ) .await; let body = assert_no_jsonrpc_error(&lookup, "wallet_lookup_tx"); - let result = body.get("result").unwrap_or(&body); + let result = body.get("result").unwrap_or(body); assert_eq!(result.get("found").and_then(Value::as_bool), Some(true)); // web3_bridge rejects same-chain requests. This gate runs *before* any @@ -7110,7 +7100,7 @@ async fn json_rpc_wallet_tron_prepare_execute_round_trips() { ); assert_eq!( exec_result.get("transactionHash").and_then(Value::as_str), - Some(format!("{}", "cd".repeat(32)).as_str()), + Some("cd".repeat(32).to_string().as_str()), ); // Native TRX must go through createtransaction, NOT triggersmartcontract. let create_hits = *tron_mock.state.create_hits.lock().unwrap(); @@ -12158,8 +12148,7 @@ async fn json_rpc_task_sources_crud_and_status() { mod task_sources_stub { use async_trait::async_trait; use openhuman_core::openhuman::memory_sync::composio::providers::{ - ComposioProvider, NormalizedTask, ProviderContext, ProviderUserProfile, SyncOutcome, - SyncReason, TaskFetchFilter, + ComposioProvider, NormalizedTask, ProviderContext, ProviderUserProfile, TaskFetchFilter, }; pub struct StubGithubProvider { @@ -13785,7 +13774,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let initial_outer = assert_no_jsonrpc_error(&initial, "get_memory_sync_settings initial"); - let initial_result = initial_outer.get("result").unwrap_or(&initial_outer); + let initial_result = initial_outer.get("result").unwrap_or(initial_outer); assert!( initial_result.get("sync_interval_secs").map(Value::is_null) == Some(true), "default stored value should be null, envelope: {initial_outer}" @@ -13810,7 +13799,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let update_outer = assert_no_jsonrpc_error(&update, "update_memory_sync_settings 4h"); - let update_result = update_outer.get("result").unwrap_or(&update_outer); + let update_result = update_outer.get("result").unwrap_or(update_outer); assert_eq!( update_result .get("sync_interval_secs") @@ -13832,7 +13821,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let after_outer = assert_no_jsonrpc_error(&after, "get_memory_sync_settings after 4h"); - let after_result = after_outer.get("result").unwrap_or(&after_outer); + let after_result = after_outer.get("result").unwrap_or(after_outer); assert_eq!( after_result .get("sync_interval_secs") @@ -13850,7 +13839,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let manual_outer = assert_no_jsonrpc_error(&manual, "update_memory_sync_settings manual"); - let manual_result = manual_outer.get("result").unwrap_or(&manual_outer); + let manual_result = manual_outer.get("result").unwrap_or(manual_outer); assert_eq!( manual_result.get("is_manual").and_then(Value::as_bool), Some(true), @@ -13866,7 +13855,7 @@ async fn json_rpc_memory_sync_settings_roundtrip_interval_and_manual() { ) .await; let manual_get_outer = assert_no_jsonrpc_error(&manual_get, "get_memory_sync_settings manual"); - let manual_get_result = manual_get_outer.get("result").unwrap_or(&manual_get_outer); + let manual_get_result = manual_get_outer.get("result").unwrap_or(manual_get_outer); assert_eq!( manual_get_result.get("is_manual").and_then(Value::as_bool), Some(true), @@ -13918,7 +13907,7 @@ async fn json_rpc_memory_sync_settings_env_override_is_reflected() { ) .await; let outer = assert_no_jsonrpc_error(&resp, "get_memory_sync_settings env-override"); - let result = outer.get("result").unwrap_or(&outer); + let result = outer.get("result").unwrap_or(outer); assert_eq!( result.get("sync_interval_secs").and_then(Value::as_u64), Some(28_800), @@ -14517,7 +14506,7 @@ async fn json_rpc_workflow_run_engine_executes_builtin_to_completion_inner() { // Authenticate so the child agents' provider has a backend session. let store = post_json_rpc( &rpc_base, - 37_500_1, + 375_001, "openhuman.auth_store_session", json!({ "token": "e2e-test-jwt", "user_id": "e2e-user" }), ) @@ -14527,7 +14516,7 @@ async fn json_rpc_workflow_run_engine_executes_builtin_to_completion_inner() { // Sanity: the builtin definition is listed. let defs = post_json_rpc( &rpc_base, - 37_500_2, + 375_002, "openhuman.workflow_run_list_definitions", json!({}), ) @@ -14548,7 +14537,7 @@ async fn json_rpc_workflow_run_engine_executes_builtin_to_completion_inner() { // `modelOverride` pin → every phase hits the mock chat-completions route. let start = post_json_rpc( &rpc_base, - 37_500_3, + 375_003, "openhuman.workflow_run_start", json!({ "definitionId": definition_id, @@ -14662,7 +14651,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Authenticate so the spawned workers' provider has a backend session. let store = post_json_rpc( &rpc_base, - 38_000_1, + 380_001, "openhuman.auth_store_session", json!({ "token": "e2e-test-jwt", "user_id": "e2e-user" }), ) @@ -14672,7 +14661,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Lead creates a team with two named teammates. let created = post_json_rpc( &rpc_base, - 38_000_2, + 380_002, "openhuman.agent_team_create", json!({ "leadAgentId": "lead", @@ -14709,7 +14698,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Task A (no deps) owned by alice; Task B depends on A, owned by bob. let assign_a = post_json_rpc( &rpc_base, - 38_000_3, + 380_003, "openhuman.agent_team_assign_task", json!({ "teamId": team_id, "title": "Task A", "ownerMemberId": alice_id, "dependsOn": [] }), ) @@ -14722,7 +14711,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { .to_string(); let assign_b = post_json_rpc( &rpc_base, - 38_000_4, + 380_004, "openhuman.agent_team_assign_task", json!({ "teamId": team_id, "title": "Task B", "ownerMemberId": bob_id, "dependsOn": [task_a_id.clone()] }), ) @@ -14737,7 +14726,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Lead messages a named teammate (fromMemberId omitted → lead origin). let msg = post_json_rpc( &rpc_base, - 38_000_5, + 380_005, "openhuman.agent_team_message_member", json!({ "teamId": team_id, "toMemberId": alice_id, "content": "please start Task A" }), ) @@ -14747,7 +14736,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // Start alice live on Task A → kind "started". let start_a = post_json_rpc( &rpc_base, - 38_000_6, + 380_006, "openhuman.agent_team_start_member", json!({ "teamId": team_id, "memberId": alice_id, "taskId": task_a_id, "modelOverride": "e2e-mock-model" }), ) @@ -14770,7 +14759,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // With A done, start bob live on Task B. let start_b = post_json_rpc( &rpc_base, - 38_000_7, + 380_007, "openhuman.agent_team_start_member", json!({ "teamId": team_id, "memberId": bob_id, "taskId": task_b_id, "modelOverride": "e2e-mock-model" }), ) @@ -14792,7 +14781,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { // lead message is in the team timeline. let got = post_json_rpc( &rpc_base, - 38_000_8, + 380_008, "openhuman.agent_team_get", json!({ "teamId": team_id }), ) @@ -14833,7 +14822,7 @@ async fn json_rpc_agent_team_live_member_run_roundtrip_inner() { let messages = post_json_rpc( &rpc_base, - 38_000_9, + 380_009, "openhuman.agent_team_list_messages", json!({ "teamId": team_id }), ) diff --git a/tests/keyring_secretstore_e2e.rs b/tests/keyring_secretstore_e2e.rs index 5adaea2b6f..a5d3fa1cd5 100644 --- a/tests/keyring_secretstore_e2e.rs +++ b/tests/keyring_secretstore_e2e.rs @@ -1,10 +1,12 @@ use openhuman_core::openhuman::config::schema::{Config, StreamMode, TelegramConfig}; use openhuman_core::openhuman::keyring; -use std::sync::{Mutex, OnceLock}; +use std::sync::OnceLock; -fn env_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() +async fn env_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await } struct EnvGuard { @@ -45,7 +47,7 @@ impl Drop for EnvGuard { #[tokio::test] async fn config_secrets_roundtrip_via_keyring_backed_master_key_migration() { - let _guard = env_lock(); + let _guard = env_lock().await; let tmp = tempfile::tempdir().expect("tempdir"); let openhuman_dir = tmp.path().join("user-123"); let workspace_dir = openhuman_dir.join("workspace"); diff --git a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs index 6865e8da0c..37de29f215 100644 --- a/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs +++ b/tests/raw_coverage/agent_session_round24_raw_coverage_e2e.rs @@ -411,6 +411,10 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { let calls = Arc::new(AtomicUsize::new(0)); let hook_calls = Arc::new(AtomicUsize::new(0)); let hook_contexts = Arc::new(Mutex::new(Vec::new())); + // The wrap-up ignores the no-tools instruction and emits another + // prompt-formatted tool call plus a streamed delta. Validation must reject + // both before progress consumers see them, then use the deterministic + // checkpoint fallback. let provider = ScriptedProvider::with_stream( vec![ xml_tool_response("alpha"), @@ -454,7 +458,7 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { let answer = agent.turn("hit the cap").await.unwrap(); assert!(answer.contains("I reached the tool-call limit for this turn (1 steps)")); - // The unified TurnEngine digest uses `- round24_echo [ok]: ...` format (no backticks). + // The deterministic checkpoint lists each executed tool, e.g. ``- `round24_echo` — ok``. assert!(answer.contains("round24_echo")); assert_eq!(calls.load(Ordering::SeqCst), 1); wait_for_hook_calls(&hook_calls, 1).await; @@ -480,13 +484,13 @@ async fn max_iteration_checkpoint_uses_deterministic_fallback_and_hooks() { while let Ok(event) = progress_rx.try_recv() { streamed.push(event); } - assert!(streamed.iter().any(|event| matches!( + assert!(!streamed.iter().any(|event| matches!( event, openhuman_core::openhuman::agent::progress::AgentProgress::TextDelta { delta, iteration: 2 } if delta == "checkpoint delta" - ))); + )), "rejected checkpoint deltas must not leak to progress consumers"); } #[tokio::test] diff --git a/tests/subconscious_conversation_e2e.rs b/tests/subconscious_conversation_e2e.rs index 8b4e2fd6cb..713c940ceb 100644 --- a/tests/subconscious_conversation_e2e.rs +++ b/tests/subconscious_conversation_e2e.rs @@ -217,11 +217,11 @@ impl SessionExecutor for ScriptedSession { // ───────────────────────────────────────────────────────────────────────────── /// Serializes tests that touch the process-global event bus. -fn bus_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| StdMutex::new(())) +async fn bus_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) .lock() - .unwrap_or_else(|p| p.into_inner()) + .await } struct Harness { @@ -350,7 +350,7 @@ fn human_msg(channel: &str, sender: &str, message: &str) -> DomainEvent { #[tokio::test] async fn conversation_human_delegates_then_subagent_reports_back() { - let _g = bus_lock(); + let _g = bus_lock().await; let h = Harness::new(OrchestratorConfig::default()); // Human asks for deep work. @@ -382,7 +382,7 @@ async fn conversation_human_delegates_then_subagent_reports_back() { #[tokio::test] async fn conversation_subagent_failure_recovers_with_retry() { - let _g = bus_lock(); + let _g = bus_lock().await; let h = Harness::new(OrchestratorConfig::default()); // Inject a sub-agent FAILURE conclusion directly (as if a prior spawn failed). @@ -408,7 +408,7 @@ async fn conversation_subagent_failure_recovers_with_retry() { #[tokio::test] async fn conversation_interleaved_traffic_is_handled() { - let _g = bus_lock(); + let _g = bus_lock().await; let h = Harness::new(OrchestratorConfig::default()); // Routine cron tick — gate should drop it (no session run). @@ -446,7 +446,7 @@ async fn conversation_interleaved_traffic_is_handled() { #[tokio::test] async fn conversation_promotion_budget_caps_a_burst() { - let _g = bus_lock(); + let _g = bus_lock().await; // The scripted gate intentionally has no promotion budget (that lives in // the real GatePass), so this scenario isolates the *rate limiter*: a // flood of distinct user messages from one source is capped by the token diff --git a/tests/transcript_search_e2e.rs b/tests/transcript_search_e2e.rs index c2cecc013c..84b1f8b9f0 100644 --- a/tests/transcript_search_e2e.rs +++ b/tests/transcript_search_e2e.rs @@ -13,7 +13,7 @@ //! Run with: `cargo test --test transcript_search_e2e` use std::path::Path; -use std::sync::{Mutex, OnceLock}; +use std::sync::OnceLock; use serde_json::json; use tempfile::tempdir; @@ -53,13 +53,13 @@ impl Drop for EnvVarGuard { } /// Serialises tests: `HOME` + `OPENHUMAN_WORKSPACE` are process-global. -static ENV_LOCK: OnceLock> = OnceLock::new(); +static ENV_LOCK: OnceLock> = OnceLock::new(); -fn env_lock() -> std::sync::MutexGuard<'static, ()> { +async fn env_lock() -> tokio::sync::MutexGuard<'static, ()> { ENV_LOCK - .get_or_init(|| Mutex::new(())) + .get_or_init(|| tokio::sync::Mutex::new(())) .lock() - .expect("env lock poisoned") + .await } // ── Fixture helpers ────────────────────────────────────────────────────────── @@ -143,7 +143,7 @@ fn seed_workspace(workspace: &Path) -> ConversationStore { /// scopes the hit to the thread that actually contains it. #[tokio::test] async fn transcript_search_op_finds_message_in_prior_thread() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -174,7 +174,7 @@ async fn transcript_search_op_finds_message_in_prior_thread() { /// orchestrator can use to omit the active chat it already has in hand. #[tokio::test] async fn transcript_search_op_honours_exclude_thread() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -195,7 +195,7 @@ async fn transcript_search_op_honours_exclude_thread() { /// A query that matches nothing returns no hits (not an error). #[tokio::test] async fn transcript_search_op_returns_empty_on_no_match() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -215,7 +215,7 @@ async fn transcript_search_op_returns_empty_on_no_match() { /// source thread and quotes a snippet of the matched message. #[tokio::test] async fn transcript_search_tool_formats_hits_for_the_agent() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -247,7 +247,7 @@ async fn transcript_search_tool_formats_hits_for_the_agent() { /// can record "nothing in past chats" and move on. #[tokio::test] async fn transcript_search_tool_reports_no_match_cleanly() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -276,7 +276,7 @@ async fn transcript_search_tool_reports_no_match_cleanly() { /// clean no-match line. #[tokio::test] async fn transcript_search_tool_excludes_named_thread() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); @@ -301,7 +301,7 @@ async fn transcript_search_tool_excludes_named_thread() { /// searches all — this pins the empty-string contract regardless.) #[tokio::test] async fn transcript_search_tool_empty_exclude_searches_all_threads() { - let _lock = env_lock(); + let _lock = env_lock().await; let tmp = tempdir().expect("tempdir"); let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); let workspace = tmp.path().join("workspace"); diff --git a/vendor/tinyflows b/vendor/tinyflows index f18238904f..e5327de9f6 160000 --- a/vendor/tinyflows +++ b/vendor/tinyflows @@ -1 +1 @@ -Subproject commit f18238904f48c395dce99c0c1d759c3dd1bfb678 +Subproject commit e5327de9f602c1fbbf72d45150729f45b91fa3a8