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 RelayObservation::CommandQueued {
119 command:
120 RelayCommand::DeliverMailboxEvent {
121 event: mailbox_event,
122 },
123 ..
124 } => {
125 if mailbox_event.message_text().is_none() {
126 ids.insert(observation::MAILBOX_EVENTS_NOTICE_ID.to_owned());
127 }
128 }
129 RelayObservation::MailboxEventsDelivered {
130 event_keys,
131 events,
132 steering,
133 ..
134 } => {
135 ids.extend(
136 event_keys
137 .iter()
138 .chain(events.iter().map(|event| &event.key))
139 .map(|key| mj_core::transcript::message_item_id(key)),
140 );
141 if let Some(steering) = steering {
142 ids.insert(format!("user:{}", steering.queued_prompt_id));
143 ids.insert(format!("user:{}", steering.command_id));
144 }
145 }
146 RelayObservation::CommandQueued {
147 command: RelayCommand::MailboxWake { events },
148 ..
149 } => {
150 ids.extend(
151 events
152 .iter()
153 .map(|event| mj_core::transcript::message_item_id(&event.key)),
154 );
155 }
156 _ => {}
157 }
158 }
159 for terminal in &terminals {
160 ids.insert(terminal_item_id(terminal));
161 ids.insert(fallback_terminal_tool_item_id(terminal));
162 }
163 Ok((ids.into_iter().collect(), terminals.into_iter().collect()))
164}
165
166impl ProjectionIndex {
167 pub fn new(current: &MaterializedSession) -> Self {
168 let mut index = Self {
169 transcript: HashMap::with_capacity(current.transcript.len()),
170 transcript_positions: HashMap::with_capacity(current.transcript.len()),
171 open_agent_streams: BTreeSet::new(),
172 open_thought_streams: BTreeSet::new(),
173 terminal_referrers: HashMap::new(),
174 };
175 for (position, item) in current.transcript.iter().enumerate() {
176 index.insert_at(item.clone(), position);
177 }
178 index
179 }
180
181 fn get(&self, stable_id: &str) -> Option<&Arc<TranscriptItem>> {
182 self.transcript.get(stable_id)
183 }
184
185 fn position(&self, stable_id: &str) -> Option<usize> {
186 self.transcript_positions.get(stable_id).copied()
187 }
188
189 fn insert(&mut self, item: Arc<TranscriptItem>) {
190 let position = self
191 .remove(&item.stable_id)
192 .unwrap_or(self.transcript.len());
193 self.insert_at(item, position);
194 }
195
196 fn insert_at(&mut self, item: Arc<TranscriptItem>, position: usize) {
197 let stream = (item.position, item.stable_id.clone());
198 match &item.body {
199 TranscriptBody::Agent {
200 streaming: true, ..
201 } => {
202 self.open_agent_streams.insert(stream);
203 }
204 TranscriptBody::Thought {
205 streaming: true, ..
206 } => {
207 self.open_thought_streams.insert(stream);
208 }
209 TranscriptBody::Tool {
210 call,
211 terminal_refs,
212 ..
213 } => {
214 let mut terminal_ids = tool_call_terminal_ids(call);
215 terminal_ids.extend(terminal_refs.iter().cloned());
216 for terminal_id in terminal_ids {
217 self.terminal_referrers
218 .entry(terminal_id)
219 .or_default()
220 .insert(item.stable_id.clone());
221 }
222 }
223 _ => {}
224 }
225 self.transcript_positions
226 .insert(item.stable_id.clone(), position);
227 self.transcript.insert(item.stable_id.clone(), item);
228 }
229
230 fn remove(&mut self, stable_id: &str) -> Option<usize> {
231 let item = self.transcript.remove(stable_id)?;
232 let position = self.transcript_positions.remove(stable_id);
233 debug_assert!(position.is_some());
234 let stream = (item.position, item.stable_id.clone());
235 self.open_agent_streams.remove(&stream);
236 self.open_thought_streams.remove(&stream);
237 if let TranscriptBody::Tool {
238 call,
239 terminal_refs,
240 ..
241 } = &item.body
242 {
243 let mut terminal_ids = tool_call_terminal_ids(call);
244 terminal_ids.extend(terminal_refs.iter().cloned());
245 for terminal_id in terminal_ids {
246 if let Some(referrers) = self.terminal_referrers.get_mut(&terminal_id) {
247 referrers.remove(stable_id);
248 if referrers.is_empty() {
249 self.terminal_referrers.remove(&terminal_id);
250 }
251 }
252 }
253 }
254 position
255 }
256
257 fn reindex_after_removal(&mut self, transcript: &[Arc<TranscriptItem>], removed: usize) {
258 for (position, item) in transcript.iter().enumerate().skip(removed) {
259 self.transcript_positions
260 .insert(item.stable_id.clone(), position);
261 }
262 }
263
264 fn latest_open_stream(&self, agent: bool) -> Option<&Arc<TranscriptItem>> {
265 let streams = if agent {
266 &self.open_agent_streams
267 } else {
268 &self.open_thought_streams
269 };
270 streams
271 .last()
272 .and_then(|(_, stable_id)| self.transcript.get(stable_id))
273 }
274
275 fn open_streams(&self, agent: bool) -> impl Iterator<Item = &Arc<TranscriptItem>> {
276 let streams = if agent {
277 &self.open_agent_streams
278 } else {
279 &self.open_thought_streams
280 };
281 streams
282 .iter()
283 .filter_map(|(_, stable_id)| self.transcript.get(stable_id))
284 }
285
286 fn terminal_referrers(&self, terminal_id: &str) -> impl Iterator<Item = &Arc<TranscriptItem>> {
287 self.terminal_referrers
288 .get(terminal_id)
289 .into_iter()
290 .flatten()
291 .filter_map(|stable_id| self.transcript.get(stable_id))
292 }
293}
294
295pub fn project_relay_event(
299 current: &MaterializedSession,
300 event: &RelayEvent,
301) -> Result<ProjectedRelayEvent> {
302 let index = ProjectionIndex::new(current);
303 project_relay_event_indexed(current, &index, event)
304}
305
306pub fn project_relay_event_indexed(
307 current: &MaterializedSession,
308 index: &ProjectionIndex,
309 event: &RelayEvent,
310) -> Result<ProjectedRelayEvent> {
311 validate_relay_event(
312 current.applied_event_ordinal,
313 ¤t.applied_event_digest,
314 event,
315 )?;
316
317 let mut mutation = MaterializedSessionMutation {
318 last_activity_at_ms: Some(event.recorded_at_ms),
319 ..MaterializedSessionMutation::default()
320 };
321 project_observation(current, index, event, &mut mutation)?;
322 mutation.api_events = api_events::derive(current, event, &mutation);
323 Ok(ProjectedRelayEvent { mutation })
324}
325
326pub fn apply_committed_projection_event(
331 current: &mut MaterializedSession,
332 event: &RelayEvent,
333 mutation: MaterializedSessionMutation,
334) -> Result<()> {
335 apply_committed_projection_event_inner(current, event, mutation, None)
336}
337
338pub fn apply_committed_projection_event_indexed(
339 current: &mut MaterializedSession,
340 index: &mut ProjectionIndex,
341 event: &RelayEvent,
342 mutation: MaterializedSessionMutation,
343) -> Result<()> {
344 apply_committed_projection_event_inner(current, event, mutation, Some(index))
345}
346
347fn apply_committed_projection_event_inner(
348 current: &mut MaterializedSession,
349 event: &RelayEvent,
350 mutation: MaterializedSessionMutation,
351 mut index: Option<&mut ProjectionIndex>,
352) -> Result<()> {
353 validate_relay_event(
354 current.applied_event_ordinal,
355 ¤t.applied_event_digest,
356 event,
357 )?;
358 if let Some(execution) = mutation.execution {
359 current.execution = execution;
360 }
361 if let Some(title) = mutation.session_title {
362 current.session_title = title;
363 }
364 if let Some(configuration) = mutation.configuration {
365 current.configuration = configuration;
366 }
367 for item_mutation in mutation.transcript {
368 match item_mutation {
369 TranscriptMutation::Upsert(item) => {
370 item.validate(event.ordinal)?;
371 let existing_position = index
372 .as_deref()
373 .and_then(|index| index.position(&item.stable_id));
374 let existing = if let Some(position) = existing_position {
375 Some(current.transcript.get_mut(position).with_context(|| {
376 format!(
377 "transcript index position {position} for {:?} is out of bounds",
378 item.stable_id
379 )
380 })?)
381 } else if index.is_none() {
382 current
383 .transcript
384 .iter_mut()
385 .find(|existing| existing.stable_id == item.stable_id)
386 } else {
387 None
388 };
389 if let Some(existing) = existing {
390 if existing.stable_id != item.stable_id {
391 return Err(ProjectionIntegrityError(format!(
392 "transcript index for {:?} points to {:?}",
393 item.stable_id, existing.stable_id
394 ))
395 .into());
396 }
397 if existing.position != item.position
398 || existing.created_at_ms != item.created_at_ms
399 {
400 return Err(ProjectionIntegrityError(format!(
401 "transcript item {:?} changed immutable identity fields",
402 item.stable_id
403 ))
404 .into());
405 }
406 if item.last_changed_at_ms < existing.last_changed_at_ms {
407 return Err(ProjectionIntegrityError(format!(
408 "transcript item {:?} moved its changed timestamp backwards",
409 item.stable_id
410 ))
411 .into());
412 }
413 if existing
414 .latest_content_event_ordinal
415 .is_some_and(|existing| {
416 item.latest_content_event_ordinal
417 .is_none_or(|next| next < existing)
418 })
419 {
420 return Err(ProjectionIntegrityError(format!(
421 "transcript item {:?} moved its latest content ordinal backwards",
422 item.stable_id
423 ))
424 .into());
425 }
426 if let Some(owned) = Arc::get_mut(existing) {
430 *owned = item;
431 } else {
432 *existing = Arc::new(item);
433 }
434 if let Some(index) = index.as_deref_mut() {
435 index.insert(existing.clone());
436 }
437 } else {
438 let item = Arc::new(item);
439 if let Some(index) = index.as_deref_mut() {
440 index.insert(item.clone());
441 }
442 current.transcript.push(item);
443 }
444 }
445 TranscriptMutation::Remove { stable_id } => {
446 if let Some(index) = index.as_deref_mut() {
447 if let Some(position) = index.remove(&stable_id) {
448 let removed = current.transcript.remove(position);
449 if removed.stable_id != stable_id {
450 return Err(ProjectionIntegrityError(format!(
451 "transcript index for {stable_id:?} removed {:?}",
452 removed.stable_id
453 ))
454 .into());
455 }
456 index.reindex_after_removal(¤t.transcript, position);
457 }
458 } else {
459 current
460 .transcript
461 .retain(|item| item.stable_id != stable_id);
462 }
463 }
464 }
465 }
466 if let Some(queued_prompts) = mutation.queued_prompts {
467 current.queued_prompts = queued_prompts;
468 }
469 if let Some(pending_elicitations) = mutation.pending_elicitations {
470 current.pending_elicitations = pending_elicitations;
471 }
472 if let Some(active_turn) = mutation.active_turn {
473 current.active_turn = active_turn;
474 }
475 if mutation.clear_turn_outcome {
476 current.last_turn_outcome = None;
477 }
478 if let Some(last_turn_outcome) = mutation.last_turn_outcome {
479 current.last_turn_outcome = Some(last_turn_outcome);
480 }
481 if let Some(activity) = mutation.last_activity_at_ms {
482 current.last_activity_at_ms = Some(
483 current
484 .last_activity_at_ms
485 .map_or(activity, |existing| existing.max(activity)),
486 );
487 }
488 current.applied_event_ordinal = event.ordinal;
489 current.applied_event_digest.clone_from(&event.digest);
490 Ok(())
491}
492
493#[cfg(test)]
494mod tests;
495
496pub fn project_native_update(
500 current: &MaterializedSession,
501 event: &RelayEvent,
502 update: &agent_client_protocol::schema::v1::SessionUpdate,
503) -> Result<MaterializedSessionMutation> {
504 let mut mutation = MaterializedSessionMutation {
505 last_activity_at_ms: Some(event.recorded_at_ms),
506 ..Default::default()
507 };
508 if let agent_client_protocol::schema::v1::SessionUpdate::UserMessageChunk(chunk) = update {
509 mutation
510 .transcript
511 .push(TranscriptMutation::Upsert(TranscriptItem {
512 stable_id: format!("native-user:{}", event.ordinal),
513 position: event.ordinal,
514 latest_content_event_ordinal: None,
515 created_at_ms: event.recorded_at_ms,
516 last_changed_at_ms: event.recorded_at_ms,
517 body: TranscriptBody::User {
518 content: vec![serde_json::to_value(&chunk.content)?],
519 },
520 }));
521 return Ok(mutation);
522 }
523 let index = ProjectionIndex::new(current);
524 session_update::project_session_update(current, &index, event, update, &mut mutation)?;
525 Ok(mutation)
526}
527
528pub(crate) use terminals::tool_call_terminal_ids;