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};
47use mj_core::transcript::{coalesce_content_chunks, push_content_chunk};
48
49#[derive(Debug, Clone, PartialEq)]
50pub struct ProjectedRelayEvent {
51 pub mutation: MaterializedSessionMutation,
52}
53
54#[derive(Debug, Clone)]
59pub struct ProjectionIndex {
60 transcript: HashMap<String, Arc<TranscriptItem>>,
61 transcript_positions: HashMap<String, usize>,
62 open_agent_streams: BTreeSet<(u64, String)>,
63 open_thought_streams: BTreeSet<(u64, String)>,
64 terminal_referrers: HashMap<String, BTreeSet<String>>,
65}
66
67impl ProjectionIndex {
68 pub fn new(current: &MaterializedSession) -> Self {
69 let mut index = Self {
70 transcript: HashMap::with_capacity(current.transcript.len()),
71 transcript_positions: HashMap::with_capacity(current.transcript.len()),
72 open_agent_streams: BTreeSet::new(),
73 open_thought_streams: BTreeSet::new(),
74 terminal_referrers: HashMap::new(),
75 };
76 for (position, item) in current.transcript.iter().enumerate() {
77 index.insert_at(item.clone(), position);
78 }
79 index
80 }
81
82 fn get(&self, stable_id: &str) -> Option<&Arc<TranscriptItem>> {
83 self.transcript.get(stable_id)
84 }
85
86 fn position(&self, stable_id: &str) -> Option<usize> {
87 self.transcript_positions.get(stable_id).copied()
88 }
89
90 fn insert(&mut self, item: Arc<TranscriptItem>) {
91 let position = self
92 .remove(&item.stable_id)
93 .unwrap_or(self.transcript.len());
94 self.insert_at(item, position);
95 }
96
97 fn insert_at(&mut self, item: Arc<TranscriptItem>, position: usize) {
98 let stream = (item.position, item.stable_id.clone());
99 match &item.body {
100 TranscriptBody::Agent {
101 streaming: true, ..
102 } => {
103 self.open_agent_streams.insert(stream);
104 }
105 TranscriptBody::Thought {
106 streaming: true, ..
107 } => {
108 self.open_thought_streams.insert(stream);
109 }
110 TranscriptBody::Tool {
111 call,
112 terminal_refs,
113 ..
114 } => {
115 let mut terminal_ids = tool_call_terminal_ids(call);
116 terminal_ids.extend(terminal_refs.iter().cloned());
117 for terminal_id in terminal_ids {
118 self.terminal_referrers
119 .entry(terminal_id)
120 .or_default()
121 .insert(item.stable_id.clone());
122 }
123 }
124 _ => {}
125 }
126 self.transcript_positions
127 .insert(item.stable_id.clone(), position);
128 self.transcript.insert(item.stable_id.clone(), item);
129 }
130
131 fn remove(&mut self, stable_id: &str) -> Option<usize> {
132 let item = self.transcript.remove(stable_id)?;
133 let position = self.transcript_positions.remove(stable_id);
134 debug_assert!(position.is_some());
135 let stream = (item.position, item.stable_id.clone());
136 self.open_agent_streams.remove(&stream);
137 self.open_thought_streams.remove(&stream);
138 if let TranscriptBody::Tool {
139 call,
140 terminal_refs,
141 ..
142 } = &item.body
143 {
144 let mut terminal_ids = tool_call_terminal_ids(call);
145 terminal_ids.extend(terminal_refs.iter().cloned());
146 for terminal_id in terminal_ids {
147 if let Some(referrers) = self.terminal_referrers.get_mut(&terminal_id) {
148 referrers.remove(stable_id);
149 if referrers.is_empty() {
150 self.terminal_referrers.remove(&terminal_id);
151 }
152 }
153 }
154 }
155 position
156 }
157
158 fn reindex_after_removal(&mut self, transcript: &[Arc<TranscriptItem>], removed: usize) {
159 for (position, item) in transcript.iter().enumerate().skip(removed) {
160 self.transcript_positions
161 .insert(item.stable_id.clone(), position);
162 }
163 }
164
165 fn latest_open_stream(&self, agent: bool) -> Option<&Arc<TranscriptItem>> {
166 let streams = if agent {
167 &self.open_agent_streams
168 } else {
169 &self.open_thought_streams
170 };
171 streams
172 .last()
173 .and_then(|(_, stable_id)| self.transcript.get(stable_id))
174 }
175
176 fn open_streams(&self, agent: bool) -> impl Iterator<Item = &Arc<TranscriptItem>> {
177 let streams = if agent {
178 &self.open_agent_streams
179 } else {
180 &self.open_thought_streams
181 };
182 streams
183 .iter()
184 .filter_map(|(_, stable_id)| self.transcript.get(stable_id))
185 }
186
187 fn terminal_referrers(&self, terminal_id: &str) -> impl Iterator<Item = &Arc<TranscriptItem>> {
188 self.terminal_referrers
189 .get(terminal_id)
190 .into_iter()
191 .flatten()
192 .filter_map(|stable_id| self.transcript.get(stable_id))
193 }
194}
195
196pub fn project_relay_event(
200 current: &MaterializedSession,
201 event: &RelayEvent,
202) -> Result<ProjectedRelayEvent> {
203 let index = ProjectionIndex::new(current);
204 project_relay_event_indexed(current, &index, event)
205}
206
207pub fn project_relay_event_indexed(
208 current: &MaterializedSession,
209 index: &ProjectionIndex,
210 event: &RelayEvent,
211) -> Result<ProjectedRelayEvent> {
212 validate_relay_event(
213 current.applied_event_ordinal,
214 ¤t.applied_event_digest,
215 event,
216 )?;
217
218 let mut mutation = MaterializedSessionMutation {
219 last_activity_at_ms: Some(event.recorded_at_ms),
220 ..MaterializedSessionMutation::default()
221 };
222 project_observation(current, index, event, &mut mutation)?;
223 mutation.api_events = api_events::derive(current, event, &mutation);
224 Ok(ProjectedRelayEvent { mutation })
225}
226
227pub fn apply_committed_projection_event(
232 current: &mut MaterializedSession,
233 event: &RelayEvent,
234 mutation: MaterializedSessionMutation,
235) -> Result<()> {
236 apply_committed_projection_event_inner(current, event, mutation, None)
237}
238
239pub fn apply_committed_projection_event_indexed(
240 current: &mut MaterializedSession,
241 index: &mut ProjectionIndex,
242 event: &RelayEvent,
243 mutation: MaterializedSessionMutation,
244) -> Result<()> {
245 apply_committed_projection_event_inner(current, event, mutation, Some(index))
246}
247
248fn apply_committed_projection_event_inner(
249 current: &mut MaterializedSession,
250 event: &RelayEvent,
251 mutation: MaterializedSessionMutation,
252 mut index: Option<&mut ProjectionIndex>,
253) -> Result<()> {
254 validate_relay_event(
255 current.applied_event_ordinal,
256 ¤t.applied_event_digest,
257 event,
258 )?;
259 if let Some(execution) = mutation.execution {
260 current.execution = execution;
261 }
262 if let Some(title) = mutation.session_title {
263 current.session_title = title;
264 }
265 if let Some(configuration) = mutation.configuration {
266 current.configuration = configuration;
267 }
268 for item_mutation in mutation.transcript {
269 match item_mutation {
270 TranscriptMutation::Upsert(item) => {
271 item.validate(event.ordinal)?;
272 let existing_position = index
273 .as_deref()
274 .and_then(|index| index.position(&item.stable_id));
275 let existing = if let Some(position) = existing_position {
276 Some(current.transcript.get_mut(position).with_context(|| {
277 format!(
278 "transcript index position {position} for {:?} is out of bounds",
279 item.stable_id
280 )
281 })?)
282 } else if index.is_none() {
283 current
284 .transcript
285 .iter_mut()
286 .find(|existing| existing.stable_id == item.stable_id)
287 } else {
288 None
289 };
290 if let Some(existing) = existing {
291 if existing.stable_id != item.stable_id {
292 return Err(ProjectionIntegrityError(format!(
293 "transcript index for {:?} points to {:?}",
294 item.stable_id, existing.stable_id
295 ))
296 .into());
297 }
298 if existing.position != item.position
299 || existing.created_at_ms != item.created_at_ms
300 {
301 return Err(ProjectionIntegrityError(format!(
302 "transcript item {:?} changed immutable identity fields",
303 item.stable_id
304 ))
305 .into());
306 }
307 if item.last_changed_at_ms < existing.last_changed_at_ms {
308 return Err(ProjectionIntegrityError(format!(
309 "transcript item {:?} moved its changed timestamp backwards",
310 item.stable_id
311 ))
312 .into());
313 }
314 if existing
315 .latest_content_event_ordinal
316 .is_some_and(|existing| {
317 item.latest_content_event_ordinal
318 .is_none_or(|next| next < existing)
319 })
320 {
321 return Err(ProjectionIntegrityError(format!(
322 "transcript item {:?} moved its latest content ordinal backwards",
323 item.stable_id
324 ))
325 .into());
326 }
327 if let Some(owned) = Arc::get_mut(existing) {
331 *owned = item;
332 } else {
333 *existing = Arc::new(item);
334 }
335 if let Some(index) = index.as_deref_mut() {
336 index.insert(existing.clone());
337 }
338 } else {
339 let item = Arc::new(item);
340 if let Some(index) = index.as_deref_mut() {
341 index.insert(item.clone());
342 }
343 current.transcript.push(item);
344 }
345 }
346 TranscriptMutation::Remove { stable_id } => {
347 if let Some(index) = index.as_deref_mut() {
348 if let Some(position) = index.remove(&stable_id) {
349 let removed = current.transcript.remove(position);
350 if removed.stable_id != stable_id {
351 return Err(ProjectionIntegrityError(format!(
352 "transcript index for {stable_id:?} removed {:?}",
353 removed.stable_id
354 ))
355 .into());
356 }
357 index.reindex_after_removal(¤t.transcript, position);
358 }
359 } else {
360 current
361 .transcript
362 .retain(|item| item.stable_id != stable_id);
363 }
364 }
365 }
366 }
367 if let Some(queued_prompts) = mutation.queued_prompts {
368 current.queued_prompts = queued_prompts;
369 }
370 if let Some(pending_elicitations) = mutation.pending_elicitations {
371 current.pending_elicitations = pending_elicitations;
372 }
373 if let Some(active_turn) = mutation.active_turn {
374 current.active_turn = active_turn;
375 }
376 if mutation.clear_turn_outcome {
377 current.last_turn_outcome = None;
378 }
379 if let Some(last_turn_outcome) = mutation.last_turn_outcome {
380 current.last_turn_outcome = Some(last_turn_outcome);
381 }
382 if let Some(activity) = mutation.last_activity_at_ms {
383 current.last_activity_at_ms = Some(
384 current
385 .last_activity_at_ms
386 .map_or(activity, |existing| existing.max(activity)),
387 );
388 }
389 current.applied_event_ordinal = event.ordinal;
390 current.applied_event_digest.clone_from(&event.digest);
391 Ok(())
392}
393
394#[cfg(test)]
395mod tests;
396
397pub fn project_native_update(
401 current: &MaterializedSession,
402 event: &RelayEvent,
403 update: &agent_client_protocol::schema::v1::SessionUpdate,
404) -> Result<MaterializedSessionMutation> {
405 let mut mutation = MaterializedSessionMutation {
406 last_activity_at_ms: Some(event.recorded_at_ms),
407 ..Default::default()
408 };
409 if let agent_client_protocol::schema::v1::SessionUpdate::UserMessageChunk(chunk) = update {
410 mutation
411 .transcript
412 .push(TranscriptMutation::Upsert(TranscriptItem {
413 stable_id: format!("native-user:{}", event.ordinal),
414 position: event.ordinal,
415 latest_content_event_ordinal: None,
416 created_at_ms: event.recorded_at_ms,
417 last_changed_at_ms: event.recorded_at_ms,
418 body: TranscriptBody::User {
419 content: vec![serde_json::to_value(&chunk.content)?],
420 },
421 }));
422 return Ok(mutation);
423 }
424 let index = ProjectionIndex::new(current);
425 session_update::project_session_update(current, &index, event, update, &mut mutation)?;
426 Ok(mutation)
427}
428
429pub(crate) use terminals::tool_call_terminal_ids;