Skip to content

Commit 7b85497

Browse files
tlongwell-blocknpub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
andauthored
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>
1 parent bfafdd4 commit 7b85497

3 files changed

Lines changed: 286 additions & 10 deletions

File tree

crates/sprout-acp/src/acp.rs

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -360,12 +360,29 @@ impl AcpClient {
360360
idle_timeout: std::time::Duration,
361361
max_duration: std::time::Duration,
362362
) -> Result<StopReason, AcpError> {
363-
let params = serde_json::json!({
364-
"sessionId": session_id,
365-
"prompt": [
366-
{ "type": "text", "text": prompt_text }
367-
]
368-
});
363+
self.session_prompt_blocks_with_idle_timeout(
364+
session_id,
365+
std::slice::from_ref(&prompt_text),
366+
idle_timeout,
367+
max_duration,
368+
)
369+
.await
370+
}
371+
372+
/// Like [`session_prompt_with_idle_timeout`](Self::session_prompt_with_idle_timeout),
373+
/// but sends each entry in `prompt_blocks` as a separate text content block.
374+
///
375+
/// Used for slash-command pass-through: ACP connectors detect commands via
376+
/// the **first** block's text starting with `/`, so the harness sends
377+
/// `["/cmd args", "<sprout context>"]` instead of one wrapped block.
378+
pub async fn session_prompt_blocks_with_idle_timeout(
379+
&mut self,
380+
session_id: &str,
381+
prompt_blocks: &[&str],
382+
idle_timeout: std::time::Duration,
383+
max_duration: std::time::Duration,
384+
) -> Result<StopReason, AcpError> {
385+
let params = build_prompt_params(session_id, prompt_blocks);
369386
let hard_deadline = tokio::time::Instant::now() + max_duration;
370387
self.current_hard_deadline = Some(hard_deadline);
371388

@@ -916,6 +933,20 @@ impl AcpClient {
916933
tracing::debug!(target: "acp::thought", "{text}");
917934
}
918935
}
936+
"available_commands_update" => {
937+
// Advertised slash commands (ACP slash-commands extension).
938+
// Logged for observability; UI surfacing is a follow-up.
939+
let names: Vec<&str> = update["availableCommands"]
940+
.as_array()
941+
.map(|cmds| cmds.iter().filter_map(|c| c["name"].as_str()).collect())
942+
.unwrap_or_default();
943+
tracing::info!(
944+
target: "acp::update",
945+
"available_commands_update: {} commands [{}]",
946+
names.len(),
947+
names.join(", ")
948+
);
949+
}
919950
other => {
920951
tracing::debug!(target: "acp::update", "session/update: {other}");
921952
}
@@ -1020,6 +1051,18 @@ impl AcpClient {
10201051

10211052
// ─── Permission response constructors ────────────────────────────────────────
10221053

1054+
/// Build `session/prompt` params from one or more text content blocks.
1055+
fn build_prompt_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json::Value {
1056+
let blocks: Vec<serde_json::Value> = prompt_blocks
1057+
.iter()
1058+
.map(|text| serde_json::json!({ "type": "text", "text": text }))
1059+
.collect();
1060+
serde_json::json!({
1061+
"sessionId": session_id,
1062+
"prompt": blocks,
1063+
})
1064+
}
1065+
10231066
/// Build a JSON-RPC permission response with `outcome: "selected"`.
10241067
fn permission_response_selected(id: &serde_json::Value, option_id: &str) -> serde_json::Value {
10251068
serde_json::json!({
@@ -1400,6 +1443,24 @@ mod tests {
14001443
assert_eq!(prompt[0]["text"].as_str(), Some(prompt_text));
14011444
}
14021445

1446+
#[test]
1447+
fn session_prompt_slash_command_two_block_format() {
1448+
// Slash-command pass-through: bare command first, wrapped context second.
1449+
let params = build_prompt_params(
1450+
"sess_abc123",
1451+
&[
1452+
"/goal ship it",
1453+
"[Sprout event: @mention]\nContent: @Eva /goal ship it",
1454+
],
1455+
);
1456+
let prompt = params["prompt"].as_array().unwrap();
1457+
assert_eq!(prompt.len(), 2);
1458+
assert_eq!(prompt[0]["type"].as_str(), Some("text"));
1459+
assert_eq!(prompt[0]["text"].as_str(), Some("/goal ship it"));
1460+
assert!(prompt[0]["text"].as_str().unwrap().starts_with('/'));
1461+
assert_eq!(prompt[1]["type"].as_str(), Some("text"));
1462+
}
1463+
14031464
#[test]
14041465
fn permission_response_selected_format() {
14051466
let id: u64 = 5;

crates/sprout-acp/src/pool.rs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,12 @@ pub async fn run_prompt_task(
918918

919919
// ── Build prompt text (with optional context fetch) ──────────────────
920920

921+
// When the batch is a single slash-command message (e.g. "@Eva /goal …"),
922+
// `slash_command` holds the bare command. It is sent as the FIRST prompt
923+
// content block so ACP connectors' slash-command detection
924+
// (`prompt[0].text.startsWith("/")`) fires; the wrapped Sprout context
925+
// follows as a second block.
926+
let mut slash_command: Option<String> = None;
921927
let prompt_text = if let Some(text) = prompt_text {
922928
// Pre-built prompt (heartbeat or legacy path).
923929
text
@@ -941,6 +947,22 @@ pub async fn run_prompt_task(
941947
let profile_lookup =
942948
fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await;
943949

950+
let known_names: Vec<&str> = profile_lookup
951+
.iter()
952+
.flat_map(|lookup| lookup.values())
953+
.flat_map(|p| [p.display_name.as_deref(), p.nip05_handle.as_deref()])
954+
.flatten()
955+
.collect();
956+
slash_command = crate::queue::slash_command_for_batch(b, &known_names);
957+
if let Some(ref cmd) = slash_command {
958+
tracing::info!(
959+
target: "pool::prompt",
960+
channel = %b.channel_id,
961+
command = %cmd,
962+
"slash-command pass-through"
963+
);
964+
}
965+
944966
let agent_core_section = agent.state.core_sections.get(&b.channel_id).cloned();
945967
crate::queue::format_prompt(
946968
b,
@@ -979,6 +1001,13 @@ pub async fn run_prompt_task(
9791001

9801002
// ── Send the actual prompt ────────────────────────────────────────────
9811003

1004+
// Slash-command pass-through sends two text blocks: the bare command
1005+
// first (so connector detection fires), then the wrapped Sprout context.
1006+
let prompt_blocks: Vec<&str> = match slash_command {
1007+
Some(ref cmd) => vec![cmd.as_str(), prompt_text.as_str()],
1008+
None => vec![prompt_text.as_str()],
1009+
};
1010+
9821011
// ── Cancel-aware prompt dispatch ──────────────────────────────────────
9831012
// When cancel_rx is Some (channel tasks), wrap the prompt in select! so
9841013
// the main loop can interrupt it. Heartbeats (cancel_rx=None) take the
@@ -988,9 +1017,9 @@ pub async fn run_prompt_task(
9881017
// Heartbeat / non-cancellable path.
9891018
agent
9901019
.acp
991-
.session_prompt_with_idle_timeout(
1020+
.session_prompt_blocks_with_idle_timeout(
9921021
&session_id,
993-
&prompt_text,
1022+
&prompt_blocks,
9941023
ctx.idle_timeout,
9951024
ctx.max_turn_duration,
9961025
)
@@ -999,9 +1028,9 @@ pub async fn run_prompt_task(
9991028
Some(rx) => {
10001029
tokio::select! {
10011030
biased;
1002-
result = agent.acp.session_prompt_with_idle_timeout(
1031+
result = agent.acp.session_prompt_blocks_with_idle_timeout(
10031032
&session_id,
1004-
&prompt_text,
1033+
&prompt_blocks,
10051034
ctx.idle_timeout,
10061035
ctx.max_turn_duration,
10071036
) => result,

crates/sprout-acp/src/queue.rs

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,87 @@ pub fn parse_thread_tags(event: &Event) -> ThreadTags {
628628
}
629629
}
630630

631+
// ── Slash command detection ───────────────────────────────────────────────────
632+
633+
/// Extract a leading slash command from message content.
634+
///
635+
/// ACP connectors (claude-agent-acp, codex-acp) detect slash commands by
636+
/// checking whether the **first** prompt content block starts with `/`. Sprout
637+
/// users must @mention an agent to reach it, so the wire content is typically
638+
/// `"@Eva /goal ship it"`. This strips leading mention tokens — `@word`,
639+
/// multi-word display names from `known_names`, and NIP-27 `nostr:npub1…` /
640+
/// `nostr:nprofile1…` references — and returns the remainder iff it is a
641+
/// slash command.
642+
///
643+
/// Returns `Some("/goal ship it")` when the first non-mention token starts
644+
/// with `/` followed by an ASCII alphanumeric; `None` otherwise. A `/`
645+
/// appearing later in the text (e.g. `"@Eva see /tmp/foo"`) never matches.
646+
pub fn extract_slash_command(content: &str, known_names: &[&str]) -> Option<String> {
647+
// Longest-first so "Dawn Smith" wins over "Dawn".
648+
let mut names: Vec<&str> = known_names
649+
.iter()
650+
.copied()
651+
.filter(|n| !n.trim().is_empty())
652+
.collect();
653+
names.sort_by_key(|n| std::cmp::Reverse(n.len()));
654+
655+
let mut rest = content.trim_start();
656+
loop {
657+
if rest.starts_with("nostr:npub1") || rest.starts_with("nostr:nprofile1") {
658+
// NIP-27 inline reference — skip the whole token.
659+
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
660+
rest = rest[end..].trim_start();
661+
} else if let Some(after_at) = rest.strip_prefix('@') {
662+
// Known display names first (longest match wins, case-insensitive,
663+
// must end at whitespace or end-of-string), then a single-word
664+
// token of the characters Sprout allows in plain @mentions.
665+
let name_len = names
666+
.iter()
667+
.find_map(|name| {
668+
let candidate = after_at.get(..name.len())?;
669+
if !candidate.eq_ignore_ascii_case(name) {
670+
return None;
671+
}
672+
match after_at[name.len()..].chars().next() {
673+
None => Some(name.len()),
674+
Some(c) if c.is_whitespace() => Some(name.len()),
675+
_ => None,
676+
}
677+
})
678+
.or_else(|| {
679+
let len = after_at
680+
.find(|c: char| {
681+
!(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
682+
})
683+
.unwrap_or(after_at.len());
684+
(len > 0).then_some(len)
685+
});
686+
match name_len {
687+
Some(len) => rest = after_at[len..].trim_start(),
688+
None => return None, // bare '@' — not a mention
689+
}
690+
} else {
691+
break;
692+
}
693+
}
694+
695+
let mut chars = rest.chars();
696+
(chars.next() == Some('/') && chars.next().is_some_and(|c| c.is_ascii_alphanumeric()))
697+
.then(|| rest.to_string())
698+
}
699+
700+
/// Return the slash command for a batch, if it qualifies for pass-through.
701+
///
702+
/// Pass-through is deliberately conservative: exactly one event, no cancelled
703+
/// carryover (a cancel + re-prompt needs the merged context format), and
704+
/// content that is a slash command after leading mentions.
705+
pub fn slash_command_for_batch(batch: &FlushBatch, known_names: &[&str]) -> Option<String> {
706+
if batch.events.len() != 1 || !batch.cancelled_events.is_empty() {
707+
return None;
708+
}
709+
extract_slash_command(&batch.events[0].event.content, known_names)
710+
}
711+
631712
// ── Prompt formatting ─────────────────────────────────────────────────────────
632713

633714
/// Conversation context fetched by the harness before prompting.
@@ -3003,4 +3084,109 @@ mod tests {
30033084
"batched prompt where last event is top-level should NOT include reply instruction"
30043085
);
30053086
}
3087+
3088+
// ── Slash command extraction ──────────────────────────────────────────────
3089+
3090+
/// Build a single-event FlushBatch with the given content.
3091+
fn make_single_batch(content: &str) -> FlushBatch {
3092+
FlushBatch {
3093+
channel_id: Uuid::new_v4(),
3094+
events: vec![BatchEvent {
3095+
event: make_event(content),
3096+
prompt_tag: "test".into(),
3097+
received_at: Instant::now(),
3098+
}],
3099+
cancelled_events: vec![],
3100+
}
3101+
}
3102+
3103+
#[test]
3104+
fn test_extract_slash_command_basic() {
3105+
assert_eq!(
3106+
extract_slash_command("/init", &[]),
3107+
Some("/init".to_string())
3108+
);
3109+
assert_eq!(
3110+
extract_slash_command("@Eva /goal ship it", &[]),
3111+
Some("/goal ship it".to_string())
3112+
);
3113+
// Multiple leading mentions.
3114+
assert_eq!(
3115+
extract_slash_command("@Eva @Max /review", &[]),
3116+
Some("/review".to_string())
3117+
);
3118+
// NIP-27 inline reference.
3119+
assert_eq!(
3120+
extract_slash_command(
3121+
"nostr:npub1xhqc4cnnln86lqxk983qulu8yxusfxfhntwl75es2jkvy5zvz26qzr0685 /status",
3122+
&[]
3123+
),
3124+
Some("/status".to_string())
3125+
);
3126+
}
3127+
3128+
#[test]
3129+
fn test_extract_slash_command_multi_word_display_name() {
3130+
// "@Dawn Smith /goal" — "Smith /goal" would otherwise be prose.
3131+
assert_eq!(
3132+
extract_slash_command("@Dawn Smith /goal go", &["Dawn Smith", "Eva"]),
3133+
Some("/goal go".to_string())
3134+
);
3135+
// Longest match wins over the single-word fallback.
3136+
assert_eq!(
3137+
extract_slash_command("@Dawn Smith /goal", &["Dawn"]),
3138+
None,
3139+
"single-word match leaves 'Smith /goal' — not a command"
3140+
);
3141+
}
3142+
3143+
#[test]
3144+
fn test_extract_slash_command_rejects_non_commands() {
3145+
// Slash not the first token after mentions.
3146+
assert_eq!(extract_slash_command("@Eva see /tmp/foo", &[]), None);
3147+
// Plain message.
3148+
assert_eq!(extract_slash_command("@Eva hello", &[]), None);
3149+
// Bare slash or non-alphanumeric after slash.
3150+
assert_eq!(extract_slash_command("@Eva /", &[]), None);
3151+
assert_eq!(extract_slash_command("@Eva //comment", &[]), None);
3152+
// Dot-prefix is NOT a slash command.
3153+
assert_eq!(extract_slash_command("@Eva .goal", &[]), None);
3154+
// Bare '@' is not a mention.
3155+
assert_eq!(extract_slash_command("@ /goal", &[]), None);
3156+
// Email-like text shouldn't strip.
3157+
assert_eq!(extract_slash_command("user@host.com /x", &[]), None);
3158+
}
3159+
3160+
#[test]
3161+
fn test_slash_command_for_batch_gating() {
3162+
// Single qualifying event → pass-through.
3163+
assert_eq!(
3164+
slash_command_for_batch(&make_single_batch("@Eva /init"), &[]),
3165+
Some("/init".to_string())
3166+
);
3167+
3168+
// Multi-event batch → no pass-through.
3169+
let mut multi = make_single_batch("@Eva /init");
3170+
multi.events.push(BatchEvent {
3171+
event: make_event("another message"),
3172+
prompt_tag: "test".into(),
3173+
received_at: Instant::now(),
3174+
});
3175+
assert_eq!(slash_command_for_batch(&multi, &[]), None);
3176+
3177+
// Cancelled carryover → no pass-through.
3178+
let mut cancelled = make_single_batch("@Eva /init");
3179+
cancelled.cancelled_events.push(BatchEvent {
3180+
event: make_event("interrupted"),
3181+
prompt_tag: "test".into(),
3182+
received_at: Instant::now(),
3183+
});
3184+
assert_eq!(slash_command_for_batch(&cancelled, &[]), None);
3185+
3186+
// Non-command single event → no pass-through.
3187+
assert_eq!(
3188+
slash_command_for_batch(&make_single_batch("@Eva hello"), &[]),
3189+
None
3190+
);
3191+
}
30063192
}

0 commit comments

Comments
 (0)