mirror of
https://github.com/block/buzz.git
synced 2026-11-08 10:21:46 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user