1use af_agent_session::{ContentBlock, DeliveryMode, Event, SessionEvent, SessionProjection};
2use af_context::RunId;
3use af_llm::{ChatMessage, InputImage};
4
5use crate::RuntimeError;
6
7#[derive(Debug, Clone)]
9pub struct RunHistory {
10 pub(crate) run_id: RunId,
11 pub(crate) projection: SessionProjection,
12 pub(crate) events: Vec<SessionEvent>,
13 pub(crate) transcript: Vec<ChatMessage>,
14}
15
16pub struct RunHistoryReplay {
19 run_id: RunId,
20 projection: SessionProjection,
21 events: Vec<SessionEvent>,
22 capturing_run: bool,
23 latest_images: Vec<InputImage>,
24 pending_summary: Option<(u64, String, Vec<InputImage>)>,
25 summary: Option<(u64, String, Vec<InputImage>)>,
26 model_seq: Option<u64>,
27 transcript: Vec<ChatMessage>,
28}
29
30impl RunHistoryReplay {
31 pub fn new(run_id: RunId) -> Self {
33 Self {
34 run_id,
35 projection: Default::default(),
36 events: Vec::new(),
37 capturing_run: false,
38 latest_images: Vec::new(),
39 pending_summary: None,
40 summary: None,
41 model_seq: None,
42 transcript: Vec::new(),
43 }
44 }
45
46 pub fn facts(&mut self, events: &[SessionEvent]) -> Result<(), RuntimeError> {
48 if self.model_seq.is_some() {
49 return Err(RuntimeError::Invariant(
50 "facts after model replay began".into(),
51 ));
52 }
53 for event in events {
54 let open = self.projection.open_compaction.clone();
55 let active = self.projection.active_run_id.clone();
56 self.projection
57 .apply_facts(event)
58 .map_err(|error| RuntimeError::Invariant(error.to_string()))?;
59 match &event.event {
60 Event::InputQueued {
61 run_id,
62 mode: DeliveryMode::Followup,
63 ..
64 }
65 | Event::RunStarted { run_id, .. }
66 if run_id == &self.run_id =>
67 {
68 self.capturing_run = true
69 }
70 Event::UserMessage { content, .. } => {
71 self.latest_images.clear();
72 for block in content {
73 if let ContentBlock::Resource {
74 resource_id,
75 media_type,
76 } = block
77 {
78 if media_type.starts_with("image/") {
79 let image = InputImage {
80 asset_id: resource_id.parse().map_err(|_| {
81 RuntimeError::InvalidInput(
82 "invalid stored image identity".into(),
83 )
84 })?,
85 media_type: media_type.clone(),
86 };
87 image.validate().map_err(|error| {
88 RuntimeError::InvalidInput(error.to_string())
89 })?;
90 self.latest_images.push(image);
91 }
92 }
93 }
94 }
95 Event::SummaryReplaced {
96 run_id,
97 through_seq,
98 summary,
99 ..
100 } if active.as_ref() == Some(run_id)
101 && open.as_ref().is_some_and(|(_, seq)| seq == through_seq)
102 && *through_seq < event.seq =>
103 {
104 self.pending_summary =
105 Some((event.seq, summary.clone(), self.latest_images.clone()));
106 }
107 Event::CompactionFinished { status, error, .. } => {
108 if status == "completed" && error.is_none() {
109 if let Some(summary) = self.pending_summary.take() {
110 self.summary = Some(summary);
111 }
112 } else {
113 self.pending_summary = None;
114 }
115 }
116 Event::CompactionStarted { .. } => self.pending_summary = None,
117 _ => {}
118 }
119 if self.capturing_run {
120 self.events.push(event.clone());
121 }
122 }
123 Ok(())
124 }
125
126 pub fn begin_model_tail(&mut self) -> u64 {
128 if let Some(seq) = self.model_seq {
129 return seq;
130 }
131 let seq = if let Some((seq, summary, images)) = &self.summary {
132 if !images.is_empty() {
133 let mut message = ChatMessage::user("");
134 message.images = images.clone();
135 self.transcript.push(message);
136 }
137 crate::replay::replace_with_summary(&mut self.transcript, summary);
138 *seq
139 } else {
140 0
141 };
142 self.model_seq = Some(seq);
143 seq
144 }
145
146 pub fn model_tail(&mut self, events: &[SessionEvent]) -> Result<(), RuntimeError> {
148 let mut seq = self
149 .model_seq
150 .ok_or_else(|| RuntimeError::Invariant("model tail was not initialized".into()))?;
151 for event in events {
152 if event.seq != seq + 1
153 || event.seq > self.projection.last_seq
154 || self.projection.session_id.as_ref() != Some(&event.session_id)
155 {
156 return Err(RuntimeError::Invariant(
157 "model tail boundary changed".into(),
158 ));
159 }
160 if !matches!(event.event, Event::SummaryReplaced { .. }) {
162 crate::replay::apply_transcript_event(&mut self.transcript, event)?;
163 }
164 seq = event.seq;
165 }
166 self.model_seq = Some(seq);
167 Ok(())
168 }
169
170 pub fn finish(self) -> Result<RunHistory, RuntimeError> {
172 if self.model_seq != Some(self.projection.last_seq) {
173 return Err(RuntimeError::Invariant("incomplete model tail".into()));
174 }
175 Ok(RunHistory {
176 run_id: self.run_id,
177 projection: self.projection,
178 events: self.events,
179 transcript: self.transcript,
180 })
181 }
182}
183
184impl RunHistory {
185 pub fn last_seq(&self) -> u64 {
187 self.projection.last_seq
188 }
189
190 pub fn from_events(run_id: RunId, events: &[SessionEvent]) -> Result<Self, RuntimeError> {
192 let mut replay = RunHistoryReplay::new(run_id);
193 replay.facts(events)?;
194 let after = replay.begin_model_tail();
195 replay.model_tail(&events[events.partition_point(|event| event.seq <= after)..])?;
196 replay.finish()
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use af_agent_session::{text, RunStatus};
204
205 fn history(completed: bool) -> Vec<SessionEvent> {
206 let run_id: RunId = "old-run".parse().unwrap();
207 let events = vec![
208 Event::SessionCreated {
209 profile_revision_id: "profile".parse().unwrap(),
210 },
211 Event::InputQueued {
212 input_id: "old-input".parse().unwrap(),
213 run_id: run_id.clone(),
214 mode: DeliveryMode::Followup,
215 content: text("old"),
216 explicit_skill: None,
217 },
218 Event::InputClaimed {
219 input_id: "old-input".parse().unwrap(),
220 run_id: run_id.clone(),
221 },
222 Event::RunStarted {
223 input_id: "old-input".parse().unwrap(),
224 run_id: run_id.clone(),
225 },
226 Event::TurnStarted {
227 run_id: run_id.clone(),
228 turn: 1,
229 },
230 Event::StepStarted {
231 run_id: run_id.clone(),
232 step: 1,
233 },
234 Event::UserMessage {
235 run_id: run_id.clone(),
236 content: vec![
237 ContentBlock::Text {
238 text: "old large transcript".repeat(1000),
239 },
240 ContentBlock::Resource {
241 resource_id: "image".into(),
242 media_type: "image/png".into(),
243 },
244 ],
245 },
246 Event::UsageRecorded {
247 metering: None,
248 run_id: run_id.clone(),
249 operation_id: "attempt".into(),
250 prompt_tokens: 5,
251 completion_tokens: 3,
252 cost_units: 0,
253 },
254 Event::CompactionStarted {
255 run_id: run_id.clone(),
256 compaction_id: "compact".into(),
257 source_through_seq: 8,
258 },
259 Event::SummaryReplaced {
260 run_id: run_id.clone(),
261 through_seq: 8,
262 summary: "confirmed summary".into(),
263 compactor: "test".into(),
264 model: "test".into(),
265 },
266 Event::CompactionFinished {
267 run_id: run_id.clone(),
268 compaction_id: "compact".into(),
269 status: if completed { "completed" } else { "failed" }.into(),
270 error: (!completed).then(|| "failed".into()),
271 },
272 Event::StepFinished {
273 run_id: run_id.clone(),
274 step: 1,
275 },
276 Event::TurnFinished {
277 run_id: run_id.clone(),
278 turn: 1,
279 },
280 Event::RunFinished {
281 run_id,
282 status: RunStatus::Completed,
283 error_code: None,
284 },
285 Event::InputQueued {
286 input_id: "input".parse().unwrap(),
287 run_id: "new-run".parse().unwrap(),
288 mode: DeliveryMode::Followup,
289 content: text("new"),
290 explicit_skill: None,
291 },
292 ];
293 events
294 .into_iter()
295 .enumerate()
296 .map(|(index, event)| SessionEvent {
297 session_id: "session".parse().unwrap(),
298 seq: index as u64 + 1,
299 occurred_at: chrono::Utc::now(),
300 event,
301 })
302 .collect()
303 }
304
305 #[test]
306 fn successful_summary_starts_model_tail_without_losing_images_or_usage() {
307 let events = history(true);
308 let mut replay = RunHistoryReplay::new("new-run".parse().unwrap());
309 for page in events.chunks(3) {
310 replay.facts(page).unwrap();
311 }
312 assert_eq!(replay.begin_model_tail(), 10);
313 for page in events[10..].chunks(2) {
314 replay.model_tail(page).unwrap();
315 }
316 let resumed = replay.finish().unwrap();
317 assert_eq!(resumed.projection.usage_for("old-run"), (5, 3));
318 assert_eq!(resumed.events.len(), 1);
319 assert_eq!(resumed.transcript.len(), 2);
320 assert_eq!(
321 resumed.transcript[0].content.as_deref(),
322 Some("Conversation summary:\nconfirmed summary")
323 );
324 assert_eq!(resumed.transcript[1].images[0].asset_id, "image");
325 assert!(resumed.projection.messages.is_empty());
326 }
327
328 #[test]
329 fn failed_or_unfinished_summaries_never_hide_original_messages() {
330 for events in [history(false), history(true)[..10].to_vec()] {
331 let mut replay = RunHistoryReplay::new("new-run".parse().unwrap());
332 replay.facts(&events).unwrap();
333 assert_eq!(replay.begin_model_tail(), 0);
334 replay.model_tail(&events).unwrap();
335 let resumed = replay.finish().unwrap();
336 assert!(resumed.transcript[0]
337 .content
338 .as_deref()
339 .unwrap()
340 .contains("old large transcript"));
341 assert_eq!(resumed.projection.usage_for("old-run"), (5, 3));
342 }
343 }
344
345 #[test]
346 fn a_model_tail_cannot_skip_events_or_cross_the_validated_boundary() {
347 let events = history(true);
348 let mut replay = RunHistoryReplay::new("new-run".parse().unwrap());
349 replay.facts(&events).unwrap();
350 assert!(replay.model_tail(&events).is_err());
351 replay.begin_model_tail();
352 assert!(replay.model_tail(&events[11..]).is_err());
353 assert!(replay.facts(&[]).is_err());
354 assert!(replay.finish().is_err());
355 }
356}