Skip to main content

forge_server/
composer.rs

1//! The composer's live state: whether a seat is compacting and the landed
2//! dictation nobody has sent yet.
3//!
4//! The wire announces each of these once and retains nothing, so what a
5//! composer draws is folded here from the stream. A view that attaches to an
6//! already-running session reads none of it from the core, which is why it
7//! lives beside the surface rather than in the view - and for the landed
8//! words it is also the point: a page that reloads between the landing and
9//! the send reads them back from the record rather than losing them (#1360).
10
11use std::collections::{HashMap, HashSet};
12
13use forge_primitives::Message;
14use forge_primitives::SessionSlot;
15
16use crate::SessionUpdate;
17
18/// What the core's own reads cannot answer: the compaction the CLI announced
19/// on the status frame and the landed take nobody sent.
20#[derive(Default, Clone)]
21pub struct Composer {
22    compacting: HashSet<SessionSlot>,
23    /// The last landed take's words per seat, kept until a prompt carries
24    /// them away: the wire lands them once, to the connection that dictated,
25    /// and a page that reloads before the send reads them back from here.
26    landed: HashMap<SessionSlot, Landed>,
27}
28
29/// A landed take's words, as the record re-hands them: what would have gone
30/// into the draft, whether the take was partial, and the take's generation -
31/// the landing memory's discriminator, so a re-hand is read as the same words
32/// rather than a new landing.
33#[derive(Clone, serde::Serialize, serde::Deserialize)]
34pub struct Landed {
35    pub text: String,
36    pub truncated: bool,
37    pub generation: u64,
38    /// The surface whose own capture began the take, as the stream carried
39    /// it: the words are that surface's to place, and the record says so on
40    /// every re-hand.
41    #[serde(default)]
42    pub surface: Option<String>,
43}
44
45impl Composer {
46    /// Fold one update in: the compaction the CLI announced and the landed
47    /// take's words.
48    pub fn apply(&mut self, update: &SessionUpdate) {
49        match update {
50            // **A landing is the seat's until it is sent** (#1360). The wire
51            // hands one to its own dictating connection and retains nothing,
52            // so a reload in between used to lose the words. A later failure
53            // (any other outcome) leaves the older words standing - the
54            // reader never sent them.
55            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            // **The dock's answer is a send too** (#1360): a reader who dictated
73            // into an ask's own box sends the words through `Respond*`, which
74            // writes no user row - so a resolution whose words ARE the landed
75            // ones takes them with it. Any other answer leaves them: the
76            // reader has still not sent those words.
77            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            // **A person's send takes the words away, and nothing else** (#1360).
83            // A user row is a prompt OR a tool-result envelope - the CLI
84            // answers every tool call with one, and a compaction or an
85            // interrupt writes more - so the frame's kind alone would drop
86            // the words seconds after they landed, mid-turn. `source` says
87            // who dispatched it, and only `You` is a person's send (#2198's
88            // rule): a tool result carries none, and forge's own machinery
89            // (auto-continue, a worker kick) carries `Forge`.
90            SessionUpdate::ChatAppended {
91                key,
92                source: Some(forge_workspace::PromptSource::You),
93                ..
94            } => {
95                self.landed.remove(key);
96            }
97            // **A compaction dies with the session running it.** A seat that
98            // died mid-compaction and one replaced under it would otherwise
99            // read "compacting this session" until forge restarts.
100            SessionUpdate::ConnectionFailed { key, .. }
101            | SessionUpdate::SessionReplaced { key, .. } => {
102                self.compacting.remove(key);
103            }
104            // The CLI announces a compaction on the status frame and
105            // clears it with a null, which is the only place either is
106            // said. Everything else on the conversation is the chat's.
107            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    /// The words a landed take left that nobody has sent yet, if there are any.
128    pub fn landed(&self, slot: &SessionSlot) -> Option<&Landed> {
129        self.landed.get(slot)
130    }
131
132    /// A write sent the seat's words through a path this fold does not see -
133    /// a review or document comment - so the take they came from is spent.
134    ///
135    /// **Only the landed words take them.** A comment about something else
136    /// leaves the take's words in the seat: the reader has still not sent
137    /// them (#2283).
138    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    /// A tool-result envelope: the CLI answers every tool call with one of
188    /// these, and it is a user row.
189    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    /// **A compaction dies with the session that was running it.** A seat
291    /// that died mid-compaction, or one replaced under it, would otherwise
292    /// read "compacting this session" until forge restarts.
293    #[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}