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
67pub fn historical_references(events: &[RelayEvent]) -> Result<(Vec<String>, Vec<String>)> {
70 let mut ids = BTreeSet::new();
71 let mut terminals = BTreeSet::new();
72 for event in events {
73 match &event.observation {
74 RelayObservation::SessionUpdate { update } => {
75 let value = match update.as_ref() {
76 SessionUpdate::ToolCall(call) => {
77 ids.insert(format!("tool:{}", call.tool_call_id));
78 Some(serde_json::to_value(call)?)
79 }
80 SessionUpdate::ToolCallUpdate(update) => {
81 ids.insert(format!("tool:{}", update.tool_call_id));
82 Some(serde_json::to_value(&update.fields)?)
83 }
84 SessionUpdate::AgentMessageChunk(chunk) => {
85 if let Some(id) = &chunk.message_id {
86 ids.insert(format!("agent:{id}"));
87 }
88 None
89 }
90 SessionUpdate::AgentThoughtChunk(chunk) => {
91 if let Some(id) = &chunk.message_id {
92 ids.insert(format!("thought:{id}"));
93 }
94 None
95 }
96 _ => None,
97 };
98 if let Some(value) = value {
99 terminals.extend(tool_call_terminal_ids(&value));
100 }
101 }
102 RelayObservation::TerminalOutput { terminal_id, .. } => {
103 terminals.insert(terminal_id.clone());
104 }
105 RelayObservation::CommandStarted { command_id, .. }
106 | RelayObservation::CommandCompleted { command_id, .. }
107 | RelayObservation::CommandRejected { command_id, .. }
108 | RelayObservation::CommandInterrupted { command_id, .. }
109 | RelayObservation::UserShellOutput { command_id, .. } => {
110 ids.insert(user_shell_item_id(command_id));
111 ids.insert(format!("user:{command_id}"));
112 }
113 RelayObservation::Notice { .. } => {
114 if let Some(id) = &event.command_id {
115 ids.insert(format!("system:notice:{id}"));
116 }
117 }
118 _ => {}
119 }
120 }
121 for terminal in &terminals {
122 ids.insert(terminal_item_id(terminal));
123 ids.insert(fallback_terminal_tool_item_id(terminal));
124 }
125 Ok((ids.into_iter().collect(), terminals.into_iter().collect()))
126}
127
128impl ProjectionIndex {
129 pub fn new(current: &MaterializedSession) -> Self {
130 let mut index = Self {
131 transcript: HashMap::with_capacity(current.transcript.len()),
132 transcript_positions: HashMap::with_capacity(current.transcript.len()),
133 open_agent_streams: BTreeSet::new(),
134 open_thought_streams: BTreeSet::new(),
135 terminal_referrers: HashMap::new(),
136 };
137 for (position, item) in current.transcript.iter().enumerate() {
138 index.insert_at(item.clone(), position);
139 }
140 index
141 }
142
143 fn get(&self, stable_id: &str) -> Option<&Arc<TranscriptItem>> {
144 self.transcript.get(stable_id)
145 }
146
147 fn position(&self, stable_id: &str) -> Option<usize> {
148 self.transcript_positions.get(stable_id).copied()
149 }
150
151 fn insert(&mut self, item: Arc<TranscriptItem>) {
152 let position = self
153 .remove(&item.stable_id)
154 .unwrap_or(self.transcript.len());
155 self.insert_at(item, position);
156 }
157
158 fn insert_at(&mut self, item: Arc<TranscriptItem>, position: usize) {
159 let stream = (item.position, item.stable_id.clone());
160 match &item.body {
161 TranscriptBody::Agent {
162 streaming: true, ..
163 } => {
164 self.open_agent_streams.insert(stream);
165 }
166 TranscriptBody::Thought {
167 streaming: true, ..
168 } => {
169 self.open_thought_streams.insert(stream);
170 }
171 TranscriptBody::Tool {
172 call,
173 terminal_refs,
174 ..
175 } => {
176 let mut terminal_ids = tool_call_terminal_ids(call);
177 terminal_ids.extend(terminal_refs.iter().cloned());
178 for terminal_id in terminal_ids {
179 self.terminal_referrers
180 .entry(terminal_id)
181 .or_default()
182 .insert(item.stable_id.clone());
183 }
184 }
185 _ => {}
186 }
187 self.transcript_positions
188 .insert(item.stable_id.clone(), position);
189 self.transcript.insert(item.stable_id.clone(), item);
190 }
191
192 fn remove(&mut self, stable_id: &str) -> Option<usize> {
193 let item = self.transcript.remove(stable_id)?;
194 let position = self.transcript_positions.remove(stable_id);
195 debug_assert!(position.is_some());
196 let stream = (item.position, item.stable_id.clone());
197 self.open_agent_streams.remove(&stream);
198 self.open_thought_streams.remove(&stream);
199 if let TranscriptBody::Tool {
200 call,
201 terminal_refs,
202 ..
203 } = &item.body
204 {
205 let mut terminal_ids = tool_call_terminal_ids(call);
206 terminal_ids.extend(terminal_refs.iter().cloned());
207 for terminal_id in terminal_ids {
208 if let Some(referrers) = self.terminal_referrers.get_mut(&terminal_id) {
209 referrers.remove(stable_id);
210 if referrers.is_empty() {
211 self.terminal_referrers.remove(&terminal_id);
212 }
213 }
214 }
215 }
216 position
217 }
218
219 fn reindex_after_removal(&mut self, transcript: &[Arc<TranscriptItem>], removed: usize) {
220 for (position, item) in transcript.iter().enumerate().skip(removed) {
221 self.transcript_positions
222 .insert(item.stable_id.clone(), position);
223 }
224 }
225
226 fn latest_open_stream(&self, agent: bool) -> Option<&Arc<TranscriptItem>> {
227 let streams = if agent {
228 &self.open_agent_streams
229 } else {
230 &self.open_thought_streams
231 };
232 streams
233 .last()
234 .and_then(|(_, stable_id)| self.transcript.get(stable_id))
235 }
236
237 fn open_streams(&self, agent: bool) -> impl Iterator<Item = &Arc<TranscriptItem>> {
238 let streams = if agent {
239 &self.open_agent_streams
240 } else {
241 &self.open_thought_streams
242 };
243 streams
244 .iter()
245 .filter_map(|(_, stable_id)| self.transcript.get(stable_id))
246 }
247
248 fn terminal_referrers(&self, terminal_id: &str) -> impl Iterator<Item = &Arc<TranscriptItem>> {
249 self.terminal_referrers
250 .get(terminal_id)
251 .into_iter()
252 .flatten()
253 .filter_map(|stable_id| self.transcript.get(stable_id))
254 }
255}
256
257pub fn project_relay_event(
261 current: &MaterializedSession,
262 event: &RelayEvent,
263) -> Result<ProjectedRelayEvent> {
264 let index = ProjectionIndex::new(current);
265 project_relay_event_indexed(current, &index, event)
266}
267
268pub fn project_relay_event_indexed(
269 current: &MaterializedSession,
270 index: &ProjectionIndex,
271 event: &RelayEvent,
272) -> Result<ProjectedRelayEvent> {
273 validate_relay_event(
274 current.applied_event_ordinal,
275 ¤t.applied_event_digest,
276 event,
277 )?;
278
279 let mut mutation = MaterializedSessionMutation {
280 last_activity_at_ms: Some(event.recorded_at_ms),
281 ..MaterializedSessionMutation::default()
282 };
283 project_observation(current, index, event, &mut mutation)?;
284 mutation.api_events = api_events::derive(current, event, &mutation);
285 Ok(ProjectedRelayEvent { mutation })
286}
287
288pub fn apply_committed_projection_event(
293 current: &mut MaterializedSession,
294 event: &RelayEvent,
295 mutation: MaterializedSessionMutation,
296) -> Result<()> {
297 apply_committed_projection_event_inner(current, event, mutation, None)
298}
299
300pub fn apply_committed_projection_event_indexed(
301 current: &mut MaterializedSession,
302 index: &mut ProjectionIndex,
303 event: &RelayEvent,
304 mutation: MaterializedSessionMutation,
305) -> Result<()> {
306 apply_committed_projection_event_inner(current, event, mutation, Some(index))
307}
308
309fn apply_committed_projection_event_inner(
310 current: &mut MaterializedSession,
311 event: &RelayEvent,
312 mutation: MaterializedSessionMutation,
313 mut index: Option<&mut ProjectionIndex>,
314) -> Result<()> {
315 validate_relay_event(
316 current.applied_event_ordinal,
317 ¤t.applied_event_digest,
318 event,
319 )?;
320 if let Some(execution) = mutation.execution {
321 current.execution = execution;
322 }
323 if let Some(title) = mutation.session_title {
324 current.session_title = title;
325 }
326 if let Some(configuration) = mutation.configuration {
327 current.configuration = configuration;
328 }
329 for item_mutation in mutation.transcript {
330 match item_mutation {
331 TranscriptMutation::Upsert(item) => {
332 item.validate(event.ordinal)?;
333 let existing_position = index
334 .as_deref()
335 .and_then(|index| index.position(&item.stable_id));
336 let existing = if let Some(position) = existing_position {
337 Some(current.transcript.get_mut(position).with_context(|| {
338 format!(
339 "transcript index position {position} for {:?} is out of bounds",
340 item.stable_id
341 )
342 })?)
343 } else if index.is_none() {
344 current
345 .transcript
346 .iter_mut()
347 .find(|existing| existing.stable_id == item.stable_id)
348 } else {
349 None
350 };
351 if let Some(existing) = existing {
352 if existing.stable_id != item.stable_id {
353 return Err(ProjectionIntegrityError(format!(
354 "transcript index for {:?} points to {:?}",
355 item.stable_id, existing.stable_id
356 ))
357 .into());
358 }
359 if existing.position != item.position
360 || existing.created_at_ms != item.created_at_ms
361 {
362 return Err(ProjectionIntegrityError(format!(
363 "transcript item {:?} changed immutable identity fields",
364 item.stable_id
365 ))
366 .into());
367 }
368 if item.last_changed_at_ms < existing.last_changed_at_ms {
369 return Err(ProjectionIntegrityError(format!(
370 "transcript item {:?} moved its changed timestamp backwards",
371 item.stable_id
372 ))
373 .into());
374 }
375 if existing
376 .latest_content_event_ordinal
377 .is_some_and(|existing| {
378 item.latest_content_event_ordinal
379 .is_none_or(|next| next < existing)
380 })
381 {
382 return Err(ProjectionIntegrityError(format!(
383 "transcript item {:?} moved its latest content ordinal backwards",
384 item.stable_id
385 ))
386 .into());
387 }
388 if let Some(owned) = Arc::get_mut(existing) {
392 *owned = item;
393 } else {
394 *existing = Arc::new(item);
395 }
396 if let Some(index) = index.as_deref_mut() {
397 index.insert(existing.clone());
398 }
399 } else {
400 let item = Arc::new(item);
401 if let Some(index) = index.as_deref_mut() {
402 index.insert(item.clone());
403 }
404 current.transcript.push(item);
405 }
406 }
407 TranscriptMutation::Remove { stable_id } => {
408 if let Some(index) = index.as_deref_mut() {
409 if let Some(position) = index.remove(&stable_id) {
410 let removed = current.transcript.remove(position);
411 if removed.stable_id != stable_id {
412 return Err(ProjectionIntegrityError(format!(
413 "transcript index for {stable_id:?} removed {:?}",
414 removed.stable_id
415 ))
416 .into());
417 }
418 index.reindex_after_removal(¤t.transcript, position);
419 }
420 } else {
421 current
422 .transcript
423 .retain(|item| item.stable_id != stable_id);
424 }
425 }
426 }
427 }
428 if let Some(queued_prompts) = mutation.queued_prompts {
429 current.queued_prompts = queued_prompts;
430 }
431 if let Some(pending_elicitations) = mutation.pending_elicitations {
432 current.pending_elicitations = pending_elicitations;
433 }
434 if let Some(active_turn) = mutation.active_turn {
435 current.active_turn = active_turn;
436 }
437 if mutation.clear_turn_outcome {
438 current.last_turn_outcome = None;
439 }
440 if let Some(last_turn_outcome) = mutation.last_turn_outcome {
441 current.last_turn_outcome = Some(last_turn_outcome);
442 }
443 if let Some(activity) = mutation.last_activity_at_ms {
444 current.last_activity_at_ms = Some(
445 current
446 .last_activity_at_ms
447 .map_or(activity, |existing| existing.max(activity)),
448 );
449 }
450 current.applied_event_ordinal = event.ordinal;
451 current.applied_event_digest.clone_from(&event.digest);
452 Ok(())
453}
454
455#[cfg(test)]
456mod tests;
457
458pub fn project_native_update(
462 current: &MaterializedSession,
463 event: &RelayEvent,
464 update: &agent_client_protocol::schema::v1::SessionUpdate,
465) -> Result<MaterializedSessionMutation> {
466 let mut mutation = MaterializedSessionMutation {
467 last_activity_at_ms: Some(event.recorded_at_ms),
468 ..Default::default()
469 };
470 if let agent_client_protocol::schema::v1::SessionUpdate::UserMessageChunk(chunk) = update {
471 mutation
472 .transcript
473 .push(TranscriptMutation::Upsert(TranscriptItem {
474 stable_id: format!("native-user:{}", event.ordinal),
475 position: event.ordinal,
476 latest_content_event_ordinal: None,
477 created_at_ms: event.recorded_at_ms,
478 last_changed_at_ms: event.recorded_at_ms,
479 body: TranscriptBody::User {
480 content: vec![serde_json::to_value(&chunk.content)?],
481 },
482 }));
483 return Ok(mutation);
484 }
485 let index = ProjectionIndex::new(current);
486 session_update::project_session_update(current, &index, event, update, &mut mutation)?;
487 Ok(mutation)
488}
489
490pub(crate) use terminals::tool_call_terminal_ids;