@@ -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