chore(release): promote main → release (22 commits) (#4936)

Co-authored-by: Cyrus Gray <144336577+graycyrus@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Co-authored-by: oxoxDev <164490987+oxoxDev@users.noreply.github.com>
Co-authored-by: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
Co-authored-by: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mega Mind
2026-07-16 04:19:28 +05:30
committed by GitHub
co-authored by Cyrus Gray github-actions[bot] <github-actions[bot]@users.noreply.github.com> Steven Enamakel oxoxDev YellowSnnowmann Steven Enamakel CodeGhost21 Claude Opus 4.8
parent f4b8534d59
commit abffbe6427
211 changed files with 7902 additions and 1897 deletions
+23 -4
View File
@@ -338,6 +338,7 @@ jobs:
with:
workspaces: |
. -> target
app/src-tauri -> target
cache-on-failure: true
# shared-key (not key) so the cache name is stable and not suffixed
# with the job id. Swatinem caches the full target/, which is why we
@@ -349,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:
+6 -6
View File
@@ -93,10 +93,10 @@ pnpm compile
COMPILE_EXIT=$?
set -e
# Run Rust compile checks for both the core and Tauri codebases
# Run Clippy for both Rust codebases; Clippy also performs compile checks.
set +e
pnpm rust:check
RUST_CHECK_EXIT=$?
pnpm rust:clippy
RUST_CLIPPY_EXIT=$?
set -e
# Enforce scoped cmd-* tokens in components/commands/
@@ -106,7 +106,7 @@ CMD_TOKENS_EXIT=$?
set -e
# Exit with error if any command still fails after fixes
if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CHECK_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then
if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CLIPPY_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then
echo
echo "============================================================"
echo "Pre-push checks failed."
@@ -116,10 +116,10 @@ if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ] ||
echo " git add -A && git commit -m 'chore: apply auto-fixes'"
echo " git push"
fi
if [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CHECK_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then
if [ $COMPILE_EXIT -ne 0 ] || [ $RUST_CLIPPY_EXIT -ne 0 ] || [ $CMD_TOKENS_EXIT -ne 0 ]; then
echo "Fix the remaining errors above (TypeScript / Rust / cmd-tokens)"
echo "before re-pushing — these have no auto-fix path."
fi
echo "============================================================"
exit 1
fi
fi
+3
View File
@@ -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.
+26 -1
View File
@@ -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 "<explicit list without media>"`.
# 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.
+1 -1
View File
@@ -62,7 +62,7 @@
"rust:check": "cargo check --manifest-path src-tauri/Cargo.toml",
"rust:format": "cargo fmt --manifest-path ../Cargo.toml --all && cargo fmt --manifest-path src-tauri/Cargo.toml --all",
"rust:format:check": "cargo fmt --manifest-path ../Cargo.toml --all --check && cargo fmt --manifest-path src-tauri/Cargo.toml --all --check",
"rust:clippy": "cargo clippy -p openhuman -- -D warnings",
"rust:clippy": "cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings",
"format": "prettier --write . && pnpm rust:format",
"format:check": "prettier --check . && pnpm rust:format:check",
"lint": "eslint . --ext .ts,.tsx --cache",
+8
View File
@@ -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",
+26 -2
View File
@@ -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" }
-236
View File
@@ -1,236 +0,0 @@
//! Thin helpers around `Input.dispatchMouseEvent` and
//! `Input.dispatchKeyEvent` so providers can drive web UIs without
//! touching the page's JavaScript.
//!
//! All coordinates are CSS pixels relative to the viewport — the same
//! frame `DOMSnapshot.captureSnapshot(includeDOMRects=true)` returns
//! bounding rects in. Callers typically pair these with
//! [`crate::cdp::Snapshot::rect`] to find the click target.
//!
//! Everything here is CEF-only — CDP requires a remote-debugging port,
//! which wry doesn't expose.
//!
//! # Cookbook
//!
//! ```ignore
//! let snap = Snapshot::capture_with_rects(&mut cdp, &session).await?;
//! let idx = snap.find_descendant(0, |s, i| s.attr(i, "aria-label") == Some("Search mail"))
//! .ok_or("search box not found")?;
//! let rect = snap.rect(idx).ok_or("search box has no layout rect")?;
//! let (cx, cy) = rect.center();
//! input::click(&mut cdp, &session, cx, cy).await?;
//! input::type_text(&mut cdp, &session, "from:linkedin.com").await?;
//! input::press_key(&mut cdp, &session, Key::Enter).await?;
//! ```
use serde_json::{json, Value};
use super::CdpConn;
#[allow(dead_code)] // helper is used by currently gated input paths.
#[cfg(target_os = "macos")]
const SELECT_ALL_MODIFIER: u32 = 4;
#[allow(dead_code)] // helper is used by currently gated input paths.
#[cfg(not(target_os = "macos"))]
const SELECT_ALL_MODIFIER: u32 = 2;
/// Names recognised by `Input.dispatchKeyEvent`'s `key` field. We
/// hand-pick the ones Gmail's keyboard handlers care about so callers
/// can use a typed value rather than stringly-typed literals scattered
/// across providers.
#[allow(dead_code)] // variants reserved for upcoming providers / write ops.
#[derive(Debug, Clone, Copy)]
pub enum Key {
Enter,
Escape,
Tab,
Backspace,
ArrowDown,
ArrowUp,
}
impl Key {
/// `(key, code, windowsVirtualKeyCode)` triple. Gmail's listeners
/// branch on different fields depending on browser; we set all three
/// to maximise compatibility.
fn cdp_fields(self) -> (&'static str, &'static str, u32) {
match self {
Key::Enter => ("Enter", "Enter", 13),
Key::Escape => ("Escape", "Escape", 27),
Key::Tab => ("Tab", "Tab", 9),
Key::Backspace => ("Backspace", "Backspace", 8),
Key::ArrowDown => ("ArrowDown", "ArrowDown", 40),
Key::ArrowUp => ("ArrowUp", "ArrowUp", 38),
}
}
}
/// Click at `(x, y)` — left button, no modifiers, single click.
/// Issues mouseMoved → mousePressed → mouseReleased so hover handlers
/// (Gmail's search-box has one) fire correctly before the click.
pub async fn click(cdp: &mut CdpConn, session: &str, x: f64, y: f64) -> Result<(), String> {
log::debug!("[cdp::input] click session={session} x={x:.1} y={y:.1}");
let _ = mouse_event(cdp, session, "mouseMoved", x, y, 0).await?;
let _ = mouse_event(cdp, session, "mousePressed", x, y, 1).await?;
let _ = mouse_event(cdp, session, "mouseReleased", x, y, 1).await?;
log::debug!("[cdp::input] click complete session={session}");
Ok(())
}
async fn mouse_event(
cdp: &mut CdpConn,
session: &str,
kind: &str,
x: f64,
y: f64,
click_count: u32,
) -> Result<Value, String> {
log::debug!(
"[cdp::input] mouse_event session={session} kind={kind} x={x:.1} y={y:.1} clicks={click_count}"
);
cdp.call(
"Input.dispatchMouseEvent",
json!({
"type": kind,
"x": x,
"y": y,
"button": "left",
"buttons": if kind == "mousePressed" { 1 } else { 0 },
"clickCount": click_count,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchMouseEvent {kind}: {e}"))
}
/// Type a literal string by dispatching one `keyDown`/`char`/`keyUp`
/// triple per character. CDP's `dispatchKeyEvent type=char` is what
/// actually inserts text into focused editable fields — `keyDown`
/// alone leaves the input empty for most letters. The `keyDown`
/// + `keyUp` pair is still needed so listeners (autocomplete,
/// keystroke counters) see a normal keystroke.
pub async fn type_text(cdp: &mut CdpConn, session: &str, text: &str) -> Result<(), String> {
log::debug!(
"[cdp::input] type_text session={session} chars={}",
text.chars().count()
);
for ch in text.chars() {
let s = ch.to_string();
// keyDown — Gmail's command/keyboard router observes these.
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "keyDown",
"text": s,
"unmodifiedText": s,
"key": s,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent keyDown {ch:?}: {e}"))?;
// char — actual text insertion into the focused editable.
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "char",
"text": s,
"unmodifiedText": s,
"key": s,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent char {ch:?}: {e}"))?;
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "keyUp",
"text": s,
"unmodifiedText": s,
"key": s,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent keyUp {ch:?}: {e}"))?;
}
log::debug!("[cdp::input] type_text complete session={session}");
Ok(())
}
/// Press a non-character key (Enter, Esc, …). Sends `rawKeyDown` →
/// `keyUp`; no `char` because non-printables don't insert text.
pub async fn press_key(cdp: &mut CdpConn, session: &str, key: Key) -> Result<(), String> {
let (key_name, code, vk) = key.cdp_fields();
log::debug!("[cdp::input] press_key session={session} key={key_name}");
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "rawKeyDown",
"key": key_name,
"code": code,
"windowsVirtualKeyCode": vk,
"nativeVirtualKeyCode": vk,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent rawKeyDown {key_name}: {e}"))?;
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "keyUp",
"key": key_name,
"code": code,
"windowsVirtualKeyCode": vk,
"nativeVirtualKeyCode": vk,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent keyUp {key_name}: {e}"))?;
log::debug!("[cdp::input] press_key complete session={session} key={key_name}");
Ok(())
}
/// Dispatch Cmd/Ctrl+A to select-all in the focused contenteditable / input.
/// Useful when the search box already has a previous query in it that
/// we need to overwrite — Gmail keeps the last query rendered in the
/// search input so a fresh visit sees stale text.
pub async fn select_all_in_focused(cdp: &mut CdpConn, session: &str) -> Result<(), String> {
log::debug!(
"[cdp::input] select_all_in_focused session={session} modifier={SELECT_ALL_MODIFIER}"
);
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "rawKeyDown",
"key": "a",
"code": "KeyA",
"windowsVirtualKeyCode": 65,
"nativeVirtualKeyCode": 65,
"modifiers": SELECT_ALL_MODIFIER,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent select-all keyDown: {e}"))?;
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "keyUp",
"key": "a",
"code": "KeyA",
"windowsVirtualKeyCode": 65,
"nativeVirtualKeyCode": 65,
"modifiers": SELECT_ALL_MODIFIER,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent select-all keyUp: {e}"))?;
log::debug!("[cdp::input] select_all_in_focused complete session={session}");
Ok(())
}
+6 -14
View File
@@ -6,29 +6,21 @@
//! and `Webview::on_dev_tools_protocol`. There is no listener and no
//! network surface; any same-UID process is shut out by construction.
//!
//! Scanners pick up a [`CdpConn`] either via [`conn_for_account`] (for
//! `acct_<id>`-labelled webviews) or [`conn_for_label`] /
//! [`connect_and_attach_matching_in_process_by_label`] (for other
//! Scanners pick up a [`CdpConn`] either via [`target::conn_for_account`] (for
//! `acct_<id>`-labelled webviews) or [`target::conn_for_label`] /
//! [`target::connect_and_attach_matching_in_process_by_label`] (for other
//! surfaces such as the Meet call window).
pub mod conn;
pub mod in_process;
pub mod input;
pub mod session;
pub mod snapshot;
pub mod target;
pub use conn::CdpConn;
pub use in_process::{
install_for_account, install_for_label, install_for_webview, set_cef_app_handle, CdpRegistry,
EventFrame, WebviewCdpTransport, CALL_TIMEOUT,
};
pub use in_process::{install_for_account, install_for_label, set_cef_app_handle, CdpRegistry};
pub use session::{
placeholder_marker, placeholder_url, spawn_session, target_url_fragment, SpawnedSession,
};
#[allow(unused_imports)] // `Rect` re-export consumed once turn 2 lands; keep stable.
pub use snapshot::{Rect, Snapshot};
pub use target::{
conn_for_account, conn_for_label, connect_and_attach_matching_in_process,
connect_and_attach_matching_in_process_by_label, detach_session, find_page_target_where,
};
pub use snapshot::Snapshot;
pub use target::{detach_session, find_page_target_where};
+2 -2
View File
@@ -24,8 +24,8 @@ use tokio::task::JoinHandle;
// elapsed check honours `tokio::time::pause()` / `advance()` in unit tests.
use tokio::time::{sleep, Instant};
use super::find_page_target_where;
use super::target::conn_for_account;
use super::{find_page_target_where, CdpConn};
use crate::webview_accounts::{emit_load_finished, redact_url_for_log, RevealTrigger};
/// Backoff between failed attach attempts / reconnects. Intentionally
@@ -517,7 +517,7 @@ async fn run_session_cycle<R: Runtime>(
// page reaches the CEF helper's notify-IPC, which posts back to
// `forward_native_notification` in `webview_accounts`. Without it,
// the constructor silently no-ops and no toast ever fires (#1016).
if let Some(origin) = origin_of(&real_url) {
if let Some(origin) = origin_of(real_url) {
// Default permission set every embedded provider needs. Origin-scoped
// so we don't leak grants across providers running in the same CEF
// browser process.
+77 -104
View File
@@ -33,35 +33,6 @@ struct CaptureSnapshot {
struct DocumentSnap {
#[serde(default)]
nodes: NodeTreeSnap,
#[serde(default)]
layout: LayoutSnap,
}
/// Subset of `documents[i].layout` we care about. Each layout node
/// references a DOM node by index and carries its `[x, y, w, h]` bounds
/// in CSS pixels. Only populated when `includeDOMRects: true` is passed
/// to `DOMSnapshot.captureSnapshot`.
#[derive(Deserialize, Debug, Default)]
struct LayoutSnap {
#[serde(rename = "nodeIndex", default)]
node_index: Vec<i32>,
#[serde(default)]
bounds: Vec<Vec<f64>>,
}
/// Element bounding rect in CSS pixels relative to the viewport.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
impl Rect {
pub fn center(self) -> (f64, f64) {
(self.x + self.width / 2.0, self.y + self.height / 2.0)
}
}
#[derive(Deserialize, Debug, Default)]
@@ -82,46 +53,38 @@ pub struct Snapshot {
strings: Vec<String>,
nodes: NodeTreeSnap,
children: Vec<Vec<usize>>,
/// `rects[node_idx]` is `Some(Rect)` when layout info was requested
/// AND the node has a layout box (text + element nodes do; pure
/// metadata like `<head>` doesn't). `None` otherwise.
rects: Vec<Option<Rect>>,
}
impl Snapshot {
/// Run `DOMSnapshot.captureSnapshot` on an attached session and return
/// the parsed main-document tree. Iframes are ignored — none of the
/// migrated providers render chat lists inside iframes.
/// one parsed tree containing the main document and any iframe documents.
pub async fn capture(cdp: &mut CdpConn, session: &str) -> Result<Self, String> {
Self::capture_inner(cdp, session, false).await
}
/// Same as [`capture`] but also requests element bounding rects.
/// Use when the caller needs to drive `Input.dispatchMouseEvent` —
/// pulling rects on every snapshot adds protocol overhead, so the
/// cheap path stays cheap.
pub async fn capture_with_rects(cdp: &mut CdpConn, session: &str) -> Result<Self, String> {
Self::capture_inner(cdp, session, true).await
}
async fn capture_inner(
cdp: &mut CdpConn,
session: &str,
with_rects: bool,
) -> Result<Self, String> {
log::debug!("[cdp::snapshot] capture start session={session}");
let raw = cdp
.call(
"DOMSnapshot.captureSnapshot",
json!({
"computedStyles": [],
"includePaintOrder": false,
"includeDOMRects": with_rects,
}),
capture_request(),
Some(session),
)
.await?;
let snap: CaptureSnapshot =
serde_json::from_value(raw).map_err(|e| format!("decode DOMSnapshot: {e}"))?;
.await
.map_err(|error| {
log::warn!("[cdp::snapshot] capture call failed session={session} error={error}");
error
})?;
log::debug!("[cdp::snapshot] capture call complete session={session}");
let snap: CaptureSnapshot = serde_json::from_value(raw).map_err(|error| {
log::warn!("[cdp::snapshot] decode failed session={session} error={error}");
format!("decode DOMSnapshot: {error}")
})?;
let snapshot = Self::from_capture(snap);
log::debug!(
"[cdp::snapshot] decode complete session={session} nodes={}",
snapshot.len()
);
Ok(snapshot)
}
fn from_capture(snap: CaptureSnapshot) -> Self {
let strings = snap.strings;
// Merge every document (main frame + all iframes) into a single
// flat node array. CDP returns each frame as its own document
@@ -138,12 +101,9 @@ impl Snapshot {
let mut merged_node_name: Vec<i32> = Vec::new();
let mut merged_node_value: Vec<i32> = Vec::new();
let mut merged_attributes: Vec<Vec<i32>> = Vec::new();
let mut merged_layout_node_index: Vec<i32> = Vec::new();
let mut merged_layout_bounds: Vec<Vec<f64>> = Vec::new();
for document in snap.documents {
let doc_offset = merged_node_type.len() as i32;
let doc_nodes = document.nodes;
let doc_count = doc_nodes.node_type.len();
for &p in &doc_nodes.parent_index {
merged_parent_index.push(if p < 0 { -1 } else { p + doc_offset });
}
@@ -162,12 +122,6 @@ impl Snapshot {
while merged_attributes.len() < merged_node_type.len() {
merged_attributes.push(Vec::new());
}
// Per-document layout indices need the same offset.
for &li in &document.layout.node_index {
merged_layout_node_index.push(if li < 0 { -1 } else { li + doc_offset });
}
merged_layout_bounds.extend(document.layout.bounds);
let _ = doc_count;
}
let nodes = NodeTreeSnap {
parent_index: merged_parent_index,
@@ -176,10 +130,6 @@ impl Snapshot {
node_value: merged_node_value,
attributes: merged_attributes,
};
let layout = LayoutSnap {
node_index: merged_layout_node_index,
bounds: merged_layout_bounds,
};
let count = nodes.node_type.len();
let mut children: Vec<Vec<usize>> = vec![Vec::new(); count];
for (i, &p) in nodes.parent_index.iter().enumerate() {
@@ -187,34 +137,11 @@ impl Snapshot {
children[p as usize].push(i);
}
}
let mut rects: Vec<Option<Rect>> = vec![None; count];
if with_rects {
for (layout_i, &node_i) in layout.node_index.iter().enumerate() {
if node_i < 0 {
continue;
}
let node_i = node_i as usize;
if node_i >= count {
continue;
}
let bounds = match layout.bounds.get(layout_i) {
Some(b) if b.len() >= 4 => b,
_ => continue,
};
rects[node_i] = Some(Rect {
x: bounds[0],
y: bounds[1],
width: bounds[2],
height: bounds[3],
});
}
}
Ok(Self {
Self {
strings,
nodes,
children,
rects,
})
}
}
pub fn len(&self) -> usize {
@@ -268,13 +195,6 @@ impl Snapshot {
self.children.get(idx).map(|v| v.as_slice()).unwrap_or(&[])
}
/// Layout rect for `idx`. `None` when the snapshot was captured
/// without `includeDOMRects` OR the node has no layout box (e.g.
/// `<head>`, comment nodes, `display:none` elements).
pub fn rect(&self, idx: usize) -> Option<Rect> {
self.rects.get(idx).copied().flatten()
}
/// Depth-first pre-order walk of every descendant of `root` (including
/// `root` itself). Cheap enough for chat-list scrapes that run every
/// 2 seconds — DOM has thousands of nodes, not millions.
@@ -336,6 +256,14 @@ impl Snapshot {
}
}
fn capture_request() -> serde_json::Value {
json!({
"computedStyles": [],
"includePaintOrder": false,
"includeDOMRects": false,
})
}
fn collapse_ws(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut last_space = true;
@@ -357,6 +285,51 @@ fn collapse_ws(s: &str) -> String {
mod tests {
use super::*;
#[test]
fn capture_request_disables_dom_rects() {
let request = capture_request();
assert_eq!(request["includeDOMRects"], false);
assert_eq!(request["includePaintOrder"], false);
assert_eq!(request["computedStyles"], json!([]));
}
#[test]
fn from_capture_offsets_documents_and_builds_child_adjacency_without_layout() {
let capture: CaptureSnapshot = serde_json::from_value(json!({
"strings": ["DIV", "first", "SPAN", "second"],
"documents": [
{
"nodes": {
"parentIndex": [-1, 0],
"nodeType": [1, 3],
"nodeName": [0, -1],
"nodeValue": [-1, 1]
}
},
{
"nodes": {
"parentIndex": [-1, 0],
"nodeType": [1, 3],
"nodeName": [2, -1],
"nodeValue": [-1, 3]
}
}
]
}))
.expect("snapshot fixture should decode without layout data");
let snapshot = Snapshot::from_capture(capture);
assert_eq!(snapshot.len(), 4);
assert_eq!(snapshot.children(0), &[1]);
assert_eq!(snapshot.children(2), &[3]);
assert!(snapshot.children(1).is_empty());
assert_eq!(snapshot.tag(0), "DIV");
assert_eq!(snapshot.tag(2), "SPAN");
assert_eq!(snapshot.text_content(0), "first");
assert_eq!(snapshot.text_content(2), "second");
}
#[test]
fn collapse_ws_collapses_and_trims() {
assert_eq!(collapse_ws(" hello world "), "hello world");
-6
View File
@@ -18,7 +18,6 @@ pub struct CdpTarget {
pub id: String,
pub kind: String,
pub url: String,
pub title: String,
}
/// Parse the response of a `Target.getTargets` CDP call into a list of
@@ -38,11 +37,6 @@ pub fn parse_targets(v: &Value) -> Vec<CdpTarget> {
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string(),
title: t
.get("title")
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string(),
})
})
.collect()
+1 -1
View File
@@ -61,7 +61,7 @@ end tell"#;
Err(_) => continue,
}
}
return Err("no terminal emulator found (tried x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm). Run `claude login` manually.".into());
Err("no terminal emulator found (tried x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm). Run `claude login` manually.".into())
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
+4 -2
View File
@@ -108,13 +108,15 @@ pub(crate) fn try_forward_deep_links() -> ForwardResult {
// Pending URLs collected before setup() has an app handle.
static PENDING_URLS: OnceLock<Arc<Mutex<Vec<String>>>> = OnceLock::new();
// Live handler installed by drain_pending_urls — dispatches directly to app.
static LIVE_HANDLER: OnceLock<Mutex<Option<Box<dyn Fn(String) + Send + Sync>>>> = OnceLock::new();
type LiveHandler = Box<dyn Fn(String) + Send + Sync>;
type LiveHandlerSlot = Mutex<Option<LiveHandler>>;
static LIVE_HANDLER: OnceLock<LiveHandlerSlot> = OnceLock::new();
fn pending_queue() -> &'static Arc<Mutex<Vec<String>>> {
PENDING_URLS.get_or_init(|| Arc::new(Mutex::new(Vec::new())))
}
fn live_handler() -> &'static Mutex<Option<Box<dyn Fn(String) + Send + Sync>>> {
fn live_handler() -> &'static LiveHandlerSlot {
LIVE_HANDLER.get_or_init(|| Mutex::new(None))
}
@@ -339,7 +339,6 @@ fn page(id: &str, url: &str) -> crate::cdp::target::CdpTarget {
id: id.to_string(),
kind: "page".to_string(),
url: url.to_string(),
title: String::new(),
}
}
@@ -409,7 +408,6 @@ fn resolve_none_when_no_prefix_target() {
id: "iframe".to_string(),
kind: "iframe".to_string(),
url: "https://discord.com/channels/@me".to_string(),
title: String::new(),
},
];
assert!(
@@ -465,13 +465,6 @@ impl ScannerRegistry {
pub fn new() -> Self {
Self
}
pub fn ensure_scanner<R: tauri::Runtime>(
self: std::sync::Arc<Self>,
_app: tauri::AppHandle<R>,
_account_id: String,
) {
}
pub fn shutdown(&self) {}
}
+19 -5
View File
@@ -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<AppRuntime>) -> Result<(), String> {
perform_early_teardown_async(&app).await;
log::info!("[app] restart_app — early teardown complete, restarting");
app.restart();
// restart() does not return, but we must satisfy the signature
Ok(())
app.restart()
}
/// Read the authoritative active user id from `active_user.toml` so the
@@ -1214,7 +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<AppRuntime>) {
log::info!("[app] perform_early_teardown_async — early teardown complete");
}
/// Explicitly winds down CEF and Tauri before an app.exit(0)
/// Explicitly wind down CEF and the core before exiting from desktop-owned
/// quit actions. Linux does not build the tray or macOS application menu.
#[cfg(not(target_os = "linux"))]
fn shutdown_app_sync(app_handle: &AppHandle<AppRuntime>, exit_code: i32) {
log::info!("[app] shutdown_app_sync — starting early teardown");
perform_early_teardown_sync_once(app_handle, "shutdown_app_sync");
@@ -2,8 +2,8 @@
//! `captions_bridge.js` we install at session start, and forwards each
//! new line to core's `meet_agent_push_caption` RPC.
//!
//! Replaces the old [`super::listen_capture`] (CEF audio handler
//! Whisper STT) which proved unreliable: CEF's `cef_audio_handler_t`
//! Replaces the old CEF audio-handler and Whisper STT path, which proved
//! unreliable: CEF's `cef_audio_handler_t`
//! is queried lazily on first audio output, so a solo agent in a
//! lobby never engaged the pipeline. Captions handle that case for
//! free — Meet's STT is already running, speaker-attributed, and
@@ -34,7 +34,6 @@ const MAX_CONSECUTIVE_ERRORS: u32 = 30;
/// RAII handle. Drop to stop the listener task.
pub struct CaptionListener {
pub request_id: String,
pub(crate) _shutdown_tx: Option<oneshot::Sender<()>>,
}
@@ -88,7 +87,6 @@ pub fn start(request_id: String, cdp: CdpConn, session_id: String) -> CaptionLis
});
CaptionListener {
request_id,
_shutdown_tx: Some(shutdown_tx),
}
}
@@ -1,327 +0,0 @@
//! Capture the embedded Meet webview's audio output and forward it to
//! the core meet_agent loop.
//!
//! ## Pipeline
//!
//! 1. `tauri_runtime_cef::audio::register_audio_handler` taps the
//! per-browser `cef_audio_handler_t`. CEF delivers planar
//! float32 PCM at the renderer's native rate (typically 48 kHz,
//! 12 channels) directly from the audio output device path —
//! *before* it hits the OS speaker. No system permission needed.
//!
//! 2. Downsample-to-mono runs inline on the CEF audio thread:
//! - average across channels → mono float32
//! - linear-interpolate down to 16 kHz (the rate `voice::streaming`
//! and the smoke test in `meet_agent::session` expect)
//! - clamp + scale to PCM16LE
//!
//! 3. Accumulate ~100 ms per chunk (1 600 samples @ 16 kHz). We push
//! via the core RPC on every flush boundary; smaller pushes would
//! overload the JSON envelope, larger ones would slow VAD.
//!
//! 4. RPC pushes are spawned on the tokio runtime so the audio
//! callback never blocks on network IO. A bounded channel
//! backpressures: if core is wedged, we drop the oldest queued
//! chunk rather than holding CEF's audio thread.
use std::sync::{Arc, Mutex};
use tauri_runtime_cef::audio::{
register_audio_handler, AudioHandlerRegistration, AudioStreamEvent,
};
use tokio::sync::mpsc;
const TARGET_SAMPLE_RATE: u32 = 16_000;
/// 100 ms @ 16 kHz mono. `meet_agent::ops::Vad` pushes hangover counts
/// based on per-frame cadence, so changing this changes the VAD wall
/// time too. 100 ms feels responsive without burning RPC.
const FLUSH_SAMPLES: usize = (TARGET_SAMPLE_RATE as usize) / 10;
/// Bounded channel between the CEF callback (producer) and the
/// async-runtime forwarder (consumer). 32 chunks ≈ 3.2 s at the flush
/// cadence — generous slack for transient core latency, but bounded
/// so a wedged core can't OOM us.
const FORWARD_CHANNEL_CAPACITY: usize = 32;
/// RAII handle. Drop to release the CEF audio registration and shut
/// down the forwarder task. Both happen synchronously — the channel
/// closes first, the task exits its recv loop, and the registration
/// drop unhooks CEF in the same tick.
pub struct ListenSession {
pub request_id: String,
_registration: AudioHandlerRegistration,
/// Held so `Drop` closes the channel even if there are no in-flight
/// chunks. The forwarder task observes the close and exits.
_shutdown_tx: mpsc::Sender<Vec<u8>>,
}
/// Opens the audio capture for `meet_url`. The same exact URL must
/// have been used to build the CEF window — `register_audio_handler`
/// matches by prefix.
pub fn start(meet_url: &str, request_id: String) -> Result<ListenSession, String> {
let (tx, rx) = mpsc::channel::<Vec<u8>>(FORWARD_CHANNEL_CAPACITY);
let resampler = Arc::new(Mutex::new(Resampler::new()));
let resampler_for_handler = resampler.clone();
let tx_for_handler = tx.clone();
let request_id_for_log = request_id.clone();
let registration = register_audio_handler(meet_url.to_string(), move |event| {
on_audio_event(
&request_id_for_log,
event,
&resampler_for_handler,
&tx_for_handler,
);
});
spawn_forwarder(request_id.clone(), rx);
log::info!(
"[meet-audio] listen registered request_id={} url_chars={}",
request_id,
meet_url.chars().count()
);
Ok(ListenSession {
request_id,
_registration: registration,
_shutdown_tx: tx,
})
}
/// Process one CEF audio event. Speech/Stopped/Error all flow through
/// here; only `Packet` produces RPC traffic, but the others are logged
/// at info so an aborted call leaves a breadcrumb in the file logs.
fn on_audio_event(
request_id: &str,
event: AudioStreamEvent,
resampler: &Arc<Mutex<Resampler>>,
tx: &mpsc::Sender<Vec<u8>>,
) {
match event {
AudioStreamEvent::Started {
sample_rate_hz,
channels,
frames_per_buffer,
} => {
log::info!(
"[meet-audio] cef stream start request_id={request_id} hz={sample_rate_hz} channels={channels} frames_per_buffer={frames_per_buffer}"
);
if let Ok(mut r) = resampler.lock() {
r.reset(sample_rate_hz as u32);
}
}
AudioStreamEvent::Packet {
channels: planes,
pts_ms: _,
} => {
let pcm_bytes = match resampler.lock() {
Ok(mut r) => r.feed_and_drain(&planes),
Err(_) => return,
};
for chunk in pcm_bytes.chunks(FLUSH_SAMPLES * 2) {
// `try_send` drops the chunk on a full channel rather
// than blocking the CEF audio thread. Better to lose
// a frame than to stall the renderer.
if tx.try_send(chunk.to_vec()).is_err() {
log::warn!(
"[meet-audio] forward channel full; dropping {} bytes request_id={request_id}",
chunk.len()
);
}
}
}
AudioStreamEvent::Stopped => {
log::info!("[meet-audio] cef stream stopped request_id={request_id}");
if let Ok(mut r) = resampler.lock() {
r.reset(0);
}
}
AudioStreamEvent::Error(msg) => {
log::warn!("[meet-audio] cef stream error request_id={request_id} msg={msg}");
}
}
}
/// Pull chunks off the bounded channel and POST each to core. Lives in
/// its own task so the CEF callback never blocks on HTTP.
fn spawn_forwarder(request_id: String, mut rx: mpsc::Receiver<Vec<u8>>) {
tauri::async_runtime::spawn(async move {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
while let Some(chunk) = rx.recv().await {
let pcm_b64 = B64.encode(&chunk);
let res = super::rpc_call(
"openhuman.meet_agent_push_listen_pcm",
serde_json::json!({
"request_id": request_id,
"pcm_base64": pcm_b64,
}),
)
.await;
if let Err(err) = res {
log::debug!(
"[meet-audio] push_listen_pcm err request_id={request_id} bytes={} err={err}",
chunk.len()
);
}
}
log::info!("[meet-audio] forwarder exiting request_id={request_id}");
});
}
/// Stateful float32-planar → PCM16LE mono @ 16 kHz resampler.
///
/// Uses linear interpolation, which is good enough for speech (the
/// downstream STT does not care about ultrasonics or pristine high
/// frequencies). Carry the previous sample across `feed_and_drain`
/// calls so we don't introduce a tick at every CEF buffer boundary.
/// Pick a source sample by signed index. Negative indices return the
/// carry sample from the previous call (so phase < 0 keeps the
/// interpolation continuous across buffer boundaries); past-the-end
/// indices clamp to the last sample (which is what the next call will
/// install as its own carry, so the output stays smooth even if a
/// caller stops feeding mid-stream).
fn sample_at(mono: &[f32], carry: f32, idx: i64) -> f32 {
if idx < 0 {
carry
} else if (idx as usize) < mono.len() {
mono[idx as usize]
} else {
*mono.last().unwrap_or(&0.0)
}
}
struct Resampler {
source_rate_hz: u32,
/// Fractional position into the source buffer between calls.
/// 0.0 means "start cleanly with the next sample". Negative is
/// not used — the source rate is always known before we feed.
phase: f64,
/// Last source sample of the previous call, used as the "left"
/// neighbour when we interpolate the first sample of the next call.
last_sample: f32,
}
impl Resampler {
fn new() -> Self {
Self {
source_rate_hz: 0,
phase: 0.0,
last_sample: 0.0,
}
}
fn reset(&mut self, source_rate_hz: u32) {
self.source_rate_hz = source_rate_hz;
self.phase = 0.0;
self.last_sample = 0.0;
}
fn feed_and_drain(&mut self, planes: &[Vec<f32>]) -> Vec<u8> {
if planes.is_empty() || self.source_rate_hz == 0 {
return Vec::new();
}
let frames = planes[0].len();
if frames == 0 {
return Vec::new();
}
// Mono mix.
let mono: Vec<f32> = (0..frames)
.map(|i| {
let mut sum = 0.0_f32;
for plane in planes {
if let Some(v) = plane.get(i) {
sum += *v;
}
}
sum / planes.len() as f32
})
.collect();
let ratio = self.source_rate_hz as f64 / TARGET_SAMPLE_RATE as f64;
let mut out = Vec::with_capacity((mono.len() as f64 / ratio).ceil() as usize * 2);
// `pos` floats through `mono` indices. `pos < 0` means "still
// sampling the carry sample from the previous call"; `pos = 0`
// means "right at mono[0]".
let mut pos = self.phase;
while pos < mono.len() as f64 {
let idx_f = pos.floor();
let frac = pos - idx_f;
let idx = idx_f as i64;
let s_left = sample_at(mono.as_slice(), self.last_sample, idx);
let s_right = sample_at(mono.as_slice(), self.last_sample, idx + 1);
let sample = s_left as f64 + (s_right as f64 - s_left as f64) * frac;
// Float32 [-1.0, 1.0] → i16. Clamp because Chromium can
// overshoot a touch on heavy compression.
let s_i16 = (sample.clamp(-1.0, 1.0) * i16::MAX as f64) as i16;
out.extend_from_slice(&s_i16.to_le_bytes());
pos += ratio;
}
// Carry the trailing fractional position into the next call.
// It will be negative when we overshot (next call resumes
// mid-source-sample), so the next call interpolates between
// `last_sample` and the new mono[0].
self.phase = pos - mono.len() as f64;
self.last_sample = *mono.last().unwrap_or(&0.0);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resampler_with_no_source_rate_yields_nothing() {
let mut r = Resampler::new();
let out = r.feed_and_drain(&[vec![0.5; 100]]);
assert!(out.is_empty(), "no source rate set, must produce nothing");
}
#[test]
fn resampler_48k_to_16k_mono_drops_samples_3to1() {
let mut r = Resampler::new();
r.reset(48_000);
let plane = vec![0.5_f32; 4_800]; // 100ms @ 48k
let bytes = r.feed_and_drain(&[plane]);
// 100ms @ 16k = 1600 samples * 2 bytes. Allow ±2 samples slop
// from the fractional phase carry.
let samples = bytes.len() / 2;
assert!(
(1598..=1602).contains(&samples),
"expected ~1600 samples, got {samples}"
);
}
#[test]
fn resampler_stereo_to_mono_averages_channels() {
let mut r = Resampler::new();
r.reset(16_000);
let left = vec![0.8_f32; 1600];
let right = vec![-0.2_f32; 1600];
let bytes = r.feed_and_drain(&[left, right]);
// Avg = 0.3 → ~9830 in i16. First two bytes are LE i16.
let first = i16::from_le_bytes([bytes[0], bytes[1]]);
assert!(
(9000..11000).contains(&first),
"expected mid-amplitude i16, got {first}"
);
}
#[test]
fn resampler_clamps_out_of_range_floats() {
let mut r = Resampler::new();
r.reset(16_000);
let bytes = r.feed_and_drain(&[vec![5.0_f32; 100]]);
let first = i16::from_le_bytes([bytes[0], bytes[1]]);
assert_eq!(first, i16::MAX);
}
#[test]
fn resampler_passthrough_when_rates_match() {
let mut r = Resampler::new();
r.reset(16_000);
let plane = vec![0.5_f32; 1600];
let bytes = r.feed_and_drain(&[plane]);
assert_eq!(bytes.len(), 1600 * 2);
}
}
+7 -25
View File
@@ -2,12 +2,8 @@
//!
//! ## Pieces
//!
//! - [`listen_capture`] — taps the embedded Meet webview's audio output
//! via the per-browser `CefAudioHandler` exposed by our vendored
//! `tauri-runtime-cef::audio` extension, downsamples to 16 kHz mono
//! PCM16LE, batches into ~100 ms chunks, and posts them to core via
//! `openhuman.meet_agent_push_listen_pcm`. Zero OS-level audio
//! permission needed: we read frames straight out of the renderer.
//! - [`caption_listener`] — drains Meet's built-in captions through CDP
//! and forwards speaker-attributed lines to core.
//!
//! - [`speak_pump`] — drains synthesized PCM the brain enqueued (via
//! `openhuman.meet_agent_poll_speech`) and writes it into the
@@ -19,15 +15,12 @@
//!
//! [`start`] is invoked once the meet-call window has been built (in
//! `meet_call::meet_call_open_window`). It opens the core session,
//! registers the audio handler keyed by the call's URL, and spawns the
//! poll-speech loop. [`stop`] runs from the window-destroyed handler:
//! it drops the audio handler registration (which silences capture
//! immediately), stops the speak pump, and tells core to close the
//! session and report counters.
//! starts the caption listener and poll-speech loop. [`stop`] runs from
//! the window-destroyed handler: it stops both tasks and tells core to
//! close the session and report counters.
pub mod caption_listener;
pub mod inject;
pub mod listen_capture;
pub mod speak_pump;
use std::collections::HashMap;
@@ -54,14 +47,8 @@ impl MeetAudioState {
/// teardown synchronously — no async drop needed because the caption
/// listener and speak pump both shut down on signal/drop.
///
/// The legacy CEF-audio `listen_capture::ListenSession` is kept as an
/// optional field so the pre-register flow still has somewhere to
/// hand the registration off if a future build re-enables it. In the
/// caption-driven path it stays `None`.
pub struct MeetAudioSession {
pub request_id: String,
_captions: caption_listener::CaptionListener,
_legacy_listen: Option<listen_capture::ListenSession>,
_speak: speak_pump::SpeakPump,
}
@@ -229,9 +216,7 @@ pub async fn start<R: Runtime>(
state.inner.lock().unwrap().insert(
request_id.clone(),
MeetAudioSession {
request_id: request_id.clone(),
_captions: captions,
_legacy_listen: None,
_speak: speak,
},
);
@@ -353,11 +338,8 @@ pub(crate) async fn rpc_call(
/// No-op caption listener used when CDP attach failed at session
/// start. Lets the rest of the lifecycle hold a uniform value.
fn caption_listener_disabled(request_id: String) -> caption_listener::CaptionListener {
caption_listener::CaptionListener {
request_id,
_shutdown_tx: None,
}
fn caption_listener_disabled(_request_id: String) -> caption_listener::CaptionListener {
caption_listener::CaptionListener { _shutdown_tx: None }
}
/// Trim a string for logging without panicking on multi-byte chars.
+2 -7
View File
@@ -48,7 +48,6 @@ const SPEAKING_STATE_EVENT: &str = "meet-video:speaking-state";
/// RAII handle. Drop to stop the pump task. The shutdown channel
/// causes the spawned loop to exit on the next select tick.
pub struct SpeakPump {
pub request_id: String,
_shutdown_tx: Option<oneshot::Sender<()>>,
}
@@ -130,7 +129,6 @@ pub fn start<R: Runtime>(
});
SpeakPump {
request_id,
_shutdown_tx: Some(shutdown_tx),
}
}
@@ -277,11 +275,8 @@ fn next_speaking_state(
/// No-op pump used when bridge install failed at session start. Keeps
/// the rest of the session lifecycle uniform — `MeetAudioSession` can
/// still hold a `SpeakPump` regardless of speak-path readiness.
pub fn start_disabled(request_id: String) -> SpeakPump {
SpeakPump {
request_id,
_shutdown_tx: None,
}
pub fn start_disabled(_request_id: String) -> SpeakPump {
SpeakPump { _shutdown_tx: None }
}
/// Run a single pump tick. Returns `true` when the tick actually
+2 -2
View File
@@ -134,8 +134,8 @@ pub async fn meet_call_open_window<R: Runtime>(
}
// Only one meet-call window can be live at a time — concurrent bot
// sessions race the CEF audio handler registration (`listen_capture`)
// and confuse the user with multiple "Meet — OpenHuman" windows in
// sessions race the shared audio bridge and confuse the user with
// multiple "Meet — OpenHuman" windows in
// their Dock. Close any stragglers from a prior Join before opening
// a fresh one. The CloseRequested handler will tear down their
// scanner + audio session via the per-window event listeners below.
+1 -1
View File
@@ -35,7 +35,7 @@
use std::time::Duration;
use serde_json::{json, Value};
use tauri::{AppHandle, Manager, Runtime};
use tauri::{AppHandle, Runtime};
use crate::cdp::{self, CdpConn};
+7 -6
View File
@@ -191,14 +191,16 @@ async fn handle_connection(
// while waiting for the producer's next tick.
let writer = tokio::spawn(async move {
let initial = latest_rx.borrow().clone();
if !initial.is_empty() {
if sink
if !initial.is_empty()
&& sink
.send(Message::Binary((*initial).clone()))
.await
.is_err()
{
return;
}
{
log::debug!(
"[meet-video] initial WebSocket frame send failed; closing writer peer_transport=disconnected"
);
return;
}
while latest_rx.changed().await.is_ok() {
let frame = latest_rx.borrow().clone();
@@ -237,7 +239,6 @@ async fn handle_connection(
#[cfg(test)]
mod tests {
use super::*;
use futures_util::{SinkExt as _, StreamExt as _};
use tokio_tungstenite::connect_async;
#[tokio::test]
-9
View File
@@ -14,8 +14,6 @@ pub(crate) enum PttError {
EmptyShortcut,
ModifierOnlyShortcut,
ConflictsWithDictation(String),
UnsupportedOnWayland,
RegistrationFailed(String),
}
impl std::fmt::Display for PttError {
@@ -29,13 +27,6 @@ impl std::fmt::Display for PttError {
PttError::ConflictsWithDictation(s) => {
write!(f, "ptt shortcut '{s}' conflicts with the dictation hotkey")
}
PttError::UnsupportedOnWayland => write!(
f,
"global shortcuts are not supported in this Wayland session — switch to X11 or use in-app dictation"
),
PttError::RegistrationFailed(s) => {
write!(f, "failed to register ptt shortcut: {s}")
}
}
}
}
+4 -6
View File
@@ -23,7 +23,7 @@ pub(crate) fn ensure_window<R: Runtime>(app: &AppHandle<R>) -> Result<(), String
return Ok(());
}
let url = WebviewUrl::App("index.html#/ptt-overlay".into());
let mut builder = WebviewWindowBuilder::new(app, OVERLAY_LABEL, url)
let builder = WebviewWindowBuilder::new(app, OVERLAY_LABEL, url)
.title("OpenHuman Push-to-Talk")
.inner_size(160.0, 56.0)
.decorations(false)
@@ -39,11 +39,9 @@ pub(crate) fn ensure_window<R: Runtime>(app: &AppHandle<R>) -> Result<(), String
.visible(false);
#[cfg(target_os = "macos")]
{
builder = builder
.visible_on_all_workspaces(true)
.accept_first_mouse(false);
}
let builder = builder
.visible_on_all_workspaces(true)
.accept_first_mouse(false);
let _window = builder
.build()
+2
View File
@@ -82,6 +82,7 @@ pub struct ScreenSource {
/// What kind of source a parsed DesktopMediaID-format string describes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg(any(target_os = "macos", test))]
pub(crate) enum SourceKind {
Screen,
Window,
@@ -93,6 +94,7 @@ pub(crate) enum SourceKind {
/// match what the enumerator emits. Pure logic so it can be unit-tested
/// without touching platform APIs; macOS callers use it before dispatching
/// to the capture backend.
#[cfg(any(target_os = "macos", test))]
pub(crate) fn parse_source_id(id: &str) -> Option<(SourceKind, u32)> {
let mut parts = id.splitn(3, ':');
let kind = match parts.next()? {
+28 -31
View File
@@ -35,16 +35,16 @@ pub struct ExtractedMessage {
pub ts: String,
}
/// Main entry: walks every record in the dump and returns
/// `(messages, user_id → display_name, channel_id → name, workspace_name)`.
pub fn harvest(
dump: &IdbDump,
) -> (
type HarvestResult = (
Vec<ExtractedMessage>,
HashMap<String, String>,
HashMap<String, String>,
Option<String>,
) {
);
/// Main entry: walks every record in the dump and returns
/// `(messages, user_id → display_name, channel_id → name, workspace_name)`.
pub fn harvest(dump: &IdbDump) -> HarvestResult {
let mut messages: Vec<ExtractedMessage> = Vec::new();
let mut users: HashMap<String, String> = HashMap::new();
let mut channels: HashMap<String, String> = HashMap::new();
@@ -173,15 +173,13 @@ fn walk(
.or_insert_with(|| n.to_string());
}
}
'T' => {
if workspace.is_none() {
if let Some(n) = map
.get("name")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
*workspace = Some(n.to_string());
}
'T' if workspace.is_none() => {
if let Some(n) = map
.get("name")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
*workspace = Some(n.to_string());
}
}
_ => {}
@@ -222,24 +220,23 @@ fn walk(
);
}
}
Value::String(s) => {
// Redux-persist default: values are JSON-encoded strings. If
// this string is plausibly JSON, parse and recurse.
Value::String(s)
if s.len() > 20
&& (s.starts_with('{') || s.starts_with('['))
&& (s.ends_with('}') || s.ends_with(']'))
{
if let Ok(inner) = serde_json::from_str::<Value>(s) {
walk(
&inner,
channel_hint,
messages,
users,
channels,
workspace,
depth + 1,
);
}
&& (s.ends_with('}') || s.ends_with(']')) =>
{
// Redux-persist default: values are JSON-encoded strings. If
// this string is plausibly JSON, parse and recurse.
if let Ok(inner) = serde_json::from_str::<Value>(s) {
walk(
&inner,
channel_hint,
messages,
users,
channels,
workspace,
depth + 1,
);
}
}
_ => {}
+1 -1
View File
@@ -283,7 +283,7 @@ async fn serialize_values(
chunk.len()
));
}
for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) {
for ((idx, _), val) in chunk.iter().zip(serialised) {
result[*idx] = val;
}
}
@@ -166,16 +166,15 @@ fn walk(v: &Value, peer_hint: Option<&str>, out: &mut Harvest, depth: u32) {
walk(vv, peer_hint, out, depth + 1);
}
}
Value::String(s) => {
// Some tweb stores persist state as JSON-encoded strings.
// Recurse when the shape looks plausibly JSON.
Value::String(s)
if s.len() > 20
&& (s.starts_with('{') || s.starts_with('['))
&& (s.ends_with('}') || s.ends_with(']'))
{
if let Ok(inner) = serde_json::from_str::<Value>(s) {
walk(&inner, peer_hint, out, depth + 1);
}
&& (s.ends_with('}') || s.ends_with(']')) =>
{
// Some tweb stores persist state as JSON-encoded strings.
// Recurse when the shape looks plausibly JSON.
if let Ok(inner) = serde_json::from_str::<Value>(s) {
walk(&inner, peer_hint, out, depth + 1);
}
}
_ => {}
+1 -1
View File
@@ -283,7 +283,7 @@ async fn serialize_values(
chunk.len()
));
}
for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) {
for ((idx, _), val) in chunk.iter().zip(serialised) {
result[*idx] = val;
}
}
@@ -23,7 +23,6 @@ pub struct MessageRow {
pub struct DomScan {
pub chat_rows: Vec<ChatRow>,
pub messages: Vec<MessageRow>,
pub active_chat_name: Option<String>,
pub unread: u32,
pub hash: u64,
}
@@ -71,7 +70,6 @@ pub async fn scan(cdp: &mut CdpConn, session: &str) -> Result<DomScan, String> {
Ok(DomScan {
chat_rows,
messages,
active_chat_name,
unread,
hash,
})
+1 -1
View File
@@ -237,7 +237,7 @@ async fn serialize_values(
chunk.len()
));
}
for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) {
for ((idx, _), val) in chunk.iter().zip(serialised) {
result[*idx] = val;
}
}
+1 -3
View File
@@ -29,8 +29,6 @@ use tauri::{AppHandle, Emitter, Runtime};
use tokio::task::AbortHandle;
use tokio::time::sleep;
use crate::cdp::CdpConn;
mod dom_snapshot;
#[cfg(test)]
mod dom_snapshot_tests;
@@ -1209,7 +1207,7 @@ fn merge_dom_into_snapshot(
continue;
}
if let Some(mid) = mid_opt {
let bare_mid = mid.rsplitn(2, '_').next().map(str::to_string);
let bare_mid = mid.rsplit('_').next().map(str::to_string);
let lookup = by_msg_id
.get(&mid)
.cloned()
+12 -5
View File
@@ -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());
});
});
+26 -71
View File
@@ -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 (
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M12 2v10" />
<path d="M18.36 6.64a9 9 0 11-12.73 0" />
</svg>
);
}
function TrashIcon() {
return (
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true">
<path d="M3 6h18M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2m2 0v14a1 1 0 01-1 1H7a1 1 0 01-1-1V6" />
<path d="M10 11v6M14 11v6" />
</svg>
);
}
/** 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 = ({
</div>
{/* 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). */}
<div className="flex flex-shrink-0 items-center gap-1.5">
<Button
type="button"
variant="tertiary"
size="sm"
iconOnly
data-testid={`flow-toggle-${flow.id}`}
aria-label={t('flows.list.toggleEnabled')}
aria-pressed={flow.enabled}
title={flow.enabled ? t('flows.list.enabled') : t('flows.list.paused')}
<SettingsSwitch
id={`flow-switch-${flow.id}`}
checked={flow.enabled}
onCheckedChange={() => onToggle(flow)}
disabled={toggleBusy}
className={
flow.enabled
? 'text-sage-600 dark:text-sage-300'
: 'text-content-faint hover:text-content-secondary'
}
onClick={() => onToggle(flow)}>
<PowerIcon />
</Button>
aria-label={t('flows.list.toggleEnabled')}
data-testid={`flow-toggle-${flow.id}`}
/>
<Button
type="button"
variant="primary"
@@ -187,18 +147,6 @@ const FlowListRow = ({
onClick={() => onRun(flow)}>
<PlayIcon />
</Button>
<Button
type="button"
variant="tertiary"
tone="danger"
size="sm"
iconOnly
data-testid={`flow-delete-${flow.id}`}
aria-label={t('flows.list.delete')}
title={t('flows.list.delete')}
onClick={() => onDelete(flow)}>
<TrashIcon />
</Button>
<FlowRowMenu
rowId={flow.id}
items={[
@@ -220,6 +168,13 @@ const FlowListRow = ({
onSelect: () => onDuplicate(flow),
testId: `flow-duplicate-${flow.id}`,
},
{
key: 'delete',
label: t('flows.list.delete'),
onSelect: () => onDelete(flow),
danger: true,
testId: `flow-delete-${flow.id}`,
},
]}
/>
</div>
@@ -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<FlowRun[]>(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(
<Provider store={store}>
<FlowRunsDrawer flowId="flow-a" onClose={vi.fn()} />
</Provider>
);
// 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(
<Provider store={store}>
<FlowRunsDrawer flowId="flow-b" onClose={vi.fn()} />
</Provider>
);
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();
}
});
});
+48 -8
View File
@@ -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<string | null>(null);
const [selectedRunId, setSelectedRunId] = useState<string | null>(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
<ul className="space-y-2" data-testid="flow-runs-list">
{runs.map(run => {
const startedAt = formatTimestamp(run.started_at);
const displayStatus = resolveDisplayStatus(run, pendingRunIds);
return (
<li key={run.id}>
<button
@@ -188,12 +228,12 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr
className="flex w-full items-center gap-2 rounded-lg border border-line bg-surface-muted px-3 py-2 text-left text-xs hover:bg-surface-hover">
<span
data-testid={`flow-run-row-dot-${run.id}`}
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[run.status]}`}
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[displayStatus]}`}
aria-hidden
/>
<span
className={`inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 font-medium ${FLOW_RUN_STATUS_ACCENT[run.status]}`}>
{t(FLOW_RUN_STATUS_KEY[run.status])}
className={`inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 font-medium ${FLOW_RUN_STATUS_ACCENT[displayStatus]}`}>
{t(FLOW_RUN_STATUS_KEY[displayStatus])}
</span>
{startedAt && (
<span className="truncate text-content-muted">{startedAt}</span>
@@ -21,6 +21,9 @@ import FlowRunsSidebar from './FlowRunsSidebar';
const listFlowRuns = vi.hoisted(() => vi.fn());
vi.mock('../../services/api/flowsApi', () => ({ listFlowRuns }));
const fetchPendingApprovals = vi.hoisted(() => vi.fn());
vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals }));
// Capture the props handed to the drawer so "Fix with agent" can be invoked
// directly without standing up the drawer's own run-polling machinery
// (mirrors `FlowApprovalCard.test.tsx`'s stub pattern).
@@ -97,6 +100,7 @@ describe('FlowRunsSidebar', () => {
beforeEach(() => {
vi.clearAllMocks();
inspectorDrawerProps.current = null;
fetchPendingApprovals.mockResolvedValue([]);
});
it('lists runs and opens the inspector drawer for the clicked run', async () => {
@@ -179,4 +183,33 @@ describe('FlowRunsSidebar', () => {
expect(await screen.findByTestId('flow-runs-sidebar-empty')).toBeInTheDocument();
});
it('shows "Awaiting approval" for a running run halted at an approval gate', async () => {
listFlowRuns.mockResolvedValue([makeRun({ 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-07-13T18:23:00Z',
expires_at: null,
source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' },
},
]);
renderSidebar();
const runRow = await screen.findByTestId('flow-runs-sidebar-run-run-1');
await waitFor(() => expect(runRow).toHaveTextContent('Awaiting approval'));
});
it('leaves a running run without a matching approval labeled "Running"', async () => {
listFlowRuns.mockResolvedValue([makeRun({ status: 'running' })]);
fetchPendingApprovals.mockResolvedValue([]);
renderSidebar();
const runRow = await screen.findByTestId('flow-runs-sidebar-run-run-1');
await waitFor(() => expect(runRow).toHaveTextContent('Running'));
});
});
+37 -25
View File
@@ -3,8 +3,9 @@
* dynamic left sidebar while a flow is open on the canvas (`/flows/:id`). A
* compact, scannable run history (status dot + status + relative time); clicking
* a run opens the full {@link FlowRunInspectorDrawer} (which polls its live
* status). One-shot fetch via `listFlowRuns` with a manual refresh — the engine
* emits no list-level socket events, so this mirrors `FlowRunsDrawer`'s model.
* status). Fetches via `listFlowRuns`, with a manual refresh button plus
* {@link useFlowRunsLiveRefresh} keeping the list itself live while any run
* shown here is still active (no manual refresh/navigate-away required).
*
* Rendered by `FlowCanvasPage` inside a `SidebarContent` portal, so it only
* appears for a persisted flow (a draft has no runs yet).
@@ -13,6 +14,11 @@ import createDebug from 'debug';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
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 { CenteredLoadingState, ErrorBanner } from '../ui/LoadingState';
@@ -98,6 +104,9 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) {
void load();
}, [load]);
useFlowRunsLiveRefresh(runs, load);
const pendingRunIds = useRunsPendingApprovalSet(runs);
return (
<div className="flex h-full flex-col" data-testid="flow-runs-sidebar">
<div className="flex flex-shrink-0 items-center justify-between gap-2 px-3 py-2">
@@ -146,31 +155,34 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) {
)}
<ul className="space-y-1">
{runs.map(run => (
<li key={run.id}>
<button
type="button"
data-testid={`flow-runs-sidebar-run-${run.id}`}
onClick={() => setSelectedRunId(run.id)}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-surface-hover ${
selectedRunId === run.id ? 'bg-surface-hover' : ''
}`}>
<span
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[run.status]}`}
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
{runs.map(run => {
const displayStatus = resolveDisplayStatus(run, pendingRunIds);
return (
<li key={run.id}>
<button
type="button"
data-testid={`flow-runs-sidebar-run-${run.id}`}
onClick={() => setSelectedRunId(run.id)}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-surface-hover ${
selectedRunId === run.id ? 'bg-surface-hover' : ''
}`}>
<span
className={`inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${FLOW_RUN_STATUS_ACCENT[run.status]}`}>
{t(FLOW_RUN_STATUS_KEY[run.status])}
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[displayStatus]}`}
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span
className={`inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${FLOW_RUN_STATUS_ACCENT[displayStatus]}`}>
{t(FLOW_RUN_STATUS_KEY[displayStatus])}
</span>
<span className="mt-0.5 block truncate text-[11px] text-content-faint">
{relativeTime(run.started_at, t)}
</span>
</span>
<span className="mt-0.5 block truncate text-[11px] text-content-faint">
{relativeTime(run.started_at, t)}
</span>
</span>
</button>
</li>
))}
</button>
</li>
);
})}
</ul>
</div>
@@ -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(
<WorkflowCopilotPanel
@@ -315,7 +315,94 @@ describe('WorkflowCopilotPanel', () => {
);
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<void>(resolve => {
resolveSave = resolve;
});
const onAccept = vi.fn().mockReturnValue(savePromise);
hookState.proposal = proposalWith(['a', 'c']);
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={onAccept}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
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(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={onAccept}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
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<void>(resolve => {
resolveSave = resolve;
});
const onAccept = vi.fn().mockReturnValue(savePromise);
const onReject = vi.fn();
hookState.proposal = proposalWith(['a', 'c']);
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={onAccept}
onReject={onReject}
onClose={vi.fn()}
/>
);
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', () => {
@@ -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<void>;
/** 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')}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={acceptSaving}
data-testid="workflow-copilot-reject"
onClick={reject}>
{t('flows.copilot.reject')}
@@ -1,18 +1,30 @@
/**
* EditableFlowCanvas — validation UX (Phase 3c) + draft/dirty state (Phase 3d).
*
* Drives the canvas through the public `FlowCanvas editable` entry point with a
* mocked `flowsApi` so `validateFlow` is deterministic. Covers:
* - an invalid graph shows the inline error banner, rings the offending node,
* and blocks Save;
* - a valid-with-warnings graph surfaces warnings distinctly and allows Save;
* - dirty tracking gates Save/Discard, Discard resets to baseline, and a
* successful Save clears the dirty flag.
* A canvas-refactor moved the Save / Discard / dirty-badge *buttons* out of
* this component and up into `FlowCanvasPage`'s header — the canvas now only
* exposes them through the `EditableFlowCanvasHandle` ref (`save()`/
* `discard()`) and reports state up via `onSaveMetaChange`
* (`{ dirty, hasErrors, saving }`), same as `onDirtyChange`. See
* `FlowCanvasPage.test.tsx` for the header-button + confirm-dialog + RPC
* integration coverage (clicking `flow-editor-save`/`flow-editor-discard`).
*
* This file drives the canvas through the public `FlowCanvas editable` entry
* point with a mocked `flowsApi` so `validateFlow` is deterministic, and
* covers what `FlowCanvas`/`EditableFlowCanvas` itself still owns:
* - an invalid graph shows the inline error banner, rings the offending
* node, and the imperative `save()` handle refuses to fire `onSave`;
* - a valid-with-warnings graph surfaces warnings distinctly and still
* lets `save()` fire;
* - dirty tracking gates `save()`/`discard()`, `discard()` resets to
* baseline, and a successful `save()` clears the dirty flag.
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { createRef } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { FlowNode } from '../../../../lib/flows/graphAdapter';
import type { EditableFlowCanvasHandle, EditorSaveMeta } from '../EditableFlowCanvas';
import FlowCanvas from '../FlowCanvas';
const validateFlow = vi.hoisted(() => vi.fn());
@@ -38,16 +50,27 @@ function triggerNode(): FlowNode {
const META = { schema_version: 1, id: 'wf_1', name: 'My flow' } as const;
function renderCanvas(props: Partial<React.ComponentProps<typeof FlowCanvas>> = {}) {
return render(
const ref = createRef<EditableFlowCanvasHandle>();
const onSaveMetaChange = vi.fn<(meta: EditorSaveMeta) => void>();
const utils = render(
<FlowCanvas
ref={ref}
editable
nodes={[triggerNode()]}
edges={[]}
meta={META}
onSave={vi.fn().mockResolvedValue(undefined)}
onSaveMetaChange={onSaveMetaChange}
{...props}
/>
);
return { ...utils, ref, onSaveMetaChange };
}
/** Latest `{ dirty, hasErrors, saving }` the canvas reported to its host. */
function lastSaveMeta(onSaveMetaChange: ReturnType<typeof vi.fn<(meta: EditorSaveMeta) => void>>) {
const calls = onSaveMetaChange.mock.calls;
return calls[calls.length - 1][0];
}
describe('EditableFlowCanvas — validation + dirty state', () => {
@@ -57,24 +80,35 @@ describe('EditableFlowCanvas — validation + dirty state', () => {
listFlowConnections.mockResolvedValue([]);
});
it('surfaces hard errors, rings the offending node, and blocks Save', async () => {
it('surfaces hard errors, rings the offending node, and blocks save()', async () => {
validateFlow.mockResolvedValue({
valid: false,
errors: ['invalid config for node t: missing schedule'],
warnings: [],
});
const { container } = renderCanvas();
const onSave = vi.fn().mockResolvedValue(undefined);
const { container, ref, onSaveMetaChange } = renderCanvas({ onSave });
// Make an edit so the graph is dirty (Save is only ever enabled when dirty).
// Make an edit so the graph is dirty (Save is only ever attempted when
// dirty). Validation runs automatically on the debounce after the edit
// (the manual Validate button now lives on the selected node card).
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
// Validation runs automatically on the debounce after the edit (the manual
// Validate button now lives on the selected node card).
const errors = await screen.findByTestId('flow-editor-errors');
expect(errors).toHaveTextContent('invalid config for node t: missing schedule');
// Save is blocked while there are hard errors, even though the graph is dirty.
expect(screen.getByTestId('flow-editor-save')).toBeDisabled();
// The host header reads `hasErrors` off `onSaveMetaChange` to disable its
// Save button; the canvas itself also refuses to fire `onSave` through
// the imperative handle while hard errors exist, even though dirty.
// `hasErrors` reaches the host via a follow-up effect that can lag the
// error-banner render (see `onSaveMetaChange` in EditableFlowCanvas), so
// poll the reported meta rather than reading it once — under a loaded
// full-suite run the synchronous read can still see the pre-validation meta.
await waitFor(() =>
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true, hasErrors: true })
);
act(() => ref.current?.save());
expect(onSave).not.toHaveBeenCalled();
// The named node ('t') is ringed with the error class on its RF wrapper.
await waitFor(() =>
@@ -84,13 +118,14 @@ describe('EditableFlowCanvas — validation + dirty state', () => {
);
});
it('shows warnings distinctly from errors and allows Save', async () => {
it('shows warnings distinctly from errors and still lets save() fire', async () => {
validateFlow.mockResolvedValue({
valid: true,
errors: [],
warnings: ['this trigger kind does not fire automatically yet'],
});
renderCanvas();
const onSave = vi.fn().mockResolvedValue(undefined);
const { ref, onSaveMetaChange } = renderCanvas({ onSave });
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
// Auto-validation (debounced) surfaces the warning.
@@ -99,71 +134,77 @@ describe('EditableFlowCanvas — validation + dirty state', () => {
expect(warnings).toHaveTextContent('does not fire automatically');
// A valid graph never renders the errors list…
expect(screen.queryByTestId('flow-editor-errors')).not.toBeInTheDocument();
// …and Save is allowed (warnings don't block).
expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled();
// …and warnings don't block Save.
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true, hasErrors: false });
act(() => ref.current?.save());
expect(onSave).toHaveBeenCalledTimes(1);
});
it('tracks dirty state: Save/Discard gate on it, Discard resets, Save clears it', async () => {
it('tracks dirty state: save()/discard() gate on it, discard resets, save clears it', async () => {
validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] });
const onSave = vi.fn().mockResolvedValue(undefined);
const onDirtyChange = vi.fn();
renderCanvas({ onSave, onDirtyChange });
const { ref, onSaveMetaChange } = renderCanvas({ onSave, onDirtyChange });
// Pristine: no dirty badge, Save + Discard disabled.
expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument();
expect(screen.getByTestId('flow-editor-save')).toBeDisabled();
expect(screen.getByTestId('flow-editor-discard')).toBeDisabled();
// Pristine: not dirty; save()/discard() both no-op through the imperative
// handle (the host header renders both buttons disabled in this state).
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: false });
act(() => ref.current?.discard());
act(() => ref.current?.save());
expect(onSave).not.toHaveBeenCalled();
// Edit → dirty.
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument();
expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled();
expect(screen.getByTestId('flow-editor-discard')).not.toBeDisabled();
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true });
expect(screen.getAllByTestId('flow-node')).toHaveLength(2);
// Discard → back to the single trigger, no longer dirty.
fireEvent.click(screen.getByTestId('flow-editor-discard'));
act(() => ref.current?.discard());
expect(screen.getAllByTestId('flow-node')).toHaveLength(1);
expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument();
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: false });
// Edit again and Save → onSave called, dirty cleared once it resolves.
// Edit again and save() → onSave called, dirty cleared once it resolves.
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
fireEvent.click(screen.getByTestId('flow-editor-save'));
act(() => ref.current?.save());
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
const graph = onSave.mock.calls[0][0];
expect(graph.nodes.map((n: { kind: string }) => n.kind).sort()).toEqual(['agent', 'trigger']);
await waitFor(() => expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument());
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(false));
await waitFor(() => expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: false }));
});
it('starts dirty when the host passes initialDirty (a remount carrying unsaved content)', async () => {
it('starts dirty when the host passes initialDirty (a remount carrying unsaved content)', () => {
validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] });
const onSave = vi.fn().mockResolvedValue(undefined);
const onDirtyChange = vi.fn();
// Mirrors `FlowCanvasPage` remounting the canvas (`key={canvasVersion}`)
// after accepting a copilot proposal: the incoming nodes/edges ARE the
// component's "initial" graph, so without `initialDirty` the canvas would
// seed its baseline from them and instantly read as clean even though
// nothing was persisted (the P1 this regression test guards against).
renderCanvas({ onDirtyChange, initialDirty: true });
const { ref, onSaveMetaChange } = renderCanvas({ onSave, onDirtyChange, initialDirty: true });
expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument();
expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled();
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
expect(lastSaveMeta(onSaveMetaChange)).toMatchObject({ dirty: true });
act(() => ref.current?.save());
expect(onSave).toHaveBeenCalledTimes(1);
});
it('surfaces a Save failure inline and leaves the graph dirty', async () => {
validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] });
const onSave = vi.fn().mockRejectedValue(new Error('core unreachable'));
renderCanvas({ onSave });
const onDirtyChange = vi.fn();
const { ref } = renderCanvas({ onSave, onDirtyChange });
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
fireEvent.click(screen.getByTestId('flow-editor-save'));
act(() => ref.current?.save());
const saveError = await screen.findByTestId('flow-editor-save-error');
expect(saveError).toHaveTextContent('core unreachable');
// Still dirty — nothing persisted.
expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument();
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
});
});
@@ -0,0 +1,92 @@
import { fireEvent, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
import AppSidebar from './AppSidebar';
const mockNavigate = vi.fn();
const mockTrackEvent = vi.fn();
// Mutable so each test can pick the session kind. `isReady` sits alongside
// `snapshot` on the core-state value (not inside the snapshot). Must be
// `mock`-prefixed so the hoisted vi.mock factory below may close over it.
let mockCoreState: { snapshot: { sessionToken: string | null }; isReady: boolean } = {
snapshot: { sessionToken: 'cloud.session.token' },
isReady: true,
};
vi.mock('react-router-dom', async importOriginal => {
const actual = await importOriginal<typeof import('react-router-dom')>();
return { ...actual, useNavigate: () => mockNavigate };
});
// Render i18n keys verbatim so assertions don't depend on locale copy.
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('../../../services/analytics', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}));
vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => mockCoreState }));
// Keep the mount light: the footer affordance rows are the unit under test, not
// the header/nav/rail children (SidebarHeader in particular needs the
// RootShellLayout context the test harness doesn't provide). SidebarSlot is left
// real on purpose — the harness itself imports SidebarSlotProvider from it.
vi.mock('./SidebarHeader', () => ({ default: () => null }));
vi.mock('./SidebarNav', () => ({ default: () => null }));
vi.mock('./SidebarAppRail', () => ({ default: () => null }));
vi.mock('../../ConnectionIndicator', () => ({ default: () => null }));
describe('AppSidebar — Rewards footer entry', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCoreState = { snapshot: { sessionToken: 'cloud.session.token' }, isReady: true };
});
it('shows the Rewards row for a resolved cloud session and navigates + tracks on click', () => {
renderWithProviders(<AppSidebar />, { initialEntries: ['/chat'] });
const rewards = screen.getByTitle('nav.rewards');
expect(rewards).toBeInTheDocument();
fireEvent.click(rewards);
expect(mockNavigate).toHaveBeenCalledWith('/rewards');
expect(mockTrackEvent).toHaveBeenCalledWith(
'tab_bar_change',
expect.objectContaining({ to_tab: 'rewards', to_path: '/rewards' })
);
});
it('normalizes from_path to a route template so entity IDs never reach analytics', () => {
renderWithProviders(<AppSidebar />, { initialEntries: ['/chat/thread-abc123'] });
fireEvent.click(screen.getByTitle('nav.rewards'));
expect(mockTrackEvent).toHaveBeenCalledWith(
'tab_bar_change',
expect.objectContaining({ from_path: '/chat/:threadId', to_path: '/rewards' })
);
});
it('hides the Rewards row for a local session but keeps Feedback', () => {
mockCoreState = { snapshot: { sessionToken: 'header.payload.local' }, isReady: true };
renderWithProviders(<AppSidebar />, { initialEntries: ['/chat'] });
expect(screen.queryByTitle('nav.rewards')).not.toBeInTheDocument();
expect(screen.getByTitle('nav.feedback')).toBeInTheDocument();
});
it('hides the Rewards row until core state has bootstrapped (no flash)', () => {
// Initial snapshot before the first refresh: not ready, null token.
// isLocalSessionToken(null) is false, so gating on the token alone would
// briefly show Rewards here — the isReady guard prevents that flash.
mockCoreState = { snapshot: { sessionToken: null }, isReady: false };
renderWithProviders(<AppSidebar />, { initialEntries: ['/chat'] });
expect(screen.queryByTitle('nav.rewards')).not.toBeInTheDocument();
expect(screen.getByTitle('nav.feedback')).toBeInTheDocument();
});
it('marks the Rewards row active on the /rewards route', () => {
renderWithProviders(<AppSidebar />, { initialEntries: ['/rewards'] });
expect(screen.getByTitle('nav.rewards')).toHaveAttribute('aria-current', 'page');
});
});
+102 -23
View File
@@ -1,8 +1,13 @@
import debugFactory from 'debug';
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { trackEvent } from '../../../services/analytics';
import { normalizeAnalyticsPagePath } from '../../../services/analyticsRoutes';
import { APP_VERSION } from '../../../utils/config';
import { isLocalSessionToken } from '../../../utils/localSession';
import ConnectionIndicator from '../../ConnectionIndicator';
import { NavIcon } from './navIcons';
import SidebarAppRail from './SidebarAppRail';
@@ -10,6 +15,49 @@ import SidebarHeader from './SidebarHeader';
import SidebarNav from './SidebarNav';
import { SidebarSlotOutlet } from './SidebarSlot';
const log = debugFactory('sidebar');
interface FooterNavButtonProps {
/** `NavTab.id`-style icon key resolved by {@link NavIcon}. */
iconId: string;
/** Already-translated label (also used as the `title`). */
label: string;
/** Whether the current route matches this entry. */
active: boolean;
/** `data-walkthrough` attribute for the walkthrough tour. */
walkthroughAttr: string;
onClick: () => void;
}
/**
* Slim footer affordance button shared by the Rewards and Feedback rows. Kept
* thin and low-profile so it reads as a footer entry, not a primary nav tab.
*/
function FooterNavButton({
iconId,
label,
active,
walkthroughAttr,
onClick,
}: FooterNavButtonProps) {
return (
<button
type="button"
data-walkthrough={walkthroughAttr}
onClick={onClick}
title={label}
aria-current={active ? 'page' : undefined}
className={`group flex flex-shrink-0 items-center justify-center gap-2 border-t border-line/70 px-3 py-1 text-[11px] transition-colors cursor-pointer dark:border-line/70 ${
active
? 'bg-surface text-content font-medium'
: 'text-content-muted hover:bg-surface-strong/70 hover:text-content-secondary dark:hover:bg-surface-muted/60'
}`}>
<NavIcon id={iconId} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="min-w-0 truncate">{label}</span>
</button>
);
}
/**
* 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';
const handleFeedbackClick = () => {
if (!feedbackActive) {
// 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 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-*`. */}
<SidebarSlotOutlet className="flex h-full flex-col" />
</div>
{/* 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). */}
<button
type="button"
data-walkthrough="tab-feedback"
onClick={handleFeedbackClick}
title={t('nav.feedback')}
aria-current={feedbackActive ? 'page' : undefined}
className={`group flex flex-shrink-0 items-center justify-center gap-2 border-t border-line/70 px-3 py-1 text-[11px] transition-colors cursor-pointer dark:border-line/70 ${
feedbackActive
? 'bg-surface text-content font-medium'
: 'text-content-muted hover:bg-surface-strong/70 hover:text-content-secondary dark:hover:bg-surface-muted/60'
}`}>
<NavIcon id="feedback" className="h-3.5 w-3.5 flex-shrink-0" />
<span className="min-w-0 truncate">{t('nav.feedback')}</span>
</button>
{/* Slim account affordances pinned above the status bar — Rewards then
Feedback. Rewards is shown only for a resolved cloud session. */}
{showRewards && (
<FooterNavButton
iconId="rewards"
label={t('nav.rewards')}
active={rewardsActive}
walkthroughAttr="tab-rewards"
onClick={() => handleFooterNav('rewards', '/rewards', rewardsActive)}
/>
)}
<FooterNavButton
iconId="feedback"
label={t('nav.feedback')}
active={feedbackActive}
walkthroughAttr="tab-feedback"
onClick={() => handleFooterNav('feedback', '/feedback', feedbackActive)}
/>
{/* App-wide footer: connectivity status + build/version, pinned to the
bottom of the sidebar. */}
<div className="flex flex-shrink-0 flex-wrap items-center justify-center gap-x-2 gap-y-0.5 border-t border-line px-2 py-0.5">
@@ -193,6 +193,19 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) {
/>
</svg>
);
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 (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M12 8v8m0-8l-3-3m3 3l3-3M8 14H6a2 2 0 01-2-2V7a2 2 0 012-2h2m8 9h2a2 2 0 002-2V7a2 2 0 00-2-2h-2M7 19h10"
/>
</svg>
);
default:
return null;
}
+36 -3
View File
@@ -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<typeof import('./voice/sttClient')>()),
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(<MicComposer disabled={false} onSubmit={onSubmit} onError={onError} />);
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 () => {
+9 -2
View File
@@ -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');
+34 -7
View File
@@ -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<typeof vi.fn>;
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<typeof vi.fn>;
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<typeof vi.fn>;
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<typeof vi.fn>;
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.toThrow(/not compiled into the app/i);
});
it('does not advise restarting for a compile-time voice gate', async () => {
const mock = callCoreRpc as ReturnType<typeof vi.fn>;
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.not.toThrow(
/restart the openhuman desktop app/i
);
});
it('passes through non-unknown-method errors verbatim', async () => {
+56 -13
View File
@@ -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;
@@ -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<string, Set<(data: unknown) => 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> = {}): 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();
});
});
@@ -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> = {}): 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> = {}): 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');
});
});
+100
View File
@@ -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<FlowRunStatus>([
'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<typeof setTimeout> | 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;
+118
View File
@@ -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<string> {
const [pendingRunIds, setPendingRunIds] = useState<Set<string>>(() => 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<string>();
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<string>): FlowRunStatus {
if (run.status === 'running' && pendingRunIds.has(run.id)) {
return 'pending_approval';
}
return run.status;
}
export default useRunsPendingApprovalSet;
+4
View File
@@ -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': 'فشل تشغيل؛ راجعه واقترح إصلاحًا.',
+4
View File
@@ -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':
'একটি প্রস্তাবিত খসড়া পর্যালোচনা হচ্ছে: এখনও কিছু সংরক্ষণ করা হয়নি।',
+4
View File
@@ -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.',
+4
View File
@@ -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.',
+4
View File
@@ -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.',
+4
View File
@@ -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 dun brouillon proposé: rien nest encore enregistré.',
'flows.copilot.repairDisplay': 'Une exécution a échoué ; examinez-la et proposez une correction.',
+4
View File
@@ -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':
'एक प्रस्तावित ड्राफ़्ट की समीक्षा हो रही है: अभी कुछ सहेजा नहीं गया।',
+4
View File
@@ -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.',
+4
View File
@@ -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': 'Unesecuzione è fallita; esaminala e proponi una correzione.',
+4
View File
@@ -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': '실행이 실패했습니다. 확인하고 수정을 제안하세요.',
+4
View File
@@ -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.',
+4
View File
@@ -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.',
+5 -1
View File
@@ -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': 'Запуск завершился ошибкой; изучите его и предложите исправление.',
+3
View File
@@ -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': '一次运行失败了,请查看并提出修复方案。',
+15 -1
View File
@@ -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);
});
});
+14
View File
@@ -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);
}
+187
View File
@@ -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<string, string> = {
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<string, string> {
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<string, string> {
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<string, string> = {};
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);
}
});
});
}
});
+22 -5
View File
@@ -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: {},
+191 -52
View File
@@ -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 (
<svg
@@ -294,6 +307,14 @@ function FlowEditor({
const [name, setName] = useState(editorFlow.name);
const [titleDraft, setTitleDraft] = useState(editorFlow.name);
const [renaming, setRenaming] = useState(false);
// Last name actually persisted to the backend (mirrors `persistedGraphRef`
// below, same rationale): a manual rename persists immediately via
// `commitRename`, but adopting a copilot proposal's name (below) only
// updates local state — Save is still the gate for THAT change. Tracking
// the real baseline (rather than diffing against the initial `editorFlow.name`
// prop) lets `handleSave` include `name` in its `flows_update` payload only
// when it actually diverges from what's on the server.
const persistedNameRef = useRef<string>(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<WorkflowGraph>(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;
+4 -2
View File
@@ -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(<FlowsPage />, { 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'));
+107 -2
View File
@@ -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 }) => <div>{children}</div>,
@@ -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(<WorkflowRunsPage />);
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(<WorkflowRunsPage />);
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(<WorkflowRunsPage />);
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(<WorkflowRunsPage />);
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();
});
});
});
+56 -26
View File
@@ -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<FlowRunStatus, string> = {
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() {
<ul
className="divide-y divide-line rounded-xl border border-line"
data-testid="workflow-runs-list">
{runs.map(run => (
<li key={run.id}>
<button
type="button"
data-testid={`workflow-run-${run.id}`}
onClick={() => navigate(`/flows/${run.flow_id}`)}
className="flex w-full items-center gap-3 p-3 text-left hover:bg-surface-hover">
<span
className={`flex-shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_CLASS[run.status]}`}>
{statusLabel(run.status)}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
{flowNames[run.flow_id] ?? t('flows.allRuns.unknownWorkflow')}
</span>
<span className="flex-shrink-0 text-[11px] text-content-faint">
{new Date(run.started_at).toLocaleString()}
</span>
</button>
{run.error && (
<p className="px-3 pb-2 text-[11px] text-coral-600 dark:text-coral-300">
{run.error}
</p>
)}
</li>
))}
{runs.map(run => {
const displayStatus = resolveDisplayStatus(run, pendingRunIds);
return (
<li key={run.id}>
<button
type="button"
data-testid={`workflow-run-${run.id}`}
onClick={() => navigate(`/flows/${run.flow_id}`)}
className="flex w-full items-center gap-3 p-3 text-left hover:bg-surface-hover">
<span
className={`flex-shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_CLASS[displayStatus]}`}>
{statusLabel(displayStatus)}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
{flowNames[run.flow_id] ?? t('flows.allRuns.unknownWorkflow')}
</span>
<span className="flex-shrink-0 text-[11px] text-content-faint">
{new Date(run.started_at).toLocaleString()}
</span>
</button>
{run.error && (
<p className="px-3 pb-2 text-[11px] text-coral-600 dark:text-coral-300">
{run.error}
</p>
)}
</li>
);
})}
</ul>
)}
</div>
@@ -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> = {}): 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(
<MemoryRouter initialEntries={[`/flows/${id}`]}>
<Routes>
<Route path="/flows/:id" element={<FlowCanvasPage />} />
<Route path="/flows" element={<div data-testid="flows-list">Flows list</div>} />
</Routes>
</MemoryRouter>
);
}
// `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<void>)(
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/<id>', async () => {
createFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' }));
getFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' }));
render(
<MemoryRouter
initialEntries={[
{
pathname: '/flows/draft',
state: {
name: 'New workflow',
graph: {
schema_version: 1,
name: 'New workflow',
nodes: [
{
id: 't',
kind: 'trigger',
name: 'Start',
config: {},
ports: [],
position: { x: 0, y: 0 },
},
],
edges: [],
},
// Route (pre-existing draft) policy is FALSE — the opposite of
// the proposal below — so the assertion can't pass by both
// values coincidentally matching.
requireApproval: false,
},
},
]}>
<Routes>
<Route path="/flows/draft" element={<FlowCanvasDraftPage />} />
<Route path="/flows/:id" element={<FlowCanvasPage />} />
</Routes>
</MemoryRouter>
);
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<void>)(
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
// `<FlowCanvas key=...>` 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({
+16 -19
View File
@@ -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);
}
@@ -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
+2 -2
View File
@@ -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,
});
});
+7 -5
View File
@@ -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
+167
View File
@@ -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();
+32 -9
View File
@@ -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;
}
+1
View File
@@ -53,6 +53,7 @@
"debug": "bash scripts/debug/cli.sh",
"test:install-ps1": "pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1",
"rust:check": "pnpm --filter openhuman-app rust:check",
"rust:clippy": "cargo clippy -p openhuman -- -D warnings && pnpm --filter openhuman-app rust:clippy",
"typecheck": "pnpm --filter openhuman-app compile",
"tauri:ios:init": "bash scripts/ios-init.sh",
"tauri:ios:dev": "pnpm --filter openhuman-app tauri:ios:dev",
+4 -2
View File
@@ -57,8 +57,10 @@ fn main() -> ExitCode {
}
};
let mut cfg = Config::default();
cfg.workspace_dir = workspace.clone();
let cfg = Config {
workspace_dir: workspace.clone(),
..Config::default()
};
let db_path = workspace.join("memory_tree").join("chunks.db");
let cold = !db_path.exists();
+37 -6
View File
@@ -323,8 +323,8 @@ async fn main() -> Result<()> {
enum Outcome {
Ok,
Ratelimit,
OtherFail(String),
Transport(String),
OtherFail,
Transport,
}
let mut outcomes: Vec<(u32, std::time::Duration, Outcome)> = Vec::with_capacity(n as usize);
let probe_started = Instant::now();
@@ -342,7 +342,10 @@ async fn main() -> Result<()> {
.await;
let dt = t0.elapsed();
let outcome = match resp {
Err(e) => Outcome::Transport(format!("{e:#}")),
Err(e) => {
log::warn!("[probe-ratelimit] transport error on call {i}: {e:#}");
Outcome::Transport
}
Ok(r) if r.successful => Outcome::Ok,
Ok(r) => {
let err = r.error.as_deref().unwrap_or("provider failure");
@@ -356,7 +359,11 @@ async fn main() -> Result<()> {
);
Outcome::Ratelimit
} else {
Outcome::OtherFail(err.to_string())
log::warn!(
"[probe-ratelimit] provider failure on call {i}: {}",
sanitize_probe_error(err)
);
Outcome::OtherFail
}
}
};
@@ -379,11 +386,11 @@ async fn main() -> Result<()> {
.count();
let other = outcomes
.iter()
.filter(|(_, _, o)| matches!(o, Outcome::OtherFail(_)))
.filter(|(_, _, o)| matches!(o, Outcome::OtherFail))
.count();
let transport = outcomes
.iter()
.filter(|(_, _, o)| matches!(o, Outcome::Transport(_)))
.filter(|(_, _, o)| matches!(o, Outcome::Transport))
.count();
let avg_ms = if !outcomes.is_empty() {
outcomes.iter().map(|(_, d, _)| d.as_millis()).sum::<u128>() / outcomes.len() as u128
@@ -568,3 +575,27 @@ fn component_status(endpoint: &Option<String>, model: &Option<String>) -> String
_ => "off".to_string(),
}
}
fn sanitize_probe_error(error: &str) -> String {
let single_line = error.split_whitespace().collect::<Vec<_>>().join(" ");
openhuman_core::openhuman::inference::provider::ops::sanitize_api_error(&single_line)
}
#[cfg(test)]
mod tests {
use super::sanitize_probe_error;
#[test]
fn probe_error_summary_is_single_line_bounded_and_secret_safe() {
let raw = format!(
"provider failed\nwith xoxb-secret-token {}",
"x".repeat(300)
);
let summary = sanitize_probe_error(&raw);
assert!(!summary.contains('\n'));
assert!(!summary.contains("xoxb-secret-token"));
assert!(summary.contains("[REDACTED]"));
assert!(summary.chars().count() <= 203);
}
}
-12
View File
@@ -14,10 +14,6 @@ use crate::core::jsonrpc::{default_state, invoke_method, parse_json_params};
use crate::core::logging::CliLogDefault;
use crate::core::{ControllerSchema, TypeSchema};
/// Debug/e2e agent paths can build deep async poll stacks while assembling
/// prompts, provider requests, and sub-agent tool loops.
const CLI_RUNTIME_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024;
/// The ASCII banner displayed when the CLI starts.
const CLI_BANNER: &str = r#"
@@ -450,14 +446,6 @@ fn run_namespace_command(
Ok(())
}
fn build_cli_runtime() -> Result<tokio::runtime::Runtime> {
tokio::runtime::Builder::new_multi_thread()
.thread_stack_size(CLI_RUNTIME_THREAD_STACK_SIZE)
.enable_all()
.build()
.map_err(Into::into)
}
/// Parses command-line arguments into a JSON map based on a function's schema.
///
/// # Arguments
+1 -1
View File
@@ -664,7 +664,7 @@ impl AutomateBackend for RealBackend {
// `summarization` keeps M1 free of Config-schema churn while still keeping
// the chat model out of the loop.
use tinyagents::harness::message::Message;
use tinyagents::harness::model::{ChatModel, ModelRequest};
use tinyagents::harness::model::ModelRequest;
let model = crate::openhuman::inference::provider::create_chat_model(
"summarization",
&self.config,
-2
View File
@@ -28,8 +28,6 @@ mod vision_click;
#[cfg(target_os = "windows")]
mod uia_interact;
#[cfg(test)]
pub(crate) use automation_state::test_lock as automation_state_test_lock;
pub use automation_state::{
clear as clear_automation_denial, mark_system_events_denied, system_events_denied,
};
@@ -158,8 +158,7 @@ fn permission_state_serde_round_trip() {
mod automation_state_stale_cache {
use crate::openhuman::accessibility::automation_state;
use crate::openhuman::accessibility::{
automation_state_test_lock, clear_automation_denial, mark_system_events_denied,
system_events_denied,
clear_automation_denial, mark_system_events_denied, system_events_denied,
};
#[test]
@@ -30,8 +30,6 @@ pub use types::ArchivistHook;
#[cfg(test)]
pub(crate) use crate::openhuman::agent::hooks::PostTurnHook;
#[cfg(test)]
pub(crate) use crate::openhuman::config::Config;
#[cfg(test)]
pub(crate) use crate::openhuman::memory_store::profile;
#[cfg(test)]
pub(crate) use helpers::extract_profile_key;
@@ -64,6 +64,7 @@ impl ArchivistHook {
/// - NEVER mutates DB state (no `segment_set_summary`, no embedding).
/// - NEVER closes a segment.
/// - Safe to call on both open and closed segments.
///
/// Summarize a set of episodic entries into a recap string.
///
/// Returns `(text, produced_by_llm)`. `produced_by_llm == false` means the
@@ -23,10 +23,9 @@
//! - `<invoke tool=…>` XML attribute form — the parser does not parse attributes;
//! only the tag body (JSON) is used.
use crate::openhuman::agent::error::AgentError;
use crate::openhuman::inference::provider::traits::ProviderCapabilities;
use crate::openhuman::inference::provider::Provider;
use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, UsageInfo};
use crate::openhuman::inference::provider::{ChatRequest, ChatResponse};
use crate::openhuman::tool_timeout::parse_tool_timeout_secs;
use crate::openhuman::tools::{Tool, ToolResult};
use async_trait::async_trait;
@@ -491,7 +491,7 @@ impl Agent {
)?;
let supports_native = resolved_chat_model
.profile()
.map_or(true, |profile| profile.tool_calling);
.is_none_or(|profile| profile.tool_calling);
log::info!(
"[session-builder] agent_id={} provider_role={} resolved_model={} supports_native_tools={}",
agent_id,
@@ -871,7 +871,7 @@ fn read_thread_usage_summary_sums_multiple_transcripts() {
let raw = raw_session_dir(ws.path());
std::fs::create_dir_all(&raw).unwrap();
let mut mk = |stem: &str, input: u64, cost: f64| {
let mk = |stem: &str, input: u64, cost: f64| {
let mut meta = sample_meta();
meta.thread_id = Some("thr-multi".into());
meta.input_tokens = input;
@@ -928,7 +928,7 @@ fn read_thread_usage_summary_groups_subagents_by_archetype() {
.unwrap();
// Sub-agent transcripts (stems contain `__`): coder x2 + researcher x1.
let mut sub = |stem: &str, agent: &str, input: u64, output: u64| {
let sub = |stem: &str, agent: &str, input: u64, output: u64| {
let mut m = sample_meta();
m.thread_id = Some("thr-sub".into());
m.agent_name = agent.into();
@@ -5,7 +5,9 @@ use super::super::types::Agent;
use crate::openhuman::agent::harness;
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::context::ARCHIVIST_EXTRACTION_PROMPT;
use crate::openhuman::inference::provider::{ChatMessage, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS};
use crate::openhuman::inference::provider::{
ChatMessage, ChatResponse, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS,
};
use futures::StreamExt;
use tinyagents::harness::model::{ModelRequest, ModelStreamItem};
@@ -69,8 +71,9 @@ impl Agent {
/// Ask the provider for a short wrap-up message with native tools
/// **disabled** so the model returns prose rather than another tool call.
/// Streams text deltas to the progress sink (when attached) so the summary
/// appears in the UI like any other reply.
/// Buffers text deltas and forwards them to the progress sink (when
/// attached) only after the completed response is validated as prose, so
/// prompt-formatted tool calls cannot flash in the UI before fallback.
///
/// `instruction` is the synthetic user turn that steers the wrap-up — the
/// tool-call-cap checkpoint (`MAX_ITER_CHECKPOINT_INSTRUCTION`) or the
@@ -128,19 +131,13 @@ impl Agent {
};
let mut streamed_text = String::new();
let mut streamed_deltas = Vec::new();
let mut completed = None;
while let Some(item) = stream.next().await {
match item {
ModelStreamItem::MessageDelta(delta) if !delta.text.is_empty() => {
streamed_text.push_str(&delta.text);
if let Some(sink) = &self.on_progress {
let _ = sink
.send(AgentProgress::TextDelta {
delta: delta.text,
iteration: iteration_for_stream,
})
.await;
}
streamed_deltas.push(delta.text);
}
ModelStreamItem::Completed(response) => completed = Some(response),
ModelStreamItem::Failed(error) => {
@@ -163,10 +160,49 @@ impl Agent {
let checkpoint = if !text.trim().is_empty() {
text
} else if response.tool_calls().is_empty() {
streamed_text
streamed_text.clone()
} else {
String::new()
};
if !checkpoint.trim().is_empty() {
let mut provider_response = ChatResponse {
text: Some(checkpoint.clone()),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
};
let (_, mut prompt_tool_calls) =
self.tool_dispatcher.parse_response(&provider_response);
if streamed_text != checkpoint {
provider_response.text = Some(streamed_text);
let (_, streamed_tool_calls) =
self.tool_dispatcher.parse_response(&provider_response);
prompt_tool_calls.extend(streamed_tool_calls);
}
if !prompt_tool_calls.is_empty() {
tracing::warn!(
parsed_tool_calls = prompt_tool_calls.len(),
"[agent::session] wrap-up model returned a prompt-formatted tool call; using deterministic fallback"
);
return (String::new(), usage);
}
tracing::debug!(
checkpoint_chars = checkpoint.chars().count(),
buffered_deltas = streamed_deltas.len(),
iteration = iteration_for_stream,
"[agent::session] wrap-up checkpoint validation passed"
);
if let Some(sink) = &self.on_progress {
for delta in streamed_deltas {
let _ = sink
.send(AgentProgress::TextDelta {
delta,
iteration: iteration_for_stream,
})
.await;
}
}
}
(checkpoint, usage)
}
@@ -1,5 +1,4 @@
use super::*;
use crate::core::event_bus::{global, init_global, DomainEvent};
use crate::openhuman::agent::dispatcher::XmlToolDispatcher;
use crate::openhuman::agent::hooks::{PostTurnHook, TurnContext};
use crate::openhuman::agent::tool_policy::{
@@ -8,8 +7,7 @@ use crate::openhuman::agent::tool_policy::{
};
use crate::openhuman::agent_memory::memory_loader::MemoryLoader;
use crate::openhuman::inference::provider::{
ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ToolResultMessage,
UsageInfo,
ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, UsageInfo,
};
use crate::openhuman::memory::Memory;
use crate::openhuman::tools::ToolResult;
@@ -20,7 +18,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::sync::Mutex as AsyncMutex;
use tokio::sync::Notify;
use tokio::time::{sleep, timeout, Duration};
use tokio::time::{timeout, Duration};
struct DummyProvider;
@@ -1197,6 +1195,49 @@ async fn turn_final_answer_falls_back_to_deterministic_summary_when_reprompt_emp
);
}
#[tokio::test]
async fn summarize_turn_wrapup_rejects_prompt_tool_call_and_preserves_usage() {
let provider: Arc<dyn Provider> = Arc::new(SequenceProvider {
responses: AsyncMutex::new(vec![Ok(ChatResponse {
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
tool_calls: vec![],
usage: Some(UsageInfo {
input_tokens: 13,
output_tokens: 5,
cached_input_tokens: 3,
charged_amount_usd: 0.07,
..UsageInfo::default()
}),
reasoning_content: None,
})]),
requests: AsyncMutex::new(Vec::new()),
});
let agent = make_agent_with_builder(
provider,
vec![],
Box::new(FixedMemoryLoader {
context: String::new(),
}),
vec![],
crate::openhuman::config::AgentConfig::default(),
crate::openhuman::config::ContextConfig::default(),
);
let (summary, usage) = agent
.summarize_turn_wrapup(&[], "test-model", 1, "write a wrap-up")
.await;
assert!(
summary.is_empty(),
"prompt-formatted tool calls must trigger the deterministic fallback"
);
let usage = usage.expect("rejected wrap-up must preserve provider usage");
assert_eq!(usage.input_tokens, 13);
assert_eq!(usage.output_tokens, 5);
assert_eq!(usage.cached_input_tokens, 3);
assert_eq!(usage.charged_amount_usd, 0.07);
}
#[tokio::test]
async fn turn_checkpoint_usage_is_folded_into_transcript_accounting() {
// The extra checkpoint provider call costs tokens; those must land in
@@ -61,9 +61,7 @@ pub(super) use provider::LazyToolkitResolver;
pub(super) use super::tool_prep::filter_tool_indices;
// Types used by tests that were previously in scope via the flat ops.rs imports.
#[cfg(test)]
pub(super) use super::types::{
SubagentMode, SubagentRunError, SubagentRunOptions, SubagentRunOutcome,
};
pub(super) use super::types::{SubagentMode, SubagentRunError, SubagentRunOptions};
#[cfg(test)]
pub(super) use crate::openhuman::agent::harness::definition::{AgentDefinition, PromptSource};
#[cfg(test)]
+2 -3
View File
@@ -5,10 +5,9 @@ use super::parse::{
parse_tool_calls, parse_tool_calls_from_json_value, tools_to_openai_format,
};
use crate::openhuman::inference::provider::traits::ProviderCapabilities;
use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, Provider};
use crate::openhuman::tools::{self, Tool};
use crate::openhuman::inference::provider::{ChatRequest, ChatResponse, Provider};
use crate::openhuman::tools;
use async_trait::async_trait;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
+1 -1
View File
@@ -81,7 +81,7 @@ pub fn filter_actions_by_prompt(
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0));
scored.sort_by_key(|item| std::cmp::Reverse(item.0));
// Only keep positively-scored results. Zero-overlap tools would add noise.
scored

Some files were not shown because too many files have changed in this diff Show More