Skip to main content

forge_server/
envelope.rs

1//! Envelope parsing for the bracket-wrapped prose `forge_workspace`
2//! injects into user-turn text, plus the Slack mrkdwn converter.
3//!
4//! Seven header shapes: the three peer kinds the workspace produces
5//! (`Message`, `DeliveryFailure`, `WorkerSpawnFailed`), the `Gotify`, `Cron`
6//! and `Slack` external sources, and forge's own `Voice` note. Nothing here
7//! renders or holds state.
8//!
9//! **Three of the parser's arms are replay-only.** The `Question` and
10//! `Reply` headers and the `Ask ... failed to deliver` header are what a
11//! transcript recorded before the peer surface collapsed to one verb
12//! holds, and resuming replays that history through this same parser -
13//! so those rows keep drawing as peer blocks rather than as raw bracket
14//! text. The two message headers resolve to the one kind that replaced
15//! them; the recorded failure keeps its own, which is still the failure
16//! kind today.
17
18/// One inbound peer block parsed from the user-turn text.
19///
20/// Wire envelopes carry fields (the send's id, the originating org) that
21/// the previous chrome surfaced as DIM meta chunks. The redesigned chat
22/// block hides those by default - the parser still retains them, and the
23/// id is what the chat-streak grouping keys a messaging group on.
24#[derive(Debug)]
25pub enum PeerInboundKind {
26    /// `[Message id=m-... from agent 'X' (org 'Y')]\n\n<body>`
27    ///
28    /// Every peer message is this one kind: a reply is just another
29    /// message, so there is nothing left for a second kind to say.
30    Message { id: String, from: String, org: String, body: String },
31    /// `[Message to agent 'X' (org 'Y') failed to deliver: <reason>]`
32    /// Sender-side delivery failure: a message parked for a sleeping
33    /// project whose spawn then failed.
34    ///
35    /// The older `[Ask id=... to agent 'X' ... failed to deliver: ...]`
36    /// shape parses to this same kind, so a replayed transcript still
37    /// draws its failure.
38    DeliveryFailure { target: String, org: String, reason: String },
39    /// `[Worker '<label>' spawn failed id=<id>: <reason>]`
40    /// Lead-side notice that a team worker's async spawn failed
41    /// (subprocess crashed inside the `--worktree` machinery before
42    /// reaching `Connected`). Reason text is verbatim from claude's
43    /// stderr. Kept as a one-line system notice rather than a peer
44    /// row because it's a workspace-generated lifecycle event, not a
45    /// peer comm - touching its render shape is out of scope for
46    /// #189.
47    WorkerSpawnFailed { label: String, reason: String },
48    /// `[Gotify - app 'X', priority N]\n<title>\n<message>` - an inbound
49    /// external Gotify notification delivered as a user turn. Rendered
50    /// with distinct chrome (the ◈ gotify glyph, `Gotify` source label) so it
51    /// reads as an external event, not agent traffic. Never groups with
52    /// peer envelopes (see [`PeerInboundKind::peer_sender_identity`]).
53    Gotify { app: String, title: String, message: String, priority: u8 },
54    /// `[Cron]\n\n<prompt>` - a durable cron that fired into this session,
55    /// delivered as a user turn. Rendered with the ◴ cron glyph + a `Cron`
56    /// source label so it reads as a scheduled internal event, not agent
57    /// traffic. Never groups with peer envelopes (see
58    /// [`PeerInboundKind::peer_sender_identity`]).
59    Cron { prompt: String },
60    /// `[Voice]\n\n<note>` - forge telling the seat's session the reader is
61    /// listening or has stopped, delivered as a user turn. The note is for
62    /// the agent, so it draws as an information row rather than as the
63    /// reader's own turn. Never groups with peer envelopes (see
64    /// [`PeerInboundKind::peer_sender_identity`]).
65    Voice { note: String },
66    /// `[Slack - workspace 'X', <channel>] id ... ts ...` then one
67    /// `<author>: <text> [ts ...]` line per delivered message - a
68    /// conversation's news, delivered as a user turn. Rendered with the
69    /// ◇ glyph + a `Slack` source label so it reads as an external event,
70    /// not agent traffic. Never groups with peer envelopes (see
71    /// [`PeerInboundKind::peer_sender_identity`]).
72    Slack {
73        workspace: String,
74        channel: String,
75        /// `None` when there is no name to print. The producer resolves the
76        /// author before the prose is written, so a bot's message carries the
77        /// `user` id it always had and its own profile name beside it; a name
78        /// that resolved to nothing leaves `unknown` in the clause, which is
79        /// dropped here. Also `None` for a multi-message body, whose members
80        /// each name their own author.
81        author: Option<String>,
82        body: String,
83    },
84}
85
86impl PeerInboundKind {
87    /// The `sender_org` field threaded through every variant - drives
88    /// same-project envelope grouping in a view's chat iteration.
89    /// Variants that carry an explicit org return it; the
90    /// worker-spawn-failure notice has no org of its own (it's
91    /// lead-local, with no sending project) so it returns an empty
92    /// string and naturally groups with adjacent lead-local envelopes.
93    pub fn org(&self) -> &str {
94        match self {
95            Self::Message { org, .. } | Self::DeliveryFailure { org, .. } => org,
96            Self::WorkerSpawnFailed { .. }
97            | Self::Gotify { .. }
98            | Self::Cron { .. }
99            | Self::Voice { .. }
100            | Self::Slack { .. } => "",
101        }
102    }
103
104    /// The peer/worker sender identity used for envelope grouping (the
105    /// `from` / `target` / `label` name). `None` for `Gotify`, `Cron` and
106    /// `Slack`: those are not agent traffic and must never merge into a peer
107    /// messaging group - the grouping predicates key off `Some(..)` here.
108    pub fn peer_sender_identity(&self) -> Option<&str> {
109        match self {
110            Self::Message { from, .. } => Some(from),
111            Self::DeliveryFailure { target, .. } => Some(target),
112            Self::WorkerSpawnFailed { label, .. } => Some(label),
113            Self::Gotify { .. } | Self::Cron { .. } | Self::Voice { .. } | Self::Slack { .. } => {
114                None
115            }
116        }
117    }
118}
119
120/// The envelope's own message id out of a peer wrapper header
121/// (`[Message id=m-1a2b3c from agent ...]`). Unique per delivered
122/// envelope, so it keys an inbound-led messaging group independently
123/// of where the message sits in the session - unlike a positional key,
124/// it survives history pruning and index shifts.
125pub fn inbound_envelope_id(text: &str) -> Option<&str> {
126    // split_once, not split: an unterminated bracket has no header at
127    // all, and `detect_inbound` rejects it for the same reason.
128    let (header, _) = text.strip_prefix('[')?.split_once(']')?;
129    // Anchored on the leading space: the worker-spawn-failure header
130    // puts a caller-supplied label before the id, so a label containing
131    // `id=` would otherwise win the match.
132    let after = header.split_once(" id=")?.1;
133    let id = &after[..after.find([' ', ':']).unwrap_or(after.len())];
134    (!id.is_empty()).then_some(id)
135}
136
137/// Detect a peer wrapper at the start of a user-message text. Returns
138/// `None` for any text that isn't a bracket-prefixed peer wrapper
139/// (the chat renderer falls through to the default text rendering
140/// in that case).
141pub fn detect_inbound(text: &str) -> Option<PeerInboundKind> {
142    let bracketed = text.strip_prefix('[')?;
143    let close_idx = bracketed.find(']')?;
144    let header = &bracketed[..close_idx];
145    let after_bracket = &bracketed[close_idx + 1..];
146    // The wrapper formats land the body after `]\n\n`. Some notice
147    // variants have an empty body - the bracket itself is the whole
148    // message, possibly followed by `\n\n` + extra context. Both
149    // are valid.
150    let body = after_bracket.strip_prefix("\n\n").unwrap_or("").to_owned();
151
152    if let Some(rest) = header.strip_prefix("Message id=") {
153        let (id, rest) = take_until(rest, " from agent ")?;
154        let (from, org) = extract_from_agent_after(rest)?;
155        return Some(PeerInboundKind::Message { id: id.to_owned(), from, org, body });
156    }
157
158    // replay-only: the Question/Reply headers of a transcript recorded
159    // before the peer surface collapsed to one verb. Both resolve to the
160    // one kind that replaced them.
161    if let Some(rest) = header.strip_prefix("Question id=") {
162        let (id, rest) = take_until(rest, " from agent ")?;
163        let (from, org) = extract_from_agent_after(rest)?;
164        return Some(PeerInboundKind::Message { id: id.to_owned(), from, org, body });
165    }
166
167    if let Some(rest) = header.strip_prefix("Reply id=") {
168        let (id, rest) = take_until(rest, " from agent ")?;
169        let (from, org) = extract_from_agent_after(rest)?;
170        return Some(PeerInboundKind::Message { id: id.to_owned(), from, org, body });
171    }
172
173    if let Some(rest) = header.strip_prefix("Message to agent ")
174        && header.contains("failed to deliver:")
175    {
176        let (target, org, trailing) = extract_from_agent_after_with_trailer(rest)?;
177        let reason = trailing
178            .split_once("failed to deliver:")
179            .map(|(_, after)| after.trim().to_owned())
180            .unwrap_or_default();
181        return Some(PeerInboundKind::DeliveryFailure { target, org, reason });
182    }
183
184    // replay-only: the delivery-failure header of a transcript recorded
185    // before the peer surface collapsed to one verb.
186    if let Some(rest) = header.strip_prefix("Ask id=") {
187        // Caller-side delivery failure - `to agent 'X' (org 'Y') failed to deliver: <reason>`
188        if let Some(rest_to) = rest_after_id(rest, " to agent ")
189            && header.contains("failed to deliver:")
190        {
191            let (target, org, trailing) = extract_from_agent_after_with_trailer(rest_to)?;
192            let reason = trailing
193                .split_once("failed to deliver:")
194                .map(|(_, after)| after.trim().to_owned())
195                .unwrap_or_default();
196            return Some(PeerInboundKind::DeliveryFailure { target, org, reason });
197        }
198    }
199
200    if let Some(rest) = header.strip_prefix("Worker '") {
201        let (label, rest) = take_until(rest, "' spawn failed id=")?;
202        let (_id, reason) = take_until(rest, ": ")?;
203        return Some(PeerInboundKind::WorkerSpawnFailed {
204            label: label.to_owned(),
205            reason: reason.to_owned(),
206        });
207    }
208
209    if let Some(rest) = header.strip_prefix("Gotify - app '") {
210        let (app, priority_str) = take_until(rest, "', priority ")?;
211        let priority: u8 = priority_str.parse().ok()?;
212        // The Gotify prose places the body one newline after `]` (title on
213        // its own line, then the message) - not the peer `]\n\n` shape.
214        let raw_body = after_bracket.strip_prefix('\n').unwrap_or(after_bracket);
215        let (title, message) = match raw_body.split_once('\n') {
216            Some((t, m)) => (t.to_owned(), m.to_owned()),
217            None => (raw_body.to_owned(), String::new()),
218        };
219        return Some(PeerInboundKind::Gotify { app: app.to_owned(), title, message, priority });
220    }
221
222    if let Some(rest) = header.strip_prefix("Slack - workspace '") {
223        let (workspace, channel) = take_until(rest, "', ")?;
224        // The `id … ts …` tail (and a bundle's member count) sits past the
225        // closing bracket, so the body is what follows the newline.
226        let (tail, rest) = after_bracket.split_once('\n')?;
227        // `slack_bundle_to_prose` writes `<author>: <text>` on every member
228        // line, so a body without the separator is not this shape at all.
229        let (author, body) = rest.split_once(": ")?;
230        // Every member of a bundle names its own author, so the header names
231        // none of them: promoting the first would read as the block's author
232        // and lose the rest. The count is what says so - a single message's
233        // own text may wrap just as a bundle's members do.
234        if bundle_member_count(tail).is_some() {
235            // Every member names its own author, and an id or the producer's
236            // `unknown` placeholder is not a name - so the clause is dropped
237            // line by line, exactly as the single-member header drops it.
238            let body = rest
239                .lines()
240                .map(|line| {
241                    line.split_once(": ").map_or(line, |(author, text)| {
242                        if author == "unknown" || is_slack_id(author) { text } else { line }
243                    })
244                })
245                .collect::<Vec<_>>()
246                .join("\n");
247            return Some(PeerInboundKind::Slack {
248                workspace: workspace.to_owned(),
249                channel: channel.to_owned(),
250                author: None,
251                body,
252            });
253        }
254        // A raw Slack id is not a name to print: the prose writes the
255        // resolved author when there is one, and falls back to `message.user`
256        // - a `U…` id - or to `unknown` when there is nothing at all.
257        let author =
258            if author == "unknown" || is_slack_id(author) { None } else { Some(author.to_owned()) };
259        return Some(PeerInboundKind::Slack {
260            workspace: workspace.to_owned(),
261            channel: channel.to_owned(),
262            author,
263            body: body.to_owned(),
264        });
265    }
266
267    if header == "Cron" {
268        // Display-only wrapper the CronPromptAppended reducer forges around
269        // the fired prompt; `body` is the raw prompt after `]\n\n`.
270        return Some(PeerInboundKind::Cron { prompt: body });
271    }
272
273    if header == "Voice" {
274        // The wrapper `WorkspaceNotes` puts around a voice note; `body` is the
275        // note after `]\n\n`.
276        return Some(PeerInboundKind::Voice { note: body });
277    }
278
279    None
280}
281
282/// Strip Slack's mrkdwn down to plain text: emphasis markers drop, a labelled
283/// link becomes `label: url`, and the `&`/`<`/`>` entities decode.
284pub fn tidy_mrkdwn(text: &str) -> String {
285    let mut out = String::with_capacity(text.len());
286    let mut rest = text;
287    while let Some(open) = rest.find('<') {
288        out.push_str(&strip_emphasis(&rest[..open]));
289        let after = &rest[open + 1..];
290        let Some(close) = after.find('>') else {
291            out.push_str(&strip_emphasis(&rest[open..]));
292            return decode_entities(&out);
293        };
294        // The target goes out whole: Slack reads no emphasis inside a URL.
295        if let Some((url, label)) = after[..close].split_once('|') {
296            out.push_str(&strip_emphasis(label));
297            out.push_str(": ");
298            out.push_str(url);
299        } else {
300            out.push('<');
301            out.push_str(&after[..close]);
302            out.push('>');
303        }
304        rest = &after[close + 1..];
305    }
306    out.push_str(&strip_emphasis(rest));
307    decode_entities(&out)
308}
309
310/// Drop the markers around an emphasised span. A marker pair is only emphasis
311/// when each end sits at a word boundary with a single marker, so the `_` in
312/// an identifier, an unpaired `_` and the `**` of a glob all stay literal.
313fn strip_emphasis(text: &str) -> String {
314    let chars: Vec<char> = text.chars().collect();
315    let mut dropped = vec![false; chars.len()];
316    let mut i = 0;
317    while i < chars.len() {
318        if matches!(chars[i], '*' | '_')
319            && opens_a_span(&chars, i)
320            && let Some(close) = first_closer(&chars, i)
321        {
322            dropped[i] = true;
323            dropped[close] = true;
324            i = close + 1;
325            continue;
326        }
327        i += 1;
328    }
329    chars.iter().zip(&dropped).filter(|(_, dropped)| !**dropped).map(|(c, _)| *c).collect()
330}
331
332/// A marker opens a span only at a word boundary, with text after it and no
333/// doubled marker on either side - the `_` inside an identifier and the `**`
334/// of a glob are both literals rather than openers.
335fn opens_a_span(chars: &[char], i: usize) -> bool {
336    let marker = chars[i];
337    let before = i.checked_sub(1).and_then(|p| chars.get(p));
338    let after = chars.get(i + 1).copied();
339    before.is_none_or(|p| !p.is_alphanumeric() && *p != marker)
340        && after.is_some_and(|n| !n.is_whitespace() && n != marker)
341}
342
343/// The first index able to close a span opened at `open`: the same marker,
344/// with non-space before it, no word character after it, and no ability to open
345/// a span of its own - otherwise the two `*` of a pair of globs would close a
346/// span between them.
347fn first_closer(chars: &[char], open: usize) -> Option<usize> {
348    let marker = chars[open];
349    ((open + 1)..chars.len()).find(|&j| {
350        chars[j] == marker
351            && chars.get(j - 1).is_some_and(|p| !p.is_whitespace())
352            && chars.get(j + 1).is_none_or(|n| !n.is_alphanumeric())
353            && !opens_a_span(chars, j)
354    })
355}
356
357/// Slack escapes `&`, `<` and `>` in message text. The angle-bracket entities
358/// decode first so a doubly-escaped `&amp;lt;` stays the literal it was.
359fn decode_entities(text: &str) -> String {
360    text.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
361}
362
363/// True for a raw Slack id (`C0C0T5E6RM1`, `U9ABCD`) rather than a name: an
364/// uppercase first character followed by uppercase letters and digits only.
365/// Both `message.user` and a DM's conversation label reach the prose as one
366/// until the producer resolves a handle. The shape is a heuristic, not a
367/// guarantee: an all-caps channel name matches it and loses its `#`.
368pub fn is_slack_id(text: &str) -> bool {
369    let mut chars = text.chars();
370    chars.next().is_some_and(|first| first.is_ascii_uppercase())
371        && chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
372}
373
374/// The member count a delivered bundle's header carries, and `None` for a
375/// single message - which is what tells the two apart, since one message's
376/// text may wrap over as many lines as a bundle's members do.
377fn bundle_member_count(tail: &str) -> Option<usize> {
378    tail.rsplit_once('(')?.1.strip_suffix(" messages)")?.parse().ok()
379}
380
381/// Split `s` at the first occurrence of `marker`. Returns
382/// `(before, after_marker)` - the marker itself is consumed.
383/// `None` when the marker isn't found.
384fn take_until<'a>(s: &'a str, marker: &str) -> Option<(&'a str, &'a str)> {
385    let idx = s.find(marker)?;
386    Some((&s[..idx], &s[idx + marker.len()..]))
387}
388
389/// After the `id=` slice has been peeled off, look for the marker
390/// that comes after the id (e.g. ` to agent ` or ` from agent `).
391/// Returns the slice after the marker.
392fn rest_after_id<'a>(after_id: &'a str, marker: &str) -> Option<&'a str> {
393    let idx = after_id.find(marker)?;
394    Some(&after_id[idx + marker.len()..])
395}
396
397/// Given a slice starting at `'X' (org 'Y') ...`, extract `X` and
398/// `Y`. Returns `None` when the format doesn't match.
399fn extract_from_agent_after(rest: &str) -> Option<(String, String)> {
400    let after_open = rest.strip_prefix('\'')?;
401    let (name, after_name) = take_until(after_open, "' (org '")?;
402    let (org, _trailing) = take_until(after_name, "')")?;
403    Some((name.to_owned(), org.to_owned()))
404}
405
406/// Same as `extract_from_agent_after` but also returns the trailer -
407/// the substring after `')`. Used by the delivery-failure parser
408/// which needs name, org, AND the trailing reason text.
409fn extract_from_agent_after_with_trailer(rest: &str) -> Option<(String, String, String)> {
410    let after_open = rest.strip_prefix('\'')?;
411    let (name, after_name) = take_until(after_open, "' (org '")?;
412    let (org, trailing) = take_until(after_name, "')")?;
413    Some((name.to_owned(), org.to_owned(), trailing.to_owned()))
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn detect_message_inbound_carries_the_send_id() {
422        let text = "[Message id=m-c45a8f12 from agent 'gateway-backend' (org 'Gateway')]\n\nFYI rewriter cleanup just landed.";
423        let kind = detect_inbound(text).expect("message");
424        match kind {
425            PeerInboundKind::Message { id, from, org, body } => {
426                assert_eq!(id, "m-c45a8f12", "the send's id rides the envelope");
427                assert_eq!(from, "gateway-backend");
428                assert_eq!(org, "Gateway");
429                assert_eq!(body, "FYI rewriter cleanup just landed.");
430            }
431            other => panic!("expected Message, got {other:?}"),
432        }
433    }
434
435    /// A transcript recorded before the peer surface collapsed to one verb
436    /// holds `Question` and `Reply` headers. Resume replays that history
437    /// through this same parser, so both still resolve - to the one kind
438    /// that replaced them.
439    #[test]
440    fn detect_question_and_reply_replay_as_the_one_message_kind() {
441        // The header a transcript recorded before the collapse holds, down
442        // to the retired tool name inside it.
443        let question = concat!(
444            "[Question id=q-7f3a92e0 from agent 'forge' (org 'Personal') - reply with ",
445            // replay-only: agents__tell
446            "agents__tell",
447            " in_reply_to=q-7f3a92e0]\n\nWhat's the test setup?",
448        );
449        match detect_inbound(question).expect("a recorded question still parses") {
450            PeerInboundKind::Message { id, from, org, body } => {
451                assert_eq!(id, "q-7f3a92e0");
452                assert_eq!(from, "forge");
453                assert_eq!(org, "Personal");
454                assert_eq!(body, "What's the test setup?");
455            }
456            other => panic!("expected Message, got {other:?}"),
457        }
458
459        let reply = "[Reply id=q-7f3a92e0 from agent 'gateway-backend' (org 'Gateway') to your earlier ask]\n\nWe use pgtemp for ephemeral postgres in CI.";
460        match detect_inbound(reply).expect("a recorded reply still parses") {
461            PeerInboundKind::Message { from, org, body, .. } => {
462                assert_eq!(from, "gateway-backend");
463                assert_eq!(org, "Gateway");
464                assert_eq!(body, "We use pgtemp for ephemeral postgres in CI.");
465            }
466            other => panic!("expected Message, got {other:?}"),
467        }
468    }
469
470    #[test]
471    fn detect_delivery_failure_inbound() {
472        let text = "[Message to agent 'gateway-liq-bot' (org 'Gateway') failed to deliver: target spawn failed: all pinned accounts are rate-limited]";
473        let kind = detect_inbound(text).expect("delivery failure");
474        match kind {
475            PeerInboundKind::DeliveryFailure { target, org, reason } => {
476                assert_eq!(target, "gateway-liq-bot");
477                assert_eq!(org, "Gateway");
478                assert!(reason.contains("rate-limited"), "reason carries failure detail: {reason}");
479            }
480            other => panic!("expected DeliveryFailure, got {other:?}"),
481        }
482    }
483
484    /// The failure header a transcript recorded before the collapse holds
485    /// still parses, so a replayed transcript draws it rather than the raw
486    /// bracket prose.
487    #[test]
488    fn a_recorded_ask_failure_still_parses_as_a_delivery_failure() {
489        let text = "[Ask id=q-d31fa8a3 to agent 'gateway-liq-bot' (org 'Gateway') failed to deliver: target spawn failed]\n\n";
490        match detect_inbound(text).expect("a recorded failure still parses") {
491            PeerInboundKind::DeliveryFailure { target, org, reason } => {
492                assert_eq!(target, "gateway-liq-bot");
493                assert_eq!(org, "Gateway");
494                assert!(reason.contains("target spawn failed"), "reason: {reason}");
495            }
496            other => panic!("expected DeliveryFailure, got {other:?}"),
497        }
498    }
499
500    #[test]
501    fn detect_worker_spawn_failed_inbound() {
502        let text = "[Worker 'planner' spawn failed id=plan-7f3a: spawned with --worktree but git CLI not found]\n\n";
503        let kind = detect_inbound(text).expect("worker spawn failed");
504        match kind {
505            PeerInboundKind::WorkerSpawnFailed { label, reason } => {
506                assert_eq!(label, "planner");
507                assert!(reason.contains("git CLI not found"), "reason text: {reason}");
508            }
509            other => panic!("expected WorkerSpawnFailed, got {other:?}"),
510        }
511    }
512
513    /// Every real header shape, because the id keys the messaging
514    /// group's collapse level. `DeliveryFailure` and
515    /// `WorkerSpawnFailed` terminate the id with `:` rather than a
516    /// space, so both terminators have to be handled.
517    ///
518    /// The `None` rows matter as much: the caller falls back to a
519    /// positional key when this returns `None`, which is exactly the
520    /// collision the envelope id exists to avoid.
521    #[test]
522    fn inbound_envelope_id_covers_every_header_shape() {
523        let cases: &[(&str, Option<&str>)] = &[
524            ("[Message id=m-1a2b from agent 'lead' (org 'forge')]\n\nbody", Some("m-1a2b")),
525            // replay-only: a transcript recorded before the collapse.
526            ("[Question id=q-1a2b from agent 'lead' (org 'forge')]\n\nbody", Some("q-1a2b")),
527            ("[Reply id=m-9f8e from agent 'lead' (org 'forge')]\n\nbody", Some("m-9f8e")),
528            // replay-only: the failure header recorded before the collapse.
529            ("[Ask id=q-77 to agent 'x' (org 'forge') failed to deliver: gone]\n\n", Some("q-77")),
530            // A failure header names no send id, so it keys positionally.
531            ("[Message to agent 'x' (org 'forge') failed to deliver: gone]", None),
532            ("[Worker 'runner' spawn failed id=w-5: boom]", Some("w-5")),
533            // The label is caller-supplied and precedes the id, so the
534            // `id=` anchor has to be the space-prefixed one or a label
535            // containing `id=` wins the match.
536            ("[Worker 'grid=a' spawn failed id=w-5: boom]", Some("w-5")),
537            // No id in the header at all.
538            ("[Gotify - app 'ci', priority 5]\ntitle\nbody", None),
539            ("[Cron]\n\nprompt", None),
540            ("[Voice]\n\nnote", None),
541            // Not an envelope.
542            ("plain user text", None),
543            ("[unterminated id=t-1", None),
544            // Present but empty - must not key a group on "".
545            ("[Message id= from agent 'lead' (org 'forge')]\n\nbody", None),
546        ];
547        for (text, want) in cases {
548            assert_eq!(inbound_envelope_id(text), *want, "for {text:?}");
549        }
550    }
551
552    #[test]
553    fn detect_inbound_rejects_non_peer_text() {
554        assert!(detect_inbound("plain user message").is_none());
555        assert!(detect_inbound("[not-a-peer-prefix]").is_none());
556        assert!(detect_inbound("[Question id=q-bad").is_none());
557        // The Slack header alone is not an envelope: the body line is what
558        // carries the author and the message.
559        assert!(
560            detect_inbound("[Slack - workspace 'W', chan] id C1 ts 1.2").is_none(),
561            "a Slack header with no body line is not an envelope",
562        );
563        assert!(
564            detect_inbound("[Slack - workspace 'W', chan] id C1 ts 1.2\nno author prefix")
565                .is_none(),
566            "nor is a Slack body with no `<author>: ` separator",
567        );
568    }
569
570    #[test]
571    fn detect_gotify_inbound_parses_fields() {
572        let text = "[Gotify - app 'Backups', priority 3]\nNightly backup\nAll volumes done";
573        match detect_inbound(text).expect("gotify") {
574            PeerInboundKind::Gotify { app, title, message, priority } => {
575                assert_eq!(app, "Backups");
576                assert_eq!(title, "Nightly backup");
577                assert_eq!(message, "All volumes done");
578                assert_eq!(priority, 3);
579            }
580            other => panic!("expected Gotify, got {other:?}"),
581        }
582    }
583
584    #[test]
585    fn detect_gotify_inbound_preserves_multiline_message() {
586        let text = "[Gotify - app 'CI', priority 8]\nBuild failed\nstep 1 ok\nstep 2 failed";
587        match detect_inbound(text).expect("gotify") {
588            PeerInboundKind::Gotify { title, message, priority, .. } => {
589                assert_eq!(title, "Build failed");
590                assert_eq!(message, "step 1 ok\nstep 2 failed");
591                assert_eq!(priority, 8);
592            }
593            other => panic!("expected Gotify, got {other:?}"),
594        }
595    }
596
597    #[test]
598    fn detect_gotify_inbound_rejects_non_numeric_priority() {
599        // A malformed priority falls through to plain-text rendering
600        // rather than mis-parsing.
601        assert!(detect_inbound("[Gotify - app 'X', priority high]\nt\nm").is_none());
602    }
603
604    #[test]
605    fn gotify_has_no_peer_sender_identity() {
606        // Grouping keys off peer_sender_identity; Gotify returns None so
607        // it never merges into a peer messaging group.
608        let kind = PeerInboundKind::Gotify {
609            app: "Backups".into(),
610            title: "t".into(),
611            message: "m".into(),
612            priority: 5,
613        };
614        assert_eq!(kind.peer_sender_identity(), None);
615    }
616
617    #[test]
618    fn detect_cron_inbound_parses_prompt_body() {
619        let text = "[Cron]\n\nrun the morning summary";
620        match detect_inbound(text).expect("cron") {
621            PeerInboundKind::Cron { prompt } => assert_eq!(prompt, "run the morning summary"),
622            other => panic!("expected Cron, got {other:?}"),
623        }
624    }
625
626    #[test]
627    fn detect_cron_inbound_preserves_multiline_prompt() {
628        let text = "[Cron]\n\npost the EOD report\ninclude the realized PnL";
629        match detect_inbound(text).expect("cron") {
630            PeerInboundKind::Cron { prompt } => {
631                assert_eq!(prompt, "post the EOD report\ninclude the realized PnL");
632            }
633            other => panic!("expected Cron, got {other:?}"),
634        }
635    }
636
637    #[test]
638    fn detect_voice_inbound_parses_note_body() {
639        let listening = forge_workspace::voice::LISTENING_NOTE;
640        let text = format!("[Voice]\n\n{listening}");
641        match detect_inbound(&text).expect("voice") {
642            PeerInboundKind::Voice { note } => {
643                assert_eq!(note, listening, "the envelope carries the shipped note unchanged");
644            }
645            other => panic!("expected Voice, got {other:?}"),
646        }
647    }
648
649    #[test]
650    fn voice_has_no_peer_sender_identity() {
651        let kind = PeerInboundKind::Voice { note: "x".into() };
652        assert_eq!(kind.peer_sender_identity(), None);
653    }
654
655    #[test]
656    fn cron_has_no_peer_sender_identity() {
657        // Grouping keys off peer_sender_identity; Cron returns None so it
658        // never merges into a peer messaging group (mirrors Gotify).
659        let kind = PeerInboundKind::Cron { prompt: "x".into() };
660        assert_eq!(kind.peer_sender_identity(), None);
661    }
662
663    /// The shipped prose exactly as `slack_bundle_to_prose` emits it: the
664    /// bracketed part carries the workspace and conversation, and the line
665    /// under it is `<author>: <text>`. `unknown` is the producer's last
666    /// resort when a message carries no author at all, and it is never
667    /// printed as a name.
668    #[test]
669    fn detect_slack_inbound_parses_header_and_drops_the_unknown_author() {
670        let text = "[Slack - workspace 'Trust Machines', granite-staging-alerts] id C0AE0CBJ77G ts 1789182982.499299\nunknown: _Large STX Transfer_\nAmount: 233468.293536 STX (~$60434.82 USD)";
671        match detect_inbound(text).expect("slack") {
672            PeerInboundKind::Slack { workspace, channel, author, body } => {
673                assert_eq!(workspace, "Trust Machines", "workspace off the header");
674                assert_eq!(channel, "granite-staging-alerts", "channel off the header");
675                assert_eq!(author, None, "`unknown` is not an author to print");
676                assert_eq!(
677                    body,
678                    "_Large STX Transfer_\nAmount: 233468.293536 STX (~$60434.82 USD)",
679                );
680            }
681            other => panic!("expected Slack, got {other:?}"),
682        }
683    }
684
685    /// A resolved handle is kept, lowercase or title-cased. `alert-bot` is the
686    /// shape a handle takes; today's producer emits raw ids, which the id case
687    /// below covers.
688    #[test]
689    fn detect_slack_inbound_keeps_a_real_author() {
690        let text = "[Slack - workspace 'Subspace', mainnet-chain-alerts] id C0AE1 ts 1789.5\nalert-bot: Slow slot: 57296338 took 1s";
691        match detect_inbound(text).expect("slack") {
692            PeerInboundKind::Slack { workspace, channel, author, body } => {
693                assert_eq!(workspace, "Subspace");
694                assert_eq!(channel, "mainnet-chain-alerts");
695                assert_eq!(author.as_deref(), Some("alert-bot"));
696                assert_eq!(body, "Slow slot: 57296338 took 1s");
697            }
698            other => panic!("expected Slack, got {other:?}"),
699        }
700
701        // Only an id is dropped: an uppercase first letter alone is a name.
702        let titled = "[Slack - workspace 'Subspace', mainnet-chain-alerts] id C0AE1 ts 1789.5\nGranite-Bot: Slow slot: 57296338 took 1s";
703        match detect_inbound(titled).expect("slack") {
704            PeerInboundKind::Slack { author, .. } => {
705                assert_eq!(
706                    author.as_deref(),
707                    Some("Granite-Bot"),
708                    "a title-cased handle names someone"
709                );
710            }
711            other => panic!("expected Slack, got {other:?}"),
712        }
713    }
714
715    /// The producer appends ` in thread <ts>` after the ts for a threaded
716    /// reply, so the detector skips everything past the closing bracket up to
717    /// the newline rather than assuming the body starts after the ts.
718    #[test]
719    fn detect_slack_inbound_drops_the_thread_suffix_with_the_header() {
720        let text = "[Slack - workspace 'W', chan] id C1 ts 1789.5 in thread 1789.4\nalert-bot: hi";
721        match detect_inbound(text).expect("slack") {
722            PeerInboundKind::Slack { channel, author, body, .. } => {
723                assert_eq!(channel, "chan");
724                assert_eq!(author.as_deref(), Some("alert-bot"));
725                assert_eq!(body, "hi");
726            }
727            other => panic!("expected Slack, got {other:?}"),
728        }
729    }
730
731    /// A matcher widened to catch Slack must leave the neighbouring external
732    /// sources alone: Gotify keeps its own kind, and peer traffic is still a
733    /// peer envelope rather than being swallowed as Slack.
734    #[test]
735    fn slack_detection_leaves_gotify_and_peer_prose_alone() {
736        let gotify = "[Gotify - app 'Backups', priority 3]\nNightly backup\nAll volumes done";
737        assert!(
738            matches!(detect_inbound(gotify), Some(PeerInboundKind::Gotify { .. })),
739            "a Gotify notice still detects as Gotify",
740        );
741        let peer = "[Message id=t-1a2b from agent 'lead' (org 'forge')]\n\nbody";
742        assert!(
743            matches!(detect_inbound(peer), Some(PeerInboundKind::Message { .. })),
744            "a peer wrapper still detects as a peer envelope",
745        );
746    }
747
748    #[test]
749    fn slack_has_no_peer_sender_identity() {
750        // Grouping keys off peer_sender_identity; Slack returns None so it
751        // never merges into a peer messaging group (mirrors Gotify).
752        let kind = PeerInboundKind::Slack {
753            workspace: "W".into(),
754            channel: "c".into(),
755            author: None,
756            body: "x".into(),
757        };
758        assert_eq!(kind.peer_sender_identity(), None);
759    }
760
761    /// Every member of a bundle carries its own `<author>: `, so the header
762    /// names none of them: promoting the first would read as the block's
763    /// author and silently drop the rest.
764    #[test]
765    fn a_bundle_header_names_no_author() {
766        let prose = "[Slack - workspace 'acme', general] id C1 ts 2.2 (2 messages)\n\
767                     alice: one [ts 1.1]\n\
768                     bob: two [ts 2.2]";
769        match detect_inbound(prose).expect("slack") {
770            PeerInboundKind::Slack { author, body, .. } => {
771                assert_eq!(author, None, "a multi-member body names its own authors");
772                assert!(body.contains("alice: one"), "the body keeps every member: {body}");
773            }
774            other => panic!("expected Slack, got {other:?}"),
775        }
776    }
777
778    /// A stale block carries its age between the ts and the member count;
779    /// the parser still has to read it as the same conversation block.
780    #[test]
781    fn a_stale_bundle_header_reads_as_the_same_block() {
782        let prose = "[Slack - workspace 'acme', general] id C1 ts 2.2, 8d old (2 messages)\n\
783                     alice: one [ts 1.1]\n\
784                     bob: two [ts 2.2]";
785        match detect_inbound(prose).expect("slack") {
786            PeerInboundKind::Slack { author, body, .. } => {
787                assert_eq!(author, None, "the age clause keeps the bundle shape");
788                assert!(body.contains("bob: two"), "the body keeps every member: {body}");
789            }
790            other => panic!("expected Slack, got {other:?}"),
791        }
792    }
793
794    /// One message's own text wraps over as many lines as a bundle's
795    /// members do, so the member count - never the line count - is what
796    /// tells the two apart.
797    #[test]
798    fn a_wrapped_single_message_is_not_read_as_a_bundle() {
799        let text = "[Slack - workspace 'acme', general] id C1 ts 1.1\n\
800                    alice: line one\nline two [ts 1.1]";
801        match detect_inbound(text).expect("slack") {
802            PeerInboundKind::Slack { author, body, .. } => {
803                assert_eq!(author.as_deref(), Some("alice"), "one member keeps its header author");
804                assert_eq!(body, "line one\nline two [ts 1.1]");
805            }
806            other => panic!("expected Slack, got {other:?}"),
807        }
808    }
809
810    /// A raw user id is not a name, so production drops the author clause on
811    /// every real delivery until the producer resolves a handle.
812    #[test]
813    fn detect_slack_inbound_drops_an_id_shaped_author() {
814        let text = "[Slack - workspace 'Trust Machines', granite-staging-alerts] id C0AE ts 1789.5\nU0AE0CBJ77G: Large STX Transfer";
815        match detect_inbound(text).expect("slack") {
816            PeerInboundKind::Slack { author, body, .. } => {
817                assert_eq!(author, None, "a `U…` id names nobody to print");
818                assert_eq!(body, "Large STX Transfer");
819            }
820            other => panic!("expected Slack, got {other:?}"),
821        }
822    }
823
824    #[test]
825    fn tidy_mrkdwn_strips_emphasis_markers() {
826        assert_eq!(tidy_mrkdwn("_Large STX Transfer_"), "Large STX Transfer");
827        assert_eq!(tidy_mrkdwn("*build failed*"), "build failed");
828    }
829
830    #[test]
831    fn tidy_mrkdwn_turns_a_labelled_link_into_label_colon_url() {
832        assert_eq!(
833            tidy_mrkdwn("View <https://explorer.hiro.so/txid/0x37599daf|Transaction>"),
834            "View Transaction: https://explorer.hiro.so/txid/0x37599daf",
835        );
836    }
837
838    #[test]
839    fn tidy_mrkdwn_leaves_a_plain_string_untouched() {
840        let plain = "Amount: 233468.293536 STX (~$60434.82 USD)";
841        assert_eq!(tidy_mrkdwn(plain), plain);
842    }
843
844    #[test]
845    fn tidy_mrkdwn_decodes_slack_entities() {
846        assert_eq!(tidy_mrkdwn("AT&amp;T &lt;ok&gt;"), "AT&T <ok>");
847        // A doubly-escaped `&amp;lt;` was a literal `<` the author typed, so
848        // it must survive as one rather than decoding twice.
849        assert_eq!(tidy_mrkdwn("&amp;lt;"), "&lt;");
850    }
851
852    /// Marker characters inside a link belong to the link: the URL is emitted
853    /// whole rather than having its `_` read as emphasis.
854    #[test]
855    fn tidy_mrkdwn_leaves_a_link_url_whole() {
856        assert_eq!(
857            tidy_mrkdwn("<https://example.com/a_b|log_file>"),
858            "log_file: https://example.com/a_b",
859        );
860    }
861
862    /// A marker is emphasis only at a word boundary with a single marker at
863    /// each end. An identifier, an unpaired marker and a doubled one are all
864    /// literal - alert text and file paths are full of every shape.
865    #[test]
866    fn tidy_mrkdwn_leaves_a_marker_that_is_not_emphasis() {
867        assert_eq!(tidy_mrkdwn("set some_var_name here"), "set some_var_name here");
868        assert_eq!(tidy_mrkdwn("the _bold_suffix here"), "the _bold_suffix here");
869        assert_eq!(tidy_mrkdwn("**bold**"), "**bold**");
870        assert_eq!(tidy_mrkdwn("__init__"), "__init__");
871        assert_eq!(tidy_mrkdwn("src/**/*.rs"), "src/**/*.rs");
872        assert_eq!(tidy_mrkdwn("src/*.rs and tests/*.rs"), "src/*.rs and tests/*.rs");
873    }
874
875    /// Only a labelled span is a link: a mention token and a bare URL carry no
876    /// `|`, and an unmatched `<` has no `>` to close it, so all three go out
877    /// as they came.
878    #[test]
879    fn tidy_mrkdwn_leaves_an_angle_span_that_is_not_a_link() {
880        assert_eq!(tidy_mrkdwn("ping <@U123> at <https://x>"), "ping <@U123> at <https://x>");
881        assert_eq!(tidy_mrkdwn("a < b"), "a < b");
882    }
883}