1mod materialize;
4mod observation;
5mod session_update;
6mod terminals;
7pub use materialize::*;
8pub use observation::*;
9use session_update::*;
10use terminals::*;
11
12mod api_events;
13
14use std::collections::{BTreeMap, BTreeSet, HashMap};
15use std::sync::Arc;
16
17use agent_client_protocol::schema::{
18 MaybeUndefined,
19 v1::{
20 ContentBlock, ContentChunk, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus,
21 SessionUpdate, TextContent, ToolCall, ToolCallContent, ToolCallStatus,
22 ToolCallUpdateFields,
23 },
24};
25use anyhow::{Context, Result, bail};
26use serde::Deserialize;
27use serde_json::Value;
28use sha2::{Digest, Sha256};
29
30use crate::transcript::{ChatEntry, ChatRole, PlanStatus, ToolStatus, tool_call_presentation};
31use mj_core::archive::{
32 CanonicalExecutionState, CanonicalQueuedCommandKind, CanonicalQueuedPrompt,
33 CanonicalSessionSnapshot, CanonicalSessionState, CanonicalTerminalOutput,
34 CanonicalTranscriptBody, CanonicalTranscriptItem,
35};
36use mj_core::relay::{
37 RELAY_EVENT_GENESIS_DIGEST, RelayCommand, RelayCommandKind, RelayEvent, RelayObservation,
38 SequencedEvent, WorkerEvent, WorkerPhase, validate_relay_event,
39};
40use mj_core::state::{
41 MaterializedExecutionState, MaterializedQueuedPrompt, MaterializedSession, MaterializedTurn,
42 MaterializedTurnOutcome, QueuedCommandKind, TerminalOutputRecord, TranscriptBody,
43 TranscriptItem, TurnOutcomeKind, config_command_text, normalize_session_title,
44 provisional_session_title,
45};
46use mj_core::storage::{MaterializedSessionMutation, ProjectionIntegrityError, TranscriptMutation};
47
48#[derive(Debug, Clone, PartialEq)]
49pub struct ProjectedRelayEvent {
50 pub mutation: MaterializedSessionMutation,
51}
52
53#[derive(Debug, Clone)]
58pub struct ProjectionIndex {
59 transcript: HashMap<String, Arc<TranscriptItem>>,
60 transcript_positions: HashMap<String, usize>,
61 open_agent_streams: BTreeSet<(u64, String)>,
62 open_thought_streams: BTreeSet<(u64, String)>,
63 terminal_referrers: HashMap<String, BTreeSet<String>>,
64}
65
66impl ProjectionIndex {
67 pub fn new(current: &MaterializedSession) -> Self {
68 let mut index = Self {
69 transcript: HashMap::with_capacity(current.transcript.len()),
70 transcript_positions: HashMap::with_capacity(current.transcript.len()),
71 open_agent_streams: BTreeSet::new(),
72 open_thought_streams: BTreeSet::new(),
73 terminal_referrers: HashMap::new(),
74 };
75 for (position, item) in current.transcript.iter().enumerate() {
76 index.insert_at(item.clone(), position);
77 }
78 index
79 }
80
81 fn get(&self, stable_id: &str) -> Option<&Arc<TranscriptItem>> {
82 self.transcript.get(stable_id)
83 }
84
85 fn position(&self, stable_id: &str) -> Option<usize> {
86 self.transcript_positions.get(stable_id).copied()
87 }
88
89 fn insert(&mut self, item: Arc<TranscriptItem>) {
90 let position = self
91 .remove(&item.stable_id)
92 .unwrap_or(self.transcript.len());
93 self.insert_at(item, position);
94 }
95
96 fn insert_at(&mut self, item: Arc<TranscriptItem>, position: usize) {
97 let stream = (item.position, item.stable_id.clone());
98 match &item.body {
99 TranscriptBody::Agent {
100 streaming: true, ..
101 } => {
102 self.open_agent_streams.insert(stream);
103 }
104 TranscriptBody::Thought {
105 streaming: true, ..
106 } => {
107 self.open_thought_streams.insert(stream);
108 }
109 TranscriptBody::Tool {
110 call,
111 terminal_refs,
112 ..
113 } => {
114 let mut terminal_ids = tool_call_terminal_ids(call);
115 terminal_ids.extend(terminal_refs.iter().cloned());
116 for terminal_id in terminal_ids {
117 self.terminal_referrers
118 .entry(terminal_id)
119 .or_default()
120 .insert(item.stable_id.clone());
121 }
122 }
123 _ => {}
124 }
125 self.transcript_positions
126 .insert(item.stable_id.clone(), position);
127 self.transcript.insert(item.stable_id.clone(), item);
128 }
129
130 fn remove(&mut self, stable_id: &str) -> Option<usize> {
131 let item = self.transcript.remove(stable_id)?;
132 let position = self.transcript_positions.remove(stable_id);
133 debug_assert!(position.is_some());
134 let stream = (item.position, item.stable_id.clone());
135 self.open_agent_streams.remove(&stream);
136 self.open_thought_streams.remove(&stream);
137 if let TranscriptBody::Tool {
138 call,
139 terminal_refs,
140 ..
141 } = &item.body
142 {
143 let mut terminal_ids = tool_call_terminal_ids(call);
144 terminal_ids.extend(terminal_refs.iter().cloned());
145 for terminal_id in terminal_ids {
146 if let Some(referrers) = self.terminal_referrers.get_mut(&terminal_id) {
147 referrers.remove(stable_id);
148 if referrers.is_empty() {
149 self.terminal_referrers.remove(&terminal_id);
150 }
151 }
152 }
153 }
154 position
155 }
156
157 fn reindex_after_removal(&mut self, transcript: &[Arc<TranscriptItem>], removed: usize) {
158 for (position, item) in transcript.iter().enumerate().skip(removed) {
159 self.transcript_positions
160 .insert(item.stable_id.clone(), position);
161 }
162 }
163
164 fn latest_open_stream(&self, agent: bool) -> Option<&Arc<TranscriptItem>> {
165 let streams = if agent {
166 &self.open_agent_streams
167 } else {
168 &self.open_thought_streams
169 };
170 streams
171 .last()
172 .and_then(|(_, stable_id)| self.transcript.get(stable_id))
173 }
174
175 fn open_streams(&self, agent: bool) -> impl Iterator<Item = &Arc<TranscriptItem>> {
176 let streams = if agent {
177 &self.open_agent_streams
178 } else {
179 &self.open_thought_streams
180 };
181 streams
182 .iter()
183 .filter_map(|(_, stable_id)| self.transcript.get(stable_id))
184 }
185
186 fn terminal_referrers(&self, terminal_id: &str) -> impl Iterator<Item = &Arc<TranscriptItem>> {
187 self.terminal_referrers
188 .get(terminal_id)
189 .into_iter()
190 .flatten()
191 .filter_map(|stable_id| self.transcript.get(stable_id))
192 }
193}
194
195pub fn project_relay_event(
199 current: &MaterializedSession,
200 event: &RelayEvent,
201) -> Result<ProjectedRelayEvent> {
202 let index = ProjectionIndex::new(current);
203 project_relay_event_indexed(current, &index, event)
204}
205
206pub fn project_relay_event_indexed(
207 current: &MaterializedSession,
208 index: &ProjectionIndex,
209 event: &RelayEvent,
210) -> Result<ProjectedRelayEvent> {
211 validate_relay_event(
212 current.applied_event_ordinal,
213 ¤t.applied_event_digest,
214 event,
215 )?;
216
217 let mut mutation = MaterializedSessionMutation {
218 last_activity_at_ms: Some(event.recorded_at_ms),
219 ..MaterializedSessionMutation::default()
220 };
221 project_observation(current, index, event, &mut mutation)?;
222 mutation.api_events = api_events::derive(current, event, &mutation);
223 Ok(ProjectedRelayEvent { mutation })
224}
225
226pub fn apply_committed_projection_event(
231 current: &mut MaterializedSession,
232 event: &RelayEvent,
233 mutation: MaterializedSessionMutation,
234) -> Result<()> {
235 apply_committed_projection_event_inner(current, event, mutation, None)
236}
237
238pub fn apply_committed_projection_event_indexed(
239 current: &mut MaterializedSession,
240 index: &mut ProjectionIndex,
241 event: &RelayEvent,
242 mutation: MaterializedSessionMutation,
243) -> Result<()> {
244 apply_committed_projection_event_inner(current, event, mutation, Some(index))
245}
246
247fn apply_committed_projection_event_inner(
248 current: &mut MaterializedSession,
249 event: &RelayEvent,
250 mutation: MaterializedSessionMutation,
251 mut index: Option<&mut ProjectionIndex>,
252) -> Result<()> {
253 validate_relay_event(
254 current.applied_event_ordinal,
255 ¤t.applied_event_digest,
256 event,
257 )?;
258 if let Some(execution) = mutation.execution {
259 current.execution = execution;
260 }
261 if let Some(title) = mutation.session_title {
262 current.session_title = title;
263 }
264 if let Some(configuration) = mutation.configuration {
265 current.configuration = configuration;
266 }
267 for item_mutation in mutation.transcript {
268 match item_mutation {
269 TranscriptMutation::Upsert(item) => {
270 item.validate(event.ordinal)?;
271 let existing_position = index
272 .as_deref()
273 .and_then(|index| index.position(&item.stable_id));
274 let existing = if let Some(position) = existing_position {
275 Some(current.transcript.get_mut(position).with_context(|| {
276 format!(
277 "transcript index position {position} for {:?} is out of bounds",
278 item.stable_id
279 )
280 })?)
281 } else if index.is_none() {
282 current
283 .transcript
284 .iter_mut()
285 .find(|existing| existing.stable_id == item.stable_id)
286 } else {
287 None
288 };
289 if let Some(existing) = existing {
290 if existing.stable_id != item.stable_id {
291 return Err(ProjectionIntegrityError(format!(
292 "transcript index for {:?} points to {:?}",
293 item.stable_id, existing.stable_id
294 ))
295 .into());
296 }
297 if existing.position != item.position
298 || existing.created_at_ms != item.created_at_ms
299 {
300 return Err(ProjectionIntegrityError(format!(
301 "transcript item {:?} changed immutable identity fields",
302 item.stable_id
303 ))
304 .into());
305 }
306 if item.last_changed_at_ms < existing.last_changed_at_ms {
307 return Err(ProjectionIntegrityError(format!(
308 "transcript item {:?} moved its changed timestamp backwards",
309 item.stable_id
310 ))
311 .into());
312 }
313 if existing
314 .latest_content_event_ordinal
315 .is_some_and(|existing| {
316 item.latest_content_event_ordinal
317 .is_none_or(|next| next < existing)
318 })
319 {
320 return Err(ProjectionIntegrityError(format!(
321 "transcript item {:?} moved its latest content ordinal backwards",
322 item.stable_id
323 ))
324 .into());
325 }
326 if let Some(owned) = Arc::get_mut(existing) {
330 *owned = item;
331 } else {
332 *existing = Arc::new(item);
333 }
334 if let Some(index) = index.as_deref_mut() {
335 index.insert(existing.clone());
336 }
337 } else {
338 let item = Arc::new(item);
339 if let Some(index) = index.as_deref_mut() {
340 index.insert(item.clone());
341 }
342 current.transcript.push(item);
343 }
344 }
345 TranscriptMutation::Remove { stable_id } => {
346 if let Some(index) = index.as_deref_mut() {
347 if let Some(position) = index.remove(&stable_id) {
348 let removed = current.transcript.remove(position);
349 if removed.stable_id != stable_id {
350 return Err(ProjectionIntegrityError(format!(
351 "transcript index for {stable_id:?} removed {:?}",
352 removed.stable_id
353 ))
354 .into());
355 }
356 index.reindex_after_removal(¤t.transcript, position);
357 }
358 } else {
359 current
360 .transcript
361 .retain(|item| item.stable_id != stable_id);
362 }
363 }
364 }
365 }
366 if let Some(queued_prompts) = mutation.queued_prompts {
367 current.queued_prompts = queued_prompts;
368 }
369 if let Some(pending_elicitations) = mutation.pending_elicitations {
370 current.pending_elicitations = pending_elicitations;
371 }
372 if let Some(active_turn) = mutation.active_turn {
373 current.active_turn = active_turn;
374 }
375 if let Some(last_turn_outcome) = mutation.last_turn_outcome {
376 current.last_turn_outcome = Some(last_turn_outcome);
377 }
378 if let Some(activity) = mutation.last_activity_at_ms {
379 current.last_activity_at_ms = Some(
380 current
381 .last_activity_at_ms
382 .map_or(activity, |existing| existing.max(activity)),
383 );
384 }
385 current.applied_event_ordinal = event.ordinal;
386 current.applied_event_digest.clone_from(&event.digest);
387 Ok(())
388}
389
390#[cfg(test)]
391mod tests;