1use forge_primitives::{Message, SessionSlot};
13use forge_workspace::PromptSource;
14
15use crate::surface::SessionUpdate;
16
17fn rfc3339_now() -> String {
19 time::OffsetDateTime::now_utc()
20 .format(&time::format_description::well_known::Rfc3339)
21 .unwrap_or_default()
22}
23
24pub fn delivery_turn(
32 update: &SessionUpdate,
33 slot: &SessionSlot,
34) -> Option<(Message, PromptSource)> {
35 let (source, kind, text, uuid) = match update {
36 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 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 let mut row = Message::display_only_user(text, uuid.clone());
74 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 #[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 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 #[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 #[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 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 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 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 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}