feat(desktop): auto-enable huddle transcription for agents (#3180)

## Context

Before this change, every huddle initialized with transcription off.
Joining or adding an agent did not enable it, so the agent could not
receive spoken conversation until a person clicked the transcript
control. Starting a huddle from an agent DM could also omit that agent,
and adding an agent who already belonged to the parent channel could
attempt an unnecessary role change and show a warning.

Agent detection uses authoritative huddle membership. A participant
counts as an agent when the ephemeral membership identifies it with the
`bot` role, or when the existing agent identity model identifies the
participant in an agent DM.

## Summary

Buzz now enables transcription once when the first authoritative agent
is present. After that initial automatic action, explicit user control
is authoritative: manual ON or OFF survives membership refreshes,
reconnects, and UI remounts. Removing the last agent does not change the
current transcription state.

Agent-DM huddles enroll the agent automatically. Adding an agent who
already belongs to the parent channel preserves the existing parent role
and completes without a role-mutation warning.

| Scenario | Before | With this change |
| --- | --- | --- |
| First authoritative agent joins or is hydrated | Transcription stays
off | Transcription turns on once |
| User explicitly turns transcription on or off | Manual control exists
without an agent policy | The explicit choice suppresses later automatic
changes |
| Last agent leaves | No defined agent-presence behavior | The current
transcription state remains unchanged |
| Huddle starts from an agent DM | The agent can be omitted | The known
agent is enrolled automatically |
| Added agent already belongs to the parent channel | Buzz can attempt a
role rewrite and warn | Existing parent membership and role are
preserved |
| Transcription is active | The control is not visually distinct | The
control is highlighted and exposes `aria-pressed=true` |

## Changes

- Derive agent presence from authoritative bot-role huddle membership
and known agent-DM identity.
- Apply the one-time auto-enable rule during create, join, membership
hydration, reconnect, pipeline startup, and local agent addition.
- Preserve explicit user state and use huddle-generation guards so stale
asynchronous work cannot alter a replacement huddle.
- Keep backend and React transcription state synchronized, with a
visible and accessible active control.
- Enroll known agent-DM participants and make parent-channel membership
updates idempotent.
- Cover hydration ordering, reconnects, remounts, explicit OFF,
last-agent removal, DM enrollment, existing membership, and active
styling.

## Related issue

None found.

## Testing

Manual validation in `pending-seed` confirmed the product contract:

1. Started a huddle from the owned, running Fizz agent DM.
2. Confirmed the authoritative roster contained the human and Fizz as an
agent.
3. Confirmed transcription enabled without clicking the control: `Stop
transcript`, `aria-pressed=true`, with the highlighted active
background.
4. Turned transcription off and confirmed `Start transcript`,
`aria-pressed=false` remained stable.
5. Removed Fizz while transcription was off and confirmed the state
stayed off.
6. Left the huddle cleanly.

## Screenshots

The same control has distinct active and inactive states.

![Active transcript
control](https://raw.githubusercontent.com/block/buzz/2dcb266244e93d358f85e5371d190de77b03c86d/pr-3180--active-transcription.png)

![Inactive transcript
control](https://raw.githubusercontent.com/block/buzz/2dcb266244e93d358f85e5371d190de77b03c86d/pr-3180--inactive-transcription.png)

## Reviewer-reproducible examples

From a fresh checkout:

```bash
pnpm --dir desktop build:e2e
pnpm --dir desktop exec playwright test tests/e2e/huddle-transcription.spec.ts --project=smoke
pnpm --dir desktop exec playwright test tests/e2e/mentions.spec.ts --project=smoke --grep "system agent profile exposes owned agent actions|system agent avatar exposes owned agent actions|owned bot profile exposes message and huddle actions|owned agent mention profile exposes message and huddle actions"
```

The huddle scenario exercises initial authoritative hydration, exactly
one automatic enable, explicit OFF persistence, unchanged state after
last-agent removal, newer events winning over delayed hydration,
agent-DM enrollment, and idempotent parent membership. It also asserts
`aria-pressed` and distinct computed active styling.

---------

Signed-off-by: John Tennant <jtennant@squareup.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
This commit is contained in:
John Matthew Tennant
2026-07-31 07:06:41 -04:00
committed by GitHub
parent 081f805d5e
commit 4632c55041
15 changed files with 1232 additions and 307 deletions

View File

@@ -131,6 +131,7 @@ export default defineConfig({
"**/harness-management.spec.ts",
"**/harness-catalog-screenshots.spec.ts",
"**/inline-custom-harness.spec.ts",
"**/huddle-transcription.spec.ts",
],
use: {
...devices["Desktop Chrome"],

View File

@@ -2,7 +2,8 @@
//!
//! Mental model:
//! add_agent_to_huddle → kind:9000 to ephemeral channel
//! → kind:9000 to parent channel (best-effort)
//! → preserve existing parent membership, or
//! kind:9000 to parent channel (best-effort)
//!
//! ACP spawning is NOT needed here: the running agent process auto-subscribes
//! when it receives the kind:9000 membership notification. Huddle-specific
@@ -11,7 +12,10 @@
use serde::Serialize;
use uuid::Uuid;
use crate::{app_state::AppState, events, relay::submit_event};
use crate::{
app_state::AppState, events, huddle::relay_api::fetch_channel_members_with_roles,
relay::submit_event,
};
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -61,8 +65,9 @@ with the next one.
/// The field exists for forward compatibility with future batch-add operations
/// where partial success may be meaningful.
///
/// `parent_added` reflects whether the parent-channel add succeeded;
/// `parent_error` carries the error string when it didn't.
/// `parent_added` reflects whether the parent already contained the agent or
/// the parent-channel add succeeded; `parent_error` carries the error string
/// when neither condition could be confirmed.
#[derive(Debug, Serialize)]
pub struct AgentAddResult {
/// Always `true` — invariant guaranteed by [`add_agent_to_huddle`].
@@ -91,17 +96,33 @@ pub async fn add_agent_to_huddle(
let add_eph = events::build_add_member(ephemeral_channel_id, agent_pubkey, Some("bot"))?;
submit_event(add_eph, state).await?;
// 2. Add agent to parent channel — so agent has full context.
// Best-effort: capture the error but don't propagate it.
let (parent_added, parent_error) = {
// 2. Preserve any active parent membership, regardless of role. Rewriting
// an existing DM member as `bot` is both unnecessary and forbidden for
// non-admins. Otherwise add the agent so it has full context.
// Best-effort: capture a real error but don't propagate it.
let parent_channel_id_string = parent_channel_id.to_string();
let parent_already_contains_agent =
fetch_channel_members_with_roles(&parent_channel_id_string, state)
.await
.is_ok_and(|members| contains_member(&members, agent_pubkey));
let (parent_added, parent_error) = if parent_already_contains_agent {
(true, None)
} else {
let add_parent = events::build_add_member(parent_channel_id, agent_pubkey, Some("bot"))?;
match submit_event(add_parent, state).await {
Ok(_) => (true, None),
Err(e) => {
eprintln!(
"buzz-desktop: add agent to parent channel failed (may already be member): {e}"
);
(false, Some(e))
let active_after_error =
fetch_channel_members_with_roles(&parent_channel_id_string, state)
.await
.is_ok_and(|members| contains_member(&members, agent_pubkey));
if active_after_error {
(true, None)
} else {
eprintln!("buzz-desktop: add agent to parent channel failed: {e}");
(false, Some(e))
}
}
}
};
@@ -112,3 +133,26 @@ pub async fn add_agent_to_huddle(
parent_error,
})
}
fn contains_member(members: &[(String, Option<String>)], pubkey: &str) -> bool {
members
.iter()
.any(|(member_pubkey, _)| member_pubkey.eq_ignore_ascii_case(pubkey))
}
#[cfg(test)]
mod tests {
use super::contains_member;
#[test]
fn existing_parent_membership_is_preserved_regardless_of_role() {
let members = vec![
("agent-member".to_owned(), Some("member".to_owned())),
("agent-bot".to_owned(), Some("bot".to_owned())),
];
assert!(contains_member(&members, "AGENT-MEMBER"));
assert!(contains_member(&members, "agent-bot"));
assert!(!contains_member(&members, "missing"));
}
}

View File

@@ -66,13 +66,16 @@ pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline};
// ── Imports ───────────────────────────────────────────────────────────────────
use std::sync::{atomic::Ordering, Arc};
use std::sync::atomic::Ordering;
use tauri::State;
use uuid::Uuid;
use crate::{app_state::AppState, events, relay::submit_event};
use pipeline::{maybe_start_stt_pipeline, maybe_start_tts_pipeline, post_connect_setup};
pub use pipeline::check_pipeline_hotstart;
use pipeline::{
maybe_start_stt_pipeline, maybe_start_tts_pipeline, post_connect_setup,
start_auto_enabled_transcription, PostConnectOutcome,
};
use relay_api::{
count_human_members, fetch_channel_members, parse_channel_uuid, validate_pubkey_hex,
MAX_HUDDLE_AGENTS,
@@ -186,7 +189,7 @@ pub async fn start_huddle(
};
// Transition to Creating.
{
let huddle_generation = {
let mut hs = state.huddle()?;
if hs.phase != HuddlePhase::Idle {
return Err(format!(
@@ -194,9 +197,11 @@ pub async fn start_huddle(
hs.phase
));
}
let generation = hs.begin_huddle_lifetime();
hs.phase = HuddlePhase::Creating;
hs.parent_channel_id = Some(parent_channel_id.clone());
}
generation
};
let ephemeral_uuid = Uuid::new_v4();
let ephemeral_channel_id = ephemeral_uuid.to_string();
@@ -259,27 +264,33 @@ pub async fn start_huddle(
match result {
Ok(successful_agents) => {
// 5. Store active state.
{
let committed = {
let mut hs = state.huddle()?;
hs.phase = HuddlePhase::Connected;
hs.is_creator = true;
hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone());
// Only store agents that were successfully enrolled.
*hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) =
successful_agents.clone();
// Include the current user + successfully enrolled agents as participants.
// Use successful_agents (not member_pubkeys) so failed enrollments
// are not reflected in the participant list.
let own_pubkey = state
.keys
.lock()
.map(|k| k.public_key().to_hex())
.unwrap_or_default();
let mut participants = successful_agents.clone();
if !own_pubkey.is_empty() && !participants.contains(&own_pubkey) {
participants.insert(0, own_pubkey);
if !hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) {
false
} else {
hs.phase = HuddlePhase::Connected;
hs.is_creator = true;
hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone());
*hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) =
successful_agents.clone();
hs.maybe_auto_enable_transcription_for_agents();
let own_pubkey = state
.keys
.lock()
.map(|k| k.public_key().to_hex())
.unwrap_or_default();
let mut participants = successful_agents.clone();
if !own_pubkey.is_empty() && !participants.contains(&own_pubkey) {
participants.insert(0, own_pubkey);
}
hs.participants = participants;
true
}
hs.participants = participants;
};
if !committed {
emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await;
return Err("huddle start was superseded".to_owned());
}
// 6. Notify frontend of state change.
@@ -287,16 +298,30 @@ pub async fn start_huddle(
// 7. Hydrate members, download models, start pipelines (incl. audio relay).
// Audio relay failure is fatal — no point in a huddle without audio.
if let Err(e) = post_connect_setup(&state, &ephemeral_channel_id).await {
// Rollback: audio relay failed after state was committed.
// Publish the terminal lifecycle event before archiving so
// other clients do not reconstruct a phantom active huddle.
emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await;
if let Ok(mut hs) = state.huddle_state.lock() {
hs.reset_preserving_generation();
match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await {
Ok(PostConnectOutcome::Ready) => {}
Ok(PostConnectOutcome::Stale) => {
return Err("huddle start was superseded".to_owned());
}
Err(e) => {
// Roll back only if this failed setup still owns the active
// huddle. A stale failure must not tear down its replacement.
let still_current = state
.huddle()
.map(|hs| hs.is_current_huddle(&ephemeral_channel_id, huddle_generation))
.unwrap_or(false);
if still_current {
emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state)
.await;
if let Ok(mut hs) = state.huddle_state.lock() {
if hs.is_current_huddle(&ephemeral_channel_id, huddle_generation) {
hs.reset_preserving_generation();
}
}
state.emit_huddle_state_changed();
}
return Err(e);
}
state.emit_huddle_state_changed();
return Err(e);
}
Ok(HuddleJoinInfo {
@@ -314,11 +339,11 @@ pub async fn start_huddle(
}
}
}
// Reset state to Idle so the user can retry.
// Preserve session_generation so in-flight transcription tasks
// from a prior session still see a stale generation and exit.
// Reset only if this failed attempt still owns the Creating state.
if let Ok(mut hs) = state.huddle_state.lock() {
hs.reset_preserving_generation();
if hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) {
hs.reset_preserving_generation();
}
}
Err(e)
}
@@ -340,7 +365,7 @@ pub async fn join_huddle(
state: State<'_, AppState>,
) -> Result<HuddleJoinInfo, String> {
// Transition to Connecting.
{
let huddle_generation = {
let mut hs = state.huddle()?;
if hs.phase != HuddlePhase::Idle {
return Err(format!(
@@ -348,10 +373,12 @@ pub async fn join_huddle(
hs.phase
));
}
let generation = hs.begin_huddle_lifetime();
hs.phase = HuddlePhase::Connecting;
hs.parent_channel_id = Some(parent_channel_id.clone());
hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone());
}
generation
};
// Seed participant list with own pubkey as a fallback until relay responds.
let own_pubkey = state
@@ -360,12 +387,20 @@ pub async fn join_huddle(
.map(|k| k.public_key().to_hex())
.unwrap_or_default();
{
let committed = {
let mut hs = state.huddle()?;
hs.phase = HuddlePhase::Connected;
if !own_pubkey.is_empty() {
hs.participants = vec![own_pubkey];
if !hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Connecting) {
false
} else {
hs.phase = HuddlePhase::Connected;
if !own_pubkey.is_empty() {
hs.participants = vec![own_pubkey];
}
true
}
};
if !committed {
return Err("huddle join was superseded".to_owned());
}
// Notify frontend of state change.
@@ -373,15 +408,25 @@ pub async fn join_huddle(
// Hydrate members, download models, start pipelines (incl. audio relay).
// Audio relay failure is fatal — no point in a huddle without audio.
if let Err(e) = post_connect_setup(&state, &ephemeral_channel_id).await {
// Rollback: audio relay failed after state was committed.
// Reset state to Idle so the user can retry. The ephemeral channel
// has a TTL and will expire — no manual archive needed for joiners.
if let Ok(mut hs) = state.huddle_state.lock() {
hs.reset_preserving_generation();
match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await {
Ok(PostConnectOutcome::Ready) => {}
Ok(PostConnectOutcome::Stale) => {
return Err("huddle join was superseded".to_owned());
}
Err(e) => {
// Reset only the huddle lifetime that failed.
let mut did_reset = false;
if let Ok(mut hs) = state.huddle_state.lock() {
if hs.is_current_huddle(&ephemeral_channel_id, huddle_generation) {
hs.reset_preserving_generation();
did_reset = true;
}
}
if did_reset {
state.emit_huddle_state_changed();
}
return Err(e);
}
state.emit_huddle_state_changed();
return Err(e);
}
Ok(HuddleJoinInfo {
@@ -675,123 +720,6 @@ pub fn push_audio_pcm(
}
}
/// Hot-start: check if voice models just finished downloading during an active
/// huddle and start the corresponding pipelines.
///
/// Called by the frontend on a timer or after model status changes. No-op if
/// the huddle is not active or pipelines are already running.
#[tauri::command]
pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), String> {
let (is_active, ephemeral_channel_id) = {
let hs = state.huddle()?;
(
matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active),
hs.ephemeral_channel_id.clone(),
)
};
if !is_active {
return Ok(());
}
// Detect dead pipelines: if the worker thread has exited (init failure or crash),
// clear the pipeline handle so hot-start can retry on the next cycle.
{
let mut hs = state.huddle()?;
if let Some(ref p) = hs.stt_pipeline {
if p.is_finished() {
hs.stt_pipeline = None;
}
}
if let Some(ref p) = hs.tts_pipeline {
if p.is_finished() {
hs.tts_pipeline = None;
}
}
}
// Re-read after potential cleanup.
let (has_stt, has_tts, transcription_enabled) = {
let hs = state.huddle()?;
(
hs.stt_pipeline.is_some(),
hs.tts_pipeline.is_some(),
hs.transcription_enabled,
)
};
// Check if models just became ready (one-shot flags).
let stt_ready = models::global_model_manager()
.map(|m| m.take_stt_ready())
.unwrap_or(false);
let tts_ready = models::global_model_manager()
.map(|m| m.take_tts_ready())
.unwrap_or(false);
// Start TTS first (so STT can capture tts_cancel).
if !has_tts && (tts_ready || models::is_tts_ready()) {
if let Err(e) = maybe_start_tts_pipeline(&state).await {
eprintln!("buzz-desktop: TTS hotstart failed: {e}");
}
}
if transcription_enabled && !has_stt && (stt_ready || models::is_stt_ready()) {
if let Some(eph_id) = &ephemeral_channel_id {
if let Err(e) = maybe_start_stt_pipeline(&state, eph_id).await {
eprintln!("buzz-desktop: STT hotstart failed: {e}");
}
}
}
// Periodically refresh agent_pubkeys from relay membership.
// This catches mid-huddle agent additions/removals by other participants,
// keeping STT p-tags authoritative throughout the session.
// Throttled to every 15 s (not on every 5 s hotstart poll).
//
// NOTE: The frontend ALSO polls agent membership independently (every 10 s
// via get_huddle_agent_pubkeys). This is intentional — the two polls have
// different failure semantics:
// - Rust (here): preserves stale list on failure (STT p-tags should not
// disappear on a transient network blip).
// - React (HuddleContext.tsx): clears list on failure (TTS authorization
// must fail-closed — never speak from a stale agent list).
//
// On Ok: always replace (even with empty — agents may have been removed).
// On Err: preserve the existing list (transient failure shouldn't zero it).
if let Some(eph_id) = &ephemeral_channel_id {
let should_refresh = {
let hs = state.huddle()?;
match hs.last_agent_refresh {
None => true,
Some(t) => t.elapsed() >= std::time::Duration::from_secs(15),
}
};
if should_refresh {
// Fetch agents (for STT p-tags) and all members (for participant list).
// Sequential — tokio::join! requires the `macros` feature.
// Only update the throttle timestamp when at least one fetch succeeds,
// so transient failures retry immediately on the next poll cycle.
// Fetch both lists before acquiring the lock — no lock held across await.
let fresh_agents = fetch_channel_members(eph_id, Some("bot"), &state)
.await
.ok();
let fresh_members = fetch_channel_members(eph_id, None, &state).await.ok();
if fresh_agents.is_some() || fresh_members.is_some() {
let mut hs = state.huddle()?;
if let Some(agents) = fresh_agents {
*hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents;
}
if let Some(members) = fresh_members {
hs.participants = members;
}
hs.last_agent_refresh = Some(std::time::Instant::now());
}
}
}
Ok(())
}
/// Trigger a background download of voice models (Parakeet STT + Pocket TTS).
///
/// Returns immediately — downloads run in tokio background tasks.
@@ -924,7 +852,7 @@ pub async fn add_agent_to_huddle(
) -> Result<agents::AgentAddResult, String> {
validate_pubkey_hex(&agent_pubkey)?;
let (eph_id, parent_id) = {
let (eph_id, parent_id, huddle_generation) = {
let hs = state.huddle()?;
if !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) {
return Err("no active huddle".to_string());
@@ -948,7 +876,7 @@ pub async fn add_agent_to_huddle(
.clone()
.ok_or("no ephemeral channel")?;
let parent = hs.parent_channel_id.clone().ok_or("no parent channel")?;
(eph, parent)
(eph, parent, hs.huddle_generation)
};
let eph_uuid = Uuid::parse_str(&eph_id).map_err(|e| e.to_string())?;
@@ -957,29 +885,30 @@ pub async fn add_agent_to_huddle(
// Returns Err only if the ephemeral add fails — parent failure is in the result.
let result = agents::add_agent_to_huddle(eph_uuid, parent_uuid, &agent_pubkey, &state).await?;
// Ephemeral add succeeded — safe to register for p-tagging.
// Clone the Arc first so we can drop the outer HuddleState lock before
// acquiring the inner pubkeys lock (avoids the E0597 borrow-checker error).
{
let agent_pubkeys_arc = {
let hs = state.huddle()?;
Arc::clone(&hs.agent_pubkeys)
};
let mut pubkeys = agent_pubkeys_arc.lock().unwrap_or_else(|e| e.into_inner());
// Ephemeral add succeeded — register it only if this is still the huddle
// that initiated the relay operation.
let transcription_auto_enabled = {
let mut hs = state.huddle()?;
if !hs.is_current_huddle(&eph_id, huddle_generation) {
return Ok(result);
}
let mut pubkeys = hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner());
if !pubkeys.contains(&agent_pubkey) {
pubkeys.push(agent_pubkey.clone());
}
}
drop(pubkeys);
if !hs.participants.contains(&agent_pubkey) {
hs.participants.push(agent_pubkey.clone());
}
hs.maybe_auto_enable_transcription_for_agents()
};
// No guidelines re-post needed — the agent sees the original kind:48106
// guidelines via EOSE replay when it subscribes to the ephemeral channel.
// Also add the agent to the visible participants list.
{
let mut hs = state.huddle()?;
if !hs.participants.contains(&agent_pubkey) {
hs.participants.push(agent_pubkey);
}
if transcription_auto_enabled {
start_auto_enabled_transcription(&state, &eph_id).await;
} else {
state.emit_huddle_state_changed();
}
Ok(result)

View File

@@ -9,6 +9,7 @@ use std::sync::{
};
use nostr::JsonUtil;
use tauri::State;
use uuid::Uuid;
use crate::app_state::AppState;
@@ -20,55 +21,224 @@ use super::state::{HuddlePhase, VoiceInputMode};
use super::stt;
use super::tts;
pub(crate) enum PostConnectOutcome {
Ready,
Stale,
}
/// Hot-start: check if voice models just finished downloading during an active
/// huddle and start the corresponding pipelines.
///
/// Called by the frontend on a timer or after model status changes. No-op if
/// the huddle is not active or pipelines are already running.
#[tauri::command]
pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), String> {
let (is_active, ephemeral_channel_id, huddle_generation) = {
let hs = state.huddle()?;
(
matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active),
hs.ephemeral_channel_id.clone(),
hs.huddle_generation,
)
};
if !is_active {
return Ok(());
}
// Detect dead pipelines: if the worker thread has exited (init failure or crash),
// clear the pipeline handle so hot-start can retry on the next cycle.
{
let mut hs = state.huddle()?;
if let Some(ref p) = hs.stt_pipeline {
if p.is_finished() {
hs.stt_pipeline = None;
}
}
if let Some(ref p) = hs.tts_pipeline {
if p.is_finished() {
hs.tts_pipeline = None;
}
}
}
// Re-read after potential cleanup.
let (has_stt, has_tts, transcription_enabled) = {
let hs = state.huddle()?;
(
hs.stt_pipeline.is_some(),
hs.tts_pipeline.is_some(),
hs.transcription_enabled,
)
};
// Check if models just became ready (one-shot flags).
let stt_ready = models::global_model_manager()
.map(|m| m.take_stt_ready())
.unwrap_or(false);
let tts_ready = models::global_model_manager()
.map(|m| m.take_tts_ready())
.unwrap_or(false);
// Start TTS first (so STT can capture tts_cancel).
if !has_tts && (tts_ready || models::is_tts_ready()) {
if let Err(e) = maybe_start_tts_pipeline(&state).await {
eprintln!("buzz-desktop: TTS hotstart failed: {e}");
}
}
if transcription_enabled && !has_stt && (stt_ready || models::is_stt_ready()) {
if let Some(eph_id) = &ephemeral_channel_id {
if let Err(e) = maybe_start_stt_pipeline(&state, eph_id).await {
eprintln!("buzz-desktop: STT hotstart failed: {e}");
}
}
}
// Periodically refresh agent membership from the relay.
// This catches mid-huddle additions/removals by other participants, keeps
// STT p-tags authoritative, and auto-enables transcription when the first
// agent appears unless the user has already chosen a transcription state.
// Throttled independently from the more frequent hotstart poll.
//
// NOTE: The frontend ALSO polls agent membership independently via
// get_huddle_agent_pubkeys. This is intentional — the two polls have
// different failure semantics:
// - Rust (here): preserves stale list on failure (STT p-tags should not
// disappear on a transient network blip).
// - React (HuddleContext.tsx): clears list on failure (TTS authorization
// must fail-closed — never speak from a stale agent list).
//
// On Ok: always replace (even with empty — agents may have been removed).
// On Err: preserve the existing list (transient failure shouldn't zero it).
if let Some(eph_id) = &ephemeral_channel_id {
let should_refresh = {
let hs = state.huddle()?;
match hs.last_agent_refresh {
None => true,
Some(t) => t.elapsed() >= std::time::Duration::from_secs(15),
}
};
if should_refresh {
// Fetch agents (for STT p-tags) before all members (for participant
// list) so relay membership queries remain ordered.
// Only update the throttle timestamp when at least one fetch succeeds,
// so transient failures retry immediately on the next poll cycle.
// Fetch both lists before acquiring the lock — no lock held across await.
let fresh_agents = fetch_channel_members(eph_id, Some("bot"), &state)
.await
.ok();
let fresh_members = fetch_channel_members(eph_id, None, &state).await.ok();
let transcription_auto_enabled = if fresh_agents.is_some() || fresh_members.is_some() {
let mut hs = state.huddle()?;
if !hs.is_current_huddle(eph_id, huddle_generation) {
return Ok(());
}
if let Some(agents) = fresh_agents {
*hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents;
}
if let Some(members) = fresh_members {
hs.participants = members;
}
hs.last_agent_refresh = Some(std::time::Instant::now());
hs.maybe_auto_enable_transcription_for_agents()
} else {
false
};
if transcription_auto_enabled {
start_auto_enabled_transcription(&state, eph_id).await;
}
}
}
Ok(())
}
pub(crate) async fn post_connect_setup(
state: &AppState,
ephemeral_channel_id: &str,
) -> Result<(), String> {
huddle_generation: u64,
) -> Result<PostConnectOutcome, String> {
{
let hs = state.huddle()?;
if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) {
return Ok(PostConnectOutcome::Stale);
}
}
// Hydrate agent pubkeys and participants from relay in parallel
// (authoritative — overrides local guesses).
let (agents_result, all_members_result) = tokio::join!(
fetch_channel_members(ephemeral_channel_id, Some("bot"), state),
fetch_channel_members(ephemeral_channel_id, None, state),
);
if let Ok(agents) = agents_result {
let hs = state.huddle()?;
*hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents;
}
if let Ok(all_members) = all_members_result {
if !all_members.is_empty() {
let mut hs = state.huddle()?;
hs.participants = all_members;
let transcription_auto_enabled = {
let mut hs = state.huddle()?;
if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) {
return Ok(PostConnectOutcome::Stale);
}
if let Ok(agents) = agents_result {
*hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents;
}
if let Ok(all_members) = all_members_result {
if !all_members.is_empty() {
hs.participants = all_members;
}
}
hs.maybe_auto_enable_transcription_for_agents()
};
if transcription_auto_enabled {
state.emit_huddle_state_changed();
}
// Prepare TTS for agent voice. STT is transcript-specific and starts only
// when transcription is explicitly enabled.
// Prepare voice models. Agent presence may have auto-enabled transcription;
// explicit user choices remain authoritative.
if let Some(mgr) = models::global_model_manager() {
mgr.start_tts_download(state.http_client.clone());
if state.huddle()?.transcription_enabled {
mgr.start_stt_download(state.http_client.clone());
}
}
// Connect audio relay WebSocket (Opus encode/decode pipeline).
// This is the core audio path — failure is fatal for the huddle.
let parent_id = {
let hs = state.huddle()?;
if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) {
return Ok(PostConnectOutcome::Stale);
}
hs.parent_channel_id.clone()
};
let (cancel, pcm_tx) =
relay_api::connect_audio_relay(ephemeral_channel_id, parent_id.as_deref(), state).await?;
let audio_result =
relay_api::connect_audio_relay(ephemeral_channel_id, parent_id.as_deref(), state).await;
{
let mut hs = state.huddle()?;
if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) {
if let Ok((cancel, _)) = audio_result {
cancel.cancel();
}
return Ok(PostConnectOutcome::Stale);
}
let (cancel, pcm_tx) = audio_result?;
hs.audio_ws_cancel = Some(cancel);
hs.audio_relay_pcm_tx = Some(pcm_tx);
}
// Start TTS immediately. STT/transcript posting is opt-in and starts only
// after the user explicitly enables transcription.
// Start TTS immediately, then STT when transcription is enabled either by
// the user or by authoritative agent membership.
if !state
.huddle()?
.is_current_huddle(ephemeral_channel_id, huddle_generation)
{
return Ok(PostConnectOutcome::Stale);
}
if let Err(e) = maybe_start_tts_pipeline(state).await {
eprintln!("buzz-desktop: TTS pipeline failed to start: {e}");
}
if let Err(e) = maybe_start_stt_pipeline(state, ephemeral_channel_id).await {
eprintln!("buzz-desktop: STT pipeline failed to start: {e}");
}
Ok(())
Ok(PostConnectOutcome::Ready)
}
/// Attempt to start the STT pipeline if models are present.
@@ -83,12 +253,16 @@ pub(crate) async fn maybe_start_stt_pipeline(
state: &AppState,
ephemeral_channel_id: &str,
) -> Result<bool, String> {
{
let huddle_generation = {
let hs = state.huddle()?;
if !hs.transcription_enabled {
if !hs.transcription_enabled
|| !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active)
|| hs.ephemeral_channel_id.as_deref() != Some(ephemeral_channel_id)
{
return Ok(false);
}
}
hs.huddle_generation
};
if !models::is_stt_ready() {
return Ok(false); // Models not downloaded yet — voice-only mode.
@@ -97,21 +271,29 @@ pub(crate) async fn maybe_start_stt_pipeline(
let channel_uuid = parse_channel_uuid(ephemeral_channel_id)?;
// Atomically claim the construction slot (mirrors tts_starting pattern).
{
let hs = state.huddle()?;
if hs.stt_starting.swap(true, Ordering::AcqRel) {
return Ok(false); // Another caller is already constructing.
}
}
// Grab shared flags, agent pubkeys, and session generation from HuddleState.
// Atomically claim construction and grab shared state under one lock.
// If replacing an existing pipeline, bump generation first so the old
// transcription task's next POST sees a stale generation and exits.
// Take the old pipeline OUT of the lock before dropping — Drop joins
// the worker thread (~200ms) and must not block under the mutex.
let (tts_active, tts_cancel, agent_pubkeys_arc, session_gen, ptt_active_for_stt, old_stt) = {
let (
tts_active,
tts_cancel,
agent_pubkeys_arc,
session_gen,
expected_generation,
stt_starting,
ptt_active_for_stt,
old_stt,
) = {
let mut hs = state.huddle()?;
if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) {
return Ok(false);
}
if hs.stt_starting.swap(true, Ordering::AcqRel) {
return Ok(false);
}
let stt_starting = Arc::clone(&hs.stt_starting);
// Invalidate any existing transcription task before replacing the pipeline.
if hs.stt_pipeline.is_some() {
hs.session_generation.fetch_add(1, Ordering::Release);
@@ -130,6 +312,8 @@ pub(crate) async fn maybe_start_stt_pipeline(
Some(Arc::clone(&hs.tts_cancel)),
Arc::clone(&hs.agent_pubkeys),
Arc::clone(&hs.session_generation),
hs.session_generation.load(Ordering::Acquire),
stt_starting,
ptt,
old,
)
@@ -144,13 +328,11 @@ pub(crate) async fn maybe_start_stt_pipeline(
let (pipeline, text_rx) = match constructed {
Ok(Ok(p)) => p,
Ok(Err(e)) => {
let hs = state.huddle()?;
hs.stt_starting.store(false, Ordering::Release);
stt_starting.store(false, Ordering::Release);
return Err(e);
}
Err(e) => {
let hs = state.huddle()?;
hs.stt_starting.store(false, Ordering::Release);
stt_starting.store(false, Ordering::Release);
return Err(format!("spawn_blocking failed: {e}"));
}
};
@@ -158,10 +340,14 @@ pub(crate) async fn maybe_start_stt_pipeline(
{
let mut hs = state.huddle()?;
hs.stt_starting.store(false, Ordering::Release);
stt_starting.store(false, Ordering::Release);
// Phase check: huddle may have been torn down during construction.
if !hs.transcription_enabled
|| !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active)
|| !hs.is_current_transcription_generation(
ephemeral_channel_id,
huddle_generation,
expected_generation,
)
{
return Ok(false);
}
@@ -172,6 +358,17 @@ pub(crate) async fn maybe_start_stt_pipeline(
Ok(true)
}
/// Start STT after agent presence automatically enables transcription.
pub(crate) async fn start_auto_enabled_transcription(state: &AppState, ephemeral_channel_id: &str) {
if let Some(manager) = models::global_model_manager() {
manager.start_stt_download(state.http_client.clone());
}
if let Err(error) = maybe_start_stt_pipeline(state, ephemeral_channel_id).await {
eprintln!("buzz-desktop: auto-enabled STT failed to start: {error}");
}
state.emit_huddle_state_changed();
}
/// Attempt to start the TTS pipeline if TTS models are present and TTS is enabled.
///
/// Returns `Ok(true)` if the pipeline was started, `Ok(false)` if preconditions

View File

@@ -5,7 +5,7 @@
use serde::{Deserialize, Serialize};
use std::sync::{
atomic::{AtomicBool, AtomicU64},
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
};
@@ -80,6 +80,15 @@ pub struct HuddleState {
pub tts_enabled: bool,
/// Whether STT transcript posting is enabled for this huddle.
pub transcription_enabled: bool,
/// Whether the user has explicitly used the transcription control in this
/// huddle. Agent presence may auto-enable transcription only while this is
/// false, so membership refreshes never undo an explicit user choice.
///
/// This is backend-only session state: keeping it in `HuddleState` makes it
/// survive frontend remounts and audio reconnects, while huddle teardown
/// resets it for the next session.
#[serde(skip)]
pub transcription_user_controlled: bool,
/// Shared flag: true while TTS is playing audio.
/// Shared with the STT pipeline for barge-in / echo gating.
#[serde(skip)]
@@ -103,6 +112,10 @@ pub struct HuddleState {
/// Used to throttle the refresh in check_pipeline_hotstart to every 15 s.
#[serde(skip)]
pub last_agent_refresh: Option<std::time::Instant>,
/// Monotonic identity for a local huddle lifetime. Unlike transcript
/// generation, this changes only when a new start/join attempt begins.
#[serde(skip)]
pub huddle_generation: u64,
/// Session generation — incremented on every teardown. The transcription
/// task captures this at spawn time and checks before each POST. If the
/// generation has changed, the task silently drops the transcript.
@@ -157,11 +170,13 @@ impl Clone for HuddleState {
is_creator: self.is_creator,
tts_enabled: self.tts_enabled,
transcription_enabled: self.transcription_enabled,
transcription_user_controlled: self.transcription_user_controlled,
tts_active: Arc::clone(&self.tts_active),
tts_cancel: Arc::clone(&self.tts_cancel),
tts_starting: Arc::clone(&self.tts_starting),
stt_starting: Arc::clone(&self.stt_starting),
last_agent_refresh: self.last_agent_refresh,
huddle_generation: self.huddle_generation,
session_generation: Arc::clone(&self.session_generation),
voice_input_mode: self.voice_input_mode.clone(),
ptt_active: Arc::clone(&self.ptt_active),
@@ -184,11 +199,13 @@ impl Default for HuddleState {
is_creator: false,
tts_enabled: true,
transcription_enabled: false,
transcription_user_controlled: false,
tts_active: Arc::new(AtomicBool::new(false)),
tts_cancel: Arc::new(AtomicBool::new(false)),
tts_starting: Arc::new(AtomicBool::new(false)),
stt_starting: Arc::new(AtomicBool::new(false)),
last_agent_refresh: None,
huddle_generation: 0,
session_generation: Arc::new(AtomicU64::new(0)),
voice_input_mode: VoiceInputMode::default(),
ptt_active: Arc::new(AtomicBool::new(false)),
@@ -197,13 +214,233 @@ impl Default for HuddleState {
}
impl HuddleState {
/// Begin a new local huddle lifetime and return its identity.
pub(crate) fn begin_huddle_lifetime(&mut self) -> u64 {
self.huddle_generation = self.huddle_generation.wrapping_add(1);
self.huddle_generation
}
pub(crate) fn owns_huddle_lifetime(&self, huddle_generation: u64, phase: HuddlePhase) -> bool {
self.huddle_generation == huddle_generation && self.phase == phase
}
/// Whether an async result still belongs to the active huddle that
/// initiated it. The channel id is the huddle-session identity; transcript
/// generation changes within the same huddle must not invalidate it.
pub(crate) fn is_current_huddle(
&self,
ephemeral_channel_id: &str,
huddle_generation: u64,
) -> bool {
matches!(self.phase, HuddlePhase::Connected | HuddlePhase::Active)
&& self.ephemeral_channel_id.as_deref() == Some(ephemeral_channel_id)
&& self.huddle_generation == huddle_generation
}
/// Whether an STT construction still belongs to the current transcript
/// generation within the active huddle.
pub(crate) fn is_current_transcription_generation(
&self,
ephemeral_channel_id: &str,
huddle_generation: u64,
session_generation: u64,
) -> bool {
self.is_current_huddle(ephemeral_channel_id, huddle_generation)
&& self.session_generation.load(Ordering::Acquire) == session_generation
}
/// Invalidate in-flight transcription work and give the next constructor a
/// fresh sentinel that stale constructors cannot clear.
pub(crate) fn invalidate_transcription_pipeline(&mut self) {
self.session_generation.fetch_add(1, Ordering::Release);
self.stt_starting = Arc::new(AtomicBool::new(false));
}
/// Record an explicit transcription choice made through the existing user
/// control. Later agent membership refreshes must preserve this choice.
pub(crate) fn set_transcription_enabled_by_user(&mut self, enabled: bool) {
self.transcription_enabled = enabled;
self.transcription_user_controlled = true;
}
/// Enable transcription when an agent is present and the user has not
/// explicitly chosen a transcription state for this huddle.
///
/// Returns true only for the transition from disabled to enabled, allowing
/// callers to start models/pipelines and emit state exactly once. Removing
/// the last agent deliberately leaves the current state unchanged.
pub(crate) fn maybe_auto_enable_transcription_for_agents(&mut self) -> bool {
let has_agent = !self
.agent_pubkeys
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_empty();
if has_agent && !self.transcription_user_controlled && !self.transcription_enabled {
self.transcription_enabled = true;
return true;
}
false
}
/// Reset to default state while preserving the session generation counter.
/// Used by start_huddle rollback, join_huddle rollback, and teardown_huddle
/// to invalidate in-flight transcription tasks without losing the generation.
pub(crate) fn reset_preserving_generation(&mut self) {
let gen = Arc::clone(&self.session_generation);
let huddle_generation = self.huddle_generation;
*self = Self::default();
self.session_generation = gen;
self.huddle_generation = huddle_generation;
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use super::HuddleState;
fn set_agents(state: &HuddleState, agents: &[&str]) {
*state
.agent_pubkeys
.lock()
.unwrap_or_else(|error| error.into_inner()) =
agents.iter().map(|agent| (*agent).to_owned()).collect();
}
#[test]
fn first_agent_auto_enables_transcription_once() {
let mut state = HuddleState::default();
set_agents(&state, &["agent"]);
assert!(state.maybe_auto_enable_transcription_for_agents());
assert!(state.transcription_enabled);
assert!(!state.maybe_auto_enable_transcription_for_agents());
}
#[test]
fn explicit_user_disable_is_not_undone_by_agent_presence() {
let mut state = HuddleState::default();
set_agents(&state, &["agent"]);
assert!(state.maybe_auto_enable_transcription_for_agents());
state.set_transcription_enabled_by_user(false);
assert!(!state.maybe_auto_enable_transcription_for_agents());
assert!(!state.transcription_enabled);
}
#[test]
fn last_agent_leaving_preserves_current_transcription_state() {
let mut state = HuddleState::default();
set_agents(&state, &["agent"]);
assert!(state.maybe_auto_enable_transcription_for_agents());
set_agents(&state, &[]);
assert!(!state.maybe_auto_enable_transcription_for_agents());
assert!(state.transcription_enabled);
}
#[test]
fn clone_preserves_user_control_across_frontend_state_reads() {
let mut state = HuddleState::default();
state.set_transcription_enabled_by_user(false);
let mut clone = state.clone();
set_agents(&clone, &["agent"]);
assert!(clone.transcription_user_controlled);
assert!(!clone.maybe_auto_enable_transcription_for_agents());
}
#[test]
fn stale_huddle_identity_is_rejected_after_replacement() {
let mut state = HuddleState {
phase: super::HuddlePhase::Active,
ephemeral_channel_id: Some("huddle-a".to_owned()),
..HuddleState::default()
};
let huddle_generation = state.begin_huddle_lifetime();
let generation = state.session_generation.load(Ordering::Acquire);
assert!(state.is_current_huddle("huddle-a", huddle_generation));
assert!(state.is_current_transcription_generation(
"huddle-a",
huddle_generation,
generation
));
state.session_generation.fetch_add(1, Ordering::Release);
assert!(state.is_current_huddle("huddle-a", huddle_generation));
assert!(!state.is_current_transcription_generation(
"huddle-a",
huddle_generation,
generation
));
state.ephemeral_channel_id = Some("huddle-b".to_owned());
assert!(!state.is_current_huddle("huddle-a", huddle_generation));
}
#[test]
fn same_channel_rejoin_gets_a_new_huddle_lifetime() {
let mut state = HuddleState {
phase: super::HuddlePhase::Active,
ephemeral_channel_id: Some("huddle".to_owned()),
..HuddleState::default()
};
let first_generation = state.begin_huddle_lifetime();
state.reset_preserving_generation();
state.phase = super::HuddlePhase::Active;
state.ephemeral_channel_id = Some("huddle".to_owned());
let second_generation = state.begin_huddle_lifetime();
assert_ne!(first_generation, second_generation);
assert!(!state.is_current_huddle("huddle", first_generation));
assert!(state.is_current_huddle("huddle", second_generation));
}
#[test]
fn superseded_create_cannot_commit_or_reset_replacement_lifetime() {
let mut state = HuddleState::default();
let first_generation = state.begin_huddle_lifetime();
state.phase = super::HuddlePhase::Creating;
assert!(state.owns_huddle_lifetime(first_generation, super::HuddlePhase::Creating));
state.reset_preserving_generation();
let replacement_generation = state.begin_huddle_lifetime();
state.phase = super::HuddlePhase::Creating;
assert!(!state.owns_huddle_lifetime(first_generation, super::HuddlePhase::Creating));
assert!(state.owns_huddle_lifetime(replacement_generation, super::HuddlePhase::Creating));
}
#[test]
fn superseded_join_cannot_commit_replacement_lifetime() {
let mut state = HuddleState::default();
let first_generation = state.begin_huddle_lifetime();
state.phase = super::HuddlePhase::Connecting;
state.reset_preserving_generation();
let replacement_generation = state.begin_huddle_lifetime();
state.phase = super::HuddlePhase::Connecting;
assert!(!state.owns_huddle_lifetime(first_generation, super::HuddlePhase::Connecting));
assert!(state.owns_huddle_lifetime(replacement_generation, super::HuddlePhase::Connecting));
}
#[test]
fn stale_constructor_cannot_clear_replacement_sentinel() {
let mut state = HuddleState::default();
let stale_sentinel = std::sync::Arc::clone(&state.stt_starting);
stale_sentinel.store(true, Ordering::Release);
state.invalidate_transcription_pipeline();
state.stt_starting.store(true, Ordering::Release);
stale_sentinel.store(false, Ordering::Release);
assert!(state.stt_starting.load(Ordering::Acquire));
}
}

View File

@@ -1,5 +1,3 @@
use std::sync::atomic::Ordering;
use tauri::State;
use crate::app_state::AppState;
@@ -15,10 +13,12 @@ use super::{models, pipeline::maybe_start_stt_pipeline};
pub async fn start_stt_pipeline(state: State<'_, AppState>) -> Result<(), String> {
let ephemeral_channel_id = {
let mut hs = state.huddle()?;
hs.transcription_enabled = true;
hs.ephemeral_channel_id
let ephemeral_channel_id = hs
.ephemeral_channel_id
.clone()
.ok_or("no active huddle — start or join a huddle first")?
.ok_or("no active huddle — start or join a huddle first")?;
hs.set_transcription_enabled_by_user(true);
ephemeral_channel_id
};
match maybe_start_stt_pipeline(&state, &ephemeral_channel_id).await {
@@ -41,14 +41,17 @@ pub async fn set_huddle_transcription_enabled(
) -> Result<(), String> {
let (ephemeral_channel_id, old_stt) = {
let mut hs = state.huddle()?;
hs.transcription_enabled = enabled;
let ephemeral_channel_id = hs
.ephemeral_channel_id
.clone()
.ok_or("no active huddle — start or join a huddle first")?;
hs.set_transcription_enabled_by_user(enabled);
if enabled {
(hs.ephemeral_channel_id.clone(), None)
(ephemeral_channel_id, None)
} else {
hs.session_generation.fetch_add(1, Ordering::Release);
hs.stt_starting.store(false, Ordering::Release);
(hs.ephemeral_channel_id.clone(), hs.stt_pipeline.take())
hs.invalidate_transcription_pipeline();
(ephemeral_channel_id, hs.stt_pipeline.take())
}
};
@@ -58,12 +61,10 @@ pub async fn set_huddle_transcription_enabled(
drop(old_stt);
if enabled {
let eph_id =
ephemeral_channel_id.ok_or("no active huddle — start or join a huddle first")?;
if let Some(manager) = models::global_model_manager() {
manager.start_stt_download(state.http_client.clone());
}
if let Err(e) = maybe_start_stt_pipeline(&state, &eph_id).await {
if let Err(e) = maybe_start_stt_pipeline(&state, &ephemeral_channel_id).await {
eprintln!("buzz-desktop: STT transcript start failed: {e}");
}
}

View File

@@ -11,9 +11,15 @@ import {
useManagedAgentsQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys";
import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import {
getDmHuddleMemberPubkeys,
hasOtherDmParticipant,
} from "@/features/channels/lib/dmHuddleMembers";
import { canStartHuddleInChannel } from "@/features/channels/lib/huddleAvailability";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
@@ -64,6 +70,41 @@ export function ChannelMembersBar({
const managedAgentsQuery = useManagedAgentsQuery();
const relayAgentsQuery = useRelayAgentsQuery();
const members = membersQuery.data ?? [];
const dmProfilesQuery = useUsersBatchQuery(
channel.channelType === "dm" ? channel.participantPubkeys : [],
{ enabled: channel.channelType === "dm" },
);
const huddleAgentPubkeys = React.useMemo(() => {
const pubkeys = new Set(
mergeChannelKnownAgentPubkeys(
membersQuery.data,
managedAgentsQuery.data,
relayAgentsQuery.data,
),
);
for (const [pubkey, profile] of Object.entries(
dmProfilesQuery.data?.profiles ?? {},
)) {
if (profile.isAgent) pubkeys.add(normalizePubkey(pubkey));
}
return pubkeys;
}, [
dmProfilesQuery.data?.profiles,
managedAgentsQuery.data,
membersQuery.data,
relayAgentsQuery.data,
]);
const huddleMemberPubkeys = React.useMemo(
() => getDmHuddleMemberPubkeys(channel, huddleAgentPubkeys, currentPubkey),
[channel, currentPubkey, huddleAgentPubkeys],
);
const huddleMemberPubkeysPending =
hasOtherDmParticipant(channel, currentPubkey) &&
(membersQuery.isPending ||
managedAgentsQuery.isPending ||
relayAgentsQuery.isPending ||
dmProfilesQuery.isPending ||
dmProfilesQuery.isPlaceholderData);
const memberCount = membersQuery.data?.length ?? channel.memberCount;
const providers = React.useMemo(
() =>
@@ -117,7 +158,7 @@ export function ChannelMembersBar({
try {
await startHuddle(
channel.id,
[],
[...huddleMemberPubkeys],
buildHuddleChannelName({
channel,
currentPubkey,
@@ -133,7 +174,9 @@ export function ChannelMembersBar({
}
}}
renderMode={variant === "compact" ? "menu-item" : "button"}
startDisabled={!canStartHuddle || isStartingHuddle}
startDisabled={
!canStartHuddle || isStartingHuddle || huddleMemberPubkeysPending
}
/>
);

View File

@@ -339,6 +339,28 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
[],
);
/**
* Clean up only this provider's media after its start token is superseded.
* The action that changed the token owns backend teardown; issuing a global
* leave here could terminate a replacement huddle started by a new provider.
*/
const cleanupSupersededStart = React.useCallback(
(worklet: AudioWorkletHandle | null) => {
try {
worklet?.stop();
} catch {
/* best-effort */
}
workletRef.current = null;
rustActiveRef.current = false;
setLocalAudioTrack(null);
setMicConnected(false);
setEphemeralChannelId(null);
setActiveSpeakers([]);
},
[],
);
/** Shared media setup: get mic, setup AudioWorklet, confirm active.
* Used by both startHuddle and joinHuddle after the Rust backend call succeeds. */
const connectAndSetupMedia = React.useCallback(
@@ -443,7 +465,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
await connectAndSetupMedia(joinInfo, myToken);
} catch (e) {
if (e instanceof Error && e.message === "superseded") {
await cleanupFailedStart(workletRef.current, true);
cleanupSupersededStart(workletRef.current);
return;
}
throw e;
@@ -466,7 +488,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
busyRef.current = false;
}
},
[cleanupFailedStart, connectAndSetupMedia],
[cleanupFailedStart, cleanupSupersededStart, connectAndSetupMedia],
);
const joinHuddle = React.useCallback(
@@ -489,7 +511,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
await connectAndSetupMedia(joinInfo, myToken);
} catch (e) {
if (e instanceof Error && e.message === "superseded") {
await cleanupFailedStart(workletRef.current, false);
cleanupSupersededStart(workletRef.current);
return;
}
throw e;
@@ -512,7 +534,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
busyRef.current = false;
}
},
[cleanupFailedStart, connectAndSetupMedia],
[cleanupFailedStart, cleanupSupersededStart, connectAndSetupMedia],
);
useTtsSubscription(ephemeralChannelId, selfPubkeyRef);

View File

@@ -242,7 +242,10 @@ export function HuddleBar({
// Primary: listen for Rust-emitted state change events
listen<HuddleState>("huddle-state-changed", (event) => {
if (!cancelled) applyIncomingState(event.payload);
if (!cancelled) {
stateGenerationRef.current += 1;
applyIncomingState(event.payload);
}
}).then((fn) => {
if (cancelled) fn();
else unlisten = fn;
@@ -757,14 +760,11 @@ export function HuddleBar({
transcriptionEnabled ? "Stop transcript" : "Start transcript"
}
aria-pressed={transcriptionEnabled}
className={cn(
"buzz-huddle-control-button h-12 w-12 shrink-0 rounded-md",
transcriptionEnabled && "text-foreground",
)}
className="buzz-huddle-control-button h-12 w-12 shrink-0 rounded-md"
onClick={() => void handleToggleTranscript()}
size="icon"
type="button"
variant={transcriptionEnabled ? "secondary" : "ghost"}
variant="ghost"
>
<Captions className="h-4 w-4" />
</Button>

View File

@@ -260,11 +260,18 @@ export function UserProfilePopover({
const selfProfileQuery = useProfileQuery(open && showProfileActions);
const isCurrentUserOwner = ownsAuthorAgent(profile, currentPubkey);
const viewerIsOwner = isCurrentUserOwner || isOwner === true;
const showHuddleAction =
showHumanProfileActions ||
(showProfileActions &&
isBotProfile &&
viewerIsOwner &&
!isAgentClassificationPending);
const showMessageAction =
showProfileActions &&
!isAgentClassificationPending &&
(!isBotProfile || viewerIsOwner);
const showAnyProfileActions = showHumanProfileActions || showMessageAction;
const showAnyProfileActions =
showHumanProfileActions || showMessageAction || showHuddleAction;
const canViewActivity =
isBotProfile && viewerIsOwner && canOpenAgentActivity(pubkey);
const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()];
@@ -356,7 +363,7 @@ export function UserProfilePopover({
const handleHuddle = React.useCallback(async () => {
if (
!showProfileActions ||
!showHumanProfileActions ||
!showHuddleAction ||
pendingAction !== null ||
isStartingHuddle
) {
@@ -369,7 +376,7 @@ export function UserProfilePopover({
try {
const dm = await openDmMutation.mutateAsync({ pubkeys: [pubkey] });
await goChannel(dm.id);
await startHuddle(dm.id, []);
await startHuddle(dm.id, isBotProfile ? [pubkey] : []);
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
if (isMountedRef.current) {
setOpen(false);
@@ -389,7 +396,8 @@ export function UserProfilePopover({
pendingAction,
pubkey,
queryClient,
showHumanProfileActions,
isBotProfile,
showHuddleAction,
showProfileActions,
startHuddle,
]);
@@ -722,7 +730,7 @@ export function UserProfilePopover({
Message
</Button>
) : null}
{showHumanProfileActions ? (
{showHuddleAction ? (
<Button
className="min-w-0 flex-1"
data-testid={`user-profile-popover-huddle-${pubkey}`}

View File

@@ -75,6 +75,15 @@
);
}
.buzz-huddle-drawer .buzz-huddle-control-button[aria-pressed="true"] {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.buzz-huddle-drawer .buzz-huddle-control-button[aria-pressed="true"]:hover {
background: hsl(var(--primary) / 90%);
}
.buzz-huddle-drawer .buzz-huddle-split-chevron {
background: hsl(var(--huddle-control-chevron-surface, var(--secondary)));
}

View File

@@ -1,5 +1,5 @@
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import { emit } from "@tauri-apps/api/event";
import { emit, listen } from "@tauri-apps/api/event";
import { mockIPC, mockWindows } from "@tauri-apps/api/mocks";
import { decode, npubEncode, nsecEncode } from "nostr-tools/nip19";
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
@@ -145,6 +145,19 @@ type MockSearchProfileSeed = {
isAgent?: boolean;
};
type MockHuddleMemberSeed = {
pubkey: string;
role: "owner" | "admin" | "member" | "guest" | "bot";
};
type MockHuddleSeed = {
parentChannelId: string;
ephemeralChannelId: string;
members: MockHuddleMemberSeed[];
transcriptionEnabled?: boolean;
isCreator?: boolean;
};
type E2eConfig = {
mode?: "mock" | "relay";
mock?: {
@@ -229,6 +242,14 @@ type E2eConfig = {
mcp?: MockCommandAvailability;
};
managedAgents?: MockManagedAgentSeed[];
/** Result returned by the mocked `add_agent_to_huddle` command. */
addAgentToHuddleResult?: {
ephemeral_added: boolean;
parent_added: boolean;
parent_error: string | null;
};
/** Delay an invocation-time huddle snapshot to exercise hydration ordering. */
huddleStateReadDelayMs?: number;
/** Per agent+relay runtime rows for the pair-scoped lifecycle commands
* (`list/start/stop/restart_managed_agent_runtime`). */
managedAgentRuntimes?: MockManagedAgentRuntimeSeed[];
@@ -239,6 +260,8 @@ type E2eConfig = {
personaSharePublicationStatuses?: Array<"published" | "queued">;
teams?: MockTeamSeed[];
relayAgents?: MockRelayAgentSeed[];
/** Native-like huddle state seeded from authoritative role-bearing membership. */
huddle?: MockHuddleSeed;
agentListDelayMs?: number;
agentMemory?: RawAgentMemoryListing | Record<string, RawAgentMemoryListing>;
addChannelMembersDelayMs?: number;
@@ -1058,6 +1081,10 @@ declare global {
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
__BUZZ_E2E_SET_MOCK_HUDDLE_SNAPSHOT__?: (input: {
members: MockHuddleMemberSeed[];
transcriptionEnabled: boolean;
}) => Promise<void>;
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: RawFeedItem) => RawFeedItem;
/** Replace an existing feed item by id (or push if not found) and fire the updated event. */
__BUZZ_E2E_REPLACE_MOCK_FEED_ITEM__?: (
@@ -2931,8 +2958,85 @@ function resetMockMesh() {
}
let mockPersonas: RawPersona[] = [];
let mockTeams: RawTeam[] = [];
// Listeners registered via the mock __TAURI_INTERNALS__.listen — keyed by event name.
const tauriEventListeners = new Map<string, Set<() => void>>();
type MockHuddleState = {
phase: "active";
parent_channel_id: string;
ephemeral_channel_id: string;
participants: string[];
agent_pubkeys: string[];
tts_enabled: boolean;
transcription_enabled: boolean;
is_creator: boolean;
voice_input_mode: "voice_activity";
};
type PersistedMockHuddle = {
members: MockHuddleMemberSeed[];
state: MockHuddleState;
};
const MOCK_HUDDLE_STORAGE_KEY = "buzz.e2e.mock-huddle.v1";
let mockHuddle: PersistedMockHuddle | null = null;
function persistMockHuddle() {
if (mockHuddle) {
window.sessionStorage.setItem(
MOCK_HUDDLE_STORAGE_KEY,
JSON.stringify(mockHuddle),
);
}
}
async function emitMockHuddleState() {
if (!mockHuddle) return;
await emit("huddle-state-changed", structuredClone(mockHuddle.state));
}
function refreshMockHuddleMembership() {
if (!mockHuddle) return;
mockHuddle.state.agent_pubkeys = mockHuddle.members
.filter((member) => member.role === "bot")
.map((member) => member.pubkey);
mockHuddle.state.participants = mockHuddle.members.map(
(member) => member.pubkey,
);
}
function initializeMockHuddle(seed: MockHuddleSeed | undefined) {
mockHuddle = null;
if (!seed) {
window.sessionStorage.removeItem(MOCK_HUDDLE_STORAGE_KEY);
return;
}
const persisted = window.sessionStorage.getItem(MOCK_HUDDLE_STORAGE_KEY);
if (persisted) {
try {
mockHuddle = JSON.parse(persisted) as PersistedMockHuddle;
return;
} catch {
window.sessionStorage.removeItem(MOCK_HUDDLE_STORAGE_KEY);
}
}
mockHuddle = {
members: structuredClone(seed.members),
state: {
phase: "active",
parent_channel_id: seed.parentChannelId,
ephemeral_channel_id: seed.ephemeralChannelId,
participants: [],
agent_pubkeys: [],
tts_enabled: false,
transcription_enabled: seed.transcriptionEnabled ?? false,
is_creator: seed.isCreator ?? true,
voice_input_mode: "voice_activity",
},
};
refreshMockHuddleMembership();
persistMockHuddle();
}
const openedExternalUrls: string[] = [];
const defaultMockRelayAgents: RawRelayAgent[] = [
{
@@ -7544,7 +7648,7 @@ type MockUpdatePersonaInput = {
async function handleUpdatePersona(args: {
input: MockUpdatePersonaInput;
}): Promise<RawPersona> {
return { ...applyMockPersonaUpdate(args.input) };
return { ...(await applyMockPersonaUpdate(args.input)) };
}
/**
@@ -7557,7 +7661,9 @@ async function handleUpdatePersona(args: {
* make a UI that never calls `update_persona_and_publish` look like it kept
* the "Save and publish" promise.
*/
function applyMockPersonaUpdate(input: MockUpdatePersonaInput): RawPersona {
async function applyMockPersonaUpdate(
input: MockUpdatePersonaInput,
): Promise<RawPersona> {
const persona = mockPersonas.find((candidate) => candidate.id === input.id);
if (!persona) {
throw new Error(`agent ${input.id} not found`);
@@ -7575,9 +7681,7 @@ function applyMockPersonaUpdate(input: MockUpdatePersonaInput): RawPersona {
applyMockPersonaBehavior(persona, input.behavior);
persona.updated_at = new Date().toISOString();
for (const callback of tauriEventListeners.get("agents-data-changed") ?? []) {
callback();
}
await emit("agents-data-changed");
return persona;
}
@@ -7739,7 +7843,10 @@ async function handleUpdatePersonaAndPublish(
args: { input: MockUpdatePersonaInput },
config?: E2eConfig,
): Promise<MockPersonaPublicationResult> {
return publishMockPersonaHead(applyMockPersonaUpdate(args.input), config);
return publishMockPersonaHead(
await applyMockPersonaUpdate(args.input),
config,
);
}
function ensureMockPersonaIsActive(personaId: string) {
@@ -9509,6 +9616,7 @@ export function maybeInstallE2eTauriMocks() {
resetMockPersonaCatalogEvents(config);
resetMockSaveSubscriptions(config);
resetMockPendingCommunityDeepLinks(config);
initializeMockHuddle(config.mock?.huddle);
mockWebsocketSendMutexWedged = false;
mockWindows("main");
window.__BUZZ_E2E_COMMANDS__ = [];
@@ -9516,6 +9624,19 @@ export function maybeInstallE2eTauriMocks() {
window.__BUZZ_E2E_COMMAND_LOG__ = [];
window.__BUZZ_E2E_SIGNED_EVENTS__ = [];
window.__BUZZ_E2E_WEBVIEW_ZOOM__ = 1;
window.__BUZZ_E2E_SET_MOCK_HUDDLE_SNAPSHOT__ = async ({
members,
transcriptionEnabled,
}) => {
if (!mockHuddle) {
throw new Error("No mock huddle is configured.");
}
mockHuddle.members = structuredClone(members);
mockHuddle.state.transcription_enabled = transcriptionEnabled;
refreshMockHuddleMembership();
persistMockHuddle();
await emitMockHuddleState();
};
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ = ({
channelName,
content,
@@ -9803,6 +9924,36 @@ export function maybeInstallE2eTauriMocks() {
window.__BUZZ_E2E_COMMAND_LOG__?.push({ command, payload });
switch (command) {
case "get_huddle_state": {
const snapshot = mockHuddle ? structuredClone(mockHuddle.state) : null;
const delayMs = activeConfig?.mock?.huddleStateReadDelayMs ?? 0;
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
return snapshot;
}
case "get_huddle_agent_pubkeys":
if (!mockHuddle) return [];
return [...mockHuddle.state.agent_pubkeys];
case "set_huddle_transcription_enabled":
if (!mockHuddle) {
throw new Error("No active mock huddle.");
}
mockHuddle.state.transcription_enabled = Boolean(
(payload as { enabled?: boolean }).enabled,
);
persistMockHuddle();
await emitMockHuddleState();
return null;
case "add_agent_to_huddle": {
const result = activeConfig?.mock?.addAgentToHuddleResult;
if (!result) {
throw new Error("No mock add-agent result configured.");
}
return structuredClone(result);
}
case "get_model_status":
return { stt: "ready", tts: "ready" };
case "get_builderlab_auth":
return activeConfig?.mock?.builderlabAuth ?? null;
case "start_builderlab_login": {
@@ -10753,9 +10904,7 @@ export function maybeInstallE2eTauriMocks() {
}
}
// Mirror the real Rust backend: emit "agents-data-changed" after reconcile.
for (const cb of tauriEventListeners.get("agents-data-changed") ?? []) {
cb();
}
await emit("agents-data-changed");
return undefined;
}
case "set_persona_active":
@@ -11602,9 +11751,6 @@ export function maybeInstallE2eTauriMocks() {
case "plugin:webview|set_webview_zoom":
window.__BUZZ_E2E_WEBVIEW_ZOOM__ = (payload as { value: number }).value;
return;
case "plugin:event|listen":
// Tauri event system (pairing, huddle) — no-op in e2e, return unlisten fn ID
return Math.floor(Math.random() * 1_000_000);
case "start_pairing": {
const delayMs = activeConfig?.mock?.pairingStartDelayMs ?? 0;
if (delayMs > 0) {
@@ -11794,26 +11940,21 @@ export function maybeInstallE2eTauriMocks() {
window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ = (command, payload) =>
handleMockCommand(command, payload ?? null);
mockIPC(handleMockCommand, { shouldMockEvents: true });
// Wire up __TAURI_INTERNALS__.listen so tests can subscribe to backend-emitted
// events (e.g. "agents-data-changed"). mockIPC already ensures __TAURI_INTERNALS__
// exists; we just add the listen property without clobbering invoke.
(
window as unknown as {
const tauriInternals = (
window as typeof window & {
__TAURI_INTERNALS__: {
listen?: (event: string, cb: () => void) => Promise<() => void>;
listen?: (
event: string,
callback: () => void,
) => Promise<() => Promise<void>>;
};
}
).__TAURI_INTERNALS__.listen = async (event: string, cb: () => void) => {
let listeners = tauriEventListeners.get(event);
if (!listeners) {
listeners = new Set();
tauriEventListeners.set(event, listeners);
}
listeners.add(cb);
return () => {
tauriEventListeners.get(event)?.delete(cb);
};
).__TAURI_INTERNALS__;
// Page-evaluated E2E specs use this surface; delegate to Tauri's mocked channel
// so their listeners observe the same events emitted by application test seams.
tauriInternals.listen = async (event, callback) => {
const unlisten = await listen(event, () => callback());
return async () => unlisten();
};
installed = true;

View File

@@ -0,0 +1,254 @@
import { expect, test } from "@playwright/test";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
const HUDDLE_CHANNEL_ID = "11111111-1111-4111-8111-111111111111";
const HUDDLE_PARENT_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
async function setHuddleSnapshot(
page: import("@playwright/test").Page,
members: Array<{
pubkey: string;
role: "member" | "bot";
}>,
transcriptionEnabled: boolean,
) {
await page.evaluate(
async ({ nextMembers, nextTranscriptionEnabled }) => {
const setSnapshot = (
window as Window & {
__BUZZ_E2E_SET_MOCK_HUDDLE_SNAPSHOT__?: (input: {
members: Array<{
pubkey: string;
role: "member" | "bot";
}>;
transcriptionEnabled: boolean;
}) => Promise<void>;
}
).__BUZZ_E2E_SET_MOCK_HUDDLE_SNAPSHOT__;
if (!setSnapshot) {
throw new Error("Mock huddle snapshot control is not installed.");
}
await setSnapshot({
members: nextMembers,
transcriptionEnabled: nextTranscriptionEnabled,
});
},
{ nextMembers: members, nextTranscriptionEnabled: transcriptionEnabled },
);
}
test("renders authoritative huddle snapshots and sends explicit off once", async ({
page,
}) => {
await installMockBridge(page, {
huddle: {
parentChannelId: HUDDLE_PARENT_ID,
ephemeralChannelId: HUDDLE_CHANNEL_ID,
members: [
{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" },
{ pubkey: TEST_IDENTITIES.alice.pubkey, role: "bot" },
],
transcriptionEnabled: true,
},
});
await page.goto("/");
const transcriptButton = page.getByRole("button", {
name: "Stop transcript",
});
await expect(transcriptButton).toBeVisible();
await expect(transcriptButton).toHaveAttribute("aria-pressed", "true");
const activeBackground = await transcriptButton.evaluate(
(button) => getComputedStyle(button).backgroundColor,
);
await page
.getByRole("button", { name: "Show huddle participants (2)" })
.click();
await expect(
page.getByRole("button", { name: /Remove .* from huddle/ }),
).toHaveCount(1);
await page.keyboard.press("Escape");
await setHuddleSnapshot(
page,
[{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }],
true,
);
await expect(
page.getByRole("button", { name: "Show huddle participants (1)" }),
).toBeVisible();
await expect(transcriptButton).toHaveAttribute("aria-pressed", "true");
await setHuddleSnapshot(
page,
[
{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" },
{ pubkey: TEST_IDENTITIES.alice.pubkey, role: "bot" },
],
true,
);
await expect(transcriptButton).toHaveAttribute("aria-pressed", "true");
await transcriptButton.click();
await expect(
page.getByRole("button", { name: "Start transcript" }),
).toHaveAttribute("aria-pressed", "false");
const inactiveTranscriptButton = page.getByRole("button", {
name: "Start transcript",
});
await expect
.poll(() =>
inactiveTranscriptButton.evaluate(
(button) => getComputedStyle(button).backgroundColor,
),
)
.not.toBe(activeBackground);
const explicitToggleCommands = await page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
(entry) => entry.command === "set_huddle_transcription_enabled",
),
);
expect(explicitToggleCommands).toEqual([
{
command: "set_huddle_transcription_enabled",
payload: { enabled: false },
},
]);
await page.reload();
await expect(
page.getByRole("button", { name: "Start transcript" }),
).toHaveAttribute("aria-pressed", "false");
await expect(
page.getByRole("button", { name: "Show huddle participants (2)" }),
).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
(entry) => entry.command === "set_huddle_transcription_enabled",
).length,
),
)
.toBe(0);
await setHuddleSnapshot(
page,
[{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }],
false,
);
await expect(
page.getByRole("button", { name: "Show huddle participants (1)" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Start transcript" }),
).toHaveAttribute("aria-pressed", "false");
});
test("keeps a newer huddle event over a delayed hydration snapshot", async ({
page,
}) => {
await installMockBridge(page, {
huddle: {
parentChannelId: HUDDLE_PARENT_ID,
ephemeralChannelId: HUDDLE_CHANNEL_ID,
members: [
{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" },
{ pubkey: TEST_IDENTITIES.alice.pubkey, role: "bot" },
],
transcriptionEnabled: true,
},
huddleStateReadDelayMs: 250,
});
await page.goto("/");
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).some(
(entry) => entry.command === "get_huddle_state",
),
),
)
.toBe(true);
await setHuddleSnapshot(
page,
[{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }],
false,
);
await expect(
page.getByRole("button", { name: "Show huddle participants (1)" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Start transcript" }),
).toHaveAttribute("aria-pressed", "false");
await page.waitForTimeout(300);
await expect(
page.getByRole("button", { name: "Show huddle participants (1)" }),
).toBeVisible();
});
test("starts an agent DM huddle with the agent enrolled", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
const startButton = page.getByTestId("channel-start-huddle-trigger");
await expect(startButton).toBeEnabled();
await startButton.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).find(
(entry) => entry.command === "start_huddle",
)?.payload,
),
)
.toMatchObject({
memberPubkeys: [TEST_IDENTITIES.alice.pubkey],
parentChannelId: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
});
});
test("closes add-agent dialog when parent membership is already satisfied", async ({
page,
}) => {
await installMockBridge(page, {
addAgentToHuddleResult: {
ephemeral_added: true,
parent_added: true,
parent_error: null,
},
huddle: {
parentChannelId: HUDDLE_PARENT_ID,
ephemeralChannelId: HUDDLE_CHANNEL_ID,
members: [{ pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }],
},
managedAgents: [
{
pubkey: TEST_IDENTITIES.bob.pubkey,
name: "bob",
status: "running",
},
],
});
await page.goto("/");
await page.getByRole("button", { name: "Add agent to huddle" }).click();
const dialog = page.getByRole("dialog", { name: "Add Agent to Huddle" });
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: /bob/i }).click();
await expect(dialog).toHaveCount(0);
await expect(
page.getByText("Added to huddle, but parent channel failed:"),
).toHaveCount(0);
});

View File

@@ -157,7 +157,7 @@ async function waitForTimelineSettled(page: import("@playwright/test").Page) {
await expect(page.locator("[data-render-pending]")).toHaveCount(0);
}
async function expectAgentProfileMessageOnly(
async function expectOwnedAgentProfileActions(
profilePopover: import("@playwright/test").Locator,
pubkey: string,
) {
@@ -169,7 +169,7 @@ async function expectAgentProfileMessageOnly(
).toHaveCount(0);
await expect(
profilePopover.getByTestId(`user-profile-popover-huddle-${pubkey}`),
).toHaveCount(0);
).toBeVisible();
}
async function expectAgentProfileActionsHidden(
@@ -1260,7 +1260,7 @@ test("groups member additions and joins with hidden names in the standard toolti
await expect(joinedTooltip).toContainText("Sam Rivera");
});
test("system agent profile only exposes message action", async ({ page }) => {
test("system agent profile exposes owned agent actions", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -1299,13 +1299,13 @@ test("system agent profile only exposes message action", async ({ page }) => {
'[data-testid="user-profile-popover"][data-state="open"]',
);
await expect(profilePopover).toBeVisible();
await expectAgentProfileMessageOnly(
await expectOwnedAgentProfileActions(
profilePopover,
PROFILE_ONLY_AGENT_PUBKEY,
);
});
test("system agent avatar only exposes message action", async ({ page }) => {
test("system agent avatar exposes owned agent actions", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
@@ -1340,7 +1340,7 @@ test("system agent avatar only exposes message action", async ({ page }) => {
'[data-testid="user-profile-popover"][data-state="open"]',
);
await expect(profilePopover).toBeVisible();
await expectAgentProfileMessageOnly(
await expectOwnedAgentProfileActions(
profilePopover,
PROFILE_ONLY_AGENT_PUBKEY,
);
@@ -1919,7 +1919,9 @@ test("human profile popover does not show an owner", async ({ page }) => {
).toHaveCount(0);
});
test("owned bot profile only exposes message action", async ({ page }) => {
test("owned bot profile exposes message and huddle actions", async ({
page,
}) => {
await installMockBridge(page, {
managedAgents: [
{
@@ -1943,13 +1945,29 @@ test("owned bot profile only exposes message action", async ({ page }) => {
'[data-testid="user-profile-popover"][data-state="open"]',
);
await expect(profilePopover).toBeVisible();
await expectAgentProfileMessageOnly(
await expectOwnedAgentProfileActions(
profilePopover,
TEST_IDENTITIES.charlie.pubkey,
);
await profilePopover
.getByTestId(
`user-profile-popover-huddle-${TEST_IDENTITIES.charlie.pubkey}`,
)
.click();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).find(
(entry) => entry.command === "start_huddle",
)?.payload,
),
)
.toMatchObject({ memberPubkeys: [TEST_IDENTITIES.charlie.pubkey] });
});
test("owned agent mention profile only exposes message action", async ({
test("owned agent mention profile exposes message and huddle actions", async ({
page,
}) => {
await installMockBridge(page, {
@@ -1982,7 +2000,7 @@ test("owned agent mention profile only exposes message action", async ({
'[data-testid="user-profile-popover"][data-state="open"]',
);
await expect(profilePopover).toBeVisible();
await expectAgentProfileMessageOnly(
await expectOwnedAgentProfileActions(
profilePopover,
TEST_IDENTITIES.charlie.pubkey,
);

View File

@@ -83,6 +83,17 @@ type MockRelayAgentSeed = {
status?: "online" | "away" | "offline";
};
type MockHuddleSeed = {
parentChannelId: string;
ephemeralChannelId: string;
members: Array<{
pubkey: string;
role: "owner" | "admin" | "member" | "guest" | "bot";
}>;
transcriptionEnabled?: boolean;
isCreator?: boolean;
};
type MockPersonaSeed = {
id?: string;
displayName: string;
@@ -152,6 +163,8 @@ type MockBridgeOptions = {
projectHeadBranch?: string;
/** Relay NIP-11 identity used to sign authoritative repository state. */
relaySelf?: string | null;
/** Native-like huddle state seeded from authoritative role-bearing membership. */
huddle?: MockHuddleSeed;
/** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */
builderlabAuth?: { email?: string; name?: string; expiresAt: string } | null;
/** Optional policy returned by the native join-policy discovery command. */
@@ -209,6 +222,14 @@ type MockBridgeOptions = {
mcp?: MockCommandAvailability;
};
managedAgents?: MockManagedAgentSeed[];
/** Result returned by the mocked `add_agent_to_huddle` command. */
addAgentToHuddleResult?: {
ephemeral_added: boolean;
parent_added: boolean;
parent_error: string | null;
};
/** Delay an invocation-time huddle snapshot to exercise hydration ordering. */
huddleStateReadDelayMs?: number;
/** Per agent+relay runtime rows for pair-scoped lifecycle commands. */
managedAgentRuntimes?: Array<{
pubkey: string;