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:
tlongwell-block
2026-06-09 10:02:21 -04:00
committed by GitHub
co-authored by npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
parent bfafdd46b2
commit 7b854971c6
3 changed files with 286 additions and 10 deletions
+67 -6
View File
@@ -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;
+33 -4
View File
@@ -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,
+186
View File
@@ -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
);
}
}