Skip to content

Commit 020a765

Browse files
author
Larry
committed
test(acp): prove DM steer through the dispatch handoff
The DM native-steer rule reads the running prompt's classification from a cell the queue hands to run_prompt_task. Drive that real path: dispatch a DM turn through dispatch_pending to a held ACP agent with DM channel metadata, wait for the prompt to reach the agent, then send a top-level follow-up through the listener. It must steer natively and not cancel. The test fails if run_prompt_task stops recording the classification or if dispatch passes a detached cell. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
1 parent 7c78810 commit 020a765

2 files changed

Lines changed: 147 additions & 2 deletions

File tree

‎crates/buzz-acp/src/lib.rs‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10196,6 +10196,151 @@ mod edit_native_steer_tests {
1019610196
);
1019710197
}
1019810198

10199+
/// The DM steer rule depends on the running prompt's own classification,
10200+
/// which travels from the queue turn through `dispatch_pending` into
10201+
/// `run_prompt_task`, where formatting the prompt records it. Drive that
10202+
/// production handoff with DM channel metadata, then the real listener
10203+
/// admission: a top-level follow-up must be steered natively.
10204+
#[tokio::test]
10205+
async fn dispatched_dm_turn_records_classification_for_native_steer() {
10206+
let channel_id = Uuid::new_v4();
10207+
let capture = std::env::temp_dir().join(format!(
10208+
"buzz-acp-dm-steer-handoff-{}.ndjson",
10209+
Uuid::new_v4()
10210+
));
10211+
let quoted_capture = capture.to_string_lossy().replace('\'', "'\\''");
10212+
// Record each request and never answer: the prompt stays in flight.
10213+
let script = format!(
10214+
r#"while IFS= read -r line; do printf '%s\n' "$line" >> '{quoted_capture}'; done"#
10215+
);
10216+
let acp = acp::AcpClient::spawn("bash", &["-c".into(), script], &[], false)
10217+
.await
10218+
.expect("spawn holding ACP");
10219+
let ingress = |event: nostr::Event| NormalListenerIngress {
10220+
buzz_event: relay::BuzzEvent {
10221+
connection_generation: 0,
10222+
channel_id,
10223+
event,
10224+
},
10225+
effective_author: "author".into(),
10226+
prompt_tag: "@mention".into(),
10227+
edit: None,
10228+
};
10229+
let running = ingress(message(None));
10230+
let scope = running.session_scope(scope::SessionPolicy::Channel, true);
10231+
let mut agent = pool::OwnedAgent {
10232+
index: 0,
10233+
acp,
10234+
state: pool::SessionState::default(),
10235+
model_capabilities: None,
10236+
desired_model: None,
10237+
model_overridden: false,
10238+
desired_model_request_id: None,
10239+
desired_model_pending_ack: false,
10240+
startup_effort: None,
10241+
agent_name: "dm-steer-test-agent".into(),
10242+
goose_system_prompt_supported: None,
10243+
protocol_version: 1,
10244+
};
10245+
agent
10246+
.state
10247+
.sessions
10248+
.insert(scope.clone(), "live-session".into());
10249+
// The relay has no newer metadata, project, or history: every query
10250+
// returns no events, so the prompt keeps the startup DM metadata.
10251+
let relay = tokio::net::TcpListener::bind("127.0.0.1:0")
10252+
.await
10253+
.expect("bind relay fixture");
10254+
let base_url = format!("http://{}", relay.local_addr().unwrap());
10255+
let relay = tokio::spawn(async move {
10256+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
10257+
loop {
10258+
let (mut socket, _) = relay.accept().await.expect("accept relay query");
10259+
tokio::spawn(async move {
10260+
let mut request = [0; 16384];
10261+
let _ = socket.read(&mut request).await;
10262+
let _ = socket
10263+
.write_all(
10264+
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
10265+
)
10266+
.await;
10267+
});
10268+
}
10269+
});
10270+
let mut ctx = pool::tests::make_prompt_context_no_owner();
10271+
ctx.rest_client.base_url = base_url;
10272+
ctx.channel_info = pool::ChannelInfoResolver::new(
10273+
HashMap::from([(
10274+
channel_id,
10275+
relay::ChannelInfo {
10276+
name: "dm".into(),
10277+
channel_type: "dm".into(),
10278+
description: None,
10279+
},
10280+
)]),
10281+
ctx.rest_client.clone(),
10282+
);
10283+
let ctx = Arc::new(ctx);
10284+
let mut pool = AgentPool::from_slots(vec![Some(agent)]);
10285+
let mut queue = EventQueue::new(config::DedupMode::Queue);
10286+
running.push(&mut queue, scope.clone());
10287+
let mut last_activity = tokio::time::Instant::now();
10288+
let dispatched = dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity, None);
10289+
assert_eq!(dispatched.len(), 1, "the DM turn is dispatched");
10290+
10291+
// The prompt reaches the agent only after `run_prompt_task` has
10292+
// formatted it, so its classification is settled by then.
10293+
tokio::time::timeout(Duration::from_secs(10), async {
10294+
loop {
10295+
let prompted = std::fs::read_to_string(&capture)
10296+
.unwrap_or_default()
10297+
.contains("\"session/prompt\"");
10298+
if prompted {
10299+
break;
10300+
}
10301+
tokio::time::sleep(Duration::from_millis(10)).await;
10302+
}
10303+
})
10304+
.await
10305+
.expect("dispatched DM prompt reaches the agent");
10306+
10307+
// Observe the listener's decision through the running task's own
10308+
// steer and control channels.
10309+
let (control_tx, mut control_rx) = tokio::sync::oneshot::channel();
10310+
let (steer_tx, mut steer_rx) = tokio::sync::mpsc::channel(1);
10311+
let meta = pool
10312+
.task_map_mut()
10313+
.values_mut()
10314+
.find(|meta| meta.scope.as_ref() == Some(&scope))
10315+
.expect("running DM task");
10316+
meta.control_tx = Some(control_tx);
10317+
meta.steer_tx = Some(steer_tx);
10318+
10319+
let (ack_tx, _ack_rx) = mpsc::unbounded_channel();
10320+
ingress(message(None))
10321+
.push(&mut queue, scope.clone())
10322+
.steer_or_interrupt(
10323+
MultipleEventHandling::Steer,
10324+
None,
10325+
&mut pool,
10326+
&mut queue,
10327+
&ack_tx,
10328+
);
10329+
let steer = steer_rx.try_recv().ok();
10330+
let control = control_rx.try_recv().ok();
10331+
10332+
pool.join_set.abort_all();
10333+
while pool.join_set.join_next().await.is_some() {}
10334+
relay.abort();
10335+
let _ = std::fs::remove_file(&capture);
10336+
10337+
assert!(steer.is_some(), "DM follow-up is sent as a native steer");
10338+
assert_eq!(
10339+
control, None,
10340+
"native steer must not cancel the running turn"
10341+
);
10342+
}
10343+
1019910344
/// A DM thread reply needs a `--reply-to` the running top-level turn's
1020010345
/// `<context>` does not carry, so it takes the cancel+merge path.
1020110346
#[tokio::test]

‎crates/buzz-acp/src/pool.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5665,7 +5665,7 @@ async fn clear_reactions(rest: crate::relay::RestClient, event_ids: Vec<String>)
56655665
}
56665666

56675667
#[cfg(test)]
5668-
mod tests {
5668+
pub(crate) mod tests {
56695669
use super::*;
56705670
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
56715671
use serde_json::json;
@@ -10414,7 +10414,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'"
1041410414
);
1041510415
}
1041610416

10417-
pub(super) fn make_prompt_context_no_owner() -> PromptContext {
10417+
pub(crate) fn make_prompt_context_no_owner() -> PromptContext {
1041810418
let agent_keys = nostr::Keys::generate();
1041910419
make_prompt_context_impl(&agent_keys, None)
1042010420
}

0 commit comments

Comments
 (0)