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 <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Michael Neale
2026-08-12 16:15:04 +10:00
parent 60e63a065d
commit e0d5f20475
2 changed files with 120 additions and 0 deletions

View File

@@ -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");

View File

@@ -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