Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
356 changes: 330 additions & 26 deletions crates/buzz-acp/src/acp.rs

Large diffs are not rendered by default.

145 changes: 138 additions & 7 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -906,16 +906,27 @@ fn handle_cancel_turn_control(
return;
};

let fired = signal_in_flight_task(pool, channel_id, ControlSignal::Cancel);
let status = if fired { "sent" } else { "no_active_turn" };
let Some(expected_session_id) = payload.get("sessionId").and_then(|value| value.as_str())
else {
tracing::warn!("observer cancel_turn control frame missing sessionId");
return;
};
let Some(expected_turn_id) = payload.get("turnId").and_then(|value| value.as_str()) else {
tracing::warn!("observer cancel_turn control frame missing turnId");
return;
};

let fired =
signal_expected_in_flight_task(pool, channel_id, expected_turn_id, ControlSignal::Cancel);
let status = if fired { "sent" } else { "context_mismatch" };
if let Some(observer) = observer {
observer.emit(
"control_result",
None,
&observer::ObserverContext {
channel_id: Some(channel_id.to_string()),
session_id: None,
turn_id: None,
session_id: Some(expected_session_id.to_string()),
turn_id: Some(expected_turn_id.to_string()),
started_at: None,
},
serde_json::json!({
Expand Down Expand Up @@ -2797,6 +2808,28 @@ fn signal_in_flight_task(
false
}

/// Send a control signal only when the signed observer context still names
/// the exact in-flight turn. A delayed control frame cannot cancel its successor.
fn signal_expected_in_flight_task(
pool: &mut AgentPool,
channel_id: uuid::Uuid,
expected_turn_id: &str,
mode: ControlSignal,
) -> bool {
let entry = pool
.task_map_mut()
.values_mut()
.find(|meta| meta.channel_id == Some(channel_id) && meta.turn_id == expected_turn_id);
if let Some(meta) = entry {
if let Some(tx) = meta.control_tx.take() {
tracing::info!(channel = %channel_id, turn = expected_turn_id, ?mode, "context-bound control signal sent");
let _ = tx.send(mode);
return true;
}
}
false
}

/// Attempt the non-cancelling (ACP) steer for a freshly-queued event.
///
/// Caller invariants:
Expand Down Expand Up @@ -4177,10 +4210,37 @@ async fn run_models(args: ModelsArgs) -> Result<()> {
}

fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
let browser = std::env::var("BUZZ_ACP_BROWSER_MCP_COMMAND")
.ok()
.filter(|command| !command.is_empty())
.map(|command| {
let args = std::env::var("BUZZ_ACP_BROWSER_MCP_ARGS")
.ok()
.and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())
.unwrap_or_default();
(command, args)
});
build_mcp_servers_with_browser(config, browser)
}

fn build_mcp_servers_with_browser(
config: &Config,
browser: Option<(String, Vec<String>)>,
) -> Vec<McpServer> {
let mut servers = Vec::new();
if let Some((command, args)) = browser {
servers.push(McpServer {
name: "playwright".into(),
command,
args,
env: vec![],
});
}

if config.mcp_command.is_empty() {
return vec![];
return servers;
}
vec![McpServer {
servers.push(McpServer {
name: std::path::Path::new(&config.mcp_command)
.file_stem()
.and_then(|s| s.to_str())
Expand Down Expand Up @@ -4230,7 +4290,8 @@ fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
}
env
},
}]
});
servers
}

#[cfg(test)]
Expand Down Expand Up @@ -4391,6 +4452,39 @@ mod owner_control_command_tests {
ControlSignal::Rotate
));
}

#[tokio::test]
async fn expected_turn_signal_rejects_stale_turn_without_consuming_control() {
let mut pool = AgentPool::from_slots(vec![]);
let channel_id = Uuid::new_v4();
let (control_tx, control_rx) = tokio::sync::oneshot::channel();
let abort_handle = pool.join_set.spawn(async {});
pool.task_map_mut().insert(
abort_handle.id(),
pool::TaskMeta {
agent_index: 0,
channel_id: Some(channel_id),
turn_id: "current-turn".to_string(),
recoverable_batch: None,
control_tx: Some(control_tx),
steer_tx: None,
},
);

assert!(!signal_expected_in_flight_task(
&mut pool,
channel_id,
"stale-turn",
ControlSignal::Cancel,
));
assert!(signal_expected_in_flight_task(
&mut pool,
channel_id,
"current-turn",
ControlSignal::Cancel,
));
assert_eq!(control_rx.await.unwrap(), ControlSignal::Cancel);
}
}

#[cfg(test)]
Expand Down Expand Up @@ -5184,6 +5278,43 @@ mod build_mcp_servers_tests {
"Path::new(\".\").file_stem() is None — should fall back to \"mcp\""
);
}

#[test]
fn browser_mcp_is_added_alongside_dev_mcp() {
let config = test_config();
let servers = build_mcp_servers_with_browser(
&config,
Some((
"/opt/homebrew/bin/npx".into(),
vec![
"--yes".into(),
"@playwright/mcp@0.0.78".into(),
"--browser".into(),
"chrome".into(),
"--isolated".into(),
],
)),
);

assert_eq!(servers.len(), 2);
assert_eq!(servers[0].name, "playwright");
assert_eq!(servers[0].command, "/opt/homebrew/bin/npx");
assert_eq!(servers[0].args[1], "@playwright/mcp@0.0.78");
assert_eq!(servers[1].name, "test-mcp-server");
}

#[test]
fn browser_mcp_works_without_dev_mcp() {
let mut config = test_config();
config.mcp_command.clear();
let servers = build_mcp_servers_with_browser(
&config,
Some(("npx".into(), vec!["@playwright/mcp@0.0.78".into()])),
);

assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "playwright");
}
}

#[cfg(test)]
Expand Down
63 changes: 63 additions & 0 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,10 @@ async fn create_session_and_apply_model(
}),
);

// Keep a harness-side enforcement fallback when an adapter cannot apply
// its native mode.
agent.acp.set_permission_mode(ctx.permission_mode.clone());

// Apply permission mode if not the agent's built-in default AND the agent
// advertises the requested mode in session/new. Agents that don't support
// the mode (e.g., goose crashes on unrecognized set_config_option values)
Expand Down Expand Up @@ -3975,6 +3979,65 @@ mod tests {
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use serde_json::json;

#[tokio::test]
async fn unsupported_native_lockdown_still_rejects_permission_requests() {
let script = r#"
IFS= read -r session_new
case "$session_new" in
*'"method":"session/new"'*) ;;
*) exit 10 ;;
esac
printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"sessionId":"fallback-session","modes":{"availableModes":[{"id":"default"}]}}}'

IFS= read -r prompt
case "$prompt" in
*'"method":"session/prompt"'*) ;;
*) exit 11 ;;
esac
printf '%s\n' '{"jsonrpc":"2.0","id":99,"method":"session/request_permission","params":{"options":[{"optionId":"allow","kind":"allow_once"},{"optionId":"reject","kind":"reject_once"}]}}'

IFS= read -r decision
case "$decision" in
*'"id":99'*'"optionId":"reject"'*) ;;
*) exit 12 ;;
esac
printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"stopReason":"end_turn"}}'
"#;
let acp = AcpClient::spawn("bash", &["-c".to_string(), script.to_string()], &[], false)
.await
.expect("failed to spawn fake ACP agent");
let mut agent = OwnedAgent {
index: 0,
acp,
state: SessionState::default(),
model_capabilities: None,
desired_model: None,
model_overridden: false,
agent_name: "fake-agent".into(),
goose_system_prompt_supported: None,
protocol_version: 2,
};
let mut ctx = make_prompt_context_no_owner();
ctx.permission_mode = PermissionMode::DontAsk;

let session_id = create_session_and_apply_model(&mut agent, &ctx, None, None, None)
.await
.expect("session creation should succeed without native dontAsk support");
assert_eq!(session_id, "fallback-session");

let stop = agent
.acp
.session_prompt_with_idle_timeout(
&session_id,
"exercise fallback",
Duration::from_secs(2),
Duration::from_secs(5),
)
.await
.expect("harness fallback should reject and let the turn complete");
assert_eq!(stop, StopReason::EndTurn);
}

// These pin the initial_message dispatch path (run_prompt_task, ~line 855):
// a legacy agent WITH a base_prompt must get [Base] prepended to the user
// message. This is the exact regression that shipped in the round-2 bug.
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export default defineConfig({
"**/profile-active-turn.spec.ts",
"**/config-bridge-screenshots.spec.ts",
"**/observer-feed-screenshots.spec.ts",
"**/guardian-findings.spec.ts",
"**/core-memory-screenshots.spec.ts",
"**/activity-scope-label-screenshots.spec.ts",
"**/welcome-agent-modal-screenshots.spec.ts",
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ mod media_transcode;
pub(crate) mod mesh_llm;
mod messages;
mod notifications;
pub(crate) mod numbat_findings;
mod observer_archive;
mod os_idle;
pub mod pairing;
Expand Down Expand Up @@ -89,6 +90,7 @@ pub use media_download::*;
pub use mesh_llm::*;
pub use messages::*;
pub use notifications::*;
pub use numbat_findings::*;
pub use observer_archive::*;
pub use os_idle::*;
pub use pairing::*;
Expand Down
Loading