Skip to main content

roder_api/thread/
projection.rs

1use time::OffsetDateTime;
2
3use crate::events::{EventEnvelope, RoderEvent, ThreadId};
4
5use super::{
6    ThreadItem, ThreadItemDelta, ThreadItemEvent, ThreadItemEventKind, ThreadItemStatus,
7    ThreadItemTurnRecord, TurnRecord,
8};
9
10/// Projects raw runtime event envelopes into per-turn transcript records.
11///
12/// Thread stores that persist `EventEnvelope` logs can use this to populate a
13/// snapshot's `turns` field without duplicating the runtime replay rules.
14pub fn project_turns_from_events(
15    thread_id: &ThreadId,
16    events: &[EventEnvelope],
17) -> Vec<TurnRecord> {
18    let mut turns = Vec::new();
19    for envelope in events {
20        match &envelope.event {
21            RoderEvent::TurnStarted(event) => {
22                ensure_turn_record(&mut turns, thread_id, &event.turn_id, event.timestamp);
23            }
24            RoderEvent::TranscriptItemAppended(event) => {
25                let turn =
26                    ensure_turn_record(&mut turns, thread_id, &event.turn_id, event.timestamp);
27                if let Some(item) = &event.item {
28                    turn.items.push(item.clone());
29                }
30            }
31            RoderEvent::TurnCompleted(event) => {
32                let turn =
33                    ensure_turn_record(&mut turns, thread_id, &event.turn_id, event.timestamp);
34                turn.completed_at = Some(event.timestamp);
35                turn.usage = event.usage.clone();
36                turn.finish_reason = event.finish_reason.clone();
37            }
38            RoderEvent::TurnFailed(event) => {
39                let turn =
40                    ensure_turn_record(&mut turns, thread_id, &event.turn_id, event.timestamp);
41                turn.completed_at = Some(event.timestamp);
42                turn.usage = event.usage.clone();
43            }
44            RoderEvent::TurnInterrupted(event) => {
45                let turn =
46                    ensure_turn_record(&mut turns, thread_id, &event.turn_id, event.timestamp);
47                turn.completed_at = Some(event.timestamp);
48                turn.usage = event.usage.clone();
49            }
50            _ => continue,
51        }
52    }
53    turns
54}
55
56fn ensure_turn_record<'a>(
57    turns: &'a mut Vec<TurnRecord>,
58    thread_id: &ThreadId,
59    turn_id: &str,
60    created_at: OffsetDateTime,
61) -> &'a mut TurnRecord {
62    if let Some(index) = turns.iter().position(|turn| turn.turn_id == turn_id) {
63        return &mut turns[index];
64    }
65    turns.push(TurnRecord {
66        thread_id: thread_id.clone(),
67        turn_id: turn_id.to_string(),
68        items: Vec::new(),
69        created_at,
70        completed_at: None,
71        usage: None,
72        finish_reason: None,
73    });
74    turns.last_mut().expect("turn was just pushed")
75}
76
77pub fn project_thread_item_events(events: &[ThreadItemEvent]) -> Vec<ThreadItemTurnRecord> {
78    let mut turns = Vec::<ThreadItemTurnRecord>::new();
79    for event in events {
80        let turn_index = turns
81            .iter()
82            .position(|turn| turn.turn_id == event.turn_id)
83            .unwrap_or_else(|| {
84                turns.push(ThreadItemTurnRecord {
85                    thread_id: event.thread_id.clone(),
86                    turn_id: event.turn_id.clone(),
87                    created_at: event.timestamp,
88                    items: Vec::new(),
89                });
90                turns.len() - 1
91            });
92        apply_thread_item_event(&mut turns[turn_index].items, &event.event);
93    }
94    turns
95}
96
97fn apply_thread_item_event(items: &mut Vec<ThreadItem>, event: &ThreadItemEventKind) {
98    match event {
99        ThreadItemEventKind::ItemStarted { item } => {
100            if !upsert_item_if_missing(items, item.clone()) {
101                merge_started_item(items, item);
102            }
103        }
104        ThreadItemEventKind::ItemDelta { item_id, delta } => {
105            let index = item_index(items, item_id).unwrap_or_else(|| {
106                items.push(item_for_delta(item_id, delta));
107                items.len() - 1
108            });
109            apply_thread_item_delta(&mut items[index], delta);
110        }
111        ThreadItemEventKind::ItemCompleted { item } => {
112            if let Some(index) = item_index(items, item.id()) {
113                merge_completed_item(&mut items[index], item.clone());
114            } else {
115                items.push(item.clone());
116            }
117        }
118    }
119}
120
121fn upsert_item_if_missing(items: &mut Vec<ThreadItem>, item: ThreadItem) -> bool {
122    if item_index(items, item.id()).is_some() {
123        return false;
124    }
125    items.push(item);
126    true
127}
128
129fn merge_started_item(items: &mut [ThreadItem], incoming: &ThreadItem) {
130    if let Some(index) = item_index(items, incoming.id()) {
131        merge_completed_item(&mut items[index], incoming.clone());
132    }
133}
134
135fn item_index(items: &[ThreadItem], item_id: &str) -> Option<usize> {
136    items.iter().position(|item| item.id() == item_id)
137}
138
139fn item_for_delta(item_id: &str, delta: &ThreadItemDelta) -> ThreadItem {
140    match delta {
141        ThreadItemDelta::AgentMessageText { phase, .. } => ThreadItem::AgentMessage {
142            id: item_id.to_string(),
143            text: String::new(),
144            phase: phase.clone(),
145            status: Some(ThreadItemStatus::InProgress),
146        },
147        ThreadItemDelta::ReasoningText { content_index, .. } => ThreadItem::Reasoning {
148            id: item_id.to_string(),
149            summary: Vec::new(),
150            content: vec![String::new(); content_index + 1],
151            status: Some(ThreadItemStatus::InProgress),
152        },
153        ThreadItemDelta::ReasoningSummaryPartAdded { summary_index }
154        | ThreadItemDelta::ReasoningSummaryText { summary_index, .. } => ThreadItem::Reasoning {
155            id: item_id.to_string(),
156            summary: vec![String::new(); summary_index + 1],
157            content: Vec::new(),
158            status: Some(ThreadItemStatus::InProgress),
159        },
160    }
161}
162
163fn apply_thread_item_delta(item: &mut ThreadItem, delta: &ThreadItemDelta) {
164    match (item, delta) {
165        (
166            ThreadItem::AgentMessage {
167                text,
168                phase,
169                status,
170                ..
171            },
172            ThreadItemDelta::AgentMessageText {
173                delta,
174                phase: delta_phase,
175            },
176        ) => {
177            text.push_str(delta);
178            if phase.is_none() {
179                *phase = delta_phase.clone();
180            }
181            *status = Some(ThreadItemStatus::InProgress);
182        }
183        (
184            ThreadItem::Reasoning {
185                content, status, ..
186            },
187            ThreadItemDelta::ReasoningText {
188                delta,
189                content_index,
190            },
191        ) => {
192            ensure_vec_slot(content, *content_index);
193            content[*content_index].push_str(delta);
194            *status = Some(ThreadItemStatus::InProgress);
195        }
196        (
197            ThreadItem::Reasoning {
198                summary, status, ..
199            },
200            ThreadItemDelta::ReasoningSummaryPartAdded { summary_index },
201        ) => {
202            ensure_vec_slot(summary, *summary_index);
203            *status = Some(ThreadItemStatus::InProgress);
204        }
205        (
206            ThreadItem::Reasoning {
207                summary, status, ..
208            },
209            ThreadItemDelta::ReasoningSummaryText {
210                delta,
211                summary_index,
212            },
213        ) => {
214            ensure_vec_slot(summary, *summary_index);
215            summary[*summary_index].push_str(delta);
216            *status = Some(ThreadItemStatus::InProgress);
217        }
218        (item, delta) => {
219            *item = item_for_delta(item.id(), delta);
220            apply_thread_item_delta(item, delta);
221        }
222    }
223}
224
225fn ensure_vec_slot(values: &mut Vec<String>, index: usize) {
226    while values.len() <= index {
227        values.push(String::new());
228    }
229}
230
231fn merge_completed_item(existing: &mut ThreadItem, incoming: ThreadItem) {
232    match (&mut *existing, incoming) {
233        (
234            ThreadItem::Reasoning {
235                summary,
236                content,
237                status,
238                ..
239            },
240            ThreadItem::Reasoning {
241                summary: incoming_summary,
242                content: incoming_content,
243                status: incoming_status,
244                ..
245            },
246        ) => {
247            if !incoming_summary.is_empty() {
248                *summary = incoming_summary;
249            }
250            if !incoming_content.is_empty() {
251                *content = incoming_content;
252            }
253            *status = incoming_status.or(Some(ThreadItemStatus::Completed));
254        }
255        (
256            ThreadItem::ToolExecution {
257                status,
258                input,
259                output,
260                error,
261                ..
262            },
263            ThreadItem::ToolExecution {
264                status: incoming_status,
265                input: incoming_input,
266                output: incoming_output,
267                error: incoming_error,
268                ..
269            },
270        ) => {
271            *status = incoming_status;
272            if incoming_input.is_some() {
273                *input = incoming_input;
274            }
275            if incoming_output.is_some() {
276                *output = incoming_output;
277            }
278            if incoming_error.is_some() {
279                *error = incoming_error;
280            }
281        }
282        (slot, incoming) => *slot = incoming,
283    }
284}