Skip to main content

forge_server/
delivery.rs

1//! The turn a delivery draws as: a cron fire, a Gotify notification, a Slack
2//! message or a peer comm.
3//!
4//! Each of them reaches a session's model as a prompt on its own stdin, and
5//! the CLI does not echo a prompt back, so the wire carries nothing a view
6//! could draw and a page that only drew wire frames would show the assistant
7//! answering something nobody saw. The workspace announces each as a typed
8//! update instead, and this turns that update into the display-only user turn
9//! the fold's envelope detection recognises, carrying the same prose the
10//! model received.
11
12use forge_primitives::{Message, SessionSlot};
13use forge_workspace::PromptSource;
14
15use crate::surface::SessionUpdate;
16
17/// The instant a forged row was minted, in the form the wire stamps rows with.
18fn rfc3339_now() -> String {
19    time::OffsetDateTime::now_utc()
20        .format(&time::format_description::well_known::Rfc3339)
21        .unwrap_or_default()
22}
23
24/// The display-only user turn `update` draws as, when it delivers to `slot`,
25/// with the sender the frame that draws it carries.
26/// `None` for every other update, and for a delivery to another seat: the
27/// same stream carries the whole fleet.
28///
29/// The prose is checked against the same parser the fold reads it back with,
30/// and a drift is recorded against the delivery that produced it.
31pub fn delivery_turn(
32    update: &SessionUpdate,
33    slot: &SessionSlot,
34) -> Option<(Message, PromptSource)> {
35    let (source, kind, text, uuid) = match update {
36        // The prefix is the fold's own key rather than anything the model
37        // reads: `detect_inbound` picks the envelope out of the prose.
38        SessionUpdate::CronPromptAppended { key, text, uuid, .. } if key == slot => {
39            (PromptSource::Cron, "cron_prompt", format!("[Cron]\n\n{text}"), uuid)
40        }
41        SessionUpdate::GotifyNotificationAppended { key, notification, uuid } if key == slot => {
42            (PromptSource::Gotify, "gotify_notification", notification.to_prose(), uuid)
43        }
44        SessionUpdate::SlackMessageAppended { key, prose, uuid } if key == slot => {
45            (PromptSource::Slack, "slack_message", prose.clone(), uuid)
46        }
47        SessionUpdate::PeerEnvelopeAppended { key, wrapped, uuid } if key == slot => {
48            (PromptSource::Peer, "peer_envelope", wrapped.to_prose(), uuid)
49        }
50        _ => return None,
51    };
52    // The turn draws only if the fold's parser reads the prose back, so a
53    // disagreement between forge's own two halves costs the user a message
54    // the model still received.
55    if crate::envelope::detect_inbound(&text).is_none() {
56        let head: String = text.chars().take(120).collect();
57        tracing::error!(
58            event_name = "envelope_prose_unrecognised",
59            source = kind,
60            outcome = "chat_echo_dropped",
61            org = slot.org(),
62            project = slot.project(),
63            label = slot.label(),
64            prose_head = %head,
65            "forged envelope prose did not match detect_inbound; the model still \
66             received it but no view will draw it",
67        );
68    }
69    // Forged rather than read off the wire, carrying the prompt's own id:
70    // the duplicate-skip the CLI applies to re-sent uuids is a stdin concern
71    // and never sees this frame, and the id is what lets a view hold this row
72    // while the prompt waits in the pile.
73    let mut row = Message::display_only_user(text, uuid.clone());
74    // Stamped at the forge, because this row OPENS the turn a delivery starts:
75    // the CLI never echoes a prompt, so a page attaching mid-turn has nothing
76    // else to anchor the running clock on.
77    row.stamp_if_absent(&rfc3339_now());
78    Some((row, source))
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::test_support::logged;
85    use forge_primitives::ContentBlock;
86
87    /// **A delivery's row is stamped at the forge.** It opens the turn a cron
88    /// fire, a notification or a peer comm runs - the CLI never echoes a
89    /// prompt - so a page attaching mid-turn anchors its running clock on
90    /// this row, and an unstamped one leaves it reading 0.0s until the turn's
91    /// first wire frame lands.
92    #[test]
93    fn a_delivery_row_carries_the_stamp_a_clock_starts_from() {
94        let slot = SessionSlot::new("TestOrg", "forge", "lead");
95        let update = SessionUpdate::SlackMessageAppended {
96            key: slot.clone(),
97            prose: "[Slack #acme] alice: hello".to_owned(),
98            uuid: "cap-slack".to_owned(),
99        };
100
101        let before = time::OffsetDateTime::now_utc();
102        let (turn, _) =
103            delivery_turn(&update, &slot).expect("a delivery to this seat forges a turn");
104        let after = time::OffsetDateTime::now_utc();
105
106        let value = serde_json::to_value(&turn).expect("encode");
107        let stamp =
108            value["timestamp"].as_str().expect("the row carries a stamp where a clock reads one");
109        // **The code's own clock, not merely a parseable string**: a constant
110        // stamp would draw this row a clock years old while a parse-only pin
111        // stayed green.
112        let at = time::OffsetDateTime::parse(stamp, &time::format_description::well_known::Rfc3339)
113            .unwrap_or_else(|error| {
114                panic!("the delivery stamped a time no client can read: {stamp} ({error})")
115            });
116        let slack = time::Duration::seconds(5);
117        assert!(
118            at >= before - slack && at <= after + slack,
119            "the forged row carries the forge's own clock, not a constant: {stamp} sits outside \
120             {before}..{after}",
121        );
122    }
123
124    /// Prose the parser cannot read draws nothing, while the model still
125    /// received it - the one failure this forge can produce that no test
126    /// spanning the two crates would catch, since forge-workspace cannot
127    /// depend on a view. The forger records the drift it cannot fix, and
128    /// names which delivery drifted.
129    #[test]
130    fn prose_that_does_not_read_as_an_envelope_is_recorded() {
131        let slot = SessionSlot::new("TestOrg", "forge", "lead");
132        let update = SessionUpdate::SlackMessageAppended {
133            key: slot.clone(),
134            prose: "no bracket header here".to_owned(),
135            uuid: "cap-slack".to_owned(),
136        };
137
138        let events = logged(|| {
139            let (turn, _) =
140                delivery_turn(&update, &slot).expect("a delivery to this seat still forges a turn");
141            assert!(matches!(turn, Message::User { .. }), "the model's own prose is not withheld");
142        });
143
144        assert!(
145            events.iter().any(|(level, fields)| {
146                *level == tracing::Level::ERROR
147                    && fields.contains("envelope_prose_unrecognised")
148                    && fields.contains(r#"source="slack_message""#)
149                    && fields.contains(r#"org="TestOrg""#)
150                    && fields.contains(r#"project="forge""#)
151                    && fields.contains(r#"label="lead""#)
152            }),
153            "an unreadable forge is recorded against the delivery that produced it, \
154             naming the seat that lost the message: {events:?}",
155        );
156    }
157
158    /// All four deliveries draw through the fold's parser, so each one's
159    /// prose has to survive it and come back carrying what it was built
160    /// from. Three of the four forge that prose here; the Slack variant
161    /// hands over its producer's, whose shape `forge-workspace` pins on its
162    /// own side, so this pins the parse rather than the format.
163    #[test]
164    fn every_delivery_forges_prose_the_parser_reads_back() {
165        use crate::envelope::PeerInboundKind;
166        use forge_workspace::{GotifyNotification, MessageId, WrappedKind, WrappedPrompt};
167
168        let slot = SessionSlot::new("TestOrg", "forge", "lead");
169        let cases = [
170            (
171                SessionUpdate::CronPromptAppended {
172                    key: slot.clone(),
173                    text: "run the morning summary".to_owned(),
174                    uuid: "cap-cron".to_owned(),
175                    cron_id: "c1".to_owned(),
176                    description: Some("Morning summary".to_owned()),
177                },
178                "run the morning summary",
179            ),
180            (
181                SessionUpdate::GotifyNotificationAppended {
182                    key: slot.clone(),
183                    notification: GotifyNotification {
184                        app: "Backups".to_owned(),
185                        title: "Nightly backup complete".to_owned(),
186                        message: "All volumes backed up".to_owned(),
187                        priority: 3,
188                    },
189                    uuid: "cap-gotify".to_owned(),
190                },
191                "All volumes backed up",
192            ),
193            (
194                SessionUpdate::SlackMessageAppended {
195                    key: slot.clone(),
196                    prose: "[Slack - workspace 'Trust Machines', granite-staging-alerts] \
197                            id C0AE ts 1789.5\nunknown: _Large STX Transfer_ [ts 1789.5]"
198                        .to_owned(),
199                    uuid: "cap-slack-2".to_owned(),
200                },
201                "Large STX Transfer",
202            ),
203            (
204                SessionUpdate::PeerEnvelopeAppended {
205                    key: slot.clone(),
206                    uuid: "cap-peer".to_owned(),
207                    wrapped: WrappedPrompt {
208                        id: MessageId("m-7f3a92e0".to_owned()),
209                        kind: WrappedKind::Message,
210                        sender_name: "terminal-cleanups".to_owned(),
211                        sender_org: "Busytools".to_owned(),
212                        body: "the swap case is constructed".to_owned(),
213                    },
214                },
215                "the swap case is constructed",
216            ),
217        ];
218
219        // The same stream carries the whole fleet, so a delivery addressed to
220        // another seat forges nothing here.
221        let elsewhere = SessionSlot::new("OtherOrg", "forge", "lead");
222        for (update, _) in &cases {
223            assert!(
224                delivery_turn(update, &elsewhere).is_none(),
225                "a delivery for another seat must not draw in this one",
226            );
227        }
228
229        for (update, expected) in &cases {
230            let (turn, source) =
231                delivery_turn(update, &slot).expect("a delivery to this seat forges a turn");
232            let Message::User { message, uuid, .. } = &turn else {
233                panic!("a delivery draws as the user turn the model's prompt was")
234            };
235            // **The sender rides the frame the flush forges**, and it is the
236            // delivery's own kind: a reader's follow arms on `you` alone, so a
237            // cron fire claiming anything else would either carry a parked
238            // reader or lose the delivery's own label (#2198).
239            let expected_source = match update {
240                SessionUpdate::CronPromptAppended { .. } => PromptSource::Cron,
241                SessionUpdate::GotifyNotificationAppended { .. } => PromptSource::Gotify,
242                SessionUpdate::SlackMessageAppended { .. } => PromptSource::Slack,
243                SessionUpdate::PeerEnvelopeAppended { .. } => PromptSource::Peer,
244                other => panic!("this test only carries deliveries: {other:?}"),
245            };
246            assert_eq!(
247                source, expected_source,
248                "the frame says who delivered the words, or a parked reader cannot tell them \
249                 from their own",
250            );
251            // **The prompt's own id, off the envelope.** It is what a view
252            // holds the row by while the prompt waits, and what pairs the row
253            // with the page's later copy - a row minted without it, or with a
254            // fresh one, is a row nothing can settle.
255            let carried_id = match update {
256                SessionUpdate::CronPromptAppended { uuid, .. }
257                | SessionUpdate::GotifyNotificationAppended { uuid, .. }
258                | SessionUpdate::SlackMessageAppended { uuid, .. }
259                | SessionUpdate::PeerEnvelopeAppended { uuid, .. } => uuid,
260                other => panic!("this test only carries deliveries: {other:?}"),
261            };
262            assert_eq!(
263                uuid.as_deref(),
264                Some(carried_id.as_str()),
265                "the forged row is stamped with the id the prompt was dispatched under",
266            );
267            let Some(ContentBlock::Text { text, .. }) = message.content.first() else {
268                panic!("the turn carries the prose the model received")
269            };
270            let kind = crate::envelope::detect_inbound(text).unwrap_or_else(|| {
271                panic!(
272                    "the fold has to read this delivery's prose back, or it draws nothing: {text}"
273                )
274            });
275            // Only the Slack header rejects a body it cannot read. Cron,
276            // Gotify and peer all accept an empty one, so "it parsed" is not
277            // yet "the payload arrived".
278            let carried = match kind {
279                PeerInboundKind::Cron { prompt } => prompt,
280                PeerInboundKind::Gotify { message, .. } => message,
281                PeerInboundKind::Slack { body, .. } | PeerInboundKind::Message { body, .. } => body,
282                other => panic!("this delivery forged prose the fold read as {other:?}"),
283            };
284            assert!(
285                carried.contains(expected),
286                "the fold reads this delivery's payload back, or the view draws it empty: {carried}",
287            );
288        }
289    }
290}