From 000d30039b6e095da2bc7cf484e51fb4f5470127 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 21 Oct 2026 09:48:07 -0400 Subject: [PATCH 01/12] berd-call: settle a handoff when the provider expires right behind it When the provider sent a session expiry immediately after a handoff, the Spokesperson runtime had already closed by the time the host sent back the accepted handoff tool output. The send failed with "Spokesperson runtime is closed", which ended the session with that error instead of reporting that the session expired before it could renew. The runtime queues its terminal event before it closes, so ignore the closed send and let that event settle the unresolved handoff. Signed-off-by: John Tennant Co-Authored-By: Claude Opus 5.5 --- src-tauri/crates/berd-call/src/main.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src-tauri/crates/berd-call/src/main.rs b/src-tauri/crates/berd-call/src/main.rs index 94d8f26f5..1d068ea69 100644 --- a/src-tauri/crates/berd-call/src/main.rs +++ b/src-tauri/crates/berd-call/src/main.rs @@ -4029,12 +4029,14 @@ fn run_expert_spokesperson_session( LiveSideEvent::Handoff { call_id, .. } => call_id, _ => return Err("live handoff did not produce a handoff ID".into()), }; - runtime.as_ref().expect("initialized runtime").send( + // A provider that expired right behind the handoff has already + // closed the runtime. Its queued terminal event settles the handoff. + let _ = runtime.as_ref().expect("initialized runtime").send( SpokespersonCommand::Provider(accepted_handoff_tool_output( &call_id, &handoff_id, )?), - )?; + ); } event @ (SpokespersonEvent::Expired(_) | SpokespersonEvent::SessionLost(_)) => { let (message, connection_lost) = match event { From e4492ac8cf6b2a7010ff9d2a960ab88180efecd3 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 21 Oct 2026 09:48:20 -0400 Subject: [PATCH 02/12] berd-call: match the capitalized Usage heading in management usage tests The usage error output prints the help text, whose heading has been "Usage:" since the help command was added. The management usage error test still looked for lowercase "usage:", so it failed on every machine. CI does not run the berd-call tests, so nothing caught it. Signed-off-by: John Tennant Co-Authored-By: Claude Opus 5.5 --- src-tauri/crates/berd-call/tests/management_cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/crates/berd-call/tests/management_cli.rs b/src-tauri/crates/berd-call/tests/management_cli.rs index c915ed0d7..0c21ad4ee 100644 --- a/src-tauri/crates/berd-call/tests/management_cli.rs +++ b/src-tauri/crates/berd-call/tests/management_cli.rs @@ -34,7 +34,7 @@ fn management_usage_errors_are_exit_two_and_do_not_emit_json() { assert!( String::from_utf8(output.stderr) .expect("UTF-8 stderr") - .contains("usage:"), + .contains("Usage:"), "{args:?}" ); } From 38f22080d3c82465d6b526008d8b6b1f51d9b7fb Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 21 Oct 2026 09:48:21 -0400 Subject: [PATCH 03/12] berd-call: match the capitalized Usage heading in synthesis usage tests The usage error output prints the help text, whose heading has been "Usage:" since the help command was added. The synthesis usage error test still looked for lowercase "usage:", so it failed on every machine. CI does not run the berd-call tests, so nothing caught it. Signed-off-by: John Tennant Co-Authored-By: Claude Opus 5.5 --- src-tauri/crates/berd-call/tests/synthesis_cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/crates/berd-call/tests/synthesis_cli.rs b/src-tauri/crates/berd-call/tests/synthesis_cli.rs index cfc0f22ef..c3b53ae37 100644 --- a/src-tauri/crates/berd-call/tests/synthesis_cli.rs +++ b/src-tauri/crates/berd-call/tests/synthesis_cli.rs @@ -68,7 +68,7 @@ fn synthesis_usage_errors_are_exit_two_and_emit_no_json() { let output = berd_call(&args); assert_eq!(output.status.code(), Some(2), "{args:?}"); assert!(output.stdout.is_empty(), "{args:?}"); - assert!(String::from_utf8_lossy(&output.stderr).contains("usage:")); + assert!(String::from_utf8_lossy(&output.stderr).contains("Usage:")); } } From ccef906eaf4c5923b27f0b42463f9955ebd5d7f9 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 21 Oct 2026 09:48:38 -0400 Subject: [PATCH 04/12] berd-call: write the saved settings the Spokesperson voice test starts from Saving a live setting rereads the settings file under a lock and changes only that setting. The Spokesperson voice test kept its starting settings in memory and never wrote them, so the file started empty and the saved arguments lost the voice and language. Save the starting settings first, as the other saved settings tests do. Signed-off-by: John Tennant Co-Authored-By: Claude Opus 5.5 --- src-tauri/crates/berd-call/src/host_session.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src-tauri/crates/berd-call/src/host_session.rs b/src-tauri/crates/berd-call/src/host_session.rs index 9f866f71b..a72a26cbf 100644 --- a/src-tauri/crates/berd-call/src/host_session.rs +++ b/src-tauri/crates/berd-call/src/host_session.rs @@ -4186,15 +4186,14 @@ mod tests { }, }; actor.session.lock().unwrap().spokesperson = Some(openai("marin", 1)); - actor.saved = Some(( - path.clone(), - crate::saved_settings::SavedSettings { - arguments: ["--voice", "Aaron", "--language", "en-US"] - .map(str::to_string) - .to_vec(), - ..crate::saved_settings::SavedSettings::default() - }, - )); + let saved = crate::saved_settings::SavedSettings { + arguments: ["--voice", "Aaron", "--language", "en-US"] + .map(str::to_string) + .to_vec(), + ..crate::saved_settings::SavedSettings::default() + }; + crate::saved_settings::save(&path, &saved).unwrap(); + actor.saved = Some((path.clone(), saved)); let (response, result) = mpsc::sync_channel(1); actor .handle_command(ControlCommand::SpokespersonSettings { From 1bb810bdd30ced673ee84d8d34d7661749b3cdc1 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 21 Oct 2026 09:53:39 -0400 Subject: [PATCH 05/12] ci: run Berd Call tests on macOS and Linux Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- justfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/justfile b/justfile index 73e1fea52..8eef0d1c7 100644 --- a/justfile +++ b/justfile @@ -228,7 +228,7 @@ _tauri-check-unix: _tauri-check-windows: just tauri-check-windows -# Run the Rust plugin tests plus the app-crate suites for feature-gated +# Run the Rust plugin and Berd Call tests plus app-crate suites for feature-gated # modules (telemetry consent, skill marketplace) with external sidecars # disabled. Gated lanes pass a name filter and their enabling Cargo feature — # e.g. `block-telemetry-enforced` swaps in the enforced-consent tests. @@ -252,6 +252,7 @@ _tauri-test-unix: if [ "$(uname -s)" = "Linux" ]; then rm -rf src-tauri/target/sherpa-onnx-prebuilt; fi just _tauri-cargo-unix test -p tauri-plugin-berdctl --features server just _tauri-cargo-unix test -p berdctl + just _tauri-cargo-unix test -p berd-call --locked just _tauri-cargo-unix test --lib services::acp::goose_serve::tests::storage_identity_guard just _tauri-cargo-unix test --lib services::goose_config::tests just _tauri-cargo-unix test --lib commands::message_search::tests From 84112a3ce5acb06e47e9b41d6c677dfa254d86bb Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 21 Oct 2026 10:02:29 -0400 Subject: [PATCH 06/12] ci: clear incomplete Sherpa cache on macOS too Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/justfile b/justfile index 8eef0d1c7..ce2566a00 100644 --- a/justfile +++ b/justfile @@ -249,7 +249,7 @@ _tauri-test-skill-marketplace: [unix] _tauri-test-unix: # rust-cache can restore Sherpa's generated cache directory without its native libraries. - if [ "$(uname -s)" = "Linux" ]; then rm -rf src-tauri/target/sherpa-onnx-prebuilt; fi + rm -rf src-tauri/target/sherpa-onnx-prebuilt just _tauri-cargo-unix test -p tauri-plugin-berdctl --features server just _tauri-cargo-unix test -p berdctl just _tauri-cargo-unix test -p berd-call --locked From 31218c251b8a0f52545b371d1b35ddc10d1f3cd0 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 21 Oct 2026 10:16:24 -0400 Subject: [PATCH 07/12] test(berd-call): synchronize input cutover transcription Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- src-tauri/crates/berd-call/src/openai_spokesperson.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/crates/berd-call/src/openai_spokesperson.rs b/src-tauri/crates/berd-call/src/openai_spokesperson.rs index ce9adfcf5..47c9905a8 100644 --- a/src-tauri/crates/berd-call/src/openai_spokesperson.rs +++ b/src-tauri/crates/berd-call/src/openai_spokesperson.rs @@ -1752,6 +1752,7 @@ mod tests { async fn input_cutover_waits_for_committed_transcription_terminal() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let endpoint = format!("ws://{}/", listener.local_addr().unwrap()); + let (transcribe, proceed) = tokio::sync::oneshot::channel(); let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let mut socket = accept_hdr_async(stream, require_test_authorization) @@ -1777,7 +1778,7 @@ mod tests { ) .await; send_json(&mut socket, json!({"type":"input_audio_buffer.cleared"})).await; - tokio::time::sleep(Duration::from_millis(120)).await; + proceed.await.unwrap(); send_json( &mut socket, json!({ @@ -1805,6 +1806,7 @@ mod tests { SpokespersonEvent::UserSpeaking { active: true, .. } )); assert!(events.recv_timeout(Duration::from_millis(60)).is_err()); + transcribe.send(()).unwrap(); assert!(matches!( events.recv_timeout(Duration::from_secs(2)).unwrap(), SpokespersonEvent::UserFinal { .. } From fe5ab3a0708728cef8a195163d67b94998a3aa88 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 21 Oct 2026 12:17:17 -0400 Subject: [PATCH 08/12] ci: request automatic review Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant From 3e7acf6e31fed3bf2b3a61df5d065d8e37e3dbe8 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 21 Oct 2026 12:56:50 -0400 Subject: [PATCH 09/12] test(berd-call): keep renewal fixture busy through response handoff Keep the cancelled response inflight until its successor starts, so scheduled renewal cannot snapshot an incomplete conversation between transcript and response events. Include phase and session diagnostics when renewal fails. Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- .../berd-call/tests/session_protocol.rs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src-tauri/crates/berd-call/tests/session_protocol.rs b/src-tauri/crates/berd-call/tests/session_protocol.rs index 391cd6ebf..57cc79455 100644 --- a/src-tauri/crates/berd-call/tests/session_protocol.rs +++ b/src-tauri/crates/berd-call/tests/session_protocol.rs @@ -574,6 +574,7 @@ fn framed_hello_reports_input_initialization_failure_before_ready() { #[test] fn expert_spokesperson_renews_before_provider_expiry_without_changing_settings() { + let started = std::time::Instant::now(); let (endpoint_tx, endpoint_rx) = mpsc::sync_channel(1); let (audio_ready_tx, audio_ready_rx) = mpsc::sync_channel(1); let (release_speech_tx, release_speech_rx) = mpsc::sync_channel(1); @@ -630,18 +631,21 @@ fn expert_spokesperson_renews_before_provider_expiry_without_changing_settings() let truncate = receive_realtime_json(&mut old).await; assert_eq!(truncate["type"], "conversation.item.truncate"); send_realtime_json(&mut old, json!({"type":"conversation.item.truncated","item_id":"assistant-1","content_index":0})).await; - send_realtime_json(&mut old, json!({"type":"response.done","response":{"id":"response-1","status":"cancelled"}})).await; send_realtime_json(&mut old, json!({"type":"input_audio_buffer.speech_stopped","item_id":"user-2"})).await; send_realtime_json(&mut old, json!({"type":"conversation.item.input_audio_transcription.completed","item_id":"user-2","transcript":"interrupt now"})).await; + // Keep the cancelled response inflight until its successor blocks renewal. send_realtime_json(&mut old, json!({"type":"response.created","response":{"id":"response-2"}})).await; + send_realtime_json(&mut old, json!({"type":"response.done","response":{"id":"response-1","status":"cancelled"}})).await; send_realtime_json(&mut old, json!({"type":"response.output_audio_transcript.done","response_id":"response-2","item_id":"assistant-2","output_index":0,"content_index":0,"transcript":"after interruption"})).await; send_realtime_json(&mut old, json!({"type":"response.done","response":{"id":"response-2","status":"completed"}})).await; + eprintln!("renewal: waiting for candidate at {:?}", started.elapsed()); let (candidate_stream, _) = tokio::time::timeout(Duration::from_secs(2), listener.accept()) .await .expect("renewal candidate should connect before provider expiry") .unwrap(); + eprintln!("renewal: candidate connected at {:?}", started.elapsed()); let mut candidate = accept_async(candidate_stream).await.unwrap(); let replacement = receive_realtime_json(&mut candidate).await; assert_eq!(replacement["type"], "session.update"); @@ -651,6 +655,7 @@ fn expert_spokesperson_renews_before_provider_expiry_without_changing_settings() ); assert_eq!(replacement["session"]["audio"]["output"]["speed"], 1.0); acknowledge_realtime_session(&mut candidate, &replacement).await; + eprintln!("renewal: candidate ready; replaying transcript at {:?}", started.elapsed()); let user_seed = receive_realtime_json(&mut candidate).await; assert_eq!(user_seed["item"]["role"], "user"); assert_eq!(user_seed["item"]["content"][0]["text"], "remember this"); @@ -677,10 +682,13 @@ fn expert_spokesperson_renews_before_provider_expiry_without_changing_settings() assert_eq!(seed["item"]["content"][0]["text"], text); send_realtime_json(&mut candidate, json!({"type":"conversation.item.created","item":{"id":seed["item"]["id"]}})).await; } + eprintln!("renewal: transcript replayed; waiting for input barrier at {:?}", started.elapsed()); let clear = receive_realtime_json(&mut old).await; assert_eq!(clear["type"], "input_audio_buffer.clear"); send_realtime_json(&mut old, json!({"type":"input_audio_buffer.cleared"})).await; + eprintln!("renewal: input barrier acknowledged; waiting for old session close at {:?}", started.elapsed()); let _ = old.next().await; + eprintln!("renewal: old session closed at {:?}", started.elapsed()); renewed_tx.send(()).unwrap(); let _ = candidate.next().await; }); @@ -695,7 +703,16 @@ fn expert_spokesperson_renews_before_provider_expiry_without_changing_settings() ); audio_ready_rx.recv_timeout(Duration::from_secs(2)).unwrap(); release_speech_tx.send(()).unwrap(); - renewed_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + renewed_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap_or_else(|error| { + panic!( + "renewal did not finish at {:?}: {error}; output: {:?}; stderr: {:?}", + started.elapsed(), + session.output.try_iter().collect::>(), + session.stderr.try_iter().collect::>() + ) + }); let messages = session.shutdown_and_collect(); assert!(messages .iter() From 0771c932fb0e19f13d21b408235451c96a89d136 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 21 Oct 2026 13:08:36 -0400 Subject: [PATCH 10/12] test(berdctl): disambiguate concurrent discovery fixtures Include a per-process sequence in temporary directory names so parallel fixtures cannot collide when SystemTime returns the same timestamp. Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- src-tauri/crates/berdctl/tests/discovery_paths.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src-tauri/crates/berdctl/tests/discovery_paths.rs b/src-tauri/crates/berdctl/tests/discovery_paths.rs index 2867bca4f..f6784d658 100644 --- a/src-tauri/crates/berdctl/tests/discovery_paths.rs +++ b/src-tauri/crates/berdctl/tests/discovery_paths.rs @@ -8,6 +8,7 @@ use std::net::TcpListener; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; const CAPABILITY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -16,12 +17,14 @@ struct Fixture(PathBuf); impl Fixture { fn new(port: u16) -> Self { + static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); + let sequence = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed); let unique = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); let dir = std::env::temp_dir().join(format!( - "berdctl-discovery-integration-{}-{unique}", + "berdctl-discovery-integration-{}-{unique}-{sequence}", std::process::id() )); fs::create_dir(&dir).unwrap(); From 0e5f555eb9dda10892bd06081cd53a89a68a44eb Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 21 Oct 2026 13:31:16 -0400 Subject: [PATCH 11/12] Trigger automated review Co-authored-by: Aaron Signed-off-by: John Tennant From 7953d3005fa665324099f2688cb177d38d702e87 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 22 Oct 2026 09:21:59 -0400 Subject: [PATCH 12/12] fix: clear Sherpa cache from the active Cargo target Signed-off-by: Aaron --- justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/justfile b/justfile index ce2566a00..b8c448ae8 100644 --- a/justfile +++ b/justfile @@ -249,7 +249,7 @@ _tauri-test-skill-marketplace: [unix] _tauri-test-unix: # rust-cache can restore Sherpa's generated cache directory without its native libraries. - rm -rf src-tauri/target/sherpa-onnx-prebuilt + rm -rf "$(bash ./scripts/resolve-tauri-cargo-target-dir.sh)/sherpa-onnx-prebuilt" just _tauri-cargo-unix test -p tauri-plugin-berdctl --features server just _tauri-cargo-unix test -p berdctl just _tauri-cargo-unix test -p berd-call --locked