mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
//! Thin helpers around `Input.dispatchMouseEvent` and
|
||||
//! `Input.dispatchKeyEvent` so providers can drive web UIs without
|
||||
//! touching the page's JavaScript.
|
||||
//!
|
||||
//! All coordinates are CSS pixels relative to the viewport — the same
|
||||
//! frame `DOMSnapshot.captureSnapshot(includeDOMRects=true)` returns
|
||||
//! bounding rects in. Callers typically pair these with
|
||||
//! [`crate::cdp::Snapshot::rect`] to find the click target.
|
||||
//!
|
||||
//! Everything here is CEF-only — CDP requires a remote-debugging port,
|
||||
//! which wry doesn't expose.
|
||||
//!
|
||||
//! # Cookbook
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let snap = Snapshot::capture_with_rects(&mut cdp, &session).await?;
|
||||
//! let idx = snap.find_descendant(0, |s, i| s.attr(i, "aria-label") == Some("Search mail"))
|
||||
//! .ok_or("search box not found")?;
|
||||
//! let rect = snap.rect(idx).ok_or("search box has no layout rect")?;
|
||||
//! let (cx, cy) = rect.center();
|
||||
//! input::click(&mut cdp, &session, cx, cy).await?;
|
||||
//! input::type_text(&mut cdp, &session, "from:linkedin.com").await?;
|
||||
//! input::press_key(&mut cdp, &session, Key::Enter).await?;
|
||||
//! ```
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::CdpConn;
|
||||
|
||||
#[allow(dead_code)] // helper is used by currently gated input paths.
|
||||
#[cfg(target_os = "macos")]
|
||||
const SELECT_ALL_MODIFIER: u32 = 4;
|
||||
#[allow(dead_code)] // helper is used by currently gated input paths.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const SELECT_ALL_MODIFIER: u32 = 2;
|
||||
|
||||
/// Names recognised by `Input.dispatchKeyEvent`'s `key` field. We
|
||||
/// hand-pick the ones Gmail's keyboard handlers care about so callers
|
||||
/// can use a typed value rather than stringly-typed literals scattered
|
||||
/// across providers.
|
||||
#[allow(dead_code)] // variants reserved for upcoming providers / write ops.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum Key {
|
||||
Enter,
|
||||
Escape,
|
||||
Tab,
|
||||
Backspace,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
}
|
||||
|
||||
impl Key {
|
||||
/// `(key, code, windowsVirtualKeyCode)` triple. Gmail's listeners
|
||||
/// branch on different fields depending on browser; we set all three
|
||||
/// to maximise compatibility.
|
||||
fn cdp_fields(self) -> (&'static str, &'static str, u32) {
|
||||
match self {
|
||||
Key::Enter => ("Enter", "Enter", 13),
|
||||
Key::Escape => ("Escape", "Escape", 27),
|
||||
Key::Tab => ("Tab", "Tab", 9),
|
||||
Key::Backspace => ("Backspace", "Backspace", 8),
|
||||
Key::ArrowDown => ("ArrowDown", "ArrowDown", 40),
|
||||
Key::ArrowUp => ("ArrowUp", "ArrowUp", 38),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Click at `(x, y)` — left button, no modifiers, single click.
|
||||
/// Issues mouseMoved → mousePressed → mouseReleased so hover handlers
|
||||
/// (Gmail's search-box has one) fire correctly before the click.
|
||||
pub async fn click(cdp: &mut CdpConn, session: &str, x: f64, y: f64) -> Result<(), String> {
|
||||
log::debug!("[cdp::input] click session={session} x={x:.1} y={y:.1}");
|
||||
let _ = mouse_event(cdp, session, "mouseMoved", x, y, 0).await?;
|
||||
let _ = mouse_event(cdp, session, "mousePressed", x, y, 1).await?;
|
||||
let _ = mouse_event(cdp, session, "mouseReleased", x, y, 1).await?;
|
||||
log::debug!("[cdp::input] click complete session={session}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mouse_event(
|
||||
cdp: &mut CdpConn,
|
||||
session: &str,
|
||||
kind: &str,
|
||||
x: f64,
|
||||
y: f64,
|
||||
click_count: u32,
|
||||
) -> Result<Value, String> {
|
||||
log::debug!(
|
||||
"[cdp::input] mouse_event session={session} kind={kind} x={x:.1} y={y:.1} clicks={click_count}"
|
||||
);
|
||||
cdp.call(
|
||||
"Input.dispatchMouseEvent",
|
||||
json!({
|
||||
"type": kind,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"button": "left",
|
||||
"buttons": if kind == "mousePressed" { 1 } else { 0 },
|
||||
"clickCount": click_count,
|
||||
}),
|
||||
Some(session),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Input.dispatchMouseEvent {kind}: {e}"))
|
||||
}
|
||||
|
||||
/// Type a literal string by dispatching one `keyDown`/`char`/`keyUp`
|
||||
/// triple per character. CDP's `dispatchKeyEvent type=char` is what
|
||||
/// actually inserts text into focused editable fields — `keyDown`
|
||||
/// alone leaves the input empty for most letters. The `keyDown`
|
||||
/// + `keyUp` pair is still needed so listeners (autocomplete,
|
||||
/// keystroke counters) see a normal keystroke.
|
||||
pub async fn type_text(cdp: &mut CdpConn, session: &str, text: &str) -> Result<(), String> {
|
||||
log::debug!(
|
||||
"[cdp::input] type_text session={session} chars={}",
|
||||
text.chars().count()
|
||||
);
|
||||
for ch in text.chars() {
|
||||
let s = ch.to_string();
|
||||
// keyDown — Gmail's command/keyboard router observes these.
|
||||
cdp.call(
|
||||
"Input.dispatchKeyEvent",
|
||||
json!({
|
||||
"type": "keyDown",
|
||||
"text": s,
|
||||
"unmodifiedText": s,
|
||||
"key": s,
|
||||
}),
|
||||
Some(session),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Input.dispatchKeyEvent keyDown {ch:?}: {e}"))?;
|
||||
// char — actual text insertion into the focused editable.
|
||||
cdp.call(
|
||||
"Input.dispatchKeyEvent",
|
||||
json!({
|
||||
"type": "char",
|
||||
"text": s,
|
||||
"unmodifiedText": s,
|
||||
"key": s,
|
||||
}),
|
||||
Some(session),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Input.dispatchKeyEvent char {ch:?}: {e}"))?;
|
||||
cdp.call(
|
||||
"Input.dispatchKeyEvent",
|
||||
json!({
|
||||
"type": "keyUp",
|
||||
"text": s,
|
||||
"unmodifiedText": s,
|
||||
"key": s,
|
||||
}),
|
||||
Some(session),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Input.dispatchKeyEvent keyUp {ch:?}: {e}"))?;
|
||||
}
|
||||
log::debug!("[cdp::input] type_text complete session={session}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Press a non-character key (Enter, Esc, …). Sends `rawKeyDown` →
|
||||
/// `keyUp`; no `char` because non-printables don't insert text.
|
||||
pub async fn press_key(cdp: &mut CdpConn, session: &str, key: Key) -> Result<(), String> {
|
||||
let (key_name, code, vk) = key.cdp_fields();
|
||||
log::debug!("[cdp::input] press_key session={session} key={key_name}");
|
||||
cdp.call(
|
||||
"Input.dispatchKeyEvent",
|
||||
json!({
|
||||
"type": "rawKeyDown",
|
||||
"key": key_name,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": vk,
|
||||
"nativeVirtualKeyCode": vk,
|
||||
}),
|
||||
Some(session),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Input.dispatchKeyEvent rawKeyDown {key_name}: {e}"))?;
|
||||
cdp.call(
|
||||
"Input.dispatchKeyEvent",
|
||||
json!({
|
||||
"type": "keyUp",
|
||||
"key": key_name,
|
||||
"code": code,
|
||||
"windowsVirtualKeyCode": vk,
|
||||
"nativeVirtualKeyCode": vk,
|
||||
}),
|
||||
Some(session),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Input.dispatchKeyEvent keyUp {key_name}: {e}"))?;
|
||||
log::debug!("[cdp::input] press_key complete session={session} key={key_name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch Cmd/Ctrl+A to select-all in the focused contenteditable / input.
|
||||
/// Useful when the search box already has a previous query in it that
|
||||
/// we need to overwrite — Gmail keeps the last query rendered in the
|
||||
/// search input so a fresh visit sees stale text.
|
||||
pub async fn select_all_in_focused(cdp: &mut CdpConn, session: &str) -> Result<(), String> {
|
||||
log::debug!(
|
||||
"[cdp::input] select_all_in_focused session={session} modifier={SELECT_ALL_MODIFIER}"
|
||||
);
|
||||
cdp.call(
|
||||
"Input.dispatchKeyEvent",
|
||||
json!({
|
||||
"type": "rawKeyDown",
|
||||
"key": "a",
|
||||
"code": "KeyA",
|
||||
"windowsVirtualKeyCode": 65,
|
||||
"nativeVirtualKeyCode": 65,
|
||||
"modifiers": SELECT_ALL_MODIFIER,
|
||||
}),
|
||||
Some(session),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Input.dispatchKeyEvent select-all keyDown: {e}"))?;
|
||||
cdp.call(
|
||||
"Input.dispatchKeyEvent",
|
||||
json!({
|
||||
"type": "keyUp",
|
||||
"key": "a",
|
||||
"code": "KeyA",
|
||||
"windowsVirtualKeyCode": 65,
|
||||
"nativeVirtualKeyCode": 65,
|
||||
"modifiers": SELECT_ALL_MODIFIER,
|
||||
}),
|
||||
Some(session),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Input.dispatchKeyEvent select-all keyUp: {e}"))?;
|
||||
log::debug!("[cdp::input] select_all_in_focused complete session={session}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,29 +6,21 @@
|
||||
//! and `Webview::on_dev_tools_protocol`. There is no listener and no
|
||||
//! network surface; any same-UID process is shut out by construction.
|
||||
//!
|
||||
//! Scanners pick up a [`CdpConn`] either via [`conn_for_account`] (for
|
||||
//! `acct_<id>`-labelled webviews) or [`conn_for_label`] /
|
||||
//! [`connect_and_attach_matching_in_process_by_label`] (for other
|
||||
//! Scanners pick up a [`CdpConn`] either via [`target::conn_for_account`] (for
|
||||
//! `acct_<id>`-labelled webviews) or [`target::conn_for_label`] /
|
||||
//! [`target::connect_and_attach_matching_in_process_by_label`] (for other
|
||||
//! surfaces such as the Meet call window).
|
||||
|
||||
pub mod conn;
|
||||
pub mod in_process;
|
||||
pub mod input;
|
||||
pub mod session;
|
||||
pub mod snapshot;
|
||||
pub mod target;
|
||||
|
||||
pub use conn::CdpConn;
|
||||
pub use in_process::{
|
||||
install_for_account, install_for_label, install_for_webview, set_cef_app_handle, CdpRegistry,
|
||||
EventFrame, WebviewCdpTransport, CALL_TIMEOUT,
|
||||
};
|
||||
pub use in_process::{install_for_account, install_for_label, set_cef_app_handle, CdpRegistry};
|
||||
pub use session::{
|
||||
placeholder_marker, placeholder_url, spawn_session, target_url_fragment, SpawnedSession,
|
||||
};
|
||||
#[allow(unused_imports)] // `Rect` re-export consumed once turn 2 lands; keep stable.
|
||||
pub use snapshot::{Rect, Snapshot};
|
||||
pub use target::{
|
||||
conn_for_account, conn_for_label, connect_and_attach_matching_in_process,
|
||||
connect_and_attach_matching_in_process_by_label, detach_session, find_page_target_where,
|
||||
};
|
||||
pub use snapshot::Snapshot;
|
||||
pub use target::{detach_session, find_page_target_where};
|
||||
|
||||
@@ -24,8 +24,8 @@ use tokio::task::JoinHandle;
|
||||
// elapsed check honours `tokio::time::pause()` / `advance()` in unit tests.
|
||||
use tokio::time::{sleep, Instant};
|
||||
|
||||
use super::find_page_target_where;
|
||||
use super::target::conn_for_account;
|
||||
use super::{find_page_target_where, CdpConn};
|
||||
use crate::webview_accounts::{emit_load_finished, redact_url_for_log, RevealTrigger};
|
||||
|
||||
/// Backoff between failed attach attempts / reconnects. Intentionally
|
||||
@@ -517,7 +517,7 @@ async fn run_session_cycle<R: Runtime>(
|
||||
// page reaches the CEF helper's notify-IPC, which posts back to
|
||||
// `forward_native_notification` in `webview_accounts`. Without it,
|
||||
// the constructor silently no-ops and no toast ever fires (#1016).
|
||||
if let Some(origin) = origin_of(&real_url) {
|
||||
if let Some(origin) = origin_of(real_url) {
|
||||
// Default permission set every embedded provider needs. Origin-scoped
|
||||
// so we don't leak grants across providers running in the same CEF
|
||||
// browser process.
|
||||
|
||||
@@ -33,35 +33,6 @@ struct CaptureSnapshot {
|
||||
struct DocumentSnap {
|
||||
#[serde(default)]
|
||||
nodes: NodeTreeSnap,
|
||||
#[serde(default)]
|
||||
layout: LayoutSnap,
|
||||
}
|
||||
|
||||
/// Subset of `documents[i].layout` we care about. Each layout node
|
||||
/// references a DOM node by index and carries its `[x, y, w, h]` bounds
|
||||
/// in CSS pixels. Only populated when `includeDOMRects: true` is passed
|
||||
/// to `DOMSnapshot.captureSnapshot`.
|
||||
#[derive(Deserialize, Debug, Default)]
|
||||
struct LayoutSnap {
|
||||
#[serde(rename = "nodeIndex", default)]
|
||||
node_index: Vec<i32>,
|
||||
#[serde(default)]
|
||||
bounds: Vec<Vec<f64>>,
|
||||
}
|
||||
|
||||
/// Element bounding rect in CSS pixels relative to the viewport.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub fn center(self) -> (f64, f64) {
|
||||
(self.x + self.width / 2.0, self.y + self.height / 2.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Default)]
|
||||
@@ -82,46 +53,38 @@ pub struct Snapshot {
|
||||
strings: Vec<String>,
|
||||
nodes: NodeTreeSnap,
|
||||
children: Vec<Vec<usize>>,
|
||||
/// `rects[node_idx]` is `Some(Rect)` when layout info was requested
|
||||
/// AND the node has a layout box (text + element nodes do; pure
|
||||
/// metadata like `<head>` doesn't). `None` otherwise.
|
||||
rects: Vec<Option<Rect>>,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// Run `DOMSnapshot.captureSnapshot` on an attached session and return
|
||||
/// the parsed main-document tree. Iframes are ignored — none of the
|
||||
/// migrated providers render chat lists inside iframes.
|
||||
/// one parsed tree containing the main document and any iframe documents.
|
||||
pub async fn capture(cdp: &mut CdpConn, session: &str) -> Result<Self, String> {
|
||||
Self::capture_inner(cdp, session, false).await
|
||||
}
|
||||
|
||||
/// Same as [`capture`] but also requests element bounding rects.
|
||||
/// Use when the caller needs to drive `Input.dispatchMouseEvent` —
|
||||
/// pulling rects on every snapshot adds protocol overhead, so the
|
||||
/// cheap path stays cheap.
|
||||
pub async fn capture_with_rects(cdp: &mut CdpConn, session: &str) -> Result<Self, String> {
|
||||
Self::capture_inner(cdp, session, true).await
|
||||
}
|
||||
|
||||
async fn capture_inner(
|
||||
cdp: &mut CdpConn,
|
||||
session: &str,
|
||||
with_rects: bool,
|
||||
) -> Result<Self, String> {
|
||||
log::debug!("[cdp::snapshot] capture start session={session}");
|
||||
let raw = cdp
|
||||
.call(
|
||||
"DOMSnapshot.captureSnapshot",
|
||||
json!({
|
||||
"computedStyles": [],
|
||||
"includePaintOrder": false,
|
||||
"includeDOMRects": with_rects,
|
||||
}),
|
||||
capture_request(),
|
||||
Some(session),
|
||||
)
|
||||
.await?;
|
||||
let snap: CaptureSnapshot =
|
||||
serde_json::from_value(raw).map_err(|e| format!("decode DOMSnapshot: {e}"))?;
|
||||
.await
|
||||
.map_err(|error| {
|
||||
log::warn!("[cdp::snapshot] capture call failed session={session} error={error}");
|
||||
error
|
||||
})?;
|
||||
log::debug!("[cdp::snapshot] capture call complete session={session}");
|
||||
let snap: CaptureSnapshot = serde_json::from_value(raw).map_err(|error| {
|
||||
log::warn!("[cdp::snapshot] decode failed session={session} error={error}");
|
||||
format!("decode DOMSnapshot: {error}")
|
||||
})?;
|
||||
let snapshot = Self::from_capture(snap);
|
||||
log::debug!(
|
||||
"[cdp::snapshot] decode complete session={session} nodes={}",
|
||||
snapshot.len()
|
||||
);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn from_capture(snap: CaptureSnapshot) -> Self {
|
||||
let strings = snap.strings;
|
||||
// Merge every document (main frame + all iframes) into a single
|
||||
// flat node array. CDP returns each frame as its own document
|
||||
@@ -138,12 +101,9 @@ impl Snapshot {
|
||||
let mut merged_node_name: Vec<i32> = Vec::new();
|
||||
let mut merged_node_value: Vec<i32> = Vec::new();
|
||||
let mut merged_attributes: Vec<Vec<i32>> = Vec::new();
|
||||
let mut merged_layout_node_index: Vec<i32> = Vec::new();
|
||||
let mut merged_layout_bounds: Vec<Vec<f64>> = Vec::new();
|
||||
for document in snap.documents {
|
||||
let doc_offset = merged_node_type.len() as i32;
|
||||
let doc_nodes = document.nodes;
|
||||
let doc_count = doc_nodes.node_type.len();
|
||||
for &p in &doc_nodes.parent_index {
|
||||
merged_parent_index.push(if p < 0 { -1 } else { p + doc_offset });
|
||||
}
|
||||
@@ -162,12 +122,6 @@ impl Snapshot {
|
||||
while merged_attributes.len() < merged_node_type.len() {
|
||||
merged_attributes.push(Vec::new());
|
||||
}
|
||||
// Per-document layout indices need the same offset.
|
||||
for &li in &document.layout.node_index {
|
||||
merged_layout_node_index.push(if li < 0 { -1 } else { li + doc_offset });
|
||||
}
|
||||
merged_layout_bounds.extend(document.layout.bounds);
|
||||
let _ = doc_count;
|
||||
}
|
||||
let nodes = NodeTreeSnap {
|
||||
parent_index: merged_parent_index,
|
||||
@@ -176,10 +130,6 @@ impl Snapshot {
|
||||
node_value: merged_node_value,
|
||||
attributes: merged_attributes,
|
||||
};
|
||||
let layout = LayoutSnap {
|
||||
node_index: merged_layout_node_index,
|
||||
bounds: merged_layout_bounds,
|
||||
};
|
||||
let count = nodes.node_type.len();
|
||||
let mut children: Vec<Vec<usize>> = vec![Vec::new(); count];
|
||||
for (i, &p) in nodes.parent_index.iter().enumerate() {
|
||||
@@ -187,34 +137,11 @@ impl Snapshot {
|
||||
children[p as usize].push(i);
|
||||
}
|
||||
}
|
||||
let mut rects: Vec<Option<Rect>> = vec![None; count];
|
||||
if with_rects {
|
||||
for (layout_i, &node_i) in layout.node_index.iter().enumerate() {
|
||||
if node_i < 0 {
|
||||
continue;
|
||||
}
|
||||
let node_i = node_i as usize;
|
||||
if node_i >= count {
|
||||
continue;
|
||||
}
|
||||
let bounds = match layout.bounds.get(layout_i) {
|
||||
Some(b) if b.len() >= 4 => b,
|
||||
_ => continue,
|
||||
};
|
||||
rects[node_i] = Some(Rect {
|
||||
x: bounds[0],
|
||||
y: bounds[1],
|
||||
width: bounds[2],
|
||||
height: bounds[3],
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
Self {
|
||||
strings,
|
||||
nodes,
|
||||
children,
|
||||
rects,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
@@ -268,13 +195,6 @@ impl Snapshot {
|
||||
self.children.get(idx).map(|v| v.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Layout rect for `idx`. `None` when the snapshot was captured
|
||||
/// without `includeDOMRects` OR the node has no layout box (e.g.
|
||||
/// `<head>`, comment nodes, `display:none` elements).
|
||||
pub fn rect(&self, idx: usize) -> Option<Rect> {
|
||||
self.rects.get(idx).copied().flatten()
|
||||
}
|
||||
|
||||
/// Depth-first pre-order walk of every descendant of `root` (including
|
||||
/// `root` itself). Cheap enough for chat-list scrapes that run every
|
||||
/// 2 seconds — DOM has thousands of nodes, not millions.
|
||||
@@ -336,6 +256,14 @@ impl Snapshot {
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_request() -> serde_json::Value {
|
||||
json!({
|
||||
"computedStyles": [],
|
||||
"includePaintOrder": false,
|
||||
"includeDOMRects": false,
|
||||
})
|
||||
}
|
||||
|
||||
fn collapse_ws(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut last_space = true;
|
||||
@@ -357,6 +285,51 @@ fn collapse_ws(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn capture_request_disables_dom_rects() {
|
||||
let request = capture_request();
|
||||
assert_eq!(request["includeDOMRects"], false);
|
||||
assert_eq!(request["includePaintOrder"], false);
|
||||
assert_eq!(request["computedStyles"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_capture_offsets_documents_and_builds_child_adjacency_without_layout() {
|
||||
let capture: CaptureSnapshot = serde_json::from_value(json!({
|
||||
"strings": ["DIV", "first", "SPAN", "second"],
|
||||
"documents": [
|
||||
{
|
||||
"nodes": {
|
||||
"parentIndex": [-1, 0],
|
||||
"nodeType": [1, 3],
|
||||
"nodeName": [0, -1],
|
||||
"nodeValue": [-1, 1]
|
||||
}
|
||||
},
|
||||
{
|
||||
"nodes": {
|
||||
"parentIndex": [-1, 0],
|
||||
"nodeType": [1, 3],
|
||||
"nodeName": [2, -1],
|
||||
"nodeValue": [-1, 3]
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
.expect("snapshot fixture should decode without layout data");
|
||||
|
||||
let snapshot = Snapshot::from_capture(capture);
|
||||
|
||||
assert_eq!(snapshot.len(), 4);
|
||||
assert_eq!(snapshot.children(0), &[1]);
|
||||
assert_eq!(snapshot.children(2), &[3]);
|
||||
assert!(snapshot.children(1).is_empty());
|
||||
assert_eq!(snapshot.tag(0), "DIV");
|
||||
assert_eq!(snapshot.tag(2), "SPAN");
|
||||
assert_eq!(snapshot.text_content(0), "first");
|
||||
assert_eq!(snapshot.text_content(2), "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_ws_collapses_and_trims() {
|
||||
assert_eq!(collapse_ws(" hello world "), "hello world");
|
||||
|
||||
@@ -18,7 +18,6 @@ pub struct CdpTarget {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub url: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Parse the response of a `Target.getTargets` CDP call into a list of
|
||||
@@ -38,11 +37,6 @@ pub fn parse_targets(v: &Value) -> Vec<CdpTarget> {
|
||||
.and_then(|u| u.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
title: t
|
||||
.get("title")
|
||||
.and_then(|u| u.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -61,7 +61,7 @@ end tell"#;
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
return Err("no terminal emulator found (tried x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm). Run `claude login` manually.".into());
|
||||
Err("no terminal emulator found (tried x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm). Run `claude login` manually.".into())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
|
||||
@@ -108,13 +108,15 @@ pub(crate) fn try_forward_deep_links() -> ForwardResult {
|
||||
// Pending URLs collected before setup() has an app handle.
|
||||
static PENDING_URLS: OnceLock<Arc<Mutex<Vec<String>>>> = OnceLock::new();
|
||||
// Live handler installed by drain_pending_urls — dispatches directly to app.
|
||||
static LIVE_HANDLER: OnceLock<Mutex<Option<Box<dyn Fn(String) + Send + Sync>>>> = OnceLock::new();
|
||||
type LiveHandler = Box<dyn Fn(String) + Send + Sync>;
|
||||
type LiveHandlerSlot = Mutex<Option<LiveHandler>>;
|
||||
static LIVE_HANDLER: OnceLock<LiveHandlerSlot> = OnceLock::new();
|
||||
|
||||
fn pending_queue() -> &'static Arc<Mutex<Vec<String>>> {
|
||||
PENDING_URLS.get_or_init(|| Arc::new(Mutex::new(Vec::new())))
|
||||
}
|
||||
|
||||
fn live_handler() -> &'static Mutex<Option<Box<dyn Fn(String) + Send + Sync>>> {
|
||||
fn live_handler() -> &'static LiveHandlerSlot {
|
||||
LIVE_HANDLER.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
|
||||
@@ -339,7 +339,6 @@ fn page(id: &str, url: &str) -> crate::cdp::target::CdpTarget {
|
||||
id: id.to_string(),
|
||||
kind: "page".to_string(),
|
||||
url: url.to_string(),
|
||||
title: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +408,6 @@ fn resolve_none_when_no_prefix_target() {
|
||||
id: "iframe".to_string(),
|
||||
kind: "iframe".to_string(),
|
||||
url: "https://discord.com/channels/@me".to_string(),
|
||||
title: String::new(),
|
||||
},
|
||||
];
|
||||
assert!(
|
||||
|
||||
@@ -465,13 +465,6 @@ impl ScannerRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
pub fn ensure_scanner<R: tauri::Runtime>(
|
||||
self: std::sync::Arc<Self>,
|
||||
_app: tauri::AppHandle<R>,
|
||||
_account_id: String,
|
||||
) {
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
//! 1–2 channels) directly from the audio output device path —
|
||||
//! *before* it hits the OS speaker. No system permission needed.
|
||||
//!
|
||||
//! 2. Downsample-to-mono runs inline on the CEF audio thread:
|
||||
//! - average across channels → mono float32
|
||||
//! - linear-interpolate down to 16 kHz (the rate `voice::streaming`
|
||||
//! and the smoke test in `meet_agent::session` expect)
|
||||
//! - clamp + scale to PCM16LE
|
||||
//!
|
||||
//! 3. Accumulate ~100 ms per chunk (1 600 samples @ 16 kHz). We push
|
||||
//! via the core RPC on every flush boundary; smaller pushes would
|
||||
//! overload the JSON envelope, larger ones would slow VAD.
|
||||
//!
|
||||
//! 4. RPC pushes are spawned on the tokio runtime so the audio
|
||||
//! callback never blocks on network IO. A bounded channel
|
||||
//! backpressures: if core is wedged, we drop the oldest queued
|
||||
//! chunk rather than holding CEF's audio thread.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tauri_runtime_cef::audio::{
|
||||
register_audio_handler, AudioHandlerRegistration, AudioStreamEvent,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
const TARGET_SAMPLE_RATE: u32 = 16_000;
|
||||
/// 100 ms @ 16 kHz mono. `meet_agent::ops::Vad` pushes hangover counts
|
||||
/// based on per-frame cadence, so changing this changes the VAD wall
|
||||
/// time too. 100 ms feels responsive without burning RPC.
|
||||
const FLUSH_SAMPLES: usize = (TARGET_SAMPLE_RATE as usize) / 10;
|
||||
/// Bounded channel between the CEF callback (producer) and the
|
||||
/// async-runtime forwarder (consumer). 32 chunks ≈ 3.2 s at the flush
|
||||
/// cadence — generous slack for transient core latency, but bounded
|
||||
/// so a wedged core can't OOM us.
|
||||
const FORWARD_CHANNEL_CAPACITY: usize = 32;
|
||||
|
||||
/// RAII handle. Drop to release the CEF audio registration and shut
|
||||
/// down the forwarder task. Both happen synchronously — the channel
|
||||
/// closes first, the task exits its recv loop, and the registration
|
||||
/// drop unhooks CEF in the same tick.
|
||||
pub struct ListenSession {
|
||||
pub request_id: String,
|
||||
_registration: AudioHandlerRegistration,
|
||||
/// Held so `Drop` closes the channel even if there are no in-flight
|
||||
/// chunks. The forwarder task observes the close and exits.
|
||||
_shutdown_tx: mpsc::Sender<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Opens the audio capture for `meet_url`. The same exact URL must
|
||||
/// have been used to build the CEF window — `register_audio_handler`
|
||||
/// matches by prefix.
|
||||
pub fn start(meet_url: &str, request_id: String) -> Result<ListenSession, String> {
|
||||
let (tx, rx) = mpsc::channel::<Vec<u8>>(FORWARD_CHANNEL_CAPACITY);
|
||||
let resampler = Arc::new(Mutex::new(Resampler::new()));
|
||||
|
||||
let resampler_for_handler = resampler.clone();
|
||||
let tx_for_handler = tx.clone();
|
||||
let request_id_for_log = request_id.clone();
|
||||
let registration = register_audio_handler(meet_url.to_string(), move |event| {
|
||||
on_audio_event(
|
||||
&request_id_for_log,
|
||||
event,
|
||||
&resampler_for_handler,
|
||||
&tx_for_handler,
|
||||
);
|
||||
});
|
||||
|
||||
spawn_forwarder(request_id.clone(), rx);
|
||||
|
||||
log::info!(
|
||||
"[meet-audio] listen registered request_id={} url_chars={}",
|
||||
request_id,
|
||||
meet_url.chars().count()
|
||||
);
|
||||
|
||||
Ok(ListenSession {
|
||||
request_id,
|
||||
_registration: registration,
|
||||
_shutdown_tx: tx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Process one CEF audio event. Speech/Stopped/Error all flow through
|
||||
/// here; only `Packet` produces RPC traffic, but the others are logged
|
||||
/// at info so an aborted call leaves a breadcrumb in the file logs.
|
||||
fn on_audio_event(
|
||||
request_id: &str,
|
||||
event: AudioStreamEvent,
|
||||
resampler: &Arc<Mutex<Resampler>>,
|
||||
tx: &mpsc::Sender<Vec<u8>>,
|
||||
) {
|
||||
match event {
|
||||
AudioStreamEvent::Started {
|
||||
sample_rate_hz,
|
||||
channels,
|
||||
frames_per_buffer,
|
||||
} => {
|
||||
log::info!(
|
||||
"[meet-audio] cef stream start request_id={request_id} hz={sample_rate_hz} channels={channels} frames_per_buffer={frames_per_buffer}"
|
||||
);
|
||||
if let Ok(mut r) = resampler.lock() {
|
||||
r.reset(sample_rate_hz as u32);
|
||||
}
|
||||
}
|
||||
AudioStreamEvent::Packet {
|
||||
channels: planes,
|
||||
pts_ms: _,
|
||||
} => {
|
||||
let pcm_bytes = match resampler.lock() {
|
||||
Ok(mut r) => r.feed_and_drain(&planes),
|
||||
Err(_) => return,
|
||||
};
|
||||
for chunk in pcm_bytes.chunks(FLUSH_SAMPLES * 2) {
|
||||
// `try_send` drops the chunk on a full channel rather
|
||||
// than blocking the CEF audio thread. Better to lose
|
||||
// a frame than to stall the renderer.
|
||||
if tx.try_send(chunk.to_vec()).is_err() {
|
||||
log::warn!(
|
||||
"[meet-audio] forward channel full; dropping {} bytes request_id={request_id}",
|
||||
chunk.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
AudioStreamEvent::Stopped => {
|
||||
log::info!("[meet-audio] cef stream stopped request_id={request_id}");
|
||||
if let Ok(mut r) = resampler.lock() {
|
||||
r.reset(0);
|
||||
}
|
||||
}
|
||||
AudioStreamEvent::Error(msg) => {
|
||||
log::warn!("[meet-audio] cef stream error request_id={request_id} msg={msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull chunks off the bounded channel and POST each to core. Lives in
|
||||
/// its own task so the CEF callback never blocks on HTTP.
|
||||
fn spawn_forwarder(request_id: String, mut rx: mpsc::Receiver<Vec<u8>>) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
while let Some(chunk) = rx.recv().await {
|
||||
let pcm_b64 = B64.encode(&chunk);
|
||||
let res = super::rpc_call(
|
||||
"openhuman.meet_agent_push_listen_pcm",
|
||||
serde_json::json!({
|
||||
"request_id": request_id,
|
||||
"pcm_base64": pcm_b64,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Err(err) = res {
|
||||
log::debug!(
|
||||
"[meet-audio] push_listen_pcm err request_id={request_id} bytes={} err={err}",
|
||||
chunk.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
log::info!("[meet-audio] forwarder exiting request_id={request_id}");
|
||||
});
|
||||
}
|
||||
|
||||
/// Stateful float32-planar → PCM16LE mono @ 16 kHz resampler.
|
||||
///
|
||||
/// Uses linear interpolation, which is good enough for speech (the
|
||||
/// downstream STT does not care about ultrasonics or pristine high
|
||||
/// frequencies). Carry the previous sample across `feed_and_drain`
|
||||
/// calls so we don't introduce a tick at every CEF buffer boundary.
|
||||
/// Pick a source sample by signed index. Negative indices return the
|
||||
/// carry sample from the previous call (so phase < 0 keeps the
|
||||
/// interpolation continuous across buffer boundaries); past-the-end
|
||||
/// indices clamp to the last sample (which is what the next call will
|
||||
/// install as its own carry, so the output stays smooth even if a
|
||||
/// caller stops feeding mid-stream).
|
||||
fn sample_at(mono: &[f32], carry: f32, idx: i64) -> f32 {
|
||||
if idx < 0 {
|
||||
carry
|
||||
} else if (idx as usize) < mono.len() {
|
||||
mono[idx as usize]
|
||||
} else {
|
||||
*mono.last().unwrap_or(&0.0)
|
||||
}
|
||||
}
|
||||
|
||||
struct Resampler {
|
||||
source_rate_hz: u32,
|
||||
/// Fractional position into the source buffer between calls.
|
||||
/// 0.0 means "start cleanly with the next sample". Negative is
|
||||
/// not used — the source rate is always known before we feed.
|
||||
phase: f64,
|
||||
/// Last source sample of the previous call, used as the "left"
|
||||
/// neighbour when we interpolate the first sample of the next call.
|
||||
last_sample: f32,
|
||||
}
|
||||
|
||||
impl Resampler {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
source_rate_hz: 0,
|
||||
phase: 0.0,
|
||||
last_sample: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self, source_rate_hz: u32) {
|
||||
self.source_rate_hz = source_rate_hz;
|
||||
self.phase = 0.0;
|
||||
self.last_sample = 0.0;
|
||||
}
|
||||
|
||||
fn feed_and_drain(&mut self, planes: &[Vec<f32>]) -> Vec<u8> {
|
||||
if planes.is_empty() || self.source_rate_hz == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let frames = planes[0].len();
|
||||
if frames == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Mono mix.
|
||||
let mono: Vec<f32> = (0..frames)
|
||||
.map(|i| {
|
||||
let mut sum = 0.0_f32;
|
||||
for plane in planes {
|
||||
if let Some(v) = plane.get(i) {
|
||||
sum += *v;
|
||||
}
|
||||
}
|
||||
sum / planes.len() as f32
|
||||
})
|
||||
.collect();
|
||||
|
||||
let ratio = self.source_rate_hz as f64 / TARGET_SAMPLE_RATE as f64;
|
||||
let mut out = Vec::with_capacity((mono.len() as f64 / ratio).ceil() as usize * 2);
|
||||
// `pos` floats through `mono` indices. `pos < 0` means "still
|
||||
// sampling the carry sample from the previous call"; `pos = 0`
|
||||
// means "right at mono[0]".
|
||||
let mut pos = self.phase;
|
||||
while pos < mono.len() as f64 {
|
||||
let idx_f = pos.floor();
|
||||
let frac = pos - idx_f;
|
||||
let idx = idx_f as i64;
|
||||
let s_left = sample_at(mono.as_slice(), self.last_sample, idx);
|
||||
let s_right = sample_at(mono.as_slice(), self.last_sample, idx + 1);
|
||||
let sample = s_left as f64 + (s_right as f64 - s_left as f64) * frac;
|
||||
// Float32 [-1.0, 1.0] → i16. Clamp because Chromium can
|
||||
// overshoot a touch on heavy compression.
|
||||
let s_i16 = (sample.clamp(-1.0, 1.0) * i16::MAX as f64) as i16;
|
||||
out.extend_from_slice(&s_i16.to_le_bytes());
|
||||
pos += ratio;
|
||||
}
|
||||
// Carry the trailing fractional position into the next call.
|
||||
// It will be negative when we overshot (next call resumes
|
||||
// mid-source-sample), so the next call interpolates between
|
||||
// `last_sample` and the new mono[0].
|
||||
self.phase = pos - mono.len() as f64;
|
||||
self.last_sample = *mono.last().unwrap_or(&0.0);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resampler_with_no_source_rate_yields_nothing() {
|
||||
let mut r = Resampler::new();
|
||||
let out = r.feed_and_drain(&[vec![0.5; 100]]);
|
||||
assert!(out.is_empty(), "no source rate set, must produce nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampler_48k_to_16k_mono_drops_samples_3to1() {
|
||||
let mut r = Resampler::new();
|
||||
r.reset(48_000);
|
||||
let plane = vec![0.5_f32; 4_800]; // 100ms @ 48k
|
||||
let bytes = r.feed_and_drain(&[plane]);
|
||||
// 100ms @ 16k = 1600 samples * 2 bytes. Allow ±2 samples slop
|
||||
// from the fractional phase carry.
|
||||
let samples = bytes.len() / 2;
|
||||
assert!(
|
||||
(1598..=1602).contains(&samples),
|
||||
"expected ~1600 samples, got {samples}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampler_stereo_to_mono_averages_channels() {
|
||||
let mut r = Resampler::new();
|
||||
r.reset(16_000);
|
||||
let left = vec![0.8_f32; 1600];
|
||||
let right = vec![-0.2_f32; 1600];
|
||||
let bytes = r.feed_and_drain(&[left, right]);
|
||||
// Avg = 0.3 → ~9830 in i16. First two bytes are LE i16.
|
||||
let first = i16::from_le_bytes([bytes[0], bytes[1]]);
|
||||
assert!(
|
||||
(9000..11000).contains(&first),
|
||||
"expected mid-amplitude i16, got {first}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampler_clamps_out_of_range_floats() {
|
||||
let mut r = Resampler::new();
|
||||
r.reset(16_000);
|
||||
let bytes = r.feed_and_drain(&[vec![5.0_f32; 100]]);
|
||||
let first = i16::from_le_bytes([bytes[0], bytes[1]]);
|
||||
assert_eq!(first, i16::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resampler_passthrough_when_rates_match() {
|
||||
let mut r = Resampler::new();
|
||||
r.reset(16_000);
|
||||
let plane = vec![0.5_f32; 1600];
|
||||
let bytes = r.feed_and_drain(&[plane]);
|
||||
assert_eq!(bytes.len(), 1600 * 2);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,8 @@
|
||||
//!
|
||||
//! ## Pieces
|
||||
//!
|
||||
//! - [`listen_capture`] — taps the embedded Meet webview's audio output
|
||||
//! via the per-browser `CefAudioHandler` exposed by our vendored
|
||||
//! `tauri-runtime-cef::audio` extension, downsamples to 16 kHz mono
|
||||
//! PCM16LE, batches into ~100 ms chunks, and posts them to core via
|
||||
//! `openhuman.meet_agent_push_listen_pcm`. Zero OS-level audio
|
||||
//! permission needed: we read frames straight out of the renderer.
|
||||
//! - [`caption_listener`] — drains Meet's built-in captions through CDP
|
||||
//! and forwards speaker-attributed lines to core.
|
||||
//!
|
||||
//! - [`speak_pump`] — drains synthesized PCM the brain enqueued (via
|
||||
//! `openhuman.meet_agent_poll_speech`) and writes it into the
|
||||
@@ -19,15 +15,12 @@
|
||||
//!
|
||||
//! [`start`] is invoked once the meet-call window has been built (in
|
||||
//! `meet_call::meet_call_open_window`). It opens the core session,
|
||||
//! registers the audio handler keyed by the call's URL, and spawns the
|
||||
//! poll-speech loop. [`stop`] runs from the window-destroyed handler:
|
||||
//! it drops the audio handler registration (which silences capture
|
||||
//! immediately), stops the speak pump, and tells core to close the
|
||||
//! session and report counters.
|
||||
//! starts the caption listener and poll-speech loop. [`stop`] runs from
|
||||
//! the window-destroyed handler: it stops both tasks and tells core to
|
||||
//! close the session and report counters.
|
||||
|
||||
pub mod caption_listener;
|
||||
pub mod inject;
|
||||
pub mod listen_capture;
|
||||
pub mod speak_pump;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -54,14 +47,8 @@ impl MeetAudioState {
|
||||
/// teardown synchronously — no async drop needed because the caption
|
||||
/// listener and speak pump both shut down on signal/drop.
|
||||
///
|
||||
/// The legacy CEF-audio `listen_capture::ListenSession` is kept as an
|
||||
/// optional field so the pre-register flow still has somewhere to
|
||||
/// hand the registration off if a future build re-enables it. In the
|
||||
/// caption-driven path it stays `None`.
|
||||
pub struct MeetAudioSession {
|
||||
pub request_id: String,
|
||||
_captions: caption_listener::CaptionListener,
|
||||
_legacy_listen: Option<listen_capture::ListenSession>,
|
||||
_speak: speak_pump::SpeakPump,
|
||||
}
|
||||
|
||||
@@ -229,9 +216,7 @@ pub async fn start<R: Runtime>(
|
||||
state.inner.lock().unwrap().insert(
|
||||
request_id.clone(),
|
||||
MeetAudioSession {
|
||||
request_id: request_id.clone(),
|
||||
_captions: captions,
|
||||
_legacy_listen: None,
|
||||
_speak: speak,
|
||||
},
|
||||
);
|
||||
@@ -353,11 +338,8 @@ pub(crate) async fn rpc_call(
|
||||
|
||||
/// No-op caption listener used when CDP attach failed at session
|
||||
/// start. Lets the rest of the lifecycle hold a uniform value.
|
||||
fn caption_listener_disabled(request_id: String) -> caption_listener::CaptionListener {
|
||||
caption_listener::CaptionListener {
|
||||
request_id,
|
||||
_shutdown_tx: None,
|
||||
}
|
||||
fn caption_listener_disabled(_request_id: String) -> caption_listener::CaptionListener {
|
||||
caption_listener::CaptionListener { _shutdown_tx: None }
|
||||
}
|
||||
|
||||
/// Trim a string for logging without panicking on multi-byte chars.
|
||||
|
||||
@@ -48,7 +48,6 @@ const SPEAKING_STATE_EVENT: &str = "meet-video:speaking-state";
|
||||
/// RAII handle. Drop to stop the pump task. The shutdown channel
|
||||
/// causes the spawned loop to exit on the next select tick.
|
||||
pub struct SpeakPump {
|
||||
pub request_id: String,
|
||||
_shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
@@ -130,7 +129,6 @@ pub fn start<R: Runtime>(
|
||||
});
|
||||
|
||||
SpeakPump {
|
||||
request_id,
|
||||
_shutdown_tx: Some(shutdown_tx),
|
||||
}
|
||||
}
|
||||
@@ -277,11 +275,8 @@ fn next_speaking_state(
|
||||
/// No-op pump used when bridge install failed at session start. Keeps
|
||||
/// the rest of the session lifecycle uniform — `MeetAudioSession` can
|
||||
/// still hold a `SpeakPump` regardless of speak-path readiness.
|
||||
pub fn start_disabled(request_id: String) -> SpeakPump {
|
||||
SpeakPump {
|
||||
request_id,
|
||||
_shutdown_tx: None,
|
||||
}
|
||||
pub fn start_disabled(_request_id: String) -> SpeakPump {
|
||||
SpeakPump { _shutdown_tx: None }
|
||||
}
|
||||
|
||||
/// Run a single pump tick. Returns `true` when the tick actually
|
||||
|
||||
@@ -134,8 +134,8 @@ pub async fn meet_call_open_window<R: Runtime>(
|
||||
}
|
||||
|
||||
// Only one meet-call window can be live at a time — concurrent bot
|
||||
// sessions race the CEF audio handler registration (`listen_capture`)
|
||||
// and confuse the user with multiple "Meet — OpenHuman" windows in
|
||||
// sessions race the shared audio bridge and confuse the user with
|
||||
// multiple "Meet — OpenHuman" windows in
|
||||
// their Dock. Close any stragglers from a prior Join before opening
|
||||
// a fresh one. The CloseRequested handler will tear down their
|
||||
// scanner + audio session via the per-window event listeners below.
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
use crate::cdp::{self, CdpConn};
|
||||
|
||||
|
||||
@@ -191,14 +191,16 @@ async fn handle_connection(
|
||||
// while waiting for the producer's next tick.
|
||||
let writer = tokio::spawn(async move {
|
||||
let initial = latest_rx.borrow().clone();
|
||||
if !initial.is_empty() {
|
||||
if sink
|
||||
if !initial.is_empty()
|
||||
&& sink
|
||||
.send(Message::Binary((*initial).clone()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
{
|
||||
log::debug!(
|
||||
"[meet-video] initial WebSocket frame send failed; closing writer peer_transport=disconnected"
|
||||
);
|
||||
return;
|
||||
}
|
||||
while latest_rx.changed().await.is_ok() {
|
||||
let frame = latest_rx.borrow().clone();
|
||||
@@ -237,7 +239,6 @@ async fn handle_connection(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures_util::{SinkExt as _, StreamExt as _};
|
||||
use tokio_tungstenite::connect_async;
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -14,8 +14,6 @@ pub(crate) enum PttError {
|
||||
EmptyShortcut,
|
||||
ModifierOnlyShortcut,
|
||||
ConflictsWithDictation(String),
|
||||
UnsupportedOnWayland,
|
||||
RegistrationFailed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PttError {
|
||||
@@ -29,13 +27,6 @@ impl std::fmt::Display for PttError {
|
||||
PttError::ConflictsWithDictation(s) => {
|
||||
write!(f, "ptt shortcut '{s}' conflicts with the dictation hotkey")
|
||||
}
|
||||
PttError::UnsupportedOnWayland => write!(
|
||||
f,
|
||||
"global shortcuts are not supported in this Wayland session — switch to X11 or use in-app dictation"
|
||||
),
|
||||
PttError::RegistrationFailed(s) => {
|
||||
write!(f, "failed to register ptt shortcut: {s}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ pub(crate) fn ensure_window<R: Runtime>(app: &AppHandle<R>) -> Result<(), String
|
||||
return Ok(());
|
||||
}
|
||||
let url = WebviewUrl::App("index.html#/ptt-overlay".into());
|
||||
let mut builder = WebviewWindowBuilder::new(app, OVERLAY_LABEL, url)
|
||||
let builder = WebviewWindowBuilder::new(app, OVERLAY_LABEL, url)
|
||||
.title("OpenHuman Push-to-Talk")
|
||||
.inner_size(160.0, 56.0)
|
||||
.decorations(false)
|
||||
@@ -39,11 +39,9 @@ pub(crate) fn ensure_window<R: Runtime>(app: &AppHandle<R>) -> Result<(), String
|
||||
.visible(false);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
builder = builder
|
||||
.visible_on_all_workspaces(true)
|
||||
.accept_first_mouse(false);
|
||||
}
|
||||
let builder = builder
|
||||
.visible_on_all_workspaces(true)
|
||||
.accept_first_mouse(false);
|
||||
|
||||
let _window = builder
|
||||
.build()
|
||||
|
||||
@@ -82,6 +82,7 @@ pub struct ScreenSource {
|
||||
|
||||
/// What kind of source a parsed DesktopMediaID-format string describes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
pub(crate) enum SourceKind {
|
||||
Screen,
|
||||
Window,
|
||||
@@ -93,6 +94,7 @@ pub(crate) enum SourceKind {
|
||||
/// match what the enumerator emits. Pure logic so it can be unit-tested
|
||||
/// without touching platform APIs; macOS callers use it before dispatching
|
||||
/// to the capture backend.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
pub(crate) fn parse_source_id(id: &str) -> Option<(SourceKind, u32)> {
|
||||
let mut parts = id.splitn(3, ':');
|
||||
let kind = match parts.next()? {
|
||||
|
||||
@@ -35,16 +35,16 @@ pub struct ExtractedMessage {
|
||||
pub ts: String,
|
||||
}
|
||||
|
||||
/// Main entry: walks every record in the dump and returns
|
||||
/// `(messages, user_id → display_name, channel_id → name, workspace_name)`.
|
||||
pub fn harvest(
|
||||
dump: &IdbDump,
|
||||
) -> (
|
||||
type HarvestResult = (
|
||||
Vec<ExtractedMessage>,
|
||||
HashMap<String, String>,
|
||||
HashMap<String, String>,
|
||||
Option<String>,
|
||||
) {
|
||||
);
|
||||
|
||||
/// Main entry: walks every record in the dump and returns
|
||||
/// `(messages, user_id → display_name, channel_id → name, workspace_name)`.
|
||||
pub fn harvest(dump: &IdbDump) -> HarvestResult {
|
||||
let mut messages: Vec<ExtractedMessage> = Vec::new();
|
||||
let mut users: HashMap<String, String> = HashMap::new();
|
||||
let mut channels: HashMap<String, String> = HashMap::new();
|
||||
@@ -173,15 +173,13 @@ fn walk(
|
||||
.or_insert_with(|| n.to_string());
|
||||
}
|
||||
}
|
||||
'T' => {
|
||||
if workspace.is_none() {
|
||||
if let Some(n) = map
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
*workspace = Some(n.to_string());
|
||||
}
|
||||
'T' if workspace.is_none() => {
|
||||
if let Some(n) = map
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
*workspace = Some(n.to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -222,24 +220,23 @@ fn walk(
|
||||
);
|
||||
}
|
||||
}
|
||||
Value::String(s) => {
|
||||
// Redux-persist default: values are JSON-encoded strings. If
|
||||
// this string is plausibly JSON, parse and recurse.
|
||||
Value::String(s)
|
||||
if s.len() > 20
|
||||
&& (s.starts_with('{') || s.starts_with('['))
|
||||
&& (s.ends_with('}') || s.ends_with(']'))
|
||||
{
|
||||
if let Ok(inner) = serde_json::from_str::<Value>(s) {
|
||||
walk(
|
||||
&inner,
|
||||
channel_hint,
|
||||
messages,
|
||||
users,
|
||||
channels,
|
||||
workspace,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
&& (s.ends_with('}') || s.ends_with(']')) =>
|
||||
{
|
||||
// Redux-persist default: values are JSON-encoded strings. If
|
||||
// this string is plausibly JSON, parse and recurse.
|
||||
if let Ok(inner) = serde_json::from_str::<Value>(s) {
|
||||
walk(
|
||||
&inner,
|
||||
channel_hint,
|
||||
messages,
|
||||
users,
|
||||
channels,
|
||||
workspace,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -283,7 +283,7 @@ async fn serialize_values(
|
||||
chunk.len()
|
||||
));
|
||||
}
|
||||
for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) {
|
||||
for ((idx, _), val) in chunk.iter().zip(serialised) {
|
||||
result[*idx] = val;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,16 +166,15 @@ fn walk(v: &Value, peer_hint: Option<&str>, out: &mut Harvest, depth: u32) {
|
||||
walk(vv, peer_hint, out, depth + 1);
|
||||
}
|
||||
}
|
||||
Value::String(s) => {
|
||||
// Some tweb stores persist state as JSON-encoded strings.
|
||||
// Recurse when the shape looks plausibly JSON.
|
||||
Value::String(s)
|
||||
if s.len() > 20
|
||||
&& (s.starts_with('{') || s.starts_with('['))
|
||||
&& (s.ends_with('}') || s.ends_with(']'))
|
||||
{
|
||||
if let Ok(inner) = serde_json::from_str::<Value>(s) {
|
||||
walk(&inner, peer_hint, out, depth + 1);
|
||||
}
|
||||
&& (s.ends_with('}') || s.ends_with(']')) =>
|
||||
{
|
||||
// Some tweb stores persist state as JSON-encoded strings.
|
||||
// Recurse when the shape looks plausibly JSON.
|
||||
if let Ok(inner) = serde_json::from_str::<Value>(s) {
|
||||
walk(&inner, peer_hint, out, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -283,7 +283,7 @@ async fn serialize_values(
|
||||
chunk.len()
|
||||
));
|
||||
}
|
||||
for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) {
|
||||
for ((idx, _), val) in chunk.iter().zip(serialised) {
|
||||
result[*idx] = val;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ pub struct MessageRow {
|
||||
pub struct DomScan {
|
||||
pub chat_rows: Vec<ChatRow>,
|
||||
pub messages: Vec<MessageRow>,
|
||||
pub active_chat_name: Option<String>,
|
||||
pub unread: u32,
|
||||
pub hash: u64,
|
||||
}
|
||||
@@ -71,7 +70,6 @@ pub async fn scan(cdp: &mut CdpConn, session: &str) -> Result<DomScan, String> {
|
||||
Ok(DomScan {
|
||||
chat_rows,
|
||||
messages,
|
||||
active_chat_name,
|
||||
unread,
|
||||
hash,
|
||||
})
|
||||
|
||||
@@ -237,7 +237,7 @@ async fn serialize_values(
|
||||
chunk.len()
|
||||
));
|
||||
}
|
||||
for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) {
|
||||
for ((idx, _), val) in chunk.iter().zip(serialised) {
|
||||
result[*idx] = val;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,6 @@ use tauri::{AppHandle, Emitter, Runtime};
|
||||
use tokio::task::AbortHandle;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::cdp::CdpConn;
|
||||
|
||||
mod dom_snapshot;
|
||||
#[cfg(test)]
|
||||
mod dom_snapshot_tests;
|
||||
@@ -1209,7 +1207,7 @@ fn merge_dom_into_snapshot(
|
||||
continue;
|
||||
}
|
||||
if let Some(mid) = mid_opt {
|
||||
let bare_mid = mid.rsplitn(2, '_').next().map(str::to_string);
|
||||
let bare_mid = mid.rsplit('_').next().map(str::to_string);
|
||||
let lookup = by_msg_id
|
||||
.get(&mid)
|
||||
.cloned()
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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': 'فشل تشغيل؛ راجعه واقترح إصلاحًا.',
|
||||
|
||||
@@ -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':
|
||||
'একটি প্রস্তাবিত খসড়া পর্যালোচনা হচ্ছে: এখনও কিছু সংরক্ষণ করা হয়নি।',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -3323,6 +3323,8 @@ const messages: TranslationMap = {
|
||||
'mic.lowConfidenceResult': "Impossible de comprendre l'audio clairement: réessaie",
|
||||
'mic.failedToStopRecording': "Échec de l'arrêt de l'enregistrement : {message}",
|
||||
'mic.transcriptionFailed': 'Échec de la transcription : {message}',
|
||||
'mic.voiceNotCompiled':
|
||||
"La transcription vocale n'est pas incluse dans cette version de l'application. Mettez à jour OpenHuman pour l'activer.",
|
||||
'reflections.kind.retrospective': 'Rétrospective',
|
||||
'reflections.kind.derivedFact': 'Fait dérivé',
|
||||
'reflections.kind.moodInsight': 'Insight émotionnel',
|
||||
@@ -4214,6 +4216,8 @@ const messages: TranslationMap = {
|
||||
'flows.copilot.removed': '{count} supprimés',
|
||||
'flows.copilot.noChanges': 'Cette proposition ne modifie aucun nœud.',
|
||||
'flows.copilot.accept': 'Appliquer au brouillon',
|
||||
'flows.copilot.acceptAndSave': 'Accepter et enregistrer',
|
||||
'flows.copilot.saving': 'Enregistrement…',
|
||||
'flows.copilot.reject': 'Ignorer',
|
||||
'flows.copilot.previewHint': 'Examen d’un brouillon proposé: rien n’est encore enregistré.',
|
||||
'flows.copilot.repairDisplay': 'Une exécution a échoué ; examinez-la et proposez une correction.',
|
||||
|
||||
@@ -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':
|
||||
'एक प्रस्तावित ड्राफ़्ट की समीक्षा हो रही है: अभी कुछ सहेजा नहीं गया।',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -3297,6 +3297,8 @@ const messages: TranslationMap = {
|
||||
'mic.lowConfidenceResult': "Impossibile comprendere l'audio chiaramente: riprova",
|
||||
'mic.failedToStopRecording': 'Impossibile fermare la registrazione: {message}',
|
||||
'mic.transcriptionFailed': 'Trascrizione fallita: {message}',
|
||||
'mic.voiceNotCompiled':
|
||||
"La trascrizione vocale non è inclusa in questa versione dell'app. Aggiorna OpenHuman per attivarla.",
|
||||
'reflections.kind.retrospective': 'Retrospettiva',
|
||||
'reflections.kind.derivedFact': 'Fatto derivato',
|
||||
'reflections.kind.moodInsight': "Insight sull'umore",
|
||||
@@ -4182,6 +4184,8 @@ const messages: TranslationMap = {
|
||||
'flows.copilot.removed': '{count} rimossi',
|
||||
'flows.copilot.noChanges': 'Questa proposta non modifica alcun nodo.',
|
||||
'flows.copilot.accept': 'Applica alla bozza',
|
||||
'flows.copilot.acceptAndSave': 'Accetta e salva',
|
||||
'flows.copilot.saving': 'Salvataggio…',
|
||||
'flows.copilot.reject': 'Ignora',
|
||||
'flows.copilot.previewHint': 'Revisione di una bozza proposta: non è stato ancora salvato nulla.',
|
||||
'flows.copilot.repairDisplay': 'Un’esecuzione è fallita; esaminala e proponi una correzione.',
|
||||
|
||||
@@ -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': '실행이 실패했습니다. 확인하고 수정을 제안하세요.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Запуск завершился ошибкой; изучите его и предложите исправление.',
|
||||
|
||||
@@ -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': '一次运行失败了,请查看并提出修复方案。',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"debug": "bash scripts/debug/cli.sh",
|
||||
"test:install-ps1": "pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1",
|
||||
"rust:check": "pnpm --filter openhuman-app rust:check",
|
||||
"rust:clippy": "cargo clippy -p openhuman -- -D warnings && pnpm --filter openhuman-app rust:clippy",
|
||||
"typecheck": "pnpm --filter openhuman-app compile",
|
||||
"tauri:ios:init": "bash scripts/ios-init.sh",
|
||||
"tauri:ios:dev": "pnpm --filter openhuman-app tauri:ios:dev",
|
||||
|
||||
@@ -57,8 +57,10 @@ fn main() -> ExitCode {
|
||||
}
|
||||
};
|
||||
|
||||
let mut cfg = Config::default();
|
||||
cfg.workspace_dir = workspace.clone();
|
||||
let cfg = Config {
|
||||
workspace_dir: workspace.clone(),
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
let db_path = workspace.join("memory_tree").join("chunks.db");
|
||||
let cold = !db_path.exists();
|
||||
|
||||
@@ -323,8 +323,8 @@ async fn main() -> Result<()> {
|
||||
enum Outcome {
|
||||
Ok,
|
||||
Ratelimit,
|
||||
OtherFail(String),
|
||||
Transport(String),
|
||||
OtherFail,
|
||||
Transport,
|
||||
}
|
||||
let mut outcomes: Vec<(u32, std::time::Duration, Outcome)> = Vec::with_capacity(n as usize);
|
||||
let probe_started = Instant::now();
|
||||
@@ -342,7 +342,10 @@ async fn main() -> Result<()> {
|
||||
.await;
|
||||
let dt = t0.elapsed();
|
||||
let outcome = match resp {
|
||||
Err(e) => Outcome::Transport(format!("{e:#}")),
|
||||
Err(e) => {
|
||||
log::warn!("[probe-ratelimit] transport error on call {i}: {e:#}");
|
||||
Outcome::Transport
|
||||
}
|
||||
Ok(r) if r.successful => Outcome::Ok,
|
||||
Ok(r) => {
|
||||
let err = r.error.as_deref().unwrap_or("provider failure");
|
||||
@@ -356,7 +359,11 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
Outcome::Ratelimit
|
||||
} else {
|
||||
Outcome::OtherFail(err.to_string())
|
||||
log::warn!(
|
||||
"[probe-ratelimit] provider failure on call {i}: {}",
|
||||
sanitize_probe_error(err)
|
||||
);
|
||||
Outcome::OtherFail
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -379,11 +386,11 @@ async fn main() -> Result<()> {
|
||||
.count();
|
||||
let other = outcomes
|
||||
.iter()
|
||||
.filter(|(_, _, o)| matches!(o, Outcome::OtherFail(_)))
|
||||
.filter(|(_, _, o)| matches!(o, Outcome::OtherFail))
|
||||
.count();
|
||||
let transport = outcomes
|
||||
.iter()
|
||||
.filter(|(_, _, o)| matches!(o, Outcome::Transport(_)))
|
||||
.filter(|(_, _, o)| matches!(o, Outcome::Transport))
|
||||
.count();
|
||||
let avg_ms = if !outcomes.is_empty() {
|
||||
outcomes.iter().map(|(_, d, _)| d.as_millis()).sum::<u128>() / outcomes.len() as u128
|
||||
@@ -568,3 +575,27 @@ fn component_status(endpoint: &Option<String>, model: &Option<String>) -> String
|
||||
_ => "off".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_probe_error(error: &str) -> String {
|
||||
let single_line = error.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
openhuman_core::openhuman::inference::provider::ops::sanitize_api_error(&single_line)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sanitize_probe_error;
|
||||
|
||||
#[test]
|
||||
fn probe_error_summary_is_single_line_bounded_and_secret_safe() {
|
||||
let raw = format!(
|
||||
"provider failed\nwith xoxb-secret-token {}",
|
||||
"x".repeat(300)
|
||||
);
|
||||
let summary = sanitize_probe_error(&raw);
|
||||
|
||||
assert!(!summary.contains('\n'));
|
||||
assert!(!summary.contains("xoxb-secret-token"));
|
||||
assert!(summary.contains("[REDACTED]"));
|
||||
assert!(summary.chars().count() <= 203);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,6 @@ use crate::core::jsonrpc::{default_state, invoke_method, parse_json_params};
|
||||
use crate::core::logging::CliLogDefault;
|
||||
use crate::core::{ControllerSchema, TypeSchema};
|
||||
|
||||
/// Debug/e2e agent paths can build deep async poll stacks while assembling
|
||||
/// prompts, provider requests, and sub-agent tool loops.
|
||||
const CLI_RUNTIME_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// The ASCII banner displayed when the CLI starts.
|
||||
const CLI_BANNER: &str = r#"
|
||||
|
||||
@@ -450,14 +446,6 @@ fn run_namespace_command(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_cli_runtime() -> Result<tokio::runtime::Runtime> {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.thread_stack_size(CLI_RUNTIME_THREAD_STACK_SIZE)
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Parses command-line arguments into a JSON map based on a function's schema.
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -664,7 +664,7 @@ impl AutomateBackend for RealBackend {
|
||||
// `summarization` keeps M1 free of Config-schema churn while still keeping
|
||||
// the chat model out of the loop.
|
||||
use tinyagents::harness::message::Message;
|
||||
use tinyagents::harness::model::{ChatModel, ModelRequest};
|
||||
use tinyagents::harness::model::ModelRequest;
|
||||
let model = crate::openhuman::inference::provider::create_chat_model(
|
||||
"summarization",
|
||||
&self.config,
|
||||
|
||||
@@ -28,8 +28,6 @@ mod vision_click;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod uia_interact;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use automation_state::test_lock as automation_state_test_lock;
|
||||
pub use automation_state::{
|
||||
clear as clear_automation_denial, mark_system_events_denied, system_events_denied,
|
||||
};
|
||||
|
||||
@@ -158,8 +158,7 @@ fn permission_state_serde_round_trip() {
|
||||
mod automation_state_stale_cache {
|
||||
use crate::openhuman::accessibility::automation_state;
|
||||
use crate::openhuman::accessibility::{
|
||||
automation_state_test_lock, clear_automation_denial, mark_system_events_denied,
|
||||
system_events_denied,
|
||||
clear_automation_denial, mark_system_events_denied, system_events_denied,
|
||||
};
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -30,8 +30,6 @@ pub use types::ArchivistHook;
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::openhuman::agent::hooks::PostTurnHook;
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::openhuman::config::Config;
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::openhuman::memory_store::profile;
|
||||
#[cfg(test)]
|
||||
pub(crate) use helpers::extract_profile_key;
|
||||
|
||||
@@ -64,6 +64,7 @@ impl ArchivistHook {
|
||||
/// - NEVER mutates DB state (no `segment_set_summary`, no embedding).
|
||||
/// - NEVER closes a segment.
|
||||
/// - Safe to call on both open and closed segments.
|
||||
///
|
||||
/// Summarize a set of episodic entries into a recap string.
|
||||
///
|
||||
/// Returns `(text, produced_by_llm)`. `produced_by_llm == false` means the
|
||||
|
||||
@@ -23,10 +23,9 @@
|
||||
//! - `<invoke tool=…>` XML attribute form — the parser does not parse attributes;
|
||||
//! only the tag body (JSON) is used.
|
||||
|
||||
use crate::openhuman::agent::error::AgentError;
|
||||
use crate::openhuman::inference::provider::traits::ProviderCapabilities;
|
||||
use crate::openhuman::inference::provider::Provider;
|
||||
use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, UsageInfo};
|
||||
use crate::openhuman::inference::provider::{ChatRequest, ChatResponse};
|
||||
use crate::openhuman::tool_timeout::parse_tool_timeout_secs;
|
||||
use crate::openhuman::tools::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -491,7 +491,7 @@ impl Agent {
|
||||
)?;
|
||||
let supports_native = resolved_chat_model
|
||||
.profile()
|
||||
.map_or(true, |profile| profile.tool_calling);
|
||||
.is_none_or(|profile| profile.tool_calling);
|
||||
log::info!(
|
||||
"[session-builder] agent_id={} provider_role={} resolved_model={} supports_native_tools={}",
|
||||
agent_id,
|
||||
|
||||
@@ -871,7 +871,7 @@ fn read_thread_usage_summary_sums_multiple_transcripts() {
|
||||
let raw = raw_session_dir(ws.path());
|
||||
std::fs::create_dir_all(&raw).unwrap();
|
||||
|
||||
let mut mk = |stem: &str, input: u64, cost: f64| {
|
||||
let mk = |stem: &str, input: u64, cost: f64| {
|
||||
let mut meta = sample_meta();
|
||||
meta.thread_id = Some("thr-multi".into());
|
||||
meta.input_tokens = input;
|
||||
@@ -928,7 +928,7 @@ fn read_thread_usage_summary_groups_subagents_by_archetype() {
|
||||
.unwrap();
|
||||
|
||||
// Sub-agent transcripts (stems contain `__`): coder x2 + researcher x1.
|
||||
let mut sub = |stem: &str, agent: &str, input: u64, output: u64| {
|
||||
let sub = |stem: &str, agent: &str, input: u64, output: u64| {
|
||||
let mut m = sample_meta();
|
||||
m.thread_id = Some("thr-sub".into());
|
||||
m.agent_name = agent.into();
|
||||
|
||||
@@ -5,7 +5,9 @@ use super::super::types::Agent;
|
||||
use crate::openhuman::agent::harness;
|
||||
use crate::openhuman::agent::progress::AgentProgress;
|
||||
use crate::openhuman::context::ARCHIVIST_EXTRACTION_PROMPT;
|
||||
use crate::openhuman::inference::provider::{ChatMessage, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS};
|
||||
use crate::openhuman::inference::provider::{
|
||||
ChatMessage, ChatResponse, UsageInfo, AGENT_TURN_MAX_OUTPUT_TOKENS,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use tinyagents::harness::model::{ModelRequest, ModelStreamItem};
|
||||
|
||||
@@ -69,8 +71,9 @@ impl Agent {
|
||||
|
||||
/// Ask the provider for a short wrap-up message with native tools
|
||||
/// **disabled** so the model returns prose rather than another tool call.
|
||||
/// Streams text deltas to the progress sink (when attached) so the summary
|
||||
/// appears in the UI like any other reply.
|
||||
/// Buffers text deltas and forwards them to the progress sink (when
|
||||
/// attached) only after the completed response is validated as prose, so
|
||||
/// prompt-formatted tool calls cannot flash in the UI before fallback.
|
||||
///
|
||||
/// `instruction` is the synthetic user turn that steers the wrap-up — the
|
||||
/// tool-call-cap checkpoint (`MAX_ITER_CHECKPOINT_INSTRUCTION`) or the
|
||||
@@ -128,19 +131,13 @@ impl Agent {
|
||||
};
|
||||
|
||||
let mut streamed_text = String::new();
|
||||
let mut streamed_deltas = Vec::new();
|
||||
let mut completed = None;
|
||||
while let Some(item) = stream.next().await {
|
||||
match item {
|
||||
ModelStreamItem::MessageDelta(delta) if !delta.text.is_empty() => {
|
||||
streamed_text.push_str(&delta.text);
|
||||
if let Some(sink) = &self.on_progress {
|
||||
let _ = sink
|
||||
.send(AgentProgress::TextDelta {
|
||||
delta: delta.text,
|
||||
iteration: iteration_for_stream,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
streamed_deltas.push(delta.text);
|
||||
}
|
||||
ModelStreamItem::Completed(response) => completed = Some(response),
|
||||
ModelStreamItem::Failed(error) => {
|
||||
@@ -163,10 +160,49 @@ impl Agent {
|
||||
let checkpoint = if !text.trim().is_empty() {
|
||||
text
|
||||
} else if response.tool_calls().is_empty() {
|
||||
streamed_text
|
||||
streamed_text.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if !checkpoint.trim().is_empty() {
|
||||
let mut provider_response = ChatResponse {
|
||||
text: Some(checkpoint.clone()),
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let (_, mut prompt_tool_calls) =
|
||||
self.tool_dispatcher.parse_response(&provider_response);
|
||||
if streamed_text != checkpoint {
|
||||
provider_response.text = Some(streamed_text);
|
||||
let (_, streamed_tool_calls) =
|
||||
self.tool_dispatcher.parse_response(&provider_response);
|
||||
prompt_tool_calls.extend(streamed_tool_calls);
|
||||
}
|
||||
if !prompt_tool_calls.is_empty() {
|
||||
tracing::warn!(
|
||||
parsed_tool_calls = prompt_tool_calls.len(),
|
||||
"[agent::session] wrap-up model returned a prompt-formatted tool call; using deterministic fallback"
|
||||
);
|
||||
return (String::new(), usage);
|
||||
}
|
||||
tracing::debug!(
|
||||
checkpoint_chars = checkpoint.chars().count(),
|
||||
buffered_deltas = streamed_deltas.len(),
|
||||
iteration = iteration_for_stream,
|
||||
"[agent::session] wrap-up checkpoint validation passed"
|
||||
);
|
||||
if let Some(sink) = &self.on_progress {
|
||||
for delta in streamed_deltas {
|
||||
let _ = sink
|
||||
.send(AgentProgress::TextDelta {
|
||||
delta,
|
||||
iteration: iteration_for_stream,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
(checkpoint, usage)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::*;
|
||||
use crate::core::event_bus::{global, init_global, DomainEvent};
|
||||
use crate::openhuman::agent::dispatcher::XmlToolDispatcher;
|
||||
use crate::openhuman::agent::hooks::{PostTurnHook, TurnContext};
|
||||
use crate::openhuman::agent::tool_policy::{
|
||||
@@ -8,8 +7,7 @@ use crate::openhuman::agent::tool_policy::{
|
||||
};
|
||||
use crate::openhuman::agent_memory::memory_loader::MemoryLoader;
|
||||
use crate::openhuman::inference::provider::{
|
||||
ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ToolResultMessage,
|
||||
UsageInfo,
|
||||
ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, UsageInfo,
|
||||
};
|
||||
use crate::openhuman::memory::Memory;
|
||||
use crate::openhuman::tools::ToolResult;
|
||||
@@ -20,7 +18,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::time::{sleep, timeout, Duration};
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
struct DummyProvider;
|
||||
|
||||
@@ -1197,6 +1195,49 @@ async fn turn_final_answer_falls_back_to_deterministic_summary_when_reprompt_emp
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn summarize_turn_wrapup_rejects_prompt_tool_call_and_preserves_usage() {
|
||||
let provider: Arc<dyn Provider> = Arc::new(SequenceProvider {
|
||||
responses: AsyncMutex::new(vec![Ok(ChatResponse {
|
||||
text: Some("<tool_call>{\"name\":\"echo\",\"arguments\":{}}</tool_call>".into()),
|
||||
tool_calls: vec![],
|
||||
usage: Some(UsageInfo {
|
||||
input_tokens: 13,
|
||||
output_tokens: 5,
|
||||
cached_input_tokens: 3,
|
||||
charged_amount_usd: 0.07,
|
||||
..UsageInfo::default()
|
||||
}),
|
||||
reasoning_content: None,
|
||||
})]),
|
||||
requests: AsyncMutex::new(Vec::new()),
|
||||
});
|
||||
let agent = make_agent_with_builder(
|
||||
provider,
|
||||
vec![],
|
||||
Box::new(FixedMemoryLoader {
|
||||
context: String::new(),
|
||||
}),
|
||||
vec![],
|
||||
crate::openhuman::config::AgentConfig::default(),
|
||||
crate::openhuman::config::ContextConfig::default(),
|
||||
);
|
||||
|
||||
let (summary, usage) = agent
|
||||
.summarize_turn_wrapup(&[], "test-model", 1, "write a wrap-up")
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
summary.is_empty(),
|
||||
"prompt-formatted tool calls must trigger the deterministic fallback"
|
||||
);
|
||||
let usage = usage.expect("rejected wrap-up must preserve provider usage");
|
||||
assert_eq!(usage.input_tokens, 13);
|
||||
assert_eq!(usage.output_tokens, 5);
|
||||
assert_eq!(usage.cached_input_tokens, 3);
|
||||
assert_eq!(usage.charged_amount_usd, 0.07);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_checkpoint_usage_is_folded_into_transcript_accounting() {
|
||||
// The extra checkpoint provider call costs tokens; those must land in
|
||||
|
||||
@@ -61,9 +61,7 @@ pub(super) use provider::LazyToolkitResolver;
|
||||
pub(super) use super::tool_prep::filter_tool_indices;
|
||||
// Types used by tests that were previously in scope via the flat ops.rs imports.
|
||||
#[cfg(test)]
|
||||
pub(super) use super::types::{
|
||||
SubagentMode, SubagentRunError, SubagentRunOptions, SubagentRunOutcome,
|
||||
};
|
||||
pub(super) use super::types::{SubagentMode, SubagentRunError, SubagentRunOptions};
|
||||
#[cfg(test)]
|
||||
pub(super) use crate::openhuman::agent::harness::definition::{AgentDefinition, PromptSource};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -5,10 +5,9 @@ use super::parse::{
|
||||
parse_tool_calls, parse_tool_calls_from_json_value, tools_to_openai_format,
|
||||
};
|
||||
use crate::openhuman::inference::provider::traits::ProviderCapabilities;
|
||||
use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, Provider};
|
||||
use crate::openhuman::tools::{self, Tool};
|
||||
use crate::openhuman::inference::provider::{ChatRequest, ChatResponse, Provider};
|
||||
use crate::openhuman::tools;
|
||||
use async_trait::async_trait;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ pub fn filter_actions_by_prompt(
|
||||
})
|
||||
.collect();
|
||||
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
scored.sort_by_key(|item| std::cmp::Reverse(item.0));
|
||||
|
||||
// Only keep positively-scored results. Zero-overlap tools would add noise.
|
||||
scored
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user