1use std::collections::{HashMap, HashSet};
12
13use forge_primitives::Message;
14use forge_primitives::SessionSlot;
15
16use crate::SessionUpdate;
17
18#[derive(Default, Clone)]
21pub struct Composer {
22 compacting: HashSet<SessionSlot>,
23 landed: HashMap<SessionSlot, Landed>,
27}
28
29#[derive(Clone, serde::Serialize, serde::Deserialize)]
34pub struct Landed {
35 pub text: String,
36 pub truncated: bool,
37 pub generation: u64,
38 #[serde(default)]
42 pub surface: Option<String>,
43}
44
45impl Composer {
46 pub fn apply(&mut self, update: &SessionUpdate) {
49 match update {
50 SessionUpdate::DictateEnded {
56 key,
57 outcome: crate::surface::DictateOutcome::Landed { text, truncated },
58 generation,
59 surface,
60 ..
61 } => {
62 self.landed.insert(
63 key.clone(),
64 Landed {
65 text: text.clone(),
66 truncated: *truncated,
67 generation: *generation,
68 surface: surface.clone(),
69 },
70 );
71 }
72 SessionUpdate::PendingInteractionResolved {
78 key, answered_with: Some(words), ..
79 } if self.landed.get(key).is_some_and(|landed| landed.text == *words) => {
80 self.landed.remove(key);
81 }
82 SessionUpdate::ChatAppended {
91 key,
92 source: Some(forge_workspace::PromptSource::You),
93 ..
94 } => {
95 self.landed.remove(key);
96 }
97 SessionUpdate::ConnectionFailed { key, .. }
101 | SessionUpdate::SessionReplaced { key, .. } => {
102 self.compacting.remove(key);
103 }
104 SessionUpdate::ChatAppended { key, msg, .. } => {
108 if let Message::System { subtype, data, .. } = msg
109 && subtype == "status"
110 {
111 let field = data.get("status");
112 if field.and_then(serde_json::Value::as_str) == Some("compacting") {
113 self.compacting.insert(key.clone());
114 } else if field.is_some_and(serde_json::Value::is_null) {
115 self.compacting.remove(key);
116 }
117 }
118 }
119 _ => {}
120 }
121 }
122
123 pub fn compacting(&self, slot: &SessionSlot) -> bool {
124 self.compacting.contains(slot)
125 }
126
127 pub fn landed(&self, slot: &SessionSlot) -> Option<&Landed> {
129 self.landed.get(slot)
130 }
131
132 pub fn sent(&mut self, slot: &SessionSlot, words: &str) {
139 if self.landed.get(slot).is_some_and(|landed| landed.text == words) {
140 self.landed.remove(slot);
141 }
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::surface::DictateOutcome;
149
150 fn seat() -> SessionSlot {
151 SessionSlot::lead("TestOrg", "proj")
152 }
153
154 fn landed(text: &str, generation: u64) -> SessionUpdate {
155 SessionUpdate::DictateEnded {
156 key: seat(),
157 outcome: DictateOutcome::Landed { text: text.to_owned(), truncated: false },
158 generation,
159 surface: None,
160 initiator: None,
161 }
162 }
163
164 fn a_prompt() -> SessionUpdate {
165 let msg: Message = serde_json::from_value(serde_json::json!({
166 "type": "user",
167 "session_id": "s1",
168 "message": {"role": "user", "content": "sent"},
169 }))
170 .expect("a user frame");
171 SessionUpdate::ChatAppended {
172 key: seat(),
173 msg,
174 source: Some(forge_workspace::PromptSource::You),
175 }
176 }
177
178 fn a_user_row() -> Message {
179 serde_json::from_value(serde_json::json!({
180 "type": "user",
181 "session_id": "s1",
182 "message": {"role": "user", "content": "words"},
183 }))
184 .expect("a user frame")
185 }
186
187 fn a_tool_result() -> SessionUpdate {
190 let msg: Message = serde_json::from_value(serde_json::json!({
191 "type": "user",
192 "session_id": "s1",
193 "message": {"role": "user", "content": [
194 {"type": "tool_result", "tool_use_id": "tu1", "content": "ok"},
195 ]},
196 }))
197 .expect("a tool-result frame");
198 SessionUpdate::ChatAppended { key: seat(), msg, source: None }
199 }
200
201 #[test]
202 fn a_tool_result_does_not_take_the_words() {
203 let mut composer = Composer::default();
204 composer.apply(&landed("mine", 1));
205 composer.apply(&a_tool_result());
206 assert_eq!(
207 composer.landed(&seat()).map(|words| words.text.as_str()),
208 Some("mine"),
209 "the CLI answers every tool call with a user row - a turn's own work must not \
210 swallow words the reader has not sent",
211 );
212 }
213
214 #[test]
215 fn an_answer_carrying_the_landed_words_takes_them() {
216 let mut composer = Composer::default();
217 composer.apply(&landed("the dictated answer", 5));
218 composer.apply(&SessionUpdate::PendingInteractionResolved {
219 key: seat(),
220 tool_id: "tu-ask".to_owned(),
221 question_index: None,
222 answered_with: Some("a different pick's words".to_owned()),
223 });
224 assert_eq!(
225 composer.landed(&seat()).map(|words| words.text.as_str()),
226 Some("the dictated answer"),
227 "an answer that is not the landed words leaves them - the reader has not sent them",
228 );
229 composer.apply(&SessionUpdate::PendingInteractionResolved {
230 key: seat(),
231 tool_id: "tu-ask".to_owned(),
232 question_index: None,
233 answered_with: Some("the dictated answer".to_owned()),
234 });
235 assert!(
236 composer.landed(&seat()).is_none(),
237 "and the answer carrying exactly them takes them, which is #1309's rule kept",
238 );
239 }
240
241 #[test]
242 fn forges_own_machinery_does_not_take_the_words() {
243 let mut composer = Composer::default();
244 composer.apply(&landed("mine", 1));
245 composer.apply(&SessionUpdate::ChatAppended {
246 key: seat(),
247 msg: a_user_row(),
248 source: Some(forge_workspace::PromptSource::Forge),
249 });
250 assert_eq!(
251 composer.landed(&seat()).map(|words| words.text.as_str()),
252 Some("mine"),
253 "a worker kick or an auto-continue is not the reader's send",
254 );
255 }
256
257 #[test]
258 fn a_landing_is_kept_until_a_prompt_carries_it_away() {
259 let mut composer = Composer::default();
260 composer.apply(&landed("first words", 3));
261 let kept = composer.landed(&seat()).expect("the words are kept");
262 assert_eq!(kept.text, "first words");
263 assert_eq!(kept.generation, 3, "with the take's own number");
264
265 composer.apply(&a_prompt());
266 assert!(
267 composer.landed(&seat()).is_none(),
268 "the sent prompt takes the words with it, so nothing already sent is handed back",
269 );
270 }
271
272 #[test]
273 fn a_failed_take_leaves_the_unsent_words_standing() {
274 let mut composer = Composer::default();
275 composer.apply(&landed("mine", 1));
276 composer.apply(&SessionUpdate::DictateEnded {
277 key: seat(),
278 outcome: DictateOutcome::Failed,
279 generation: 2,
280 surface: None,
281 initiator: None,
282 });
283 assert_eq!(
284 composer.landed(&seat()).map(|words| words.text.as_str()),
285 Some("mine"),
286 "the reader never sent the older words, so a later failure leaves them",
287 );
288 }
289
290 #[test]
294 fn a_death_or_a_replacement_ends_the_compaction() {
295 fn status() -> SessionUpdate {
296 let msg: Message = serde_json::from_value(serde_json::json!({
297 "type": "system", "session_id": "s1", "subtype": "status", "status": "compacting",
298 }))
299 .expect("a status frame");
300 SessionUpdate::ChatAppended { key: seat(), msg, source: None }
301 }
302 let replacement = SessionUpdate::SessionReplaced {
303 key: seat(),
304 session_id: forge_primitives::SessionId::new("new"),
305 cwd: "/tmp".to_owned(),
306 current_model: forge_primitives::runtime::CurrentModel::new("m", "m", "m"),
307 available_models: Vec::new(),
308 mode: None,
309 history: Vec::new(),
310 dropped: 0,
311 compaction_count: 0,
312 };
313 for end in [
314 SessionUpdate::ConnectionFailed {
315 key: seat(),
316 message: "the CLI exited".to_owned(),
317 fatal: false,
318 },
319 replacement,
320 ] {
321 let mut composer = Composer::default();
322 composer.apply(&status());
323 assert!(composer.compacting(&seat()), "precondition: the frame set it");
324
325 composer.apply(&end);
326
327 assert!(!composer.compacting(&seat()), "the seat is not compacting any more: {end:?}");
328 }
329 }
330
331 #[test]
332 fn a_newer_landing_replaces_the_older_words() {
333 let mut composer = Composer::default();
334 composer.apply(&landed("first", 1));
335 composer.apply(&landed("second", 2));
336 assert_eq!(
337 composer.landed(&seat()).map(|words| words.text.as_str()),
338 Some("second"),
339 "the newest landing is what the record re-hands",
340 );
341 }
342}