From e0d5f204759fb99e190ac93acec477899e9ea3a3 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 12 Aug 2026 16:15:04 +1000 Subject: [PATCH] test(buzz-agent): cover the steer and compaction operations The two operations added in 099bacbe had no direct unit tests -- they were covered only indirectly by the integration suite. Four tests, each pinning a property where the failure would be silent: - a steer drains rather than peeks (a peek repeats the instruction to the model every round) - an empty queue is NotApplicable, not an empty Applied (the loop treats applied-but-unchanged as a reason to look again, so an always-applying operation spins it) - only effects that change state count as progress, including effects this loop deliberately ignores - compaction clears the running token total (a stale total describes the pre-compaction conversation and would re-trigger compaction at once) Verified by mutation rather than assumed: removing the token reset fails the compaction test, and making drain() peek fails the steer test. 78 unit + 21 integration green. Signed-off-by: Michael Neale Co-authored-by: Michael Neale --- crates/buzz-agent/src/loop_drive.rs | 76 +++++++++++++++++++++++++++++ crates/buzz-agent/src/ops.rs | 44 +++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/crates/buzz-agent/src/loop_drive.rs b/crates/buzz-agent/src/loop_drive.rs index 3d446d3b19..9742295a55 100644 --- a/crates/buzz-agent/src/loop_drive.rs +++ b/crates/buzz-agent/src/loop_drive.rs @@ -569,6 +569,82 @@ pub(crate) fn max_reflections() -> usize { mod tests { use super::*; + fn turn_state() -> crate::turn_state::TurnState { + crate::turn_state::TurnState::new("s".to_string(), std::path::PathBuf::from("/tmp"), None) + } + + /// The loop treats "applied but changed nothing" as a reason to stop + /// looking. If an ignored effect reported progress the start machine would + /// spin forever, and a real change that reported none would end the turn + /// with the model's work unseen. + #[test] + fn only_effects_that_change_state_count_as_progress() { + use goose::agents::state_machine::StateEffect; + + let mut state = turn_state(); + assert!( + apply_effects( + &mut state, + vec![StateEffect::AppendMessage(Message::user().with_text("hi"))] + ), + "an appended message is progress" + ); + assert_eq!(state.conversation().len(), 1); + + assert!( + !apply_effects(&mut state, vec![]), + "no effects is not progress" + ); + + // goose's own operations emit effects buzz has no use for. Ignoring + // them is correct, but they must not read as progress. + assert!( + !apply_effects( + &mut state, + vec![StateEffect::PatchToolRequestMeta { + tool_call_id: "x".into(), + patch: serde_json::json!({}), + }] + ), + "an effect this loop ignores is not progress" + ); + assert_eq!(state.conversation().len(), 1, "and it changed nothing"); + } + + /// Compaction replaces the conversation rather than appending to it, and + /// must clear the running token total -- that number described a + /// conversation that no longer exists, and carrying it forward would make + /// goose think the window is still nearly full and compact again. + #[test] + fn compaction_replaces_the_conversation_and_resets_the_token_total() { + use goose::agents::state_machine::StateEffect; + + let mut state = turn_state(); + state.push(Message::user().with_text("one")); + state.push(Message::assistant().with_text("two")); + state.set_total_tokens(Some(190_000)); + + let compacted = Conversation::new_unvalidated(vec![Message::user().with_text("summary")]); + assert!(apply_effects( + &mut state, + vec![StateEffect::ReplaceConversation { + conversation: compacted, + usage: None, + }] + )); + + assert_eq!(state.conversation().len(), 1); + assert_eq!( + state.conversation().messages()[0].as_concat_text(), + "summary" + ); + assert_eq!( + state.session().usage.total_tokens, + None, + "a stale total would re-trigger compaction immediately" + ); + } + #[test] fn merge_coalesces_consecutive_text() { let mut target = Message::assistant().with_text("hel"); diff --git a/crates/buzz-agent/src/ops.rs b/crates/buzz-agent/src/ops.rs index 02a2a19948..42ba119ef4 100644 --- a/crates/buzz-agent/src/ops.rs +++ b/crates/buzz-agent/src/ops.rs @@ -854,6 +854,50 @@ mod tests { assert!(matches!(result, OperationResult::NotApplicable)); } + #[tokio::test] + async fn steering_appends_queued_messages_and_drains_them() { + let steers = crate::steer::SteerQueue::new(); + steers + .push(Message::user().with_text("actually, do X")) + .await; + let op = BuzzSteerOperation::new(steers.clone()); + + match op + .run(&Session::default(), &conversation(1), &emitter()) + .await + .unwrap() + { + OperationResult::Applied(step) => { + assert_eq!(step.effects.len(), 1); + assert!(!step.yield_to_client, "a steer must not end the turn"); + } + OperationResult::NotApplicable => panic!("expected the steer to apply"), + } + + // Drained, not peeked: a steer delivered twice would repeat the + // instruction to the model on the next round. + assert!(matches!( + op.run(&Session::default(), &conversation(1), &emitter()) + .await + .unwrap(), + OperationResult::NotApplicable + )); + } + + #[tokio::test] + async fn an_empty_steer_queue_does_not_apply() { + // Must be NotApplicable rather than an empty Applied: the loop treats + // "applied but changed nothing" as a reason to look again, and an + // always-applying operation would spin it. + let op = BuzzSteerOperation::new(crate::steer::SteerQueue::new()); + assert!(matches!( + op.run(&Session::default(), &conversation(1), &emitter()) + .await + .unwrap(), + OperationResult::NotApplicable + )); + } + #[tokio::test] async fn a_zero_cap_disables_the_veto_entirely() { // Documented behaviour on main: BUZZ_AGENT_STOP_MAX_REJECTIONS=0 turns