mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(acp): pass slash commands through to ACP connectors (#919)
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
parent
bfafdd46b2
commit
7b854971c6
@@ -360,12 +360,29 @@ impl AcpClient {
|
||||
idle_timeout: std::time::Duration,
|
||||
max_duration: std::time::Duration,
|
||||
) -> Result<StopReason, AcpError> {
|
||||
let params = serde_json::json!({
|
||||
"sessionId": session_id,
|
||||
"prompt": [
|
||||
{ "type": "text", "text": prompt_text }
|
||||
]
|
||||
});
|
||||
self.session_prompt_blocks_with_idle_timeout(
|
||||
session_id,
|
||||
std::slice::from_ref(&prompt_text),
|
||||
idle_timeout,
|
||||
max_duration,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Like [`session_prompt_with_idle_timeout`](Self::session_prompt_with_idle_timeout),
|
||||
/// but sends each entry in `prompt_blocks` as a separate text content block.
|
||||
///
|
||||
/// Used for slash-command pass-through: ACP connectors detect commands via
|
||||
/// the **first** block's text starting with `/`, so the harness sends
|
||||
/// `["/cmd args", "<sprout context>"]` instead of one wrapped block.
|
||||
pub async fn session_prompt_blocks_with_idle_timeout(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
prompt_blocks: &[&str],
|
||||
idle_timeout: std::time::Duration,
|
||||
max_duration: std::time::Duration,
|
||||
) -> Result<StopReason, AcpError> {
|
||||
let params = build_prompt_params(session_id, prompt_blocks);
|
||||
let hard_deadline = tokio::time::Instant::now() + max_duration;
|
||||
self.current_hard_deadline = Some(hard_deadline);
|
||||
|
||||
@@ -916,6 +933,20 @@ impl AcpClient {
|
||||
tracing::debug!(target: "acp::thought", "{text}");
|
||||
}
|
||||
}
|
||||
"available_commands_update" => {
|
||||
// Advertised slash commands (ACP slash-commands extension).
|
||||
// Logged for observability; UI surfacing is a follow-up.
|
||||
let names: Vec<&str> = update["availableCommands"]
|
||||
.as_array()
|
||||
.map(|cmds| cmds.iter().filter_map(|c| c["name"].as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
tracing::info!(
|
||||
target: "acp::update",
|
||||
"available_commands_update: {} commands [{}]",
|
||||
names.len(),
|
||||
names.join(", ")
|
||||
);
|
||||
}
|
||||
other => {
|
||||
tracing::debug!(target: "acp::update", "session/update: {other}");
|
||||
}
|
||||
@@ -1020,6 +1051,18 @@ impl AcpClient {
|
||||
|
||||
// ─── Permission response constructors ────────────────────────────────────────
|
||||
|
||||
/// Build `session/prompt` params from one or more text content blocks.
|
||||
fn build_prompt_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json::Value {
|
||||
let blocks: Vec<serde_json::Value> = prompt_blocks
|
||||
.iter()
|
||||
.map(|text| serde_json::json!({ "type": "text", "text": text }))
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"sessionId": session_id,
|
||||
"prompt": blocks,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a JSON-RPC permission response with `outcome: "selected"`.
|
||||
fn permission_response_selected(id: &serde_json::Value, option_id: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
@@ -1400,6 +1443,24 @@ mod tests {
|
||||
assert_eq!(prompt[0]["text"].as_str(), Some(prompt_text));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_prompt_slash_command_two_block_format() {
|
||||
// Slash-command pass-through: bare command first, wrapped context second.
|
||||
let params = build_prompt_params(
|
||||
"sess_abc123",
|
||||
&[
|
||||
"/goal ship it",
|
||||
"[Sprout event: @mention]\nContent: @Eva /goal ship it",
|
||||
],
|
||||
);
|
||||
let prompt = params["prompt"].as_array().unwrap();
|
||||
assert_eq!(prompt.len(), 2);
|
||||
assert_eq!(prompt[0]["type"].as_str(), Some("text"));
|
||||
assert_eq!(prompt[0]["text"].as_str(), Some("/goal ship it"));
|
||||
assert!(prompt[0]["text"].as_str().unwrap().starts_with('/'));
|
||||
assert_eq!(prompt[1]["type"].as_str(), Some("text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_response_selected_format() {
|
||||
let id: u64 = 5;
|
||||
|
||||
@@ -918,6 +918,12 @@ pub async fn run_prompt_task(
|
||||
|
||||
// ── Build prompt text (with optional context fetch) ──────────────────
|
||||
|
||||
// When the batch is a single slash-command message (e.g. "@Eva /goal …"),
|
||||
// `slash_command` holds the bare command. It is sent as the FIRST prompt
|
||||
// content block so ACP connectors' slash-command detection
|
||||
// (`prompt[0].text.startsWith("/")`) fires; the wrapped Sprout context
|
||||
// follows as a second block.
|
||||
let mut slash_command: Option<String> = None;
|
||||
let prompt_text = if let Some(text) = prompt_text {
|
||||
// Pre-built prompt (heartbeat or legacy path).
|
||||
text
|
||||
@@ -941,6 +947,22 @@ pub async fn run_prompt_task(
|
||||
let profile_lookup =
|
||||
fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await;
|
||||
|
||||
let known_names: Vec<&str> = profile_lookup
|
||||
.iter()
|
||||
.flat_map(|lookup| lookup.values())
|
||||
.flat_map(|p| [p.display_name.as_deref(), p.nip05_handle.as_deref()])
|
||||
.flatten()
|
||||
.collect();
|
||||
slash_command = crate::queue::slash_command_for_batch(b, &known_names);
|
||||
if let Some(ref cmd) = slash_command {
|
||||
tracing::info!(
|
||||
target: "pool::prompt",
|
||||
channel = %b.channel_id,
|
||||
command = %cmd,
|
||||
"slash-command pass-through"
|
||||
);
|
||||
}
|
||||
|
||||
let agent_core_section = agent.state.core_sections.get(&b.channel_id).cloned();
|
||||
crate::queue::format_prompt(
|
||||
b,
|
||||
@@ -979,6 +1001,13 @@ pub async fn run_prompt_task(
|
||||
|
||||
// ── Send the actual prompt ────────────────────────────────────────────
|
||||
|
||||
// Slash-command pass-through sends two text blocks: the bare command
|
||||
// first (so connector detection fires), then the wrapped Sprout context.
|
||||
let prompt_blocks: Vec<&str> = match slash_command {
|
||||
Some(ref cmd) => vec![cmd.as_str(), prompt_text.as_str()],
|
||||
None => vec![prompt_text.as_str()],
|
||||
};
|
||||
|
||||
// ── Cancel-aware prompt dispatch ──────────────────────────────────────
|
||||
// When cancel_rx is Some (channel tasks), wrap the prompt in select! so
|
||||
// the main loop can interrupt it. Heartbeats (cancel_rx=None) take the
|
||||
@@ -988,9 +1017,9 @@ pub async fn run_prompt_task(
|
||||
// Heartbeat / non-cancellable path.
|
||||
agent
|
||||
.acp
|
||||
.session_prompt_with_idle_timeout(
|
||||
.session_prompt_blocks_with_idle_timeout(
|
||||
&session_id,
|
||||
&prompt_text,
|
||||
&prompt_blocks,
|
||||
ctx.idle_timeout,
|
||||
ctx.max_turn_duration,
|
||||
)
|
||||
@@ -999,9 +1028,9 @@ pub async fn run_prompt_task(
|
||||
Some(rx) => {
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = agent.acp.session_prompt_with_idle_timeout(
|
||||
result = agent.acp.session_prompt_blocks_with_idle_timeout(
|
||||
&session_id,
|
||||
&prompt_text,
|
||||
&prompt_blocks,
|
||||
ctx.idle_timeout,
|
||||
ctx.max_turn_duration,
|
||||
) => result,
|
||||
|
||||
@@ -628,6 +628,87 @@ pub fn parse_thread_tags(event: &Event) -> ThreadTags {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slash command detection ───────────────────────────────────────────────────
|
||||
|
||||
/// Extract a leading slash command from message content.
|
||||
///
|
||||
/// ACP connectors (claude-agent-acp, codex-acp) detect slash commands by
|
||||
/// checking whether the **first** prompt content block starts with `/`. Sprout
|
||||
/// users must @mention an agent to reach it, so the wire content is typically
|
||||
/// `"@Eva /goal ship it"`. This strips leading mention tokens — `@word`,
|
||||
/// multi-word display names from `known_names`, and NIP-27 `nostr:npub1…` /
|
||||
/// `nostr:nprofile1…` references — and returns the remainder iff it is a
|
||||
/// slash command.
|
||||
///
|
||||
/// Returns `Some("/goal ship it")` when the first non-mention token starts
|
||||
/// with `/` followed by an ASCII alphanumeric; `None` otherwise. A `/`
|
||||
/// appearing later in the text (e.g. `"@Eva see /tmp/foo"`) never matches.
|
||||
pub fn extract_slash_command(content: &str, known_names: &[&str]) -> Option<String> {
|
||||
// Longest-first so "Dawn Smith" wins over "Dawn".
|
||||
let mut names: Vec<&str> = known_names
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.collect();
|
||||
names.sort_by_key(|n| std::cmp::Reverse(n.len()));
|
||||
|
||||
let mut rest = content.trim_start();
|
||||
loop {
|
||||
if rest.starts_with("nostr:npub1") || rest.starts_with("nostr:nprofile1") {
|
||||
// NIP-27 inline reference — skip the whole token.
|
||||
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
rest = rest[end..].trim_start();
|
||||
} else if let Some(after_at) = rest.strip_prefix('@') {
|
||||
// Known display names first (longest match wins, case-insensitive,
|
||||
// must end at whitespace or end-of-string), then a single-word
|
||||
// token of the characters Sprout allows in plain @mentions.
|
||||
let name_len = names
|
||||
.iter()
|
||||
.find_map(|name| {
|
||||
let candidate = after_at.get(..name.len())?;
|
||||
if !candidate.eq_ignore_ascii_case(name) {
|
||||
return None;
|
||||
}
|
||||
match after_at[name.len()..].chars().next() {
|
||||
None => Some(name.len()),
|
||||
Some(c) if c.is_whitespace() => Some(name.len()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
let len = after_at
|
||||
.find(|c: char| {
|
||||
!(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|
||||
})
|
||||
.unwrap_or(after_at.len());
|
||||
(len > 0).then_some(len)
|
||||
});
|
||||
match name_len {
|
||||
Some(len) => rest = after_at[len..].trim_start(),
|
||||
None => return None, // bare '@' — not a mention
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut chars = rest.chars();
|
||||
(chars.next() == Some('/') && chars.next().is_some_and(|c| c.is_ascii_alphanumeric()))
|
||||
.then(|| rest.to_string())
|
||||
}
|
||||
|
||||
/// Return the slash command for a batch, if it qualifies for pass-through.
|
||||
///
|
||||
/// Pass-through is deliberately conservative: exactly one event, no cancelled
|
||||
/// carryover (a cancel + re-prompt needs the merged context format), and
|
||||
/// content that is a slash command after leading mentions.
|
||||
pub fn slash_command_for_batch(batch: &FlushBatch, known_names: &[&str]) -> Option<String> {
|
||||
if batch.events.len() != 1 || !batch.cancelled_events.is_empty() {
|
||||
return None;
|
||||
}
|
||||
extract_slash_command(&batch.events[0].event.content, known_names)
|
||||
}
|
||||
|
||||
// ── Prompt formatting ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Conversation context fetched by the harness before prompting.
|
||||
@@ -3003,4 +3084,109 @@ mod tests {
|
||||
"batched prompt where last event is top-level should NOT include reply instruction"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Slash command extraction ──────────────────────────────────────────────
|
||||
|
||||
/// Build a single-event FlushBatch with the given content.
|
||||
fn make_single_batch(content: &str) -> FlushBatch {
|
||||
FlushBatch {
|
||||
channel_id: Uuid::new_v4(),
|
||||
events: vec![BatchEvent {
|
||||
event: make_event(content),
|
||||
prompt_tag: "test".into(),
|
||||
received_at: Instant::now(),
|
||||
}],
|
||||
cancelled_events: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_slash_command_basic() {
|
||||
assert_eq!(
|
||||
extract_slash_command("/init", &[]),
|
||||
Some("/init".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_slash_command("@Eva /goal ship it", &[]),
|
||||
Some("/goal ship it".to_string())
|
||||
);
|
||||
// Multiple leading mentions.
|
||||
assert_eq!(
|
||||
extract_slash_command("@Eva @Max /review", &[]),
|
||||
Some("/review".to_string())
|
||||
);
|
||||
// NIP-27 inline reference.
|
||||
assert_eq!(
|
||||
extract_slash_command(
|
||||
"nostr:npub1xhqc4cnnln86lqxk983qulu8yxusfxfhntwl75es2jkvy5zvz26qzr0685 /status",
|
||||
&[]
|
||||
),
|
||||
Some("/status".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_slash_command_multi_word_display_name() {
|
||||
// "@Dawn Smith /goal" — "Smith /goal" would otherwise be prose.
|
||||
assert_eq!(
|
||||
extract_slash_command("@Dawn Smith /goal go", &["Dawn Smith", "Eva"]),
|
||||
Some("/goal go".to_string())
|
||||
);
|
||||
// Longest match wins over the single-word fallback.
|
||||
assert_eq!(
|
||||
extract_slash_command("@Dawn Smith /goal", &["Dawn"]),
|
||||
None,
|
||||
"single-word match leaves 'Smith /goal' — not a command"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_slash_command_rejects_non_commands() {
|
||||
// Slash not the first token after mentions.
|
||||
assert_eq!(extract_slash_command("@Eva see /tmp/foo", &[]), None);
|
||||
// Plain message.
|
||||
assert_eq!(extract_slash_command("@Eva hello", &[]), None);
|
||||
// Bare slash or non-alphanumeric after slash.
|
||||
assert_eq!(extract_slash_command("@Eva /", &[]), None);
|
||||
assert_eq!(extract_slash_command("@Eva //comment", &[]), None);
|
||||
// Dot-prefix is NOT a slash command.
|
||||
assert_eq!(extract_slash_command("@Eva .goal", &[]), None);
|
||||
// Bare '@' is not a mention.
|
||||
assert_eq!(extract_slash_command("@ /goal", &[]), None);
|
||||
// Email-like text shouldn't strip.
|
||||
assert_eq!(extract_slash_command("user@host.com /x", &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slash_command_for_batch_gating() {
|
||||
// Single qualifying event → pass-through.
|
||||
assert_eq!(
|
||||
slash_command_for_batch(&make_single_batch("@Eva /init"), &[]),
|
||||
Some("/init".to_string())
|
||||
);
|
||||
|
||||
// Multi-event batch → no pass-through.
|
||||
let mut multi = make_single_batch("@Eva /init");
|
||||
multi.events.push(BatchEvent {
|
||||
event: make_event("another message"),
|
||||
prompt_tag: "test".into(),
|
||||
received_at: Instant::now(),
|
||||
});
|
||||
assert_eq!(slash_command_for_batch(&multi, &[]), None);
|
||||
|
||||
// Cancelled carryover → no pass-through.
|
||||
let mut cancelled = make_single_batch("@Eva /init");
|
||||
cancelled.cancelled_events.push(BatchEvent {
|
||||
event: make_event("interrupted"),
|
||||
prompt_tag: "test".into(),
|
||||
received_at: Instant::now(),
|
||||
});
|
||||
assert_eq!(slash_command_for_batch(&cancelled, &[]), None);
|
||||
|
||||
// Non-command single event → no pass-through.
|
||||
assert_eq!(
|
||||
slash_command_for_batch(&make_single_batch("@Eva hello"), &[]),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user