1mod api_events;
4
5use std::collections::{BTreeMap, BTreeSet, HashMap};
6use std::sync::Arc;
7
8use agent_client_protocol::schema::{
9 MaybeUndefined,
10 v1::{
11 ContentBlock, ContentChunk, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus,
12 SessionUpdate, TextContent, ToolCall, ToolCallContent, ToolCallStatus,
13 ToolCallUpdateFields,
14 },
15};
16use anyhow::{Context, Result, bail};
17use serde::Deserialize;
18use serde_json::Value;
19use sha2::{Digest, Sha256};
20
21use crate::transcript::{ChatEntry, ChatRole, PlanStatus, ToolStatus, tool_call_presentation};
22use mj_core::archive::{
23 CanonicalExecutionState, CanonicalQueuedCommandKind, CanonicalQueuedPrompt,
24 CanonicalSessionSnapshot, CanonicalSessionState, CanonicalTerminalOutput,
25 CanonicalTranscriptBody, CanonicalTranscriptItem,
26};
27use mj_core::relay::{
28 RELAY_EVENT_GENESIS_DIGEST, RelayCommand, RelayCommandKind, RelayEvent, RelayObservation,
29 SequencedEvent, WorkerEvent, WorkerPhase, validate_relay_event,
30};
31use mj_core::state::{
32 MaterializedExecutionState, MaterializedQueuedPrompt, MaterializedSession, MaterializedTurn,
33 MaterializedTurnOutcome, QueuedCommandKind, TerminalOutputRecord, TranscriptBody,
34 TranscriptItem, TurnOutcomeKind, config_command_text, normalize_session_title,
35 provisional_session_title,
36};
37use mj_core::storage::{MaterializedSessionMutation, ProjectionIntegrityError, TranscriptMutation};
38
39#[derive(Debug, Clone, PartialEq)]
40pub struct ProjectedRelayEvent {
41 pub mutation: MaterializedSessionMutation,
42}
43
44#[derive(Debug, Clone)]
49pub struct ProjectionIndex {
50 transcript: HashMap<String, Arc<TranscriptItem>>,
51 transcript_positions: HashMap<String, usize>,
52 open_agent_streams: BTreeSet<(u64, String)>,
53 open_thought_streams: BTreeSet<(u64, String)>,
54 terminal_referrers: HashMap<String, BTreeSet<String>>,
55}
56
57impl ProjectionIndex {
58 pub fn new(current: &MaterializedSession) -> Self {
59 let mut index = Self {
60 transcript: HashMap::with_capacity(current.transcript.len()),
61 transcript_positions: HashMap::with_capacity(current.transcript.len()),
62 open_agent_streams: BTreeSet::new(),
63 open_thought_streams: BTreeSet::new(),
64 terminal_referrers: HashMap::new(),
65 };
66 for (position, item) in current.transcript.iter().enumerate() {
67 index.insert_at(item.clone(), position);
68 }
69 index
70 }
71
72 fn get(&self, stable_id: &str) -> Option<&Arc<TranscriptItem>> {
73 self.transcript.get(stable_id)
74 }
75
76 fn position(&self, stable_id: &str) -> Option<usize> {
77 self.transcript_positions.get(stable_id).copied()
78 }
79
80 fn insert(&mut self, item: Arc<TranscriptItem>) {
81 let position = self
82 .remove(&item.stable_id)
83 .unwrap_or(self.transcript.len());
84 self.insert_at(item, position);
85 }
86
87 fn insert_at(&mut self, item: Arc<TranscriptItem>, position: usize) {
88 let stream = (item.position, item.stable_id.clone());
89 match &item.body {
90 TranscriptBody::Agent {
91 streaming: true, ..
92 } => {
93 self.open_agent_streams.insert(stream);
94 }
95 TranscriptBody::Thought {
96 streaming: true, ..
97 } => {
98 self.open_thought_streams.insert(stream);
99 }
100 TranscriptBody::Tool {
101 call,
102 terminal_refs,
103 ..
104 } => {
105 let mut terminal_ids = tool_call_terminal_ids(call);
106 terminal_ids.extend(terminal_refs.iter().cloned());
107 for terminal_id in terminal_ids {
108 self.terminal_referrers
109 .entry(terminal_id)
110 .or_default()
111 .insert(item.stable_id.clone());
112 }
113 }
114 _ => {}
115 }
116 self.transcript_positions
117 .insert(item.stable_id.clone(), position);
118 self.transcript.insert(item.stable_id.clone(), item);
119 }
120
121 fn remove(&mut self, stable_id: &str) -> Option<usize> {
122 let item = self.transcript.remove(stable_id)?;
123 let position = self.transcript_positions.remove(stable_id);
124 debug_assert!(position.is_some());
125 let stream = (item.position, item.stable_id.clone());
126 self.open_agent_streams.remove(&stream);
127 self.open_thought_streams.remove(&stream);
128 if let TranscriptBody::Tool {
129 call,
130 terminal_refs,
131 ..
132 } = &item.body
133 {
134 let mut terminal_ids = tool_call_terminal_ids(call);
135 terminal_ids.extend(terminal_refs.iter().cloned());
136 for terminal_id in terminal_ids {
137 if let Some(referrers) = self.terminal_referrers.get_mut(&terminal_id) {
138 referrers.remove(stable_id);
139 if referrers.is_empty() {
140 self.terminal_referrers.remove(&terminal_id);
141 }
142 }
143 }
144 }
145 position
146 }
147
148 fn reindex_after_removal(&mut self, transcript: &[Arc<TranscriptItem>], removed: usize) {
149 for (position, item) in transcript.iter().enumerate().skip(removed) {
150 self.transcript_positions
151 .insert(item.stable_id.clone(), position);
152 }
153 }
154
155 fn latest_open_stream(&self, agent: bool) -> Option<&Arc<TranscriptItem>> {
156 let streams = if agent {
157 &self.open_agent_streams
158 } else {
159 &self.open_thought_streams
160 };
161 streams
162 .last()
163 .and_then(|(_, stable_id)| self.transcript.get(stable_id))
164 }
165
166 fn open_streams(&self, agent: bool) -> impl Iterator<Item = &Arc<TranscriptItem>> {
167 let streams = if agent {
168 &self.open_agent_streams
169 } else {
170 &self.open_thought_streams
171 };
172 streams
173 .iter()
174 .filter_map(|(_, stable_id)| self.transcript.get(stable_id))
175 }
176
177 fn terminal_referrers(&self, terminal_id: &str) -> impl Iterator<Item = &Arc<TranscriptItem>> {
178 self.terminal_referrers
179 .get(terminal_id)
180 .into_iter()
181 .flatten()
182 .filter_map(|stable_id| self.transcript.get(stable_id))
183 }
184}
185
186pub fn project_relay_event(
190 current: &MaterializedSession,
191 event: &RelayEvent,
192) -> Result<ProjectedRelayEvent> {
193 let index = ProjectionIndex::new(current);
194 project_relay_event_indexed(current, &index, event)
195}
196
197pub fn project_relay_event_indexed(
198 current: &MaterializedSession,
199 index: &ProjectionIndex,
200 event: &RelayEvent,
201) -> Result<ProjectedRelayEvent> {
202 validate_relay_event(
203 current.applied_event_ordinal,
204 ¤t.applied_event_digest,
205 event,
206 )?;
207
208 let mut mutation = MaterializedSessionMutation {
209 last_activity_at_ms: Some(event.recorded_at_ms),
210 ..MaterializedSessionMutation::default()
211 };
212 project_observation(current, index, event, &mut mutation)?;
213 mutation.api_events = api_events::derive(current, event, &mutation);
214 Ok(ProjectedRelayEvent { mutation })
215}
216
217pub fn apply_committed_projection_event(
222 current: &mut MaterializedSession,
223 event: &RelayEvent,
224 mutation: MaterializedSessionMutation,
225) -> Result<()> {
226 apply_committed_projection_event_inner(current, event, mutation, None)
227}
228
229pub fn apply_committed_projection_event_indexed(
230 current: &mut MaterializedSession,
231 index: &mut ProjectionIndex,
232 event: &RelayEvent,
233 mutation: MaterializedSessionMutation,
234) -> Result<()> {
235 apply_committed_projection_event_inner(current, event, mutation, Some(index))
236}
237
238fn apply_committed_projection_event_inner(
239 current: &mut MaterializedSession,
240 event: &RelayEvent,
241 mutation: MaterializedSessionMutation,
242 mut index: Option<&mut ProjectionIndex>,
243) -> Result<()> {
244 validate_relay_event(
245 current.applied_event_ordinal,
246 ¤t.applied_event_digest,
247 event,
248 )?;
249 if let Some(execution) = mutation.execution {
250 current.execution = execution;
251 }
252 if let Some(title) = mutation.session_title {
253 current.session_title = title;
254 }
255 if let Some(configuration) = mutation.configuration {
256 current.configuration = configuration;
257 }
258 for item_mutation in mutation.transcript {
259 match item_mutation {
260 TranscriptMutation::Upsert(item) => {
261 item.validate(event.ordinal)?;
262 let existing_position = index
263 .as_deref()
264 .and_then(|index| index.position(&item.stable_id));
265 let existing = if let Some(position) = existing_position {
266 Some(current.transcript.get_mut(position).with_context(|| {
267 format!(
268 "transcript index position {position} for {:?} is out of bounds",
269 item.stable_id
270 )
271 })?)
272 } else if index.is_none() {
273 current
274 .transcript
275 .iter_mut()
276 .find(|existing| existing.stable_id == item.stable_id)
277 } else {
278 None
279 };
280 if let Some(existing) = existing {
281 if existing.stable_id != item.stable_id {
282 return Err(ProjectionIntegrityError(format!(
283 "transcript index for {:?} points to {:?}",
284 item.stable_id, existing.stable_id
285 ))
286 .into());
287 }
288 if existing.position != item.position
289 || existing.created_at_ms != item.created_at_ms
290 {
291 return Err(ProjectionIntegrityError(format!(
292 "transcript item {:?} changed immutable identity fields",
293 item.stable_id
294 ))
295 .into());
296 }
297 if item.last_changed_at_ms < existing.last_changed_at_ms {
298 return Err(ProjectionIntegrityError(format!(
299 "transcript item {:?} moved its changed timestamp backwards",
300 item.stable_id
301 ))
302 .into());
303 }
304 if existing
305 .latest_content_event_ordinal
306 .is_some_and(|existing| {
307 item.latest_content_event_ordinal
308 .is_none_or(|next| next < existing)
309 })
310 {
311 return Err(ProjectionIntegrityError(format!(
312 "transcript item {:?} moved its latest content ordinal backwards",
313 item.stable_id
314 ))
315 .into());
316 }
317 if let Some(owned) = Arc::get_mut(existing) {
321 *owned = item;
322 } else {
323 *existing = Arc::new(item);
324 }
325 if let Some(index) = index.as_deref_mut() {
326 index.insert(existing.clone());
327 }
328 } else {
329 let item = Arc::new(item);
330 if let Some(index) = index.as_deref_mut() {
331 index.insert(item.clone());
332 }
333 current.transcript.push(item);
334 }
335 }
336 TranscriptMutation::Remove { stable_id } => {
337 if let Some(index) = index.as_deref_mut() {
338 if let Some(position) = index.remove(&stable_id) {
339 let removed = current.transcript.remove(position);
340 if removed.stable_id != stable_id {
341 return Err(ProjectionIntegrityError(format!(
342 "transcript index for {stable_id:?} removed {:?}",
343 removed.stable_id
344 ))
345 .into());
346 }
347 index.reindex_after_removal(¤t.transcript, position);
348 }
349 } else {
350 current
351 .transcript
352 .retain(|item| item.stable_id != stable_id);
353 }
354 }
355 }
356 }
357 if let Some(queued_prompts) = mutation.queued_prompts {
358 current.queued_prompts = queued_prompts;
359 }
360 if let Some(pending_elicitations) = mutation.pending_elicitations {
361 current.pending_elicitations = pending_elicitations;
362 }
363 if let Some(active_turn) = mutation.active_turn {
364 current.active_turn = active_turn;
365 }
366 if let Some(last_turn_outcome) = mutation.last_turn_outcome {
367 current.last_turn_outcome = Some(last_turn_outcome);
368 }
369 if let Some(activity) = mutation.last_activity_at_ms {
370 current.last_activity_at_ms = Some(
371 current
372 .last_activity_at_ms
373 .map_or(activity, |existing| existing.max(activity)),
374 );
375 }
376 current.applied_event_ordinal = event.ordinal;
377 current.applied_event_digest.clone_from(&event.digest);
378 Ok(())
379}
380
381fn project_observation(
382 current: &MaterializedSession,
383 index: &ProjectionIndex,
384 event: &RelayEvent,
385 mutation: &mut MaterializedSessionMutation,
386) -> Result<()> {
387 match &event.observation {
388 RelayObservation::AgentInitialized { .. } => {}
389 RelayObservation::SessionOpened { resumed, .. } => {
390 mutation.pending_elicitations = Some(Vec::new());
391 if !resumed {
392 push_system(mutation, event, "harness session started");
393 }
394 }
395 RelayObservation::SessionConfigured { config_options } => {
396 mutation.configuration = Some(configuration_values(config_options));
397 }
398 RelayObservation::SessionModesConfigured { .. } => {}
399 RelayObservation::SessionUpdate { update } => {
400 project_session_update(current, index, event, update, mutation)?;
401 }
402 RelayObservation::PermissionAutoApproved {
403 option_id,
404 option_name,
405 } => push_system(
406 mutation,
407 event,
408 format!("permission auto-approved: {option_name} ({option_id})"),
409 ),
410 RelayObservation::ElicitationRequested { request } => {
411 let mut pending = current.pending_elicitations.clone();
412 pending.retain(|existing| existing.id != request.id);
413 pending.push(request.clone());
414 mutation.pending_elicitations = Some(pending);
415 if let Some(plan) = mj_core::acp::plan_review_proposal(request) {
420 close_streams(index, mutation, event.recorded_at_ms);
421 upsert(
422 mutation,
423 TranscriptItem {
424 stable_id: plan_proposal_item_id(event.ordinal),
425 position: event.ordinal,
426 latest_content_event_ordinal: None,
427 created_at_ms: event.recorded_at_ms,
428 last_changed_at_ms: event.recorded_at_ms,
429 body: TranscriptBody::PlanProposal {
430 proposal_id: request.id.clone(),
431 plan: plan.to_owned(),
432 },
433 },
434 );
435 }
436 }
437 RelayObservation::ElicitationResolved { elicitation_id, .. } => {
438 let mut pending = current.pending_elicitations.clone();
439 pending.retain(|request| request.id != *elicitation_id);
440 mutation.pending_elicitations = Some(pending);
441 }
442 RelayObservation::ElicitationsCleared => {
443 mutation.pending_elicitations = Some(Vec::new());
444 }
445 RelayObservation::CommandQueued {
446 command_id,
447 command,
448 created_at_ms,
449 } => match command {
450 RelayCommand::Prompt { prompt } => {
451 let content = prompt
452 .iter()
453 .map(serde_json::to_value)
454 .collect::<serde_json::Result<Vec<_>>>()?;
455 if current.session_title.is_none() {
456 let prompt_text = crate::transcript::materialized_content_text(&content);
457 if let Some(title) = current
458 .resolved_title()
459 .or_else(|| provisional_session_title(&prompt_text))
460 {
461 mutation.session_title = Some(Some(title));
462 }
463 }
464 let mut queue = current.queued_prompts.clone();
465 queue.retain(|queued| queued.command_id != *command_id);
466 queue.push(MaterializedQueuedPrompt {
467 command_id: command_id.clone(),
468 kind: QueuedCommandKind::Prompt,
469 content,
470 queued_at_ms: *created_at_ms,
471 accepted_ordinal: Some(event.ordinal),
472 });
473 mutation.queued_prompts = Some(queue);
474 }
475 RelayCommand::SetConfig { key, value } => {
478 let mut queue = current.queued_prompts.clone();
479 queue.retain(|queued| queued.command_id != *command_id);
480 queue.push(MaterializedQueuedPrompt {
481 command_id: command_id.clone(),
482 kind: QueuedCommandKind::SetConfig {
483 key: key.clone(),
484 value: value.clone(),
485 },
486 content: vec![serde_json::to_value(ContentBlock::Text(TextContent::new(
487 config_command_text(key, value),
488 )))?],
489 queued_at_ms: *created_at_ms,
490 accepted_ordinal: Some(event.ordinal),
491 });
492 mutation.queued_prompts = Some(queue);
493 }
494 RelayCommand::RunUserShell { command } => upsert(
495 mutation,
496 TranscriptItem {
497 stable_id: user_shell_item_id(command_id),
498 position: event.ordinal,
499 latest_content_event_ordinal: None,
500 created_at_ms: *created_at_ms,
501 last_changed_at_ms: *created_at_ms,
502 body: TranscriptBody::System {
503 text: user_shell_text(command, "queued", "", "", false, false),
504 },
505 },
506 ),
507 RelayCommand::RemoveQueuedPrompt { .. } | RelayCommand::ClearQueuedPrompts => {}
508 RelayCommand::Close { .. } => {
509 close_streams(index, mutation, event.recorded_at_ms);
510 mutation.execution = Some(MaterializedExecutionState::Closing);
511 }
512 _ => {}
513 },
514 RelayObservation::CommandStarted {
515 command_id,
516 started_at_ms,
517 } => {
518 if let Some(queue_index) = current
519 .queued_prompts
520 .iter()
521 .position(|queued| queued.command_id == *command_id)
522 {
523 let mut queue = current.queued_prompts.clone();
524 let entry = queue.remove(queue_index);
525 let entry_accepted_ordinal = entry.accepted_ordinal;
526 mutation.queued_prompts = Some(queue);
527 if entry.kind.is_prompt() {
530 close_streams(index, mutation, event.recorded_at_ms);
531 upsert(
532 mutation,
533 TranscriptItem {
534 stable_id: format!("user:{command_id}"),
535 position: event.ordinal,
536 latest_content_event_ordinal: None,
537 created_at_ms: *started_at_ms,
538 last_changed_at_ms: *started_at_ms,
539 body: TranscriptBody::User {
540 content: entry.content,
541 },
542 },
543 );
544 mutation.execution = Some(MaterializedExecutionState::Running {
545 started_at_ms: *started_at_ms,
546 });
547 mutation.active_turn = Some(Some(MaterializedTurn {
548 command_id: command_id.clone(),
549 accepted_ordinal: entry_accepted_ordinal,
550 turn_start_position: event.ordinal,
551 started_at_ms: *started_at_ms,
552 }));
553 }
554 }
555 if let Some(existing) = index.get(&user_shell_item_id(command_id)) {
556 let mut item = TranscriptItem::clone(existing);
557 if let TranscriptBody::System { text } = &mut item.body {
558 *text = text.replacen("Shell · queued", "Shell · running", 1);
559 }
560 item.last_changed_at_ms = item.last_changed_at_ms.max(*started_at_ms);
561 upsert(mutation, item);
562 }
563 }
564 RelayObservation::CommandCompleted {
565 command_id,
566 outcome,
567 } => {
568 let mut queue = current.queued_prompts.clone();
569 queue.retain(|queued| queued.command_id != *command_id);
570 match outcome {
571 mj_core::relay::RelayCommandOutcome::Prompt {
572 stop_reason,
573 usage,
574 diagnostic,
575 } => {
576 let native_running =
577 mj_core::goal::GoalState::from_configuration(¤t.configuration)?
578 .running();
579 if !native_running {
580 close_streams(index, mutation, event.recorded_at_ms);
581 mutation.execution = Some(MaterializedExecutionState::Idle);
582 }
583 let active = current
584 .active_turn
585 .as_ref()
586 .filter(|turn| turn.command_id == *command_id);
587 mutation.last_turn_outcome = Some(MaterializedTurnOutcome {
588 diagnostic: diagnostic.clone(),
589 usage: usage.clone(),
590 command_id: command_id.clone(),
591 accepted_ordinal: active.and_then(|turn| turn.accepted_ordinal),
592 turn_start_position: active.map(|turn| turn.turn_start_position),
593 completed_ordinal: event.ordinal,
594 completed_at_ms: event.recorded_at_ms,
595 outcome: TurnOutcomeKind::Completed {
596 stop_reason: stop_reason.clone(),
597 },
598 });
599 mutation.active_turn = Some(None);
600 }
601 mj_core::relay::RelayCommandOutcome::UserShell { result } => {
602 if let Some(existing) = index.get(&user_shell_item_id(command_id)) {
603 let mut item = TranscriptItem::clone(existing);
604 item.body = TranscriptBody::System {
605 text: user_shell_result_text(result),
606 };
607 item.last_changed_at_ms = item.last_changed_at_ms.max(event.recorded_at_ms);
608 upsert(mutation, item);
609 }
610 }
611 mj_core::relay::RelayCommandOutcome::Closed => {
612 close_streams(index, mutation, event.recorded_at_ms);
613 mutation.execution = Some(MaterializedExecutionState::Closed);
614 }
615 mj_core::relay::RelayCommandOutcome::QueueChanged {
616 removed_command_ids,
617 } => queue.retain(|queued| {
618 !removed_command_ids
619 .iter()
620 .any(|command_id| command_id == &queued.command_id)
621 }),
622 mj_core::relay::RelayCommandOutcome::Steered { queued_command_id } => {
623 let Some(queue_index) = queue
624 .iter()
625 .position(|queued| queued.command_id == *queued_command_id)
626 else {
627 bail!("steered prompt is missing from the materialized queue");
628 };
629 let entry = queue.remove(queue_index);
630 if !entry.kind.is_prompt() {
631 bail!("steered queue entry is not a prompt");
632 }
633 mutation.active_turn = Some(Some(MaterializedTurn {
637 command_id: queued_command_id.clone(),
638 accepted_ordinal: entry.accepted_ordinal,
639 turn_start_position: event.ordinal,
640 started_at_ms: event.recorded_at_ms,
641 }));
642 close_streams(index, mutation, event.recorded_at_ms);
643 upsert(
644 mutation,
645 TranscriptItem {
646 stable_id: format!("user:{queued_command_id}"),
647 position: event.ordinal,
648 latest_content_event_ordinal: None,
649 created_at_ms: event.recorded_at_ms,
650 last_changed_at_ms: event.recorded_at_ms,
651 body: TranscriptBody::User {
652 content: entry.content,
653 },
654 },
655 );
656 }
657 mj_core::relay::RelayCommandOutcome::Configured => {
658 mutation.config_results.push((command_id.clone(), None));
659 }
660 mj_core::relay::RelayCommandOutcome::GoalControlled
661 | mj_core::relay::RelayCommandOutcome::SessionModeSet
662 | mj_core::relay::RelayCommandOutcome::Cancelled
663 | mj_core::relay::RelayCommandOutcome::CheckpointCompleted
664 | mj_core::relay::RelayCommandOutcome::CheckpointReleased
665 | mj_core::relay::RelayCommandOutcome::RecoveryFloorAdvanced
666 | mj_core::relay::RelayCommandOutcome::NoticeRecorded
667 | mj_core::relay::RelayCommandOutcome::UserShellCancelled => {}
668 }
669 if queue != current.queued_prompts {
670 mutation.queued_prompts = Some(queue);
671 }
672 }
673 RelayObservation::CommandRejected {
674 command_id,
675 command,
676 message,
677 }
678 | RelayObservation::CommandInterrupted {
679 command_id,
680 command,
681 message,
682 } => {
683 if *command == RelayCommandKind::SetConfig {
684 mutation
685 .config_results
686 .push((command_id.clone(), Some(message.clone())));
687 }
688 let prompt_was_started = index.get(&format!("user:{command_id}")).is_some();
689 let queued_entry = current
690 .queued_prompts
691 .iter()
692 .find(|queued| queued.command_id == *command_id)
693 .cloned();
694 let mut queue = current.queued_prompts.clone();
695 queue.retain(|queued| queued.command_id != *command_id);
696 if queue != current.queued_prompts {
697 mutation.queued_prompts = Some(queue);
698 }
699 if prompt_was_started
700 && !mj_core::goal::GoalState::from_configuration(¤t.configuration)?.running()
701 {
702 close_streams(index, mutation, event.recorded_at_ms);
703 mutation.execution = Some(MaterializedExecutionState::Idle);
704 }
705 if *command == RelayCommandKind::Prompt {
706 let active = current
709 .active_turn
710 .as_ref()
711 .filter(|turn| turn.command_id == *command_id);
712 let outcome_text = message.clone();
713 mutation.last_turn_outcome = Some(MaterializedTurnOutcome {
714 diagnostic: None,
715 usage: None,
716 command_id: command_id.clone(),
717 accepted_ordinal: active.and_then(|turn| turn.accepted_ordinal).or_else(|| {
718 queued_entry
719 .as_ref()
720 .and_then(|entry| entry.accepted_ordinal)
721 }),
722 turn_start_position: active.map(|turn| turn.turn_start_position),
723 completed_ordinal: event.ordinal,
724 completed_at_ms: event.recorded_at_ms,
725 outcome: if matches!(
726 event.observation,
727 RelayObservation::CommandRejected { .. }
728 ) {
729 TurnOutcomeKind::Rejected {
730 message: outcome_text,
731 }
732 } else {
733 TurnOutcomeKind::Interrupted {
734 message: outcome_text,
735 }
736 },
737 });
738 if active.is_some() {
739 mutation.active_turn = Some(None);
740 }
741 }
742 if *command == RelayCommandKind::Close
743 && current.execution == MaterializedExecutionState::Closing
744 {
745 mutation.execution = Some(MaterializedExecutionState::Idle);
746 }
747 if matches!(command, RelayCommandKind::RunUserShell) {
748 if let Some(existing) = index.get(&user_shell_item_id(command_id)) {
749 let mut item = TranscriptItem::clone(existing);
750 if let TranscriptBody::System { text } = &mut item.body {
751 *text = format!(
752 "{}\nerror: {message}",
753 text.replacen("Shell · queued", "Shell · interrupted", 1)
754 .replacen("Shell · running", "Shell · interrupted", 1)
755 );
756 }
757 item.last_changed_at_ms = item.last_changed_at_ms.max(event.recorded_at_ms);
758 upsert(mutation, item);
759 }
760 } else {
761 push_system(mutation, event, format!("command {command_id}: {message}"));
762 }
763 }
764 RelayObservation::ConfigurationUpdated { key, value } => {
765 let mut configuration = current.configuration.clone();
766 configuration.insert(key.clone(), Value::String(value.clone()));
767 mutation.configuration = Some(configuration);
768 }
769 RelayObservation::CheckpointReady { .. } => {}
770 RelayObservation::UserShellOutput {
771 command_id,
772 command,
773 stdout,
774 stderr,
775 stdout_truncated,
776 stderr_truncated,
777 } => {
778 if let Some(existing) = index.get(&user_shell_item_id(command_id)) {
779 let mut item = TranscriptItem::clone(existing);
780 item.body = TranscriptBody::System {
781 text: user_shell_text(
782 command,
783 "running",
784 stdout,
785 stderr,
786 *stdout_truncated,
787 *stderr_truncated,
788 ),
789 };
790 item.last_changed_at_ms = item.last_changed_at_ms.max(event.recorded_at_ms);
791 upsert(mutation, item);
792 }
793 }
794 RelayObservation::TerminalOutput {
799 terminal_id,
800 output,
801 truncated,
802 exit_code,
803 signal,
804 } => {
805 let record = TerminalOutputRecord {
806 terminal_id: terminal_id.clone(),
807 output: output.clone(),
808 truncated: *truncated,
809 exit_code: *exit_code,
810 signal: signal.clone(),
811 };
812 let raw_owner = uniquely_matching_raw_tool(index, &record);
813 let referrers = index
814 .terminal_referrers(terminal_id)
815 .cloned()
816 .collect::<Vec<_>>();
817 let mut attached = false;
818 for existing in &referrers {
819 if raw_owner.as_ref().is_some_and(|owner| {
820 owner.stable_id != existing.stable_id
821 && fallback_tool_item(existing).unwrap_or(false)
822 }) {
823 mutation.transcript.push(TranscriptMutation::Remove {
824 stable_id: existing.stable_id.clone(),
825 });
826 attached = true;
827 continue;
828 }
829 let mut item = TranscriptItem::clone(existing);
830 let TranscriptBody::Tool {
831 terminal_outputs,
832 terminal_refs,
833 ..
834 } = &mut item.body
835 else {
836 unreachable!("matched a tool body above");
837 };
838 replace_or_push_terminal_record(terminal_outputs, record.clone());
839 if !terminal_refs.contains(terminal_id) {
840 terminal_refs.push(terminal_id.clone());
841 }
842 finalize_fallback_terminal_tool(&mut item)?;
843 item.last_changed_at_ms = item.last_changed_at_ms.max(event.recorded_at_ms);
844 upsert(mutation, item);
845 attached = true;
846 }
847 if let Some(existing) = raw_owner
848 && !referrers
849 .iter()
850 .any(|referrer| referrer.stable_id == existing.stable_id)
851 {
852 let mut item = TranscriptItem::clone(&existing);
853 let TranscriptBody::Tool {
854 terminal_outputs,
855 terminal_refs,
856 ..
857 } = &mut item.body
858 else {
859 unreachable!("matched a tool body above");
860 };
861 replace_or_push_terminal_record(terminal_outputs, record.clone());
862 terminal_refs.push(terminal_id.clone());
863 item.last_changed_at_ms = item.last_changed_at_ms.max(event.recorded_at_ms);
864 upsert(mutation, item);
865 attached = true;
866 }
867 if !attached {
868 let stable_id = terminal_item_id(terminal_id);
869 match index.get(&stable_id) {
870 Some(existing) => {
871 let mut item = TranscriptItem::clone(existing);
872 item.body = TranscriptBody::TerminalOutput { record };
873 item.last_changed_at_ms = item.last_changed_at_ms.max(event.recorded_at_ms);
874 upsert(mutation, item);
875 }
876 None => upsert(
877 mutation,
878 TranscriptItem {
879 stable_id,
880 position: event.ordinal,
881 latest_content_event_ordinal: None,
882 created_at_ms: event.recorded_at_ms,
883 last_changed_at_ms: event.recorded_at_ms,
884 body: TranscriptBody::TerminalOutput { record },
885 },
886 ),
887 }
888 }
889 }
890 RelayObservation::Warning { message } => {
891 push_system(mutation, event, format!("warning: {message}"));
892 }
893 RelayObservation::SessionRestarted => {
894 if let Some(value) = current.configuration.get(mj_core::goal::PROJECTION_KEY) {
895 let mut goal: mj_core::goal::GoalState = serde_json::from_value(value.clone())?;
896 goal.restart();
897 let mut configuration = current.configuration.clone();
898 configuration.insert(
899 mj_core::goal::PROJECTION_KEY.into(),
900 serde_json::to_value(goal)?,
901 );
902 mutation.configuration = Some(configuration);
903 }
904 push_system_with_id(
905 mutation,
906 event,
907 format!(
908 "{}{}",
909 crate::transcript::SESSION_RESTART_ITEM_PREFIX,
910 event.ordinal
911 ),
912 crate::transcript::SESSION_RESTART_TEXT,
913 );
914 if matches!(
918 current.execution,
919 MaterializedExecutionState::Running { .. }
920 ) {
921 close_streams(index, mutation, event.recorded_at_ms);
922 mutation.execution = Some(MaterializedExecutionState::Idle);
923 }
924 }
925 RelayObservation::HarnessTurnStarted { .. } if current.active_turn.is_some() => {
926 }
929 RelayObservation::HarnessTurnStarted { started_at_ms } => {
930 upsert(
931 mutation,
932 TranscriptItem {
933 stable_id: format!(
934 "{}{}",
935 crate::transcript::HARNESS_TURN_ITEM_PREFIX,
936 event.ordinal
937 ),
938 position: event.ordinal,
939 latest_content_event_ordinal: None,
940 created_at_ms: event.recorded_at_ms,
941 last_changed_at_ms: event.recorded_at_ms,
942 body: TranscriptBody::System {
943 text: crate::transcript::HARNESS_TURN_TEXT.to_owned(),
944 },
945 },
946 );
947 mutation.execution = Some(MaterializedExecutionState::Running {
948 started_at_ms: *started_at_ms,
949 });
950 }
951 RelayObservation::HarnessTurnSettled {
952 prompt_in_flight, ..
953 } => {
954 if !prompt_in_flight {
959 close_streams(index, mutation, event.recorded_at_ms);
960 mutation.execution = Some(MaterializedExecutionState::Idle);
961 }
962 }
963 RelayObservation::Notice { message } => {
967 let stable_id = match &event.command_id {
968 Some(command_id) => format!("system:notice:{command_id}"),
969 None => format!("system:{}", event.ordinal),
970 };
971 if index.get(&stable_id).is_none() {
972 push_system_with_id(mutation, event, stable_id, message.clone());
973 }
974 }
975 RelayObservation::Closing => {
976 close_streams(index, mutation, event.recorded_at_ms);
977 mutation.execution = Some(MaterializedExecutionState::Closing);
978 }
979 RelayObservation::Closed => {
980 close_streams(index, mutation, event.recorded_at_ms);
981 mutation.execution = Some(MaterializedExecutionState::Closed);
982 }
983 }
984 Ok(())
985}
986
987fn user_shell_item_id(command_id: &str) -> String {
988 format!("shell:{command_id}")
989}
990
991pub fn plan_proposal_item_id(ordinal: u64) -> String {
995 format!("plan-proposal:{ordinal}")
996}
997
998fn user_shell_text(
999 command: &str,
1000 status: &str,
1001 stdout: &str,
1002 stderr: &str,
1003 stdout_truncated: bool,
1004 stderr_truncated: bool,
1005) -> String {
1006 let mut text = format!("Shell · {status}\n$ {command}");
1007 if !stdout.is_empty() {
1008 text.push_str("\n\nstdout:\n");
1009 text.push_str(stdout);
1010 if stdout_truncated {
1011 text.push_str("\n[output continues; final tail will be shown on completion]");
1012 }
1013 }
1014 if !stderr.is_empty() {
1015 text.push_str("\n\nstderr:\n");
1016 text.push_str(stderr);
1017 if stderr_truncated {
1018 text.push_str("\n[output continues; final tail will be shown on completion]");
1019 }
1020 }
1021 text
1022}
1023
1024fn user_shell_result_text(result: &mj_core::relay::UserShellResult) -> String {
1025 let status = match result.status {
1026 mj_core::relay::UserShellStatus::Exited => match result.exit_code {
1027 Some(0) => "done".to_owned(),
1028 Some(code) => format!("failed (exit {code})"),
1029 None => "finished".to_owned(),
1030 },
1031 mj_core::relay::UserShellStatus::Signaled => format!(
1032 "signaled ({})",
1033 result.signal.as_deref().unwrap_or("unknown signal")
1034 ),
1035 mj_core::relay::UserShellStatus::TimedOut => "timed out".to_owned(),
1036 mj_core::relay::UserShellStatus::Cancelled => "cancelled".to_owned(),
1037 mj_core::relay::UserShellStatus::Interrupted => "interrupted".to_owned(),
1038 mj_core::relay::UserShellStatus::Failed => "failed".to_owned(),
1039 };
1040 let mut text = user_shell_text(
1041 &result.command,
1042 &format!("{status} · {} ms", result.duration_ms),
1043 &result.stdout,
1044 &result.stderr,
1045 result.stdout_truncated,
1046 result.stderr_truncated,
1047 );
1048 if let Some(error) = &result.error {
1049 text.push_str("\n\nerror: ");
1050 text.push_str(error);
1051 }
1052 text
1053}
1054
1055fn project_session_update(
1056 current: &MaterializedSession,
1057 index: &ProjectionIndex,
1058 event: &RelayEvent,
1059 update: &SessionUpdate,
1060 mutation: &mut MaterializedSessionMutation,
1061) -> Result<()> {
1062 let running = matches!(
1063 mutation.execution.as_ref().unwrap_or(¤t.execution),
1064 MaterializedExecutionState::Running { .. }
1065 );
1066 match update {
1067 SessionUpdate::AgentMessageChunk(chunk) => {
1068 close_stream_kind(index, mutation, false, event.recorded_at_ms);
1069 push_stream_chunk(current, index, mutation, event, true, running, chunk)?;
1070 }
1071 SessionUpdate::AgentThoughtChunk(chunk) => {
1072 close_stream_kind(index, mutation, true, event.recorded_at_ms);
1073 push_stream_chunk(current, index, mutation, event, false, running, chunk)?;
1074 }
1075 SessionUpdate::UserMessageChunk(_) => {}
1077 SessionUpdate::ToolCall(call) => {
1078 close_streams(index, mutation, event.recorded_at_ms);
1079 if fallback_terminal_already_claimed(index, call)? {
1080 return Ok(());
1081 }
1082 let stable_id = format!("tool:{}", call.tool_call_id);
1083 if let Some(mut item) = index
1088 .get(&stable_id)
1089 .map(|item| TranscriptItem::clone(item))
1090 {
1091 let TranscriptBody::Tool {
1092 call: existing,
1093 presentation,
1094 ..
1095 } = &mut item.body
1096 else {
1097 bail!(
1098 "ACP tool call {} conflicts with transcript item {stable_id}",
1099 call.tool_call_id
1100 );
1101 };
1102 *existing = serde_json::to_value(call)?;
1103 *presentation = Some(Box::new(tool_call_presentation(call)));
1104 item.last_changed_at_ms = item.last_changed_at_ms.max(event.recorded_at_ms);
1105 attach_terminal_outputs(current, index, mutation, &mut item);
1106 consume_fallback_terminal_tools(index, mutation, &mut item, false)?;
1107 finalize_fallback_terminal_tool(&mut item)?;
1108 upsert(mutation, item);
1109 } else {
1110 let mut item = TranscriptItem {
1111 stable_id,
1112 position: event.ordinal,
1113 latest_content_event_ordinal: None,
1114 created_at_ms: event.recorded_at_ms,
1115 last_changed_at_ms: event.recorded_at_ms,
1116 body: TranscriptBody::Tool {
1117 call: serde_json::to_value(call)?,
1118 terminal_outputs: Vec::new(),
1119 terminal_refs: Vec::new(),
1120 presentation: Some(Box::new(tool_call_presentation(call))),
1121 },
1122 };
1123 attach_terminal_outputs(current, index, mutation, &mut item);
1124 consume_fallback_terminal_tools(index, mutation, &mut item, true)?;
1125 finalize_fallback_terminal_tool(&mut item)?;
1126 upsert(mutation, item);
1127 }
1128 }
1129 SessionUpdate::ToolCallUpdate(update) => {
1130 let stable_id = format!("tool:{}", update.tool_call_id);
1131 let item = index
1132 .get(&stable_id)
1133 .map(|item| TranscriptItem::clone(item));
1134 let Some(mut item) = item else {
1135 tracing::warn!(
1142 session_id = %current.session_id,
1143 tool_call_id = %update.tool_call_id,
1144 has_public_fields = update.fields != ToolCallUpdateFields::default(),
1145 "ignored ACP update for a tool call absent from the durable transcript"
1146 );
1147 return Ok(());
1148 };
1149 close_streams(index, mutation, event.recorded_at_ms);
1150 let TranscriptBody::Tool {
1151 call, presentation, ..
1152 } = &mut item.body
1153 else {
1154 bail!(
1155 "ACP tool call {} conflicts with transcript item {stable_id}",
1156 update.tool_call_id
1157 );
1158 };
1159 let mut materialized_call: ToolCall = serde_json::from_value(call.clone())
1160 .with_context(|| {
1161 format!("parse materialized ACP tool call {}", update.tool_call_id)
1162 })?;
1163 let presentation_changed = crate::transcript::tool_call_update_changes_presentation(
1164 &materialized_call,
1165 &update.fields,
1166 );
1167 materialized_call.update(update.fields.clone());
1168 if presentation_changed
1169 || presentation.as_ref().is_none_or(|value| {
1170 value.summary_version < crate::transcript::TOOL_SUMMARY_VERSION
1171 })
1172 {
1173 *presentation = Some(Box::new(tool_call_presentation(&materialized_call)));
1174 }
1175 *call = serde_json::to_value(materialized_call)?;
1176 item.last_changed_at_ms = item.last_changed_at_ms.max(event.recorded_at_ms);
1177 attach_terminal_outputs(current, index, mutation, &mut item);
1178 consume_fallback_terminal_tools(index, mutation, &mut item, false)?;
1179 finalize_fallback_terminal_tool(&mut item)?;
1180 upsert(mutation, item);
1181 }
1182 SessionUpdate::Plan(plan) => {
1183 close_streams(index, mutation, event.recorded_at_ms);
1184 let plan = serde_json::to_value(plan)?;
1185 let latest_turn_start_position = current
1189 .transcript
1190 .iter()
1191 .rev()
1192 .find(|item| item.is_turn_start())
1193 .map_or(0, |item| item.position);
1194 if let Some(mut item) = current
1195 .transcript
1196 .iter()
1197 .rev()
1198 .find(|item| {
1199 item.position > latest_turn_start_position
1200 && matches!(item.body, TranscriptBody::Plan { .. })
1201 })
1202 .map(|item| TranscriptItem::clone(item))
1203 {
1204 item.body = TranscriptBody::Plan { plan };
1205 item.last_changed_at_ms = item.last_changed_at_ms.max(event.recorded_at_ms);
1206 upsert(mutation, item);
1207 } else {
1208 upsert(
1209 mutation,
1210 TranscriptItem {
1211 stable_id: format!("plan:{}", event.ordinal),
1212 position: event.ordinal,
1213 latest_content_event_ordinal: None,
1214 created_at_ms: event.recorded_at_ms,
1215 last_changed_at_ms: event.recorded_at_ms,
1216 body: TranscriptBody::Plan { plan },
1217 },
1218 );
1219 }
1220 }
1221 SessionUpdate::ConfigOptionUpdate(update) => {
1222 mutation.configuration = Some(configuration_values(&update.config_options));
1223 }
1224 SessionUpdate::CurrentModeUpdate(update) => {
1225 let mut configuration = current.configuration.clone();
1226 configuration.insert(
1227 "mode".into(),
1228 Value::String(update.current_mode_id.to_string()),
1229 );
1230 mutation.configuration = Some(configuration);
1231 }
1232 SessionUpdate::SessionInfoUpdate(update) => {
1233 let mut goal = mj_core::goal::GoalState::from_configuration(¤t.configuration)?;
1234 if goal.apply(&SessionUpdate::SessionInfoUpdate(update.clone()))? {
1235 let mut configuration = current.configuration.clone();
1236 configuration.insert(
1237 mj_core::goal::PROJECTION_KEY.into(),
1238 serde_json::to_value(&goal)?,
1239 );
1240 mutation.configuration = Some(configuration);
1241 }
1242 match &update.title {
1243 MaybeUndefined::Undefined => {}
1244 MaybeUndefined::Null => mutation.session_title = Some(None),
1245 MaybeUndefined::Value(title) => {
1246 mutation.session_title = Some(normalize_session_title(title));
1247 }
1248 }
1249 }
1250 SessionUpdate::UsageUpdate(update) => {
1251 if let Some(cost) = &update.cost {
1252 mutation.provider_cost = Some(mj_core::usage::ProviderCost {
1253 amount: cost.amount,
1254 currency: cost.currency.clone(),
1255 observed_at_ms: event.recorded_at_ms,
1256 });
1257 }
1258 }
1259 SessionUpdate::AvailableCommandsUpdate(_) => {}
1260 _ => {}
1261 }
1262 Ok(())
1263}
1264
1265fn push_stream_chunk(
1266 current: &MaterializedSession,
1267 index: &ProjectionIndex,
1268 mutation: &mut MaterializedSessionMutation,
1269 event: &RelayEvent,
1270 agent: bool,
1271 running: bool,
1283 chunk: &agent_client_protocol::schema::v1::ContentChunk,
1284) -> Result<()> {
1285 let explicit_id = chunk.message_id.as_ref().map(|id| {
1286 if agent {
1287 format!("agent:{id}")
1288 } else {
1289 format!("thought:{id}")
1290 }
1291 });
1292 let existing = explicit_id
1293 .as_ref()
1294 .and_then(|id| index.get(id))
1295 .or_else(|| {
1296 if explicit_id.is_none() {
1297 index.latest_open_stream(agent)
1298 } else {
1299 None
1300 }
1301 });
1302 if let Some(existing) = existing {
1303 let mut item = TranscriptItem::clone(existing);
1304 match &mut item.body {
1305 TranscriptBody::Agent { chunks, streaming } if agent => {
1306 chunks.push(serde_json::to_value(chunk)?);
1307 *streaming = running;
1308 }
1309 TranscriptBody::Thought { chunks, streaming } if !agent => {
1310 chunks.push(serde_json::to_value(chunk)?);
1311 *streaming = running;
1312 }
1313 _ => bail!(
1314 "ACP message ID conflicts with transcript item {}",
1315 item.stable_id
1316 ),
1317 }
1318 item.last_changed_at_ms = item.last_changed_at_ms.max(event.recorded_at_ms);
1319 if agent {
1320 item.latest_content_event_ordinal = Some(event.ordinal);
1321 }
1322 upsert(mutation, item);
1323 return Ok(());
1324 }
1325 if explicit_id.is_none()
1329 && !running
1330 && let Some(last) = current.transcript.last()
1331 {
1332 let same_kind = match &last.body {
1333 TranscriptBody::Agent { .. } => agent,
1334 TranscriptBody::Thought { .. } => !agent,
1335 _ => false,
1336 };
1337 if same_kind {
1338 let mut item = TranscriptItem::clone(last);
1339 match &mut item.body {
1340 TranscriptBody::Agent { chunks, .. } if agent => {
1341 chunks.push(serde_json::to_value(chunk)?);
1342 }
1343 TranscriptBody::Thought { chunks, .. } if !agent => {
1344 chunks.push(serde_json::to_value(chunk)?);
1345 }
1346 _ => unreachable!("same_kind matched the item's body above"),
1347 }
1348 item.last_changed_at_ms = item.last_changed_at_ms.max(event.recorded_at_ms);
1349 if agent {
1350 item.latest_content_event_ordinal = Some(event.ordinal);
1351 }
1352 upsert(mutation, item);
1353 return Ok(());
1354 }
1355 }
1356 upsert(
1357 mutation,
1358 TranscriptItem {
1359 stable_id: explicit_id.unwrap_or_else(|| {
1360 if agent {
1361 format!("agent:{}", event.ordinal)
1362 } else {
1363 format!("thought:{}", event.ordinal)
1364 }
1365 }),
1366 position: event.ordinal,
1367 latest_content_event_ordinal: agent.then_some(event.ordinal),
1368 created_at_ms: event.recorded_at_ms,
1369 last_changed_at_ms: event.recorded_at_ms,
1370 body: if agent {
1371 TranscriptBody::Agent {
1372 chunks: vec![serde_json::to_value(chunk)?],
1373 streaming: running,
1374 }
1375 } else {
1376 TranscriptBody::Thought {
1377 chunks: vec![serde_json::to_value(chunk)?],
1378 streaming: running,
1379 }
1380 },
1381 },
1382 );
1383 Ok(())
1384}
1385
1386fn terminal_item_id(terminal_id: &str) -> String {
1389 format!("terminal:{terminal_id}")
1390}
1391
1392fn tool_call_terminal_ids(call: &Value) -> Vec<String> {
1399 let Some(Value::Array(content)) = call.get("content") else {
1400 return Vec::new();
1401 };
1402 content
1403 .iter()
1404 .filter_map(|value| match ToolCallContent::deserialize(value) {
1405 Ok(ToolCallContent::Terminal(terminal)) => Some(terminal.terminal_id.0.to_string()),
1406 Ok(_) => None,
1407 Err(error) => {
1408 tracing::warn!(
1409 %error,
1410 "ignoring malformed tool-call content while locating terminal output"
1411 );
1412 None
1413 }
1414 })
1415 .collect()
1416}
1417
1418fn find_terminal_record(
1420 index: &ProjectionIndex,
1421 terminal_id: &str,
1422) -> Option<TerminalOutputRecord> {
1423 index
1424 .get(&terminal_item_id(terminal_id))
1425 .and_then(|item| match &item.body {
1426 TranscriptBody::TerminalOutput { record } if record.terminal_id == terminal_id => {
1427 Some(record.clone())
1428 }
1429 _ => None,
1430 })
1431}
1432
1433fn replace_or_push_terminal_record(
1434 records: &mut Vec<TerminalOutputRecord>,
1435 record: TerminalOutputRecord,
1436) {
1437 match records
1438 .iter_mut()
1439 .find(|existing| existing.terminal_id == record.terminal_id)
1440 {
1441 Some(existing) => *existing = record,
1442 None => records.push(record),
1443 }
1444}
1445
1446fn fallback_terminal_tool_item_id(terminal_id: &str) -> String {
1447 format!(
1448 "tool:{}",
1449 mj_core::acp::fallback_terminal_tool_call_id(terminal_id)
1450 )
1451}
1452
1453fn fallback_tool_item(item: &TranscriptItem) -> Result<bool> {
1454 let TranscriptBody::Tool { call, .. } = &item.body else {
1455 return Ok(false);
1456 };
1457 let call = serde_json::from_value(call.clone()).context("parse fallback terminal tool call")?;
1458 Ok(mj_core::acp::is_fallback_terminal_tool_call(&call))
1459}
1460
1461fn uniquely_matching_raw_tool(
1464 index: &ProjectionIndex,
1465 record: &TerminalOutputRecord,
1466) -> Option<Arc<TranscriptItem>> {
1467 let mut matching = index.transcript.values().filter_map(|item| {
1468 let TranscriptBody::Tool { call, .. } = &item.body else {
1469 return None;
1470 };
1471 if fallback_tool_item(item).ok()? {
1472 return None;
1473 }
1474 record
1475 .matches_tool_raw_result(call)
1476 .then(|| Arc::clone(item))
1477 });
1478 let item = matching.next()?;
1479 matching.next().is_none().then_some(item)
1480}
1481
1482fn fallback_terminal_already_claimed(index: &ProjectionIndex, call: &ToolCall) -> Result<bool> {
1486 if !mj_core::acp::is_fallback_terminal_tool_call(call) {
1487 return Ok(false);
1488 }
1489 let value = serde_json::to_value(call)?;
1490 Ok(tool_call_terminal_ids(&value)
1491 .into_iter()
1492 .any(|terminal_id| {
1493 index.terminal_referrers(&terminal_id).any(|item| {
1494 let TranscriptBody::Tool { call, .. } = &item.body else {
1495 return false;
1496 };
1497 serde_json::from_value::<ToolCall>(call.clone())
1498 .is_ok_and(|call| !mj_core::acp::is_fallback_terminal_tool_call(&call))
1499 })
1500 }))
1501}
1502
1503fn consume_fallback_terminal_tools(
1508 index: &ProjectionIndex,
1509 mutation: &mut MaterializedSessionMutation,
1510 item: &mut TranscriptItem,
1511 may_adopt_identity: bool,
1512) -> Result<()> {
1513 let TranscriptBody::Tool {
1514 call,
1515 terminal_outputs,
1516 terminal_refs,
1517 ..
1518 } = &mut item.body
1519 else {
1520 return Ok(());
1521 };
1522 let materialized: ToolCall = serde_json::from_value(call.clone())
1523 .context("parse ACP tool call while claiming fallback terminal tools")?;
1524 if mj_core::acp::is_fallback_terminal_tool_call(&materialized) {
1525 return Ok(());
1526 }
1527
1528 let mut terminal_ids = tool_call_terminal_ids(call);
1529 terminal_ids.extend(terminal_refs.iter().cloned());
1530 for terminal_id in terminal_ids {
1531 let stable_id = fallback_terminal_tool_item_id(&terminal_id);
1532 if stable_id == item.stable_id {
1533 continue;
1534 }
1535 let Some(fallback) = index.get(&stable_id) else {
1536 continue;
1537 };
1538 let TranscriptBody::Tool {
1539 call: fallback_call,
1540 terminal_outputs: fallback_outputs,
1541 terminal_refs: fallback_refs,
1542 ..
1543 } = &fallback.body
1544 else {
1545 continue;
1546 };
1547 let fallback_call: ToolCall = serde_json::from_value(fallback_call.clone())
1548 .context("parse fallback terminal tool call")?;
1549 if !mj_core::acp::is_fallback_terminal_tool_call(&fallback_call) {
1550 continue;
1551 }
1552 for record in fallback_outputs {
1553 replace_or_push_terminal_record(terminal_outputs, record.clone());
1554 }
1555 for terminal_ref in fallback_refs {
1556 if !terminal_refs.contains(terminal_ref) {
1557 terminal_refs.push(terminal_ref.clone());
1558 }
1559 }
1560 if !terminal_refs.contains(&terminal_id) {
1561 terminal_refs.push(terminal_id);
1562 }
1563 if may_adopt_identity {
1564 item.position = item.position.min(fallback.position);
1565 item.created_at_ms = item.created_at_ms.min(fallback.created_at_ms);
1566 }
1567 item.last_changed_at_ms = item.last_changed_at_ms.max(fallback.last_changed_at_ms);
1568 mutation
1569 .transcript
1570 .push(TranscriptMutation::Remove { stable_id });
1571 }
1572 Ok(())
1573}
1574
1575fn finalize_fallback_terminal_tool(item: &mut TranscriptItem) -> Result<()> {
1578 let TranscriptBody::Tool {
1579 call,
1580 terminal_outputs,
1581 ..
1582 } = &mut item.body
1583 else {
1584 return Ok(());
1585 };
1586 if terminal_outputs.is_empty() {
1587 return Ok(());
1588 }
1589 let mut materialized: ToolCall = serde_json::from_value(call.clone())
1590 .context("parse ACP tool call while finalizing fallback terminal tool")?;
1591 if !mj_core::acp::is_fallback_terminal_tool_call(&materialized) {
1592 return Ok(());
1593 }
1594 materialized.status = if terminal_outputs
1595 .iter()
1596 .all(TerminalOutputRecord::exited_cleanly)
1597 {
1598 ToolCallStatus::Completed
1599 } else {
1600 ToolCallStatus::Failed
1601 };
1602 *call = serde_json::to_value(materialized)?;
1603 Ok(())
1604}
1605
1606fn uniquely_matching_raw_terminal(current: &MaterializedSession, call: &Value) -> Option<String> {
1610 let mut matching = current.transcript.iter().filter_map(|item| {
1611 let record = match &item.body {
1612 TranscriptBody::TerminalOutput { record } => record,
1613 TranscriptBody::Tool {
1614 terminal_outputs, ..
1615 } if fallback_tool_item(item).ok()? => terminal_outputs.first()?,
1616 _ => return None,
1617 };
1618 record
1619 .matches_tool_raw_result(call)
1620 .then(|| record.terminal_id.clone())
1621 });
1622 let terminal_id = matching.next()?;
1623 matching.next().is_none().then_some(terminal_id)
1624}
1625
1626fn attach_terminal_outputs(
1635 current: &MaterializedSession,
1636 index: &ProjectionIndex,
1637 mutation: &mut MaterializedSessionMutation,
1638 item: &mut TranscriptItem,
1639) {
1640 let TranscriptBody::Tool {
1641 call,
1642 terminal_outputs,
1643 terminal_refs,
1644 ..
1645 } = &mut item.body
1646 else {
1647 return;
1648 };
1649 for terminal_id in tool_call_terminal_ids(call) {
1650 if !terminal_refs.contains(&terminal_id) {
1651 terminal_refs.push(terminal_id);
1652 }
1653 }
1654 if terminal_refs.is_empty()
1655 && let Some(terminal_id) = uniquely_matching_raw_terminal(current, call)
1656 && !terminal_refs.contains(&terminal_id)
1657 {
1658 terminal_refs.push(terminal_id);
1659 }
1660 let mut consumed = Vec::new();
1661 for terminal_id in terminal_refs.iter() {
1662 let Some(record) = find_terminal_record(index, terminal_id) else {
1663 continue;
1664 };
1665 replace_or_push_terminal_record(terminal_outputs, record);
1666 consumed.push(terminal_item_id(terminal_id));
1667 }
1668 for stable_id in consumed {
1669 mutation
1670 .transcript
1671 .push(TranscriptMutation::Remove { stable_id });
1672 }
1673}
1674
1675fn close_stream_kind(
1676 index: &ProjectionIndex,
1677 mutation: &mut MaterializedSessionMutation,
1678 agent: bool,
1679 changed_at_ms: i64,
1680) {
1681 for item in index.open_streams(agent) {
1682 let mut closed = TranscriptItem::clone(item);
1683 match &mut closed.body {
1684 TranscriptBody::Agent { streaming, .. } | TranscriptBody::Thought { streaming, .. } => {
1685 *streaming = false
1686 }
1687 _ => unreachable!(),
1688 }
1689 closed.last_changed_at_ms = closed.last_changed_at_ms.max(changed_at_ms);
1690 upsert(mutation, closed);
1691 }
1692}
1693
1694fn close_streams(
1695 index: &ProjectionIndex,
1696 mutation: &mut MaterializedSessionMutation,
1697 changed_at_ms: i64,
1698) {
1699 close_stream_kind(index, mutation, true, changed_at_ms);
1700 close_stream_kind(index, mutation, false, changed_at_ms);
1701}
1702
1703fn push_system(
1704 mutation: &mut MaterializedSessionMutation,
1705 event: &RelayEvent,
1706 text: impl Into<String>,
1707) {
1708 push_system_with_id(mutation, event, format!("system:{}", event.ordinal), text);
1709}
1710
1711fn push_system_with_id(
1712 mutation: &mut MaterializedSessionMutation,
1713 event: &RelayEvent,
1714 stable_id: String,
1715 text: impl Into<String>,
1716) {
1717 upsert(
1718 mutation,
1719 TranscriptItem {
1720 stable_id,
1721 position: event.ordinal,
1722 latest_content_event_ordinal: None,
1723 created_at_ms: event.recorded_at_ms,
1724 last_changed_at_ms: event.recorded_at_ms,
1725 body: TranscriptBody::System { text: text.into() },
1726 },
1727 );
1728}
1729
1730fn upsert(mutation: &mut MaterializedSessionMutation, item: TranscriptItem) {
1731 if let Some(existing) = mutation.transcript.iter_mut().find(|candidate| {
1732 matches!(candidate, TranscriptMutation::Upsert(current) if current.stable_id == item.stable_id)
1733 }) {
1734 *existing = TranscriptMutation::Upsert(item);
1735 } else {
1736 mutation.transcript.push(TranscriptMutation::Upsert(item));
1737 }
1738}
1739
1740fn configuration_values(
1741 options: &[agent_client_protocol::schema::v1::SessionConfigOption],
1742) -> BTreeMap<String, Value> {
1743 options
1744 .iter()
1745 .filter_map(|option| {
1746 let value = match serde_json::to_value(option) {
1747 Ok(value) => value,
1748 Err(error) => {
1749 tracing::warn!(%error, "could not serialize a session configuration option");
1750 return None;
1751 }
1752 };
1753 let Some(id) = value.get("id").and_then(Value::as_str).map(str::to_owned) else {
1754 tracing::warn!("session configuration option omitted a string id");
1755 return None;
1756 };
1757 let current = value
1758 .get("currentValue")
1759 .or_else(|| value.get("current_value"));
1760 let Some(current) = current else {
1761 tracing::warn!(option_id = %id, "session configuration option omitted its current value");
1762 return None;
1763 };
1764 Some((id, current.clone()))
1765 })
1766 .collect()
1767}
1768
1769fn canonical_terminal_output(record: &TerminalOutputRecord) -> CanonicalTerminalOutput {
1770 CanonicalTerminalOutput {
1771 terminal_id: record.terminal_id.clone(),
1772 output: record.output.clone(),
1773 truncated: record.truncated,
1774 exit_code: record.exit_code,
1775 signal: record.signal.clone(),
1776 }
1777}
1778
1779fn materialized_terminal_output(record: &CanonicalTerminalOutput) -> TerminalOutputRecord {
1780 TerminalOutputRecord {
1781 terminal_id: record.terminal_id.clone(),
1782 output: record.output.clone(),
1783 truncated: record.truncated,
1784 exit_code: record.exit_code,
1785 signal: record.signal.clone(),
1786 }
1787}
1788
1789pub fn materialized_session_from_entries(
1794 session_id: &str,
1795 entries: &[ChatEntry],
1796 latest_seq: u64,
1797 phase: WorkerPhase,
1798 configuration: BTreeMap<String, serde_json::Value>,
1799 queued_prompts: Vec<MaterializedQueuedPrompt>,
1800 pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
1801) -> MaterializedSession {
1802 let mut stable_ids = BTreeSet::new();
1803 let transcript = entries
1804 .iter()
1805 .filter(|entry| entry.start_seq > 0)
1806 .map(|entry| {
1807 let base_id = match entry.role {
1808 ChatRole::User => format!("user:{}", entry.start_seq),
1809 ChatRole::Agent => entry.message_id.as_ref().map_or_else(
1810 || format!("agent:{}", entry.start_seq),
1811 |id| format!("agent:{id}"),
1812 ),
1813 ChatRole::Thought => entry.message_id.as_ref().map_or_else(
1814 || format!("thought:{}", entry.start_seq),
1815 |id| format!("thought:{id}"),
1816 ),
1817 ChatRole::Tool => entry.tool_call_id.as_ref().map_or_else(
1818 || format!("tool:{}", entry.start_seq),
1819 |id| format!("tool:{id}"),
1820 ),
1821 ChatRole::Plan => format!("plan:{}", entry.start_seq),
1822 ChatRole::PlanProposal => format!("plan-proposal:{}", entry.start_seq),
1823 ChatRole::System => format!("system:{}", entry.start_seq),
1824 };
1825 let stable_id = if stable_ids.insert(base_id.clone()) {
1826 base_id
1827 } else {
1828 format!("{base_id}:{}", entry.start_seq)
1829 };
1830 let body = match entry.role {
1831 ChatRole::User => TranscriptBody::User {
1832 content: vec![serde_json::json!({
1833 "type": "text",
1834 "text": entry.text,
1835 })],
1836 },
1837 ChatRole::Agent | ChatRole::Thought => {
1838 let mut chunk =
1839 ContentChunk::new(ContentBlock::Text(TextContent::new(entry.text.clone())));
1840 if let Some(message_id) = &entry.message_id {
1841 chunk = chunk.message_id(message_id.as_str());
1842 }
1843 let chunks = vec![
1844 serde_json::to_value(chunk)
1845 .expect("ACP content chunk serialization cannot fail"),
1846 ];
1847 if entry.role == ChatRole::Agent {
1848 TranscriptBody::Agent {
1849 chunks,
1850 streaming: false,
1851 }
1852 } else {
1853 TranscriptBody::Thought {
1854 chunks,
1855 streaming: false,
1856 }
1857 }
1858 }
1859 ChatRole::Tool => {
1860 let call_id = entry
1861 .tool_call_id
1862 .clone()
1863 .unwrap_or_else(|| stable_id.clone());
1864 let content = entry
1865 .tool_content
1866 .iter()
1867 .cloned()
1868 .map(|text| {
1869 ToolCallContent::from(ContentBlock::Text(TextContent::new(text)))
1870 })
1871 .collect();
1872 let mut call = ToolCall::new(call_id, entry.text.clone())
1873 .status(match entry.tool_status.unwrap_or(ToolStatus::Pending) {
1874 ToolStatus::Pending => ToolCallStatus::Pending,
1875 ToolStatus::Running => ToolCallStatus::InProgress,
1876 ToolStatus::Completed => ToolCallStatus::Completed,
1877 ToolStatus::Failed => ToolCallStatus::Failed,
1878 })
1879 .content(content);
1880 if !entry.tool_diffstats.is_empty() || !entry.tool_locations.is_empty() {
1881 call = call.raw_output(serde_json::json!({
1882 "legacyDiffstats": entry.tool_diffstats,
1883 "legacyLocations": entry.tool_locations,
1884 }));
1885 }
1886 let presentation = entry
1887 .tool_presentation
1888 .clone()
1889 .or_else(|| Some(tool_call_presentation(&call)))
1890 .map(Box::new);
1891 TranscriptBody::Tool {
1892 call: serde_json::to_value(call)
1893 .expect("ACP tool call serialization cannot fail"),
1894 terminal_outputs: Vec::new(),
1895 terminal_refs: Vec::new(),
1896 presentation,
1897 }
1898 }
1899 ChatRole::Plan => TranscriptBody::Plan {
1900 plan: serde_json::to_value(Plan::new(
1901 entry
1902 .plan
1903 .iter()
1904 .map(|line| {
1905 PlanEntry::new(
1906 line.text.clone(),
1907 PlanEntryPriority::Medium,
1908 match line.status {
1909 PlanStatus::Pending => PlanEntryStatus::Pending,
1910 PlanStatus::Running => PlanEntryStatus::InProgress,
1911 PlanStatus::Completed => PlanEntryStatus::Completed,
1912 },
1913 )
1914 })
1915 .collect(),
1916 ))
1917 .expect("ACP plan serialization cannot fail"),
1918 },
1919 ChatRole::PlanProposal => TranscriptBody::PlanProposal {
1920 proposal_id: format!("legacy:{}", entry.start_seq),
1921 plan: entry.text.clone(),
1922 },
1923 ChatRole::System => TranscriptBody::System {
1924 text: entry.text.clone(),
1925 },
1926 };
1927 let timestamp = entry.recorded_at_ms.unwrap_or_default();
1928 Arc::new(TranscriptItem {
1929 stable_id,
1930 position: entry.start_seq,
1931 latest_content_event_ordinal: (entry.role == ChatRole::Agent).then_some(entry.seq),
1932 created_at_ms: timestamp,
1933 last_changed_at_ms: timestamp,
1934 body,
1935 })
1936 })
1937 .collect::<Vec<_>>();
1938 let started_at_ms = entries
1939 .iter()
1940 .rev()
1941 .find(|entry| entry.role == ChatRole::User)
1942 .and_then(|entry| entry.recorded_at_ms)
1943 .unwrap_or_default();
1944 let applied_event_digest = if latest_seq == 0 {
1945 RELAY_EVENT_GENESIS_DIGEST.to_owned()
1946 } else {
1947 let mut digest = Sha256::new();
1948 digest.update(b"hel-imported-transcript-frontier-v1\0");
1949 digest.update(session_id.as_bytes());
1950 digest.update(latest_seq.to_le_bytes());
1951 format!("{:x}", digest.finalize())
1952 };
1953 MaterializedSession {
1954 session_id: session_id.to_owned(),
1955 applied_event_ordinal: latest_seq,
1956 applied_event_digest,
1957 last_activity_at_ms: entries
1958 .iter()
1959 .filter_map(|entry| entry.recorded_at_ms)
1960 .max(),
1961 execution: match phase {
1962 WorkerPhase::Idle => MaterializedExecutionState::Idle,
1963 WorkerPhase::Running => MaterializedExecutionState::Running { started_at_ms },
1964 WorkerPhase::Closing => MaterializedExecutionState::Closing,
1965 WorkerPhase::Closed => MaterializedExecutionState::Closed,
1966 },
1967 session_title: None,
1968 configuration,
1969 transcript,
1970 queued_prompts,
1971 pending_elicitations,
1972 active_turn: None,
1975 last_turn_outcome: None,
1976 }
1977}
1978
1979pub fn imported_materialized_session(
1985 session_id: &str,
1986 events: &[SequencedEvent],
1987) -> MaterializedSession {
1988 let mut entries = Vec::new();
1989 let mut phase = WorkerPhase::Idle;
1990 let mut latest_seq = 0;
1991 for event in events {
1992 if event.seq <= latest_seq {
1993 continue;
1994 }
1995 apply_imported_event(&mut entries, &mut phase, event);
1996 latest_seq = event.seq;
1997 }
1998 materialized_session_from_entries(
1999 session_id,
2000 &entries,
2001 latest_seq,
2002 phase,
2003 BTreeMap::new(),
2004 Vec::new(),
2005 Vec::new(),
2006 )
2007}
2008
2009fn apply_imported_event(
2012 entries: &mut Vec<ChatEntry>,
2013 phase: &mut WorkerPhase,
2014 event: &SequencedEvent,
2015) {
2016 match &event.event {
2017 WorkerEvent::PromptAccepted { text, .. } => {
2018 *phase = WorkerPhase::Running;
2019 entries.push(
2020 ChatEntry::plain(event.seq, ChatRole::User, text)
2021 .with_recorded_at(event.recorded_at_ms),
2022 );
2023 }
2024 WorkerEvent::QueuedPromptPromoted { prompt, .. } => {
2025 *phase = WorkerPhase::Running;
2026 entries.push(
2027 ChatEntry::plain(event.seq, ChatRole::User, &prompt.text)
2028 .with_recorded_at(event.recorded_at_ms),
2029 );
2030 }
2031 WorkerEvent::TurnCompleted => *phase = WorkerPhase::Idle,
2032 WorkerEvent::Cancelled => *phase = WorkerPhase::Running,
2033 WorkerEvent::Closing => *phase = WorkerPhase::Closing,
2034 WorkerEvent::Closed => *phase = WorkerPhase::Closed,
2035 WorkerEvent::Adapter { payload, .. } => {
2036 let runtime =
2037 match serde_json::from_value::<mj_core::acp::RuntimeEvent>(payload.clone()) {
2038 Ok(runtime) => runtime,
2039 Err(error) => {
2040 tracing::warn!(
2041 seq = event.seq,
2042 %error,
2043 "ignoring malformed persisted runtime event"
2044 );
2045 return;
2046 }
2047 };
2048 crate::transcript::apply_runtime_event_to_entries(
2049 entries,
2050 event.seq,
2051 event.recorded_at_ms,
2052 runtime,
2053 );
2054 }
2055 _ => {}
2056 }
2057}
2058
2059pub fn canonical_session_from_materialized(
2060 materialized: &MaterializedSession,
2061) -> Result<CanonicalSessionSnapshot> {
2062 let transcript = materialized
2063 .transcript
2064 .iter()
2065 .map(|item| {
2066 let body = match &item.body {
2067 TranscriptBody::User { content } => CanonicalTranscriptBody::User {
2068 content: content.clone(),
2069 },
2070 TranscriptBody::Agent { chunks, streaming } => CanonicalTranscriptBody::Agent {
2071 chunks: chunks.clone(),
2072 streaming: *streaming,
2073 },
2074 TranscriptBody::Thought { chunks, streaming } => CanonicalTranscriptBody::Thought {
2075 chunks: chunks.clone(),
2076 streaming: *streaming,
2077 },
2078 TranscriptBody::Tool {
2079 call,
2080 terminal_outputs,
2081 terminal_refs,
2082 presentation,
2083 } => CanonicalTranscriptBody::Tool {
2084 call: call.clone(),
2085 terminal_outputs: terminal_outputs
2086 .iter()
2087 .map(canonical_terminal_output)
2088 .collect(),
2089 terminal_refs: terminal_refs.clone(),
2090 presentation: presentation.as_deref().cloned(),
2091 },
2092 TranscriptBody::TerminalOutput { record } => {
2093 CanonicalTranscriptBody::TerminalOutput {
2094 record: canonical_terminal_output(record),
2095 }
2096 }
2097 TranscriptBody::Plan { plan } => {
2098 CanonicalTranscriptBody::Plan { plan: plan.clone() }
2099 }
2100 TranscriptBody::PlanProposal { proposal_id, plan } => {
2101 CanonicalTranscriptBody::PlanProposal {
2102 proposal_id: proposal_id.clone(),
2103 plan: plan.clone(),
2104 }
2105 }
2106 TranscriptBody::System { text } => {
2107 CanonicalTranscriptBody::System { text: text.clone() }
2108 }
2109 };
2110 Ok(CanonicalTranscriptItem {
2111 stable_id: item.stable_id.clone(),
2112 position: item.position,
2113 latest_content_event_ordinal: item.latest_content_event_ordinal,
2114 created_at_ms: item.created_at_ms,
2115 last_changed_at_ms: item.last_changed_at_ms,
2116 body,
2117 })
2118 })
2119 .collect::<Result<Vec<_>>>()?;
2120 Ok(CanonicalSessionSnapshot {
2121 event_frontier: materialized.applied_event_ordinal,
2122 event_frontier_digest: materialized.applied_event_digest.clone(),
2123 session: CanonicalSessionState {
2124 execution: match materialized.execution {
2125 MaterializedExecutionState::Idle => CanonicalExecutionState::Idle,
2126 MaterializedExecutionState::Running { started_at_ms } => {
2127 CanonicalExecutionState::Running { started_at_ms }
2128 }
2129 MaterializedExecutionState::Closing => CanonicalExecutionState::Closing,
2130 MaterializedExecutionState::Closed => CanonicalExecutionState::Closed,
2131 },
2132 last_activity_at_ms: materialized.last_activity_at_ms,
2133 session_title: materialized.session_title.clone(),
2134 configuration: materialized.configuration.clone(),
2135 },
2136 transcript,
2137 queued_prompts: materialized
2138 .queued_prompts
2139 .iter()
2140 .map(|prompt| CanonicalQueuedPrompt {
2141 command_id: prompt.command_id.clone(),
2142 kind: match &prompt.kind {
2143 QueuedCommandKind::Prompt => CanonicalQueuedCommandKind::Prompt,
2144 QueuedCommandKind::SetConfig { key, value } => {
2145 CanonicalQueuedCommandKind::SetConfig {
2146 key: key.clone(),
2147 value: value.clone(),
2148 }
2149 }
2150 },
2151 content: prompt.content.clone(),
2152 queued_at_ms: prompt.queued_at_ms,
2153 })
2154 .collect(),
2155 })
2156}
2157
2158pub fn materialized_session_from_canonical(
2159 session_id: impl Into<String>,
2160 canonical: &CanonicalSessionSnapshot,
2161) -> Result<MaterializedSession> {
2162 let transcript = canonical
2163 .transcript
2164 .iter()
2165 .map(|item| {
2166 let body = match &item.body {
2167 CanonicalTranscriptBody::User { content } => TranscriptBody::User {
2168 content: content.clone(),
2169 },
2170 CanonicalTranscriptBody::Agent { chunks, streaming } => TranscriptBody::Agent {
2171 chunks: chunks.clone(),
2172 streaming: *streaming,
2173 },
2174 CanonicalTranscriptBody::Thought { chunks, streaming } => TranscriptBody::Thought {
2175 chunks: chunks.clone(),
2176 streaming: *streaming,
2177 },
2178 CanonicalTranscriptBody::Tool {
2179 call,
2180 terminal_outputs,
2181 terminal_refs,
2182 presentation,
2183 } => TranscriptBody::Tool {
2184 call: call.clone(),
2185 terminal_outputs: terminal_outputs
2186 .iter()
2187 .map(materialized_terminal_output)
2188 .collect(),
2189 terminal_refs: terminal_refs.clone(),
2190 presentation: presentation.clone().map(Box::new),
2191 },
2192 CanonicalTranscriptBody::TerminalOutput { record } => {
2193 TranscriptBody::TerminalOutput {
2194 record: materialized_terminal_output(record),
2195 }
2196 }
2197 CanonicalTranscriptBody::Plan { plan } => {
2198 TranscriptBody::Plan { plan: plan.clone() }
2199 }
2200 CanonicalTranscriptBody::PlanProposal { proposal_id, plan } => {
2201 TranscriptBody::PlanProposal {
2202 proposal_id: proposal_id.clone(),
2203 plan: plan.clone(),
2204 }
2205 }
2206 CanonicalTranscriptBody::System { text } => {
2207 TranscriptBody::System { text: text.clone() }
2208 }
2209 };
2210 Ok(Arc::new(TranscriptItem {
2211 stable_id: item.stable_id.clone(),
2212 position: item.position,
2213 latest_content_event_ordinal: item.latest_content_event_ordinal,
2214 created_at_ms: item.created_at_ms,
2215 last_changed_at_ms: item.last_changed_at_ms,
2216 body,
2217 }))
2218 })
2219 .collect::<Result<Vec<_>>>()?;
2220 Ok(MaterializedSession {
2221 session_id: session_id.into(),
2222 applied_event_ordinal: canonical.event_frontier,
2223 applied_event_digest: canonical.event_frontier_digest.clone(),
2224 last_activity_at_ms: canonical.session.last_activity_at_ms,
2225 execution: match canonical.session.execution {
2226 CanonicalExecutionState::Idle => MaterializedExecutionState::Idle,
2227 CanonicalExecutionState::Running { started_at_ms } => {
2228 MaterializedExecutionState::Running { started_at_ms }
2229 }
2230 CanonicalExecutionState::Closing => MaterializedExecutionState::Closing,
2231 CanonicalExecutionState::Closed => MaterializedExecutionState::Closed,
2232 },
2233 session_title: canonical.session.session_title.clone(),
2234 configuration: canonical.session.configuration.clone(),
2235 transcript,
2236 queued_prompts: materialized_queued_prompts_from_canonical(&canonical.queued_prompts),
2237 pending_elicitations: Vec::new(),
2238 active_turn: None,
2241 last_turn_outcome: None,
2242 })
2243}
2244
2245pub fn materialized_queued_prompts_from_canonical(
2249 queued_prompts: &[CanonicalQueuedPrompt],
2250) -> Vec<MaterializedQueuedPrompt> {
2251 queued_prompts
2252 .iter()
2253 .map(|prompt| MaterializedQueuedPrompt {
2254 command_id: prompt.command_id.clone(),
2255 kind: match &prompt.kind {
2256 CanonicalQueuedCommandKind::Prompt => QueuedCommandKind::Prompt,
2257 CanonicalQueuedCommandKind::SetConfig { key, value } => {
2258 QueuedCommandKind::SetConfig {
2259 key: key.clone(),
2260 value: value.clone(),
2261 }
2262 }
2263 },
2264 content: prompt.content.clone(),
2265 queued_at_ms: prompt.queued_at_ms,
2266 accepted_ordinal: None,
2267 })
2268 .collect()
2269}
2270
2271#[cfg(test)]
2272mod tests {
2273 use agent_client_protocol::schema::v1::{
2274 ContentBlock, TextContent, ToolCallUpdate, ToolCallUpdateFields,
2275 };
2276
2277 use super::*;
2278 use mj_core::relay::{
2279 RelayCommand, RelayCommandOutcome, RelayObservation, UserShellResult, UserShellStatus,
2280 relay_event_digest,
2281 };
2282 use serde_json::json;
2283
2284 fn event(previous: &MaterializedSession, observation: RelayObservation) -> RelayEvent {
2285 let mut event = RelayEvent {
2286 format: mj_core::relay::RELAY_EVENT_FORMAT_V1,
2287 ordinal: previous.applied_event_ordinal + 1,
2288 previous_digest: previous.applied_event_digest.clone(),
2289 digest: String::new(),
2290 recorded_at_ms: 0,
2291 command_id: None,
2292 observation,
2293 };
2294 event.recorded_at_ms = i64::try_from(event.ordinal).unwrap() * 100;
2295 event.digest = relay_event_digest(&event).unwrap();
2296 event
2297 }
2298
2299 fn apply(session: &mut MaterializedSession, event: RelayEvent) {
2300 let projected = project_relay_event(session, &event).unwrap();
2301 apply_committed_projection_event(session, &event, projected.mutation).unwrap();
2302 }
2303
2304 fn apply_observation(session: &mut MaterializedSession, observation: RelayObservation) {
2305 let next = event(session, observation);
2306 apply(session, next);
2307 }
2308
2309 fn apply_indexed_observation(
2310 session: &mut MaterializedSession,
2311 index: &mut ProjectionIndex,
2312 observation: RelayObservation,
2313 ) {
2314 let next = event(session, observation);
2315 let projected = project_relay_event_indexed(session, index, &next).unwrap();
2316 apply_committed_projection_event_indexed(session, index, &next, projected.mutation)
2317 .unwrap();
2318 }
2319
2320 #[test]
2321 fn resume_open_updates_operational_state_without_adding_transcript_noise() {
2322 let session = MaterializedSession::empty("session");
2323 let resumed = event(
2324 &session,
2325 RelayObservation::SessionOpened {
2326 native_session_id: "native".into(),
2327 resumed: true,
2328 },
2329 );
2330 let mutation = project_relay_event(&session, &resumed).unwrap().mutation;
2331 assert!(mutation.transcript.is_empty());
2332 assert_eq!(mutation.pending_elicitations, Some(Vec::new()));
2333
2334 let started = event(
2335 &session,
2336 RelayObservation::SessionOpened {
2337 native_session_id: "native".into(),
2338 resumed: false,
2339 },
2340 );
2341 assert_eq!(
2342 project_relay_event(&session, &started)
2343 .unwrap()
2344 .mutation
2345 .transcript
2346 .len(),
2347 1
2348 );
2349 }
2350
2351 #[test]
2352 fn a_completed_prompt_records_its_stop_reason_and_clears_the_running_turn() {
2353 let mut session = MaterializedSession::empty("session");
2354 apply_observation(
2355 &mut session,
2356 RelayObservation::CommandQueued {
2357 command_id: "prompt-1".into(),
2358 command: RelayCommand::Prompt {
2359 prompt: vec![agent_client_protocol::schema::v1::ContentBlock::from("go")],
2360 },
2361 created_at_ms: 10,
2362 },
2363 );
2364 let accepted = session.applied_event_ordinal;
2365 assert_eq!(
2366 session.queued_prompts[0].accepted_ordinal,
2367 Some(accepted),
2368 "a queue entry remembers the ordinal its caller was told"
2369 );
2370
2371 apply_observation(
2372 &mut session,
2373 RelayObservation::CommandStarted {
2374 command_id: "prompt-1".into(),
2375 started_at_ms: 20,
2376 },
2377 );
2378 let turn = session.active_turn.clone().expect("a running turn");
2379 assert_eq!(turn.command_id, "prompt-1");
2380 assert_eq!(turn.accepted_ordinal, Some(accepted));
2381 assert_eq!(turn.turn_start_position, session.applied_event_ordinal);
2382 assert_eq!(turn.started_at_ms, 20);
2383
2384 apply_observation(
2385 &mut session,
2386 RelayObservation::CommandCompleted {
2387 command_id: "prompt-1".into(),
2388 outcome: RelayCommandOutcome::Prompt {
2389 diagnostic: None,
2390 stop_reason: "EndTurn".into(),
2391 usage: None,
2392 },
2393 },
2394 );
2395 assert!(session.active_turn.is_none());
2396 let outcome = session.last_turn_outcome.clone().expect("an outcome");
2397 assert_eq!(outcome.command_id, "prompt-1");
2398 assert_eq!(outcome.accepted_ordinal, Some(accepted));
2399 assert_eq!(outcome.turn_start_position, Some(turn.turn_start_position));
2400 assert_eq!(outcome.completed_ordinal, session.applied_event_ordinal);
2401 assert_eq!(
2402 outcome.outcome,
2403 TurnOutcomeKind::Completed {
2404 stop_reason: "EndTurn".into()
2405 }
2406 );
2407 }
2408
2409 #[test]
2410 fn a_rejected_queued_prompt_records_its_acceptance_ordinal_without_a_turn_start() {
2411 let mut session = MaterializedSession::empty("session");
2412 apply_observation(
2413 &mut session,
2414 RelayObservation::CommandQueued {
2415 command_id: "prompt-1".into(),
2416 command: RelayCommand::Prompt {
2417 prompt: vec![agent_client_protocol::schema::v1::ContentBlock::from("go")],
2418 },
2419 created_at_ms: 10,
2420 },
2421 );
2422 let accepted = session.applied_event_ordinal;
2423
2424 apply_observation(
2425 &mut session,
2426 RelayObservation::CommandRejected {
2427 command_id: "prompt-1".into(),
2428 command: RelayCommandKind::Prompt,
2429 message: "transport failed".into(),
2430 },
2431 );
2432
2433 assert!(session.active_turn.is_none());
2434 assert!(session.queued_prompts.is_empty());
2435 let outcome = session.last_turn_outcome.clone().expect("an outcome");
2436 assert_eq!(outcome.accepted_ordinal, Some(accepted));
2437 assert_eq!(
2438 outcome.turn_start_position, None,
2439 "a prompt that never started has no turn in the transcript"
2440 );
2441 assert_eq!(
2442 outcome.outcome,
2443 TurnOutcomeKind::Rejected {
2444 message: "transport failed".into()
2445 }
2446 );
2447 }
2448
2449 #[test]
2450 fn queued_prompts_keep_their_own_acceptance_ordinals_through_their_turns() {
2451 let mut session = MaterializedSession::empty("session");
2452 let mut accepted = Vec::new();
2453 for command_id in ["prompt-a", "prompt-b"] {
2454 apply_observation(
2455 &mut session,
2456 RelayObservation::CommandQueued {
2457 command_id: command_id.into(),
2458 command: RelayCommand::Prompt {
2459 prompt: vec![agent_client_protocol::schema::v1::ContentBlock::from("go")],
2460 },
2461 created_at_ms: 10,
2462 },
2463 );
2464 accepted.push(session.applied_event_ordinal);
2465 }
2466 assert!(accepted[1] > accepted[0]);
2469
2470 for (index, command_id) in ["prompt-a", "prompt-b"].into_iter().enumerate() {
2471 apply_observation(
2472 &mut session,
2473 RelayObservation::CommandStarted {
2474 command_id: command_id.into(),
2475 started_at_ms: 20,
2476 },
2477 );
2478 apply_observation(
2479 &mut session,
2480 RelayObservation::CommandCompleted {
2481 command_id: command_id.into(),
2482 outcome: RelayCommandOutcome::Prompt {
2483 diagnostic: None,
2484 stop_reason: "EndTurn".into(),
2485 usage: None,
2486 },
2487 },
2488 );
2489 assert_eq!(
2490 session
2491 .last_turn_outcome
2492 .as_ref()
2493 .and_then(|outcome| outcome.accepted_ordinal),
2494 Some(accepted[index]),
2495 "{command_id} must report the ordinal its own submission returned"
2496 );
2497 }
2498 }
2499
2500 #[test]
2501 fn a_harness_turn_runs_the_session_and_marks_where_it_began() {
2502 let mut session = MaterializedSession::empty("session");
2503
2504 apply_observation(
2505 &mut session,
2506 RelayObservation::HarnessTurnStarted {
2507 started_at_ms: 4_200,
2508 },
2509 );
2510
2511 assert_eq!(
2512 session.execution,
2513 MaterializedExecutionState::Running {
2514 started_at_ms: 4_200
2515 }
2516 );
2517 let marker = session.transcript.last().expect("a marker item");
2518 assert_eq!(
2519 marker.stable_id,
2520 format!("{}1", crate::transcript::HARNESS_TURN_ITEM_PREFIX)
2521 );
2522 assert!(marker.is_turn_start());
2523 assert!(matches!(
2524 &marker.body,
2525 TranscriptBody::System { text } if text == crate::transcript::HARNESS_TURN_TEXT
2526 ));
2527
2528 apply_observation(
2529 &mut session,
2530 agent_chunk("picking this back up", "answer-1"),
2531 );
2532 assert!(
2533 session.transcript.iter().any(
2534 |item| matches!(&item.body, TranscriptBody::Agent { streaming, .. } if *streaming)
2535 ),
2536 "output inside the turn streams into a fresh item"
2537 );
2538
2539 apply_observation(
2540 &mut session,
2541 RelayObservation::HarnessTurnSettled {
2542 origin: Some("task-notification".into()),
2543 prompt_in_flight: false,
2544 },
2545 );
2546
2547 assert_eq!(session.execution, MaterializedExecutionState::Idle);
2548 assert!(
2549 !session.transcript.iter().any(|item| matches!(
2550 &item.body,
2551 TranscriptBody::Agent { streaming, .. } if *streaming
2552 )),
2553 "settling closes the streams a canonical export refuses to hold open"
2554 );
2555 assert_eq!(
2556 mj_core::state::latest_completed_turn_ordinal(&session),
2557 Some(1),
2558 "the finished turn is covered from the marker that began it"
2559 );
2560 assert_eq!(
2561 mj_core::state::ProjectionWindow::of(&session).latest_turn_start_position,
2562 Some(1)
2563 );
2564 }
2565
2566 #[test]
2567 fn a_turn_that_settles_under_an_in_flight_prompt_keeps_the_session_running() {
2568 let mut session = MaterializedSession::empty("session");
2569 apply_observation(
2570 &mut session,
2571 RelayObservation::HarnessTurnStarted {
2572 started_at_ms: 4_200,
2573 },
2574 );
2575 apply_observation(
2578 &mut session,
2579 RelayObservation::CommandQueued {
2580 command_id: "prompt-1".into(),
2581 command: RelayCommand::Prompt {
2582 prompt: vec![agent_client_protocol::schema::v1::ContentBlock::from("go")],
2583 },
2584 created_at_ms: 10,
2585 },
2586 );
2587 apply_observation(
2588 &mut session,
2589 RelayObservation::CommandStarted {
2590 command_id: "prompt-1".into(),
2591 started_at_ms: 20,
2592 },
2593 );
2594 apply_observation(&mut session, agent_chunk("still writing", "answer-1"));
2595
2596 apply_observation(
2597 &mut session,
2598 RelayObservation::HarnessTurnSettled {
2599 origin: Some("task-notification".into()),
2600 prompt_in_flight: true,
2601 },
2602 );
2603
2604 assert!(
2605 matches!(
2606 session.execution,
2607 MaterializedExecutionState::Running { .. }
2608 ),
2609 "the prompt is still running, so the session is not idle"
2610 );
2611 assert!(
2612 session.transcript.iter().any(
2613 |item| matches!(&item.body, TranscriptBody::Agent { streaming, .. } if *streaming)
2614 ),
2615 "the prompt's own answer keeps streaming into its item"
2616 );
2617
2618 apply_observation(
2619 &mut session,
2620 RelayObservation::CommandCompleted {
2621 command_id: "prompt-1".into(),
2622 outcome: RelayCommandOutcome::Prompt {
2623 diagnostic: None,
2624 stop_reason: "end_turn".into(),
2625 usage: None,
2626 },
2627 },
2628 );
2629
2630 assert_eq!(session.execution, MaterializedExecutionState::Idle);
2631 assert!(!session.transcript.iter().any(
2632 |item| matches!(&item.body, TranscriptBody::Agent { streaming, .. } if *streaming)
2633 ));
2634 }
2635
2636 #[test]
2637 fn finishing_an_acp_prompt_preserves_a_later_native_goal_stream() {
2638 let mut session = MaterializedSession::empty("session");
2639 apply_observation(
2640 &mut session,
2641 RelayObservation::HarnessTurnStarted {
2642 started_at_ms: 4200,
2643 },
2644 );
2645 apply_observation(&mut session, RelayObservation::SessionUpdate { update: Box::new(serde_json::from_value(serde_json::json!({"sessionUpdate":"session_info_update","_meta":{"goal":{"objective":"finish","status":"active"},"execution":{"version":1,"status":"running","turnId":"later"}}})).unwrap()) });
2646 apply_observation(&mut session, agent_chunk("autonomous work", "later-answer"));
2647 apply_observation(
2648 &mut session,
2649 RelayObservation::CommandCompleted {
2650 command_id: "initial-prompt".into(),
2651 outcome: RelayCommandOutcome::Prompt {
2652 diagnostic: None,
2653 stop_reason: "end_turn".into(),
2654 usage: None,
2655 },
2656 },
2657 );
2658 assert!(matches!(
2659 session.execution,
2660 MaterializedExecutionState::Running { .. }
2661 ));
2662 assert!(session.transcript.iter().any(|item| matches!(
2663 &item.body,
2664 TranscriptBody::Agent {
2665 streaming: true,
2666 ..
2667 }
2668 )));
2669 apply_observation(
2670 &mut session,
2671 RelayObservation::HarnessTurnSettled {
2672 origin: Some("codex".into()),
2673 prompt_in_flight: false,
2674 },
2675 );
2676 assert_eq!(session.execution, MaterializedExecutionState::Idle);
2677 }
2678
2679 #[test]
2680 fn a_restart_during_a_harness_turn_leaves_an_idle_session_with_no_open_streams() {
2681 let mut session = MaterializedSession::empty("session");
2682 apply_observation(
2683 &mut session,
2684 RelayObservation::HarnessTurnStarted {
2685 started_at_ms: 4_200,
2686 },
2687 );
2688 apply_observation(&mut session, agent_chunk("half a sentence", "answer-1"));
2689
2690 apply_observation(&mut session, RelayObservation::SessionRestarted);
2691
2692 assert_eq!(session.execution, MaterializedExecutionState::Idle);
2693 assert!(!session.transcript.iter().any(|item| matches!(
2694 &item.body,
2695 TranscriptBody::Agent { streaming, .. } | TranscriptBody::Thought { streaming, .. }
2696 if *streaming
2697 )));
2698 canonical_session_from_materialized(&session)
2699 .expect("a restarted session exports without open streams");
2700 }
2701
2702 #[test]
2703 fn a_plan_from_a_harness_turn_does_not_overwrite_the_previous_turns_plan() {
2704 let plan = |content: &str| RelayObservation::SessionUpdate {
2705 update: Box::new(SessionUpdate::Plan(
2706 agent_client_protocol::schema::v1::Plan::new(vec![
2707 agent_client_protocol::schema::v1::PlanEntry::new(
2708 content,
2709 agent_client_protocol::schema::v1::PlanEntryPriority::High,
2710 agent_client_protocol::schema::v1::PlanEntryStatus::InProgress,
2711 ),
2712 ]),
2713 )),
2714 };
2715 let mut session = MaterializedSession::empty("session");
2716 apply_observation(
2717 &mut session,
2718 RelayObservation::CommandQueued {
2719 command_id: "prompt-1".into(),
2720 command: RelayCommand::Prompt {
2721 prompt: vec![agent_client_protocol::schema::v1::ContentBlock::from("go")],
2722 },
2723 created_at_ms: 10,
2724 },
2725 );
2726 apply_observation(
2727 &mut session,
2728 RelayObservation::CommandStarted {
2729 command_id: "prompt-1".into(),
2730 started_at_ms: 20,
2731 },
2732 );
2733 apply_observation(&mut session, plan("first turn plan"));
2734 apply_observation(
2735 &mut session,
2736 RelayObservation::CommandCompleted {
2737 command_id: "prompt-1".into(),
2738 outcome: RelayCommandOutcome::Prompt {
2739 diagnostic: None,
2740 stop_reason: "end_turn".into(),
2741 usage: None,
2742 },
2743 },
2744 );
2745
2746 apply_observation(
2747 &mut session,
2748 RelayObservation::HarnessTurnStarted { started_at_ms: 30 },
2749 );
2750 apply_observation(&mut session, plan("second turn plan"));
2751
2752 let plans: Vec<&TranscriptItem> = session
2753 .transcript
2754 .iter()
2755 .filter(|item| matches!(item.body, TranscriptBody::Plan { .. }))
2756 .map(std::convert::AsRef::as_ref)
2757 .collect();
2758 assert_eq!(
2759 plans.len(),
2760 2,
2761 "the self-started turn keeps its own plan instead of rewriting the last one"
2762 );
2763 }
2764
2765 #[test]
2766 fn session_restarts_project_as_distinct_durable_system_lines() {
2767 let mut session = MaterializedSession::empty("session");
2768 apply_observation(&mut session, RelayObservation::SessionRestarted);
2769 apply_observation(&mut session, RelayObservation::SessionRestarted);
2770
2771 assert_eq!(session.transcript.len(), 2);
2772 assert!(
2773 session
2774 .transcript
2775 .iter()
2776 .all(|item| item.is_session_restart())
2777 );
2778 assert_eq!(session.unread_session_restarts_after(0), 2);
2779 assert!(session.transcript.iter().all(|item| matches!(
2780 &item.body,
2781 TranscriptBody::System { text }
2782 if text == crate::transcript::SESSION_RESTART_TEXT
2783 )));
2784 assert_ne!(
2785 session.transcript[0].stable_id,
2786 session.transcript[1].stable_id
2787 );
2788
2789 let canonical = canonical_session_from_materialized(&session).unwrap();
2790 let restored = materialized_session_from_canonical("session", &canonical).unwrap();
2791 assert_eq!(restored.unread_session_restarts_after(0), 2);
2792 assert!(
2793 restored
2794 .transcript
2795 .iter()
2796 .all(|item| item.is_session_restart())
2797 );
2798 }
2799
2800 #[test]
2801 fn shell_output_updates_one_durable_transcript_item() {
2802 let mut session = MaterializedSession::empty("session-1");
2803 apply_observation(
2804 &mut session,
2805 RelayObservation::CommandQueued {
2806 command_id: "shell-1".into(),
2807 command: RelayCommand::RunUserShell {
2808 command: "cargo test".into(),
2809 },
2810 created_at_ms: 100,
2811 },
2812 );
2813 apply_observation(
2814 &mut session,
2815 RelayObservation::CommandStarted {
2816 command_id: "shell-1".into(),
2817 started_at_ms: 200,
2818 },
2819 );
2820 apply_observation(
2821 &mut session,
2822 RelayObservation::UserShellOutput {
2823 command_id: "shell-1".into(),
2824 command: "cargo test".into(),
2825 stdout: "running tests".into(),
2826 stderr: String::new(),
2827 stdout_truncated: false,
2828 stderr_truncated: false,
2829 },
2830 );
2831 assert_eq!(session.transcript.len(), 1);
2832 assert!(matches!(
2833 &session.transcript[0].body,
2834 TranscriptBody::System { text }
2835 if text.contains("Shell · running") && text.contains("running tests")
2836 ));
2837
2838 apply_observation(
2839 &mut session,
2840 RelayObservation::CommandCompleted {
2841 command_id: "shell-1".into(),
2842 outcome: RelayCommandOutcome::UserShell {
2843 result: UserShellResult {
2844 command: "cargo test".into(),
2845 stdout: "all green".into(),
2846 stderr: String::new(),
2847 stdout_truncated: false,
2848 stderr_truncated: false,
2849 exit_code: Some(0),
2850 signal: None,
2851 duration_ms: 321,
2852 status: UserShellStatus::Exited,
2853 error: None,
2854 },
2855 },
2856 },
2857 );
2858 assert_eq!(session.transcript.len(), 1);
2859 assert_eq!(session.transcript[0].stable_id, "shell:shell-1");
2860 assert!(matches!(
2861 &session.transcript[0].body,
2862 TranscriptBody::System { text }
2863 if text.contains("Shell · done · 321 ms") && text.contains("all green")
2864 ));
2865 }
2866
2867 #[test]
2868 fn elicitation_projection_keeps_only_pending_request_metadata() {
2869 let mut session = MaterializedSession::empty("session-1");
2870 let request = mj_core::elicitation::ElicitationRequest {
2871 id: "elicitation-1".into(),
2872 message: "Choose one".into(),
2873 title: None,
2874 description: None,
2875 fields: Vec::new(),
2876 };
2877 apply_observation(
2878 &mut session,
2879 RelayObservation::ElicitationRequested {
2880 request: request.clone(),
2881 },
2882 );
2883 assert_eq!(session.pending_elicitations, vec![request]);
2884
2885 apply_observation(
2886 &mut session,
2887 RelayObservation::ElicitationResolved {
2888 elicitation_id: "elicitation-1".into(),
2889 action: "accept".into(),
2890 },
2891 );
2892 assert!(session.pending_elicitations.is_empty());
2893 assert!(session.transcript.is_empty());
2894 }
2895
2896 #[test]
2897 fn a_plan_decision_also_becomes_a_durable_proposal_item() {
2898 let mut session = MaterializedSession::empty("session-1");
2899 let plan = "1. Read the code\n2. Change it";
2900 let request = mj_core::acp::normalized_plan_review(
2901 "plan-review-3".into(),
2902 &serde_json::json!({ "plan": plan }),
2903 );
2904 apply_observation(
2905 &mut session,
2906 RelayObservation::ElicitationRequested {
2907 request: request.clone(),
2908 },
2909 );
2910
2911 assert_eq!(session.pending_elicitations, vec![request]);
2912 assert_eq!(session.transcript.len(), 1);
2913 let item = &session.transcript[0];
2914 assert_eq!(item.stable_id, plan_proposal_item_id(1));
2915 assert_eq!(item.position, 1);
2916 assert_eq!(
2917 item.body,
2918 TranscriptBody::PlanProposal {
2919 proposal_id: "plan-review-3".into(),
2920 plan: plan.into(),
2921 }
2922 );
2923
2924 apply_observation(
2926 &mut session,
2927 RelayObservation::ElicitationResolved {
2928 elicitation_id: "plan-review-3".into(),
2929 action: "accept".into(),
2930 },
2931 );
2932 assert!(session.pending_elicitations.is_empty());
2933 assert_eq!(session.transcript.len(), 1);
2934 }
2935
2936 #[test]
2937 fn a_captured_proposal_keeps_its_place_after_the_conversation_that_produced_it() {
2938 let mut session = MaterializedSession::empty("session-1");
2939 apply_observation(&mut session, untagged_agent_chunk("here is my plan"));
2940 apply_observation(
2941 &mut session,
2942 RelayObservation::ElicitationRequested {
2943 request: mj_core::acp::normalized_plan_review(
2944 "plan-review-1".into(),
2945 &serde_json::json!({ "plan": "do the work" }),
2946 ),
2947 },
2948 );
2949 apply_observation(&mut session, untagged_agent_chunk("starting now"));
2950
2951 let bodies = session
2952 .transcript
2953 .iter()
2954 .map(|item| match &item.body {
2955 TranscriptBody::Agent { .. } => "agent",
2956 TranscriptBody::PlanProposal { .. } => "proposal",
2957 _ => "other",
2958 })
2959 .collect::<Vec<_>>();
2960 assert_eq!(bodies, vec!["agent", "proposal", "agent"]);
2961 }
2962
2963 fn untagged_agent_chunk(text: &str) -> RelayObservation {
2965 RelayObservation::SessionUpdate {
2966 update: Box::new(SessionUpdate::AgentMessageChunk(
2967 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
2968 TextContent::new(text),
2969 )),
2970 )),
2971 }
2972 }
2973
2974 fn untagged_thought_chunk(text: &str) -> RelayObservation {
2976 RelayObservation::SessionUpdate {
2977 update: Box::new(SessionUpdate::AgentThoughtChunk(
2978 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
2979 TextContent::new(text),
2980 )),
2981 )),
2982 }
2983 }
2984
2985 #[test]
2986 fn streamed_chunks_are_one_unread_logical_agent_message() {
2987 let mut session = MaterializedSession::empty("session-1");
2988 apply_observation(
2989 &mut session,
2990 RelayObservation::SessionUpdate {
2991 update: Box::new(SessionUpdate::AgentMessageChunk(
2992 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
2993 TextContent::new("hel"),
2994 ))
2995 .message_id("answer-1"),
2996 )),
2997 },
2998 );
2999 assert_eq!(session.transcript[0].latest_content_event_ordinal, Some(1));
3000 assert_eq!(session.unread_agent_messages_after(0), 1);
3001 assert_eq!(session.unread_agent_messages_after(1), 0);
3002
3003 apply_observation(
3004 &mut session,
3005 RelayObservation::SessionUpdate {
3006 update: Box::new(SessionUpdate::AgentMessageChunk(
3007 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
3008 TextContent::new("lo"),
3009 ))
3010 .message_id("answer-1"),
3011 )),
3012 },
3013 );
3014 assert_eq!(session.unread_agent_messages_after(0), 1);
3015 assert_eq!(session.unread_agent_messages_after(1), 1);
3016 assert!(matches!(
3017 &session.transcript[0].body,
3018 TranscriptBody::Agent { chunks, .. }
3019 if crate::transcript::materialized_chunks_text(chunks) == "hello"
3020 ));
3021 assert_eq!(session.transcript[0].position, 1);
3022 assert_eq!(session.transcript[0].latest_content_event_ordinal, Some(2));
3023
3024 apply_observation(
3025 &mut session,
3026 RelayObservation::CommandCompleted {
3027 command_id: "prompt-1".into(),
3028 outcome: RelayCommandOutcome::Prompt {
3029 diagnostic: None,
3030 stop_reason: "end_turn".into(),
3031 usage: None,
3032 },
3033 },
3034 );
3035 assert_eq!(session.transcript[0].latest_content_event_ordinal, Some(2));
3036 assert_eq!(session.unread_agent_messages_after(2), 0);
3037 }
3038
3039 #[test]
3040 fn agent_chunk_while_idle_is_recorded_closed() {
3041 let mut session = MaterializedSession::empty("session-1");
3042 session.execution = MaterializedExecutionState::Idle;
3043 apply_observation(
3044 &mut session,
3045 RelayObservation::SessionUpdate {
3046 update: Box::new(SessionUpdate::AgentMessageChunk(
3047 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
3048 TextContent::new("trailing"),
3049 ))
3050 .message_id("msg-1"),
3051 )),
3052 },
3053 );
3054 let item = session
3055 .transcript
3056 .iter()
3057 .find(|item| item.stable_id == "agent:msg-1")
3058 .expect("trailing chunk recorded");
3059 assert!(matches!(
3060 &item.body,
3061 TranscriptBody::Agent { chunks, streaming }
3062 if !*streaming
3063 && crate::transcript::materialized_chunks_text(chunks) == "trailing"
3064 ));
3065 }
3066
3067 #[test]
3068 fn thought_chunk_while_idle_is_recorded_closed() {
3069 let mut session = MaterializedSession::empty("session-1");
3070 session.execution = MaterializedExecutionState::Idle;
3071 apply_observation(
3072 &mut session,
3073 RelayObservation::SessionUpdate {
3074 update: Box::new(SessionUpdate::AgentThoughtChunk(
3075 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
3076 TextContent::new("late thought"),
3077 ))
3078 .message_id("msg-1"),
3079 )),
3080 },
3081 );
3082 let item = session
3083 .transcript
3084 .iter()
3085 .find(|item| item.stable_id == "thought:msg-1")
3086 .expect("trailing thought recorded");
3087 assert!(matches!(
3088 &item.body,
3089 TranscriptBody::Thought { streaming, .. } if !*streaming
3090 ));
3091 }
3092
3093 #[test]
3094 fn agent_chunk_while_running_still_streams() {
3095 let mut session = MaterializedSession::empty("session-1");
3096 session.execution = MaterializedExecutionState::Running { started_at_ms: 1 };
3097 apply_observation(
3098 &mut session,
3099 RelayObservation::SessionUpdate {
3100 update: Box::new(SessionUpdate::AgentMessageChunk(
3101 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
3102 TextContent::new("live"),
3103 ))
3104 .message_id("msg-1"),
3105 )),
3106 },
3107 );
3108 let item = session
3109 .transcript
3110 .iter()
3111 .find(|item| item.stable_id == "agent:msg-1")
3112 .expect("live chunk recorded");
3113 assert!(matches!(
3114 &item.body,
3115 TranscriptBody::Agent { chunks, streaming }
3116 if *streaming && crate::transcript::materialized_chunks_text(chunks) == "live"
3117 ));
3118 }
3119
3120 #[test]
3121 fn idle_untagged_agent_chunks_coalesce_into_one_closed_item() {
3122 let mut session = MaterializedSession::empty("session-1");
3123 session.execution = MaterializedExecutionState::Idle;
3124 for word in ["Grok ", "streams ", "one ", "word ", "at ", "a ", "time"] {
3125 apply_observation(&mut session, untagged_agent_chunk(word));
3126 }
3127 assert_eq!(session.transcript.len(), 1);
3128 let item = &session.transcript[0];
3129 assert!(matches!(
3130 &item.body,
3131 TranscriptBody::Agent { chunks, streaming }
3132 if !*streaming
3133 && crate::transcript::materialized_chunks_text(chunks)
3134 == "Grok streams one word at a time"
3135 ));
3136 }
3137
3138 #[test]
3139 fn idle_untagged_thought_chunks_coalesce_into_one_closed_item() {
3140 let mut session = MaterializedSession::empty("session-1");
3141 session.execution = MaterializedExecutionState::Idle;
3142 for word in ["thinking ", "in ", "small ", "pieces"] {
3143 apply_observation(&mut session, untagged_thought_chunk(word));
3144 }
3145 assert_eq!(session.transcript.len(), 1);
3146 let item = &session.transcript[0];
3147 assert!(matches!(
3148 &item.body,
3149 TranscriptBody::Thought { chunks, streaming }
3150 if !*streaming
3151 && crate::transcript::materialized_chunks_text(chunks)
3152 == "thinking in small pieces"
3153 ));
3154 }
3155
3156 #[test]
3157 fn idle_untagged_thought_then_agent_chunks_split_into_two_items() {
3158 let mut session = MaterializedSession::empty("session-1");
3159 session.execution = MaterializedExecutionState::Idle;
3160 apply_observation(&mut session, untagged_thought_chunk("pondering "));
3161 apply_observation(&mut session, untagged_thought_chunk("the goal"));
3162 apply_observation(&mut session, untagged_agent_chunk("here's "));
3163 apply_observation(&mut session, untagged_agent_chunk("the plan"));
3164
3165 assert_eq!(session.transcript.len(), 2);
3166 assert!(matches!(
3167 &session.transcript[0].body,
3168 TranscriptBody::Thought { chunks, streaming }
3169 if !*streaming
3170 && crate::transcript::materialized_chunks_text(chunks) == "pondering the goal"
3171 ));
3172 assert!(matches!(
3173 &session.transcript[1].body,
3174 TranscriptBody::Agent { chunks, streaming }
3175 if !*streaming
3176 && crate::transcript::materialized_chunks_text(chunks) == "here's the plan"
3177 ));
3178 }
3179
3180 #[test]
3181 fn idle_untagged_agent_chunks_split_around_an_intervening_tool_call() {
3182 let mut session = MaterializedSession::empty("session-1");
3183 session.execution = MaterializedExecutionState::Idle;
3184 apply_observation(&mut session, untagged_agent_chunk("checking "));
3185 apply_observation(&mut session, untagged_agent_chunk("the repo"));
3186 apply_observation(
3187 &mut session,
3188 RelayObservation::SessionUpdate {
3189 update: Box::new(SessionUpdate::ToolCall(ToolCall::new("call-1", "grep"))),
3190 },
3191 );
3192 apply_observation(&mut session, untagged_agent_chunk("found "));
3193 apply_observation(&mut session, untagged_agent_chunk("it"));
3194
3195 let agent_items: Vec<&TranscriptItem> = session
3196 .transcript
3197 .iter()
3198 .filter(|item| matches!(item.body, TranscriptBody::Agent { .. }))
3199 .map(|item| item.as_ref())
3200 .collect();
3201 assert_eq!(agent_items.len(), 2, "transcript: {:?}", session.transcript);
3202 assert!(matches!(
3203 &agent_items[0].body,
3204 TranscriptBody::Agent { chunks, streaming }
3205 if !*streaming
3206 && crate::transcript::materialized_chunks_text(chunks) == "checking the repo"
3207 ));
3208 assert!(matches!(
3209 &agent_items[1].body,
3210 TranscriptBody::Agent { chunks, streaming }
3211 if !*streaming
3212 && crate::transcript::materialized_chunks_text(chunks) == "found it"
3213 ));
3214 assert!(
3215 session
3216 .transcript
3217 .iter()
3218 .any(|item| matches!(&item.body, TranscriptBody::Tool { .. })),
3219 "the tool call item survives between the two agent items"
3220 );
3221 }
3222
3223 #[test]
3224 fn running_untagged_agent_chunks_still_merge_into_one_open_stream() {
3225 let mut session = MaterializedSession::empty("session-1");
3226 session.execution = MaterializedExecutionState::Running { started_at_ms: 1 };
3227 for word in ["live ", "streaming ", "text"] {
3228 apply_observation(&mut session, untagged_agent_chunk(word));
3229 }
3230 assert_eq!(session.transcript.len(), 1);
3231 let item = &session.transcript[0];
3232 assert!(matches!(
3233 &item.body,
3234 TranscriptBody::Agent { chunks, streaming }
3235 if *streaming
3236 && crate::transcript::materialized_chunks_text(chunks) == "live streaming text"
3237 ));
3238 }
3239
3240 #[test]
3241 fn backward_relay_clock_never_regresses_transcript_change_times() {
3242 let mut session = MaterializedSession::empty("session-1");
3243 let mut first = event(
3244 &session,
3245 RelayObservation::SessionUpdate {
3246 update: Box::new(SessionUpdate::AgentMessageChunk(
3247 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
3248 TextContent::new("first"),
3249 ))
3250 .message_id("answer-1"),
3251 )),
3252 },
3253 );
3254 first.recorded_at_ms = 1_000;
3255 first.digest = relay_event_digest(&first).unwrap();
3256 apply(&mut session, first);
3257
3258 let mut backward = event(
3259 &session,
3260 RelayObservation::SessionUpdate {
3261 update: Box::new(SessionUpdate::AgentMessageChunk(
3262 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
3263 TextContent::new(" second"),
3264 ))
3265 .message_id("answer-1"),
3266 )),
3267 },
3268 );
3269 backward.recorded_at_ms = 500;
3270 backward.digest = relay_event_digest(&backward).unwrap();
3271 apply(&mut session, backward);
3272 assert_eq!(session.transcript[0].last_changed_at_ms, 1_000);
3273
3274 let mut completion = event(
3275 &session,
3276 RelayObservation::CommandCompleted {
3277 command_id: "prompt-1".into(),
3278 outcome: RelayCommandOutcome::Prompt {
3279 diagnostic: None,
3280 stop_reason: "end_turn".into(),
3281 usage: None,
3282 },
3283 },
3284 );
3285 completion.recorded_at_ms = 250;
3286 completion.digest = relay_event_digest(&completion).unwrap();
3287 apply(&mut session, completion);
3288 assert_eq!(session.transcript[0].last_changed_at_ms, 1_000);
3289 assert_eq!(session.last_activity_at_ms(), Some(1_000));
3290 }
3291
3292 #[test]
3293 fn tool_update_without_an_initial_call_is_ignored_and_advances_the_frontier() {
3294 let mut session = MaterializedSession::empty("session-1");
3295 let update = SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3296 "missing-tool",
3297 ToolCallUpdateFields::new().title("updated"),
3298 ));
3299 let relay_event = event(
3300 &session,
3301 RelayObservation::SessionUpdate {
3302 update: Box::new(update),
3303 },
3304 );
3305
3306 let projected = project_relay_event(&session, &relay_event)
3307 .expect("a delayed pre-resume tool update is an observable no-op");
3308 apply_committed_projection_event(&mut session, &relay_event, projected.mutation)
3309 .expect("the no-op still advances the committed relay frontier");
3310
3311 assert!(session.transcript.is_empty());
3312 assert_eq!(session.applied_event_ordinal, 1);
3313 }
3314
3315 #[test]
3316 fn metadata_only_tool_update_without_an_initial_call_is_ignored() {
3317 let mut session = MaterializedSession::empty("session-1");
3318 let update = SessionUpdate::ToolCallUpdate(
3319 ToolCallUpdate::new("pre-resume-tool", ToolCallUpdateFields::new()).meta(
3320 serde_json::Map::from_iter([(
3321 "terminal_output_delta".into(),
3322 json!({"data": "late output"}),
3323 )]),
3324 ),
3325 );
3326 let relay_event = event(
3327 &session,
3328 RelayObservation::SessionUpdate {
3329 update: Box::new(update),
3330 },
3331 );
3332
3333 let projected = project_relay_event(&session, &relay_event)
3334 .expect("private metadata cannot change the transcript projection");
3335 apply_committed_projection_event(&mut session, &relay_event, projected.mutation)
3336 .expect("the no-op still advances the committed relay frontier");
3337
3338 assert!(session.transcript.is_empty());
3339 assert_eq!(session.applied_event_ordinal, 1);
3340 }
3341
3342 #[test]
3343 fn resent_tool_call_keeps_identity_and_replaces_the_call_payload() {
3344 let mut session = MaterializedSession::empty("session-1");
3345 apply_observation(
3346 &mut session,
3347 RelayObservation::SessionUpdate {
3348 update: Box::new(SessionUpdate::ToolCall(ToolCall::new(
3349 "call-1",
3350 "read file",
3351 ))),
3352 },
3353 );
3354 let created = TranscriptItem::clone(&session.transcript[0]);
3355 assert_eq!(created.position, 1);
3356 assert_eq!(created.created_at_ms, 100);
3357
3358 let resend = event(
3359 &session,
3360 RelayObservation::SessionUpdate {
3361 update: Box::new(SessionUpdate::ToolCall(
3362 ToolCall::new("call-1", "read file again")
3363 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed),
3364 )),
3365 },
3366 );
3367 let projected = project_relay_event(&session, &resend).unwrap();
3368 let TranscriptMutation::Upsert(item) = projected
3369 .mutation
3370 .transcript
3371 .iter()
3372 .find(|mutation| {
3373 matches!(mutation, TranscriptMutation::Upsert(item) if item.stable_id == "tool:call-1")
3374 })
3375 .expect("the re-sent tool call upserts its existing item")
3376 .clone()
3377 else {
3378 unreachable!("matched an upsert above");
3379 };
3380 assert_eq!(item.position, created.position);
3381 assert_eq!(item.created_at_ms, created.created_at_ms);
3382 assert_eq!(item.last_changed_at_ms, resend.recorded_at_ms);
3383 assert_eq!(
3384 item.latest_content_event_ordinal,
3385 created.latest_content_event_ordinal
3386 );
3387 let TranscriptBody::Tool { call, .. } = &item.body else {
3388 panic!("re-sent tool call stayed a tool item");
3389 };
3390 assert_eq!(call["title"], json!("read file again"));
3391
3392 apply_committed_projection_event(&mut session, &resend, projected.mutation)
3393 .expect("the merged item passes the projection integrity checks");
3394 assert_eq!(session.transcript.len(), 1);
3395 assert_eq!(session.transcript[0].position, created.position);
3396 }
3397
3398 #[test]
3399 fn tool_call_update_then_resent_tool_call_survives_the_projection() {
3400 let mut session = MaterializedSession::empty("session-1");
3401 apply_observation(
3402 &mut session,
3403 RelayObservation::SessionUpdate {
3404 update: Box::new(SessionUpdate::ToolCall(ToolCall::new("call-1", "shell"))),
3405 },
3406 );
3407 apply_observation(
3408 &mut session,
3409 RelayObservation::SessionUpdate {
3410 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3411 "call-1",
3412 ToolCallUpdateFields::new()
3413 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed),
3414 ))),
3415 },
3416 );
3417 apply_observation(
3418 &mut session,
3419 RelayObservation::SessionUpdate {
3420 update: Box::new(SessionUpdate::ToolCall(ToolCall::new(
3421 "call-1",
3422 "shell (retried)",
3423 ))),
3424 },
3425 );
3426
3427 assert_eq!(session.transcript.len(), 1);
3428 let item = &session.transcript[0];
3429 assert_eq!(item.position, 1);
3430 assert_eq!(item.created_at_ms, 100);
3431 assert_eq!(item.last_changed_at_ms, 300);
3432 let TranscriptBody::Tool { call, .. } = &item.body else {
3433 panic!("the item stayed a tool item");
3434 };
3435 assert_eq!(call["title"], json!("shell (retried)"));
3436 }
3437
3438 fn terminal_tool_call(call_id: &'static str, terminal_id: &'static str) -> RelayObservation {
3441 RelayObservation::SessionUpdate {
3442 update: Box::new(SessionUpdate::ToolCall(
3443 ToolCall::new(call_id, "shell").content(vec![ToolCallContent::Terminal(
3444 agent_client_protocol::schema::v1::Terminal::new(terminal_id),
3445 )]),
3446 )),
3447 }
3448 }
3449
3450 fn terminal_output(terminal_id: &str) -> RelayObservation {
3451 RelayObservation::TerminalOutput {
3452 terminal_id: terminal_id.into(),
3453 output: "build finished\n".into(),
3454 truncated: false,
3455 exit_code: Some(0),
3456 signal: None,
3457 }
3458 }
3459
3460 fn fallback_terminal_tool(terminal_id: &str, command: &str) -> RelayObservation {
3461 RelayObservation::SessionUpdate {
3462 update: Box::new(SessionUpdate::ToolCall(
3463 mj_core::acp::fallback_terminal_tool_call(terminal_id, command.into()),
3464 )),
3465 }
3466 }
3467
3468 fn attached_terminal_outputs(item: &TranscriptItem) -> &[TerminalOutputRecord] {
3469 let TranscriptBody::Tool {
3470 terminal_outputs, ..
3471 } = &item.body
3472 else {
3473 panic!("expected a tool item, got {:?}", item.body);
3474 };
3475 terminal_outputs
3476 }
3477
3478 #[test]
3479 fn fallback_terminal_tool_completes_in_place_instead_of_parking_output() {
3480 let mut session = MaterializedSession::empty("session-1");
3481 apply_observation(&mut session, fallback_terminal_tool("term-1", "cargo test"));
3482 apply_observation(&mut session, terminal_output("term-1"));
3483
3484 assert_eq!(session.transcript.len(), 1);
3485 let item = &session.transcript[0];
3486 assert_eq!(item.stable_id, "tool:hel-terminal:term-1");
3487 assert_eq!(item.position, 1, "the terminal retains its start order");
3488 assert_eq!(attached_terminal_outputs(item).len(), 1);
3489 let TranscriptBody::Tool { call, .. } = &item.body else {
3490 panic!("the fallback stays a tool");
3491 };
3492 let call: ToolCall = serde_json::from_value(call.clone()).unwrap();
3493 assert_eq!(call.status, ToolCallStatus::Completed);
3494 }
3495
3496 #[test]
3497 fn real_tool_call_replaces_fallback_and_keeps_its_start_order() {
3498 let mut session = MaterializedSession::empty("session-1");
3499 apply_observation(&mut session, fallback_terminal_tool("term-1", "cargo test"));
3500 apply_observation(&mut session, terminal_tool_call("call-1", "term-1"));
3501 apply_observation(&mut session, terminal_output("term-1"));
3502
3503 assert_eq!(session.transcript.len(), 1, "the fallback was consumed");
3504 let item = &session.transcript[0];
3505 assert_eq!(item.stable_id, "tool:call-1");
3506 assert_eq!(item.position, 1);
3507 assert_eq!(attached_terminal_outputs(item).len(), 1);
3508 }
3509
3510 #[test]
3511 fn fallback_is_suppressed_when_real_tool_already_claims_terminal() {
3512 let mut session = MaterializedSession::empty("session-1");
3513 apply_observation(&mut session, terminal_tool_call("call-1", "term-1"));
3514 apply_observation(&mut session, fallback_terminal_tool("term-1", "cargo test"));
3515 apply_observation(&mut session, terminal_output("term-1"));
3516
3517 assert_eq!(session.transcript.len(), 1);
3518 assert_eq!(session.transcript[0].stable_id, "tool:call-1");
3519 assert_eq!(attached_terminal_outputs(&session.transcript[0]).len(), 1);
3520 }
3521
3522 fn kimi_raw_tool_update(call_id: &'static str, output: &'static str) -> RelayObservation {
3523 RelayObservation::SessionUpdate {
3524 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3525 call_id,
3526 ToolCallUpdateFields::new()
3527 .status(ToolCallStatus::Completed)
3528 .raw_output(json!({
3529 "type": "Bash",
3530 "output": output.as_bytes(),
3531 "exit_code": 0,
3532 "command": "cargo test"
3533 })),
3534 ))),
3535 }
3536 }
3537
3538 #[test]
3539 fn raw_result_before_terminal_close_claims_the_fallback() {
3540 let mut session = MaterializedSession::empty("session-1");
3541 apply_observation(&mut session, fallback_terminal_tool("term-1", "cargo test"));
3542 apply_observation(
3543 &mut session,
3544 RelayObservation::SessionUpdate {
3545 update: Box::new(SessionUpdate::ToolCall(ToolCall::new(
3546 "call-1",
3547 "Execute `cargo test`",
3548 ))),
3549 },
3550 );
3551 apply_observation(
3552 &mut session,
3553 kimi_raw_tool_update("call-1", "build finished\n"),
3554 );
3555 apply_observation(&mut session, terminal_output("term-1"));
3556
3557 assert_eq!(session.transcript.len(), 1);
3558 assert_eq!(session.transcript[0].stable_id, "tool:call-1");
3559 assert_eq!(session.transcript[0].position, 2);
3560 assert_eq!(attached_terminal_outputs(&session.transcript[0]).len(), 1);
3561 }
3562
3563 #[test]
3564 fn raw_result_after_terminal_close_claims_the_fallback() {
3565 let mut session = MaterializedSession::empty("session-1");
3566 apply_observation(&mut session, fallback_terminal_tool("term-1", "cargo test"));
3567 apply_observation(
3568 &mut session,
3569 RelayObservation::SessionUpdate {
3570 update: Box::new(SessionUpdate::ToolCall(ToolCall::new(
3571 "call-1",
3572 "Execute `cargo test`",
3573 ))),
3574 },
3575 );
3576 apply_observation(&mut session, terminal_output("term-1"));
3577 apply_observation(
3578 &mut session,
3579 kimi_raw_tool_update("call-1", "build finished\n"),
3580 );
3581
3582 assert_eq!(session.transcript.len(), 1);
3583 assert_eq!(session.transcript[0].stable_id, "tool:call-1");
3584 assert_eq!(session.transcript[0].position, 2);
3585 assert_eq!(attached_terminal_outputs(&session.transcript[0]).len(), 1);
3586 }
3587
3588 #[test]
3589 fn late_fallback_claims_output_from_a_fast_terminal() {
3590 let mut session = MaterializedSession::empty("session-1");
3591 apply_observation(&mut session, terminal_output("term-1"));
3592 apply_observation(&mut session, fallback_terminal_tool("term-1", "true"));
3593
3594 assert_eq!(session.transcript.len(), 1);
3595 assert_eq!(session.transcript[0].stable_id, "tool:hel-terminal:term-1");
3596 let TranscriptBody::Tool { call, .. } = &session.transcript[0].body else {
3597 panic!("the parked output became a fallback tool");
3598 };
3599 let call: ToolCall = serde_json::from_value(call.clone()).unwrap();
3600 assert_eq!(call.status, ToolCallStatus::Completed);
3601 }
3602
3603 #[test]
3604 fn terminal_output_after_the_tool_call_attaches_to_the_tool_item() {
3605 let mut session = MaterializedSession::empty("session-1");
3606 apply_observation(&mut session, terminal_tool_call("call-1", "term-1"));
3607 apply_observation(&mut session, terminal_output("term-1"));
3608
3609 assert_eq!(session.transcript.len(), 1, "no standalone item is left");
3610 let outputs = attached_terminal_outputs(&session.transcript[0]);
3611 assert_eq!(outputs.len(), 1);
3612 assert_eq!(outputs[0].terminal_id, "term-1");
3613 assert_eq!(outputs[0].output, "build finished\n");
3614 assert_eq!(outputs[0].exit_code, Some(0));
3615 assert_eq!(session.transcript[0].last_changed_at_ms, 200);
3616 }
3617
3618 #[test]
3619 fn indexed_page_projection_tracks_terminal_and_tool_replacements() {
3620 let mut session = MaterializedSession::empty("session-1");
3621 let mut index = ProjectionIndex::new(&session);
3622 apply_indexed_observation(&mut session, &mut index, terminal_output("term-1"));
3623 apply_indexed_observation(
3624 &mut session,
3625 &mut index,
3626 terminal_tool_call("call-1", "term-1"),
3627 );
3628 apply_indexed_observation(&mut session, &mut index, terminal_output("term-1"));
3629
3630 assert_eq!(session.transcript.len(), 1, "parked output was consumed");
3631 assert_eq!(session.transcript[0].stable_id, "tool:call-1");
3632 let outputs = attached_terminal_outputs(&session.transcript[0]);
3633 assert_eq!(outputs.len(), 1);
3634 assert_eq!(outputs[0].terminal_id, "term-1");
3635 }
3636
3637 #[test]
3638 fn terminal_output_before_the_tool_call_attaches_when_the_call_arrives() {
3639 let mut session = MaterializedSession::empty("session-1");
3640 apply_observation(&mut session, terminal_output("term-1"));
3641
3642 assert_eq!(session.transcript.len(), 1);
3645 assert_eq!(session.transcript[0].stable_id, "terminal:term-1");
3646 assert!(matches!(
3647 &session.transcript[0].body,
3648 TranscriptBody::TerminalOutput { record } if record.terminal_id == "term-1"
3649 ));
3650
3651 apply_observation(&mut session, terminal_tool_call("call-1", "term-1"));
3652
3653 assert_eq!(
3654 session.transcript.len(),
3655 1,
3656 "the tool call consumes the parked item: {:?}",
3657 session.transcript
3658 );
3659 assert_eq!(session.transcript[0].stable_id, "tool:call-1");
3660 let outputs = attached_terminal_outputs(&session.transcript[0]);
3661 assert_eq!(outputs.len(), 1);
3662 assert_eq!(outputs[0].output, "build finished\n");
3663
3664 let mut reversed = MaterializedSession::empty("session-1");
3666 apply_observation(&mut reversed, terminal_tool_call("call-1", "term-1"));
3667 apply_observation(&mut reversed, terminal_output("term-1"));
3668 assert_eq!(
3669 attached_terminal_outputs(&reversed.transcript[0]),
3670 outputs,
3671 "output arriving before or after the call must read the same"
3672 );
3673 }
3674
3675 #[test]
3676 fn kimi_raw_result_claims_its_unreferenced_terminal_output() {
3677 const OUTPUT: &str = "toolchain inventory\n";
3678 let mut session = MaterializedSession::empty("session-1");
3679 apply_observation(
3680 &mut session,
3681 RelayObservation::SessionUpdate {
3682 update: Box::new(SessionUpdate::ToolCall(ToolCall::new(
3683 "call-1",
3684 "Execute `inspect toolchain`",
3685 ))),
3686 },
3687 );
3688 apply_observation(
3689 &mut session,
3690 RelayObservation::TerminalOutput {
3691 terminal_id: "term-1".into(),
3692 output: OUTPUT.into(),
3693 truncated: false,
3694 exit_code: Some(1),
3695 signal: None,
3696 },
3697 );
3698 apply_observation(
3699 &mut session,
3700 RelayObservation::SessionUpdate {
3701 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3702 "call-1",
3703 ToolCallUpdateFields::new()
3704 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed)
3705 .content(vec![ToolCallContent::from(ContentBlock::Text(
3706 TextContent::new(OUTPUT),
3707 ))])
3708 .raw_output(json!({
3709 "type": "Bash",
3710 "output": OUTPUT.as_bytes(),
3711 "exit_code": 1,
3712 "command": "inspect toolchain"
3713 })),
3714 ))),
3715 },
3716 );
3717
3718 assert_eq!(
3719 session.transcript.len(),
3720 1,
3721 "the completed tool consumes the duplicate standalone item"
3722 );
3723 let TranscriptBody::Tool {
3724 terminal_outputs,
3725 terminal_refs,
3726 ..
3727 } = &session.transcript[0].body
3728 else {
3729 panic!("the surviving item is the tool call");
3730 };
3731 assert_eq!(terminal_refs, &["term-1"]);
3732 assert_eq!(terminal_outputs.len(), 1);
3733 assert_eq!(terminal_outputs[0].output, OUTPUT);
3734 assert_eq!(terminal_outputs[0].exit_code, Some(1));
3735 }
3736
3737 #[test]
3738 fn mismatched_raw_result_does_not_hide_a_genuine_orphan_failure() {
3739 let mut session = MaterializedSession::empty("session-1");
3740 apply_observation(
3741 &mut session,
3742 RelayObservation::SessionUpdate {
3743 update: Box::new(SessionUpdate::ToolCall(ToolCall::new("call-1", "Execute"))),
3744 },
3745 );
3746 apply_observation(
3747 &mut session,
3748 RelayObservation::TerminalOutput {
3749 terminal_id: "term-1".into(),
3750 output: "orphan failure\n".into(),
3751 truncated: false,
3752 exit_code: Some(1),
3753 signal: None,
3754 },
3755 );
3756 apply_observation(
3757 &mut session,
3758 RelayObservation::SessionUpdate {
3759 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3760 "call-1",
3761 ToolCallUpdateFields::new()
3762 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed)
3763 .raw_output(json!({
3764 "output": b"different output",
3765 "exit_code": 1
3766 })),
3767 ))),
3768 },
3769 );
3770
3771 assert_eq!(session.transcript.len(), 2);
3772 assert!(session.transcript.iter().any(|item| matches!(
3773 &item.body,
3774 TranscriptBody::TerminalOutput { record }
3775 if record.output == "orphan failure\n"
3776 )));
3777 }
3778
3779 #[test]
3780 fn identical_orphan_results_are_not_assigned_arbitrarily() {
3781 const OUTPUT: &str = "same output\n";
3782 let mut session = MaterializedSession::empty("session-1");
3783 apply_observation(
3784 &mut session,
3785 RelayObservation::SessionUpdate {
3786 update: Box::new(SessionUpdate::ToolCall(ToolCall::new("call-1", "Execute"))),
3787 },
3788 );
3789 for terminal_id in ["term-1", "term-2"] {
3790 apply_observation(
3791 &mut session,
3792 RelayObservation::TerminalOutput {
3793 terminal_id: terminal_id.into(),
3794 output: OUTPUT.into(),
3795 truncated: false,
3796 exit_code: Some(1),
3797 signal: None,
3798 },
3799 );
3800 }
3801 apply_observation(
3802 &mut session,
3803 RelayObservation::SessionUpdate {
3804 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3805 "call-1",
3806 ToolCallUpdateFields::new()
3807 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed)
3808 .raw_output(json!({
3809 "output": OUTPUT.as_bytes(),
3810 "exit_code": 1
3811 })),
3812 ))),
3813 },
3814 );
3815
3816 assert_eq!(
3817 session
3818 .transcript
3819 .iter()
3820 .filter(|item| matches!(item.body, TranscriptBody::TerminalOutput { .. }))
3821 .count(),
3822 2,
3823 "identical concurrent results need an explicit reference"
3824 );
3825 assert!(attached_terminal_outputs(&session.transcript[0]).is_empty());
3826 }
3827
3828 #[test]
3829 fn wholesale_tool_call_update_keeps_the_attached_terminal_output() {
3830 let mut session = MaterializedSession::empty("session-1");
3831 apply_observation(&mut session, terminal_tool_call("call-1", "term-1"));
3832 apply_observation(&mut session, terminal_output("term-1"));
3833 apply_observation(
3836 &mut session,
3837 RelayObservation::SessionUpdate {
3838 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3839 "call-1",
3840 ToolCallUpdateFields::new()
3841 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed)
3842 .content(vec![ToolCallContent::Terminal(
3843 agent_client_protocol::schema::v1::Terminal::new("term-1"),
3844 )]),
3845 ))),
3846 },
3847 );
3848
3849 assert_eq!(session.transcript.len(), 1);
3850 let outputs = attached_terminal_outputs(&session.transcript[0]);
3851 assert_eq!(outputs.len(), 1);
3852 assert_eq!(outputs[0].output, "build finished\n");
3853 let TranscriptBody::Tool { call, .. } = &session.transcript[0].body else {
3854 panic!("the item stayed a tool item");
3855 };
3856 assert_eq!(call["status"], json!("completed"));
3857 }
3858
3859 #[test]
3863 fn a_tool_call_that_dropped_its_terminal_reference_still_attaches_the_output() {
3864 let mut session = MaterializedSession::empty("session-1");
3865 apply_observation(&mut session, terminal_tool_call("call-1", "term-1"));
3866 apply_observation(
3867 &mut session,
3868 RelayObservation::SessionUpdate {
3869 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3870 "call-1",
3871 ToolCallUpdateFields::new()
3872 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed)
3873 .content(vec![ToolCallContent::from(ContentBlock::Text(
3874 TextContent::new("ran the build"),
3875 ))]),
3876 ))),
3877 },
3878 );
3879 apply_observation(&mut session, terminal_output("term-1"));
3880
3881 assert_eq!(
3882 session.transcript.len(),
3883 1,
3884 "the output attaches instead of parking in its own item: {:?}",
3885 session.transcript
3886 );
3887 assert_eq!(session.transcript[0].stable_id, "tool:call-1");
3888 let outputs = attached_terminal_outputs(&session.transcript[0]);
3889 assert_eq!(outputs.len(), 1);
3890 assert_eq!(outputs[0].output, "build finished\n");
3891 let TranscriptBody::Tool {
3892 call,
3893 terminal_refs,
3894 ..
3895 } = &session.transcript[0].body
3896 else {
3897 panic!("the item stayed a tool item");
3898 };
3899 assert_eq!(terminal_refs, &["term-1".to_owned()]);
3900 assert_eq!(
3901 tool_call_terminal_ids(call),
3902 Vec::<String>::new(),
3903 "the final call really did drop the reference"
3904 );
3905 }
3906
3907 #[test]
3908 fn queued_prompt_becomes_user_message_only_when_started() {
3909 let mut session = MaterializedSession::empty("session-1");
3910 apply_observation(
3911 &mut session,
3912 RelayObservation::CommandQueued {
3913 command_id: "prompt-1".into(),
3914 command: RelayCommand::Prompt {
3915 prompt: vec![ContentBlock::Text(TextContent::new("hello"))],
3916 },
3917 created_at_ms: 100,
3918 },
3919 );
3920 assert!(session.transcript.is_empty());
3921 assert_eq!(session.queued_prompts.len(), 1);
3922
3923 apply_observation(
3924 &mut session,
3925 RelayObservation::CommandStarted {
3926 command_id: "prompt-1".into(),
3927 started_at_ms: 200,
3928 },
3929 );
3930 assert!(session.queued_prompts.is_empty());
3931 assert!(matches!(
3932 session.transcript[0].body,
3933 TranscriptBody::User { .. }
3934 ));
3935
3936 apply_observation(
3937 &mut session,
3938 RelayObservation::CommandCompleted {
3939 command_id: "prompt-1".into(),
3940 outcome: RelayCommandOutcome::Prompt {
3941 diagnostic: None,
3942 stop_reason: "end_turn".into(),
3943 usage: None,
3944 },
3945 },
3946 );
3947 assert_eq!(session.execution, MaterializedExecutionState::Idle);
3948 }
3949
3950 #[test]
3951 fn first_queued_prompt_seeds_a_provisional_session_title() {
3952 let mut session = MaterializedSession::empty("session-1");
3953 apply_observation(
3954 &mut session,
3955 RelayObservation::CommandQueued {
3956 command_id: "prompt-1".into(),
3957 command: RelayCommand::Prompt {
3958 prompt: vec![ContentBlock::Text(TextContent::new(
3959 " fix the flaky\nresume test ",
3960 ))],
3961 },
3962 created_at_ms: 100,
3963 },
3964 );
3965
3966 assert_eq!(
3967 session.session_title.as_deref(),
3968 Some("fix the flaky resume test")
3969 );
3970 }
3971
3972 #[test]
3973 fn harness_title_replaces_the_provisional_title() {
3974 let mut session = MaterializedSession::empty("session-1");
3975 apply_observation(
3976 &mut session,
3977 RelayObservation::CommandQueued {
3978 command_id: "prompt-1".into(),
3979 command: RelayCommand::Prompt {
3980 prompt: vec![ContentBlock::Text(TextContent::new("first prompt"))],
3981 },
3982 created_at_ms: 100,
3983 },
3984 );
3985 apply_observation(
3986 &mut session,
3987 RelayObservation::CommandQueued {
3988 command_id: "prompt-2".into(),
3989 command: RelayCommand::Prompt {
3990 prompt: vec![ContentBlock::Text(TextContent::new("second prompt"))],
3991 },
3992 created_at_ms: 200,
3993 },
3994 );
3995 assert_eq!(session.session_title.as_deref(), Some("first prompt"));
3996
3997 apply_observation(
3998 &mut session,
3999 RelayObservation::SessionUpdate {
4000 update: Box::new(SessionUpdate::SessionInfoUpdate(
4001 agent_client_protocol::schema::v1::SessionInfoUpdate::new()
4002 .title("Agent-generated title"),
4003 )),
4004 },
4005 );
4006
4007 assert_eq!(
4008 session.session_title.as_deref(),
4009 Some("Agent-generated title")
4010 );
4011 }
4012
4013 #[test]
4014 fn session_info_update_without_title_preserves_the_provisional_title() {
4015 let mut session = MaterializedSession::empty("session-1");
4016 apply_observation(
4017 &mut session,
4018 RelayObservation::CommandQueued {
4019 command_id: "prompt-1".into(),
4020 command: RelayCommand::Prompt {
4021 prompt: vec![ContentBlock::Text(TextContent::new("first prompt"))],
4022 },
4023 created_at_ms: 100,
4024 },
4025 );
4026
4027 apply_observation(
4028 &mut session,
4029 RelayObservation::SessionUpdate {
4030 update: Box::new(SessionUpdate::SessionInfoUpdate(
4031 agent_client_protocol::schema::v1::SessionInfoUpdate::new()
4032 .updated_at("2026-08-31T12:00:00Z"),
4033 )),
4034 },
4035 );
4036
4037 assert_eq!(session.session_title.as_deref(), Some("first prompt"));
4038 }
4039
4040 #[test]
4041 fn explicit_session_title_clear_restores_the_prompt_fallback() {
4042 let mut session = MaterializedSession::empty("session-1");
4043 apply_observation(
4044 &mut session,
4045 RelayObservation::CommandQueued {
4046 command_id: "prompt-1".into(),
4047 command: RelayCommand::Prompt {
4048 prompt: vec![ContentBlock::Text(TextContent::new("first prompt"))],
4049 },
4050 created_at_ms: 100,
4051 },
4052 );
4053
4054 apply_observation(
4055 &mut session,
4056 RelayObservation::SessionUpdate {
4057 update: Box::new(SessionUpdate::SessionInfoUpdate(
4058 agent_client_protocol::schema::v1::SessionInfoUpdate::new().title(None),
4059 )),
4060 },
4061 );
4062
4063 assert_eq!(session.session_title, None);
4064 assert_eq!(session.resolved_title().as_deref(), Some("first prompt"));
4065 }
4066
4067 #[test]
4068 fn next_prompt_backfills_an_existing_untitled_session_from_its_first_prompt() {
4069 let mut session = MaterializedSession::empty("session-1");
4070 session.transcript.push(Arc::new(TranscriptItem {
4071 stable_id: "user:prompt-1".into(),
4072 position: 1,
4073 latest_content_event_ordinal: None,
4074 created_at_ms: 100,
4075 last_changed_at_ms: 100,
4076 body: TranscriptBody::User {
4077 content: vec![
4078 serde_json::to_value(ContentBlock::Text(TextContent::new("original task")))
4079 .unwrap(),
4080 ],
4081 },
4082 }));
4083 assert_eq!(session.resolved_title().as_deref(), Some("original task"));
4084
4085 apply_observation(
4086 &mut session,
4087 RelayObservation::CommandQueued {
4088 command_id: "prompt-2".into(),
4089 command: RelayCommand::Prompt {
4090 prompt: vec![ContentBlock::Text(TextContent::new("follow-up task"))],
4091 },
4092 created_at_ms: 200,
4093 },
4094 );
4095
4096 assert_eq!(session.session_title.as_deref(), Some("original task"));
4097 }
4098
4099 #[test]
4100 fn queued_config_change_starts_without_becoming_a_turn() {
4101 let mut session = MaterializedSession::empty("session-1");
4102 apply_observation(
4103 &mut session,
4104 RelayObservation::CommandQueued {
4105 command_id: "config-1".into(),
4106 command: RelayCommand::SetConfig {
4107 key: "model".into(),
4108 value: "sonnet".into(),
4109 },
4110 created_at_ms: 100,
4111 },
4112 );
4113 assert_eq!(session.queued_prompts.len(), 1);
4114 assert_eq!(
4115 session.queued_prompts[0].kind,
4116 QueuedCommandKind::SetConfig {
4117 key: "model".into(),
4118 value: "sonnet".into(),
4119 }
4120 );
4121 assert_eq!(
4122 crate::transcript::materialized_content_text(&session.queued_prompts[0].content),
4123 "/model sonnet"
4124 );
4125
4126 apply_observation(
4127 &mut session,
4128 RelayObservation::CommandStarted {
4129 command_id: "config-1".into(),
4130 started_at_ms: 200,
4131 },
4132 );
4133 assert!(session.queued_prompts.is_empty());
4134 assert!(session.transcript.is_empty());
4135 assert_eq!(session.execution, MaterializedExecutionState::Idle);
4136
4137 apply_observation(
4138 &mut session,
4139 RelayObservation::CommandCompleted {
4140 command_id: "config-1".into(),
4141 outcome: RelayCommandOutcome::Configured,
4142 },
4143 );
4144 assert_eq!(session.execution, MaterializedExecutionState::Idle);
4145 assert!(session.transcript.is_empty());
4146 }
4147
4148 #[test]
4149 fn queue_changes_project_only_from_their_completion_events() {
4150 let mut session = MaterializedSession::empty("session-1");
4151 session.queued_prompts.push(MaterializedQueuedPrompt {
4152 accepted_ordinal: None,
4153 command_id: "queued-1".into(),
4154 kind: QueuedCommandKind::Prompt,
4155 content: vec![json!({"type": "text", "text": "later"})],
4156 queued_at_ms: 10,
4157 });
4158
4159 apply_observation(
4160 &mut session,
4161 RelayObservation::CommandQueued {
4162 command_id: "remove-1".into(),
4163 command: RelayCommand::RemoveQueuedPrompt {
4164 queued_command_id: "queued-1".into(),
4165 },
4166 created_at_ms: 100,
4167 },
4168 );
4169 assert_eq!(session.queued_prompts.len(), 1);
4170
4171 apply_observation(
4172 &mut session,
4173 RelayObservation::CommandCompleted {
4174 command_id: "remove-1".into(),
4175 outcome: RelayCommandOutcome::QueueChanged {
4176 removed_command_ids: vec!["queued-1".into()],
4177 },
4178 },
4179 );
4180 assert!(session.queued_prompts.is_empty());
4181
4182 session.queued_prompts.extend([
4183 MaterializedQueuedPrompt {
4184 accepted_ordinal: None,
4185 command_id: "queued-2".into(),
4186 kind: QueuedCommandKind::Prompt,
4187 content: vec![json!({"type": "text", "text": "two"})],
4188 queued_at_ms: 20,
4189 },
4190 MaterializedQueuedPrompt {
4191 accepted_ordinal: None,
4192 command_id: "queued-3".into(),
4193 kind: QueuedCommandKind::Prompt,
4194 content: vec![json!({"type": "text", "text": "three"})],
4195 queued_at_ms: 30,
4196 },
4197 ]);
4198 apply_observation(
4199 &mut session,
4200 RelayObservation::CommandQueued {
4201 command_id: "clear-1".into(),
4202 command: RelayCommand::ClearQueuedPrompts,
4203 created_at_ms: 200,
4204 },
4205 );
4206 assert_eq!(session.queued_prompts.len(), 2);
4207
4208 apply_observation(
4209 &mut session,
4210 RelayObservation::CommandCompleted {
4211 command_id: "clear-1".into(),
4212 outcome: RelayCommandOutcome::QueueChanged {
4213 removed_command_ids: vec!["queued-2".into(), "queued-3".into()],
4214 },
4215 },
4216 );
4217 assert!(session.queued_prompts.is_empty());
4218 }
4219
4220 #[test]
4221 fn rejected_close_rolls_closing_projection_back_to_idle() {
4222 let mut session = MaterializedSession::empty("session-1");
4223 apply_observation(
4224 &mut session,
4225 RelayObservation::CommandQueued {
4226 command_id: "close-1".into(),
4227 command: RelayCommand::Close {
4228 barrier_command_id: "barrier-1".into(),
4229 expected: mj_core::relay::RelayCursor {
4230 ordinal: 0,
4231 digest: "0".repeat(64),
4232 },
4233 },
4234 created_at_ms: 100,
4235 },
4236 );
4237 assert_eq!(session.execution, MaterializedExecutionState::Closing);
4238
4239 apply_observation(
4240 &mut session,
4241 RelayObservation::CommandRejected {
4242 command_id: "close-1".into(),
4243 command: RelayCommandKind::Close,
4244 message: "ACP close failed".into(),
4245 },
4246 );
4247 assert_eq!(session.execution, MaterializedExecutionState::Idle);
4248 }
4249
4250 #[test]
4251 fn control_command_outcomes_do_not_end_an_active_prompt() {
4252 let mut session = MaterializedSession::empty("session-1");
4253 session.applied_event_ordinal = 2;
4254 session.applied_event_digest = "a".repeat(64);
4255 session.execution = MaterializedExecutionState::Running { started_at_ms: 100 };
4256 session.transcript.push(Arc::new(TranscriptItem {
4257 stable_id: "agent:answer-1".into(),
4258 position: 2,
4259 latest_content_event_ordinal: Some(2),
4260 created_at_ms: 200,
4261 last_changed_at_ms: 200,
4262 body: TranscriptBody::Agent {
4263 chunks: vec![json!({
4264 "content": {"type": "text", "text": "working"}
4265 })],
4266 streaming: true,
4267 },
4268 }));
4269
4270 apply_observation(
4271 &mut session,
4272 RelayObservation::CommandCompleted {
4273 command_id: "config-1".into(),
4274 outcome: RelayCommandOutcome::Configured,
4275 },
4276 );
4277 assert!(matches!(
4278 session.execution,
4279 MaterializedExecutionState::Running { .. }
4280 ));
4281 assert!(matches!(
4282 session.transcript[0].body,
4283 TranscriptBody::Agent {
4284 streaming: true,
4285 ..
4286 }
4287 ));
4288
4289 apply_observation(
4290 &mut session,
4291 RelayObservation::CommandRejected {
4292 command_id: "cancel-1".into(),
4293 command: RelayCommandKind::Cancel,
4294 message: "not cancellable".into(),
4295 },
4296 );
4297 assert!(matches!(
4298 session.execution,
4299 MaterializedExecutionState::Running { .. }
4300 ));
4301 assert!(matches!(
4302 session.transcript[0].body,
4303 TranscriptBody::Agent {
4304 streaming: true,
4305 ..
4306 }
4307 ));
4308 }
4309
4310 #[test]
4311 fn canonical_round_trip_preserves_cursor_and_logical_positions() {
4312 let mut session = MaterializedSession::empty("session-1");
4313 session.applied_event_ordinal = 4;
4314 session.applied_event_digest = "a".repeat(64);
4315 session.last_activity_at_ms = Some(40);
4316 session.session_title = Some("Build it".into());
4317 session.transcript.push(Arc::new(TranscriptItem {
4318 stable_id: "agent:a".into(),
4319 position: 2,
4320 latest_content_event_ordinal: Some(4),
4321 created_at_ms: 20,
4322 last_changed_at_ms: 40,
4323 body: TranscriptBody::Agent {
4324 chunks: vec![json!({
4325 "content": {"type": "text", "text": "done"},
4326 "messageId": "a",
4327 "_meta": {"provider": "test"}
4328 })],
4329 streaming: false,
4330 },
4331 }));
4332 session.transcript.push(Arc::new(TranscriptItem {
4333 stable_id: "thought:t".into(),
4334 position: 3,
4335 latest_content_event_ordinal: None,
4336 created_at_ms: 30,
4337 last_changed_at_ms: 30,
4338 body: TranscriptBody::Thought {
4339 chunks: vec![json!({
4340 "content": {
4341 "type": "text",
4342 "text": "reasoning",
4343 "_meta": {"contentProvider": "test"}
4344 },
4345 "messageId": "t",
4346 "_meta": {"chunkProvider": "test"}
4347 })],
4348 streaming: false,
4349 },
4350 }));
4351 session.transcript.push(Arc::new(TranscriptItem {
4352 stable_id: "tool:call-1".into(),
4353 position: 4,
4354 latest_content_event_ordinal: None,
4355 created_at_ms: 40,
4356 last_changed_at_ms: 40,
4357 body: TranscriptBody::Tool {
4358 call: json!({
4359 "toolCallId": "call-1",
4360 "title": "Read file",
4361 "kind": "read",
4362 "status": "completed",
4363 "content": [{"type": "terminal", "terminalId": "term-1"}],
4364 "rawInput": {"path": "README.md"},
4365 "rawOutput": {"bytes": 42},
4366 "_meta": {"provider": "test"}
4367 }),
4368 terminal_outputs: vec![TerminalOutputRecord {
4369 terminal_id: "term-1".into(),
4370 output: "ok\n".into(),
4371 truncated: true,
4372 exit_code: Some(0),
4373 signal: None,
4374 }],
4375 terminal_refs: vec!["term-1".into(), "term-3".into()],
4378 presentation: Some(Box::new(crate::transcript::ToolCallPresentation {
4379 summary: "Read".into(),
4380 source: "Read file".into(),
4381 source_kind: crate::transcript::ToolSummarySourceKind::Title,
4382 tool_kind: agent_client_protocol::schema::v1::ToolKind::Read,
4383 summary_version: crate::transcript::TOOL_SUMMARY_VERSION,
4384 })),
4385 },
4386 }));
4387 session.transcript.push(Arc::new(TranscriptItem {
4388 stable_id: "terminal:term-2".into(),
4389 position: 4,
4390 latest_content_event_ordinal: None,
4391 created_at_ms: 40,
4392 last_changed_at_ms: 40,
4393 body: TranscriptBody::TerminalOutput {
4394 record: TerminalOutputRecord {
4395 terminal_id: "term-2".into(),
4396 output: "orphaned output\n".into(),
4397 truncated: false,
4398 exit_code: None,
4399 signal: Some("SIGKILL".into()),
4400 },
4401 },
4402 }));
4403 session.transcript.push(Arc::new(TranscriptItem {
4404 stable_id: "plan:4".into(),
4405 position: 4,
4406 latest_content_event_ordinal: None,
4407 created_at_ms: 40,
4408 last_changed_at_ms: 40,
4409 body: TranscriptBody::Plan {
4410 plan: json!({
4411 "entries": [{
4412 "content": "finish",
4413 "priority": "high",
4414 "status": "in_progress",
4415 "_meta": {"entryProvider": "test"}
4416 }],
4417 "_meta": {"planProvider": "test"}
4418 }),
4419 },
4420 }));
4421 session.transcript.push(Arc::new(TranscriptItem {
4422 stable_id: plan_proposal_item_id(4),
4423 position: 4,
4424 latest_content_event_ordinal: None,
4425 created_at_ms: 40,
4426 last_changed_at_ms: 40,
4427 body: TranscriptBody::PlanProposal {
4428 proposal_id: "plan-review-1".into(),
4429 plan: "1. Read the code\n2. Change it".into(),
4430 },
4431 }));
4432 session.queued_prompts.push(MaterializedQueuedPrompt {
4433 accepted_ordinal: None,
4434 command_id: "queued-config".into(),
4435 kind: QueuedCommandKind::SetConfig {
4436 key: "model".into(),
4437 value: "sonnet".into(),
4438 },
4439 content: vec![json!({"type": "text", "text": "/model sonnet"})],
4440 queued_at_ms: 50,
4441 });
4442 let canonical = canonical_session_from_materialized(&session).unwrap();
4443 canonical.validate().unwrap();
4444 assert_eq!(
4445 canonical.queued_prompts[0].kind,
4446 CanonicalQueuedCommandKind::SetConfig {
4447 key: "model".into(),
4448 value: "sonnet".into(),
4449 }
4450 );
4451 let restored = materialized_session_from_canonical("session-1", &canonical).unwrap();
4452 assert_eq!(restored.applied_event_ordinal, 4);
4453 assert_eq!(restored.transcript[0].position, 2);
4454 assert_eq!(restored.unread_agent_messages_after(1), 1);
4455 assert_eq!(restored, session);
4456 }
4457
4458 #[test]
4459 fn one_chunk_projects_only_the_touched_logical_item() {
4460 let mut session = MaterializedSession::empty("session-1");
4461 session.applied_event_ordinal = 10_000;
4462 session.applied_event_digest = "a".repeat(64);
4463 session.last_activity_at_ms = Some(10_000);
4464 session.transcript = (1..=10_000)
4465 .map(|position| {
4466 Arc::new(TranscriptItem {
4467 stable_id: format!("system:{position}"),
4468 position,
4469 latest_content_event_ordinal: None,
4470 created_at_ms: i64::try_from(position).unwrap(),
4471 last_changed_at_ms: i64::try_from(position).unwrap(),
4472 body: TranscriptBody::System {
4473 text: format!("event {position}"),
4474 },
4475 })
4476 })
4477 .collect();
4478 let next = event(
4479 &session,
4480 RelayObservation::SessionUpdate {
4481 update: Box::new(SessionUpdate::AgentMessageChunk(
4482 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
4483 TextContent::new("answer"),
4484 ))
4485 .message_id("answer-1"),
4486 )),
4487 },
4488 );
4489
4490 let projected = project_relay_event(&session, &next).unwrap();
4491
4492 assert_eq!(projected.mutation.transcript.len(), 1);
4493 assert!(projected.mutation.configuration.is_none());
4494 assert!(projected.mutation.queued_prompts.is_none());
4495 assert_eq!(session.transcript.len(), 10_000);
4496 apply_committed_projection_event(&mut session, &next, projected.mutation).unwrap();
4497 assert_eq!(session.transcript.len(), 10_001);
4498 }
4499
4500 fn agent_chunk(text: &str, message_id: &str) -> RelayObservation {
4501 RelayObservation::SessionUpdate {
4502 update: Box::new(SessionUpdate::AgentMessageChunk(
4503 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
4504 TextContent::new(text),
4505 ))
4506 .message_id(message_id),
4507 )),
4508 }
4509 }
4510
4511 fn end_turn() -> RelayObservation {
4512 RelayObservation::CommandCompleted {
4513 command_id: "prompt-1".into(),
4514 outcome: RelayCommandOutcome::Prompt {
4515 diagnostic: None,
4516 stop_reason: "end_turn".into(),
4517 usage: None,
4518 },
4519 }
4520 }
4521
4522 fn agent_text(item: &TranscriptItem) -> String {
4523 let TranscriptBody::Agent { chunks, .. } = &item.body else {
4524 panic!("expected an agent message, got {:?}", item.body);
4525 };
4526 crate::transcript::materialized_chunks_text(chunks)
4527 }
4528
4529 #[test]
4530 fn appending_a_transcript_item_leaves_earlier_items_shared_with_older_snapshots() {
4531 let mut session = MaterializedSession::empty("session-1");
4532 apply_observation(&mut session, agent_chunk("answer", "answer-1"));
4533 apply_observation(&mut session, end_turn());
4534 let published = session.clone();
4535
4536 apply_observation(
4537 &mut session,
4538 RelayObservation::Warning {
4539 message: "disk is nearly full".into(),
4540 },
4541 );
4542
4543 assert_eq!(published.transcript.len(), 1);
4544 assert_eq!(session.transcript.len(), 2);
4545 assert!(matches!(
4546 &session.transcript[1].body,
4547 TranscriptBody::System { text } if text == "warning: disk is nearly full"
4548 ));
4549 assert!(
4550 Arc::ptr_eq(&session.transcript[0], &published.transcript[0]),
4551 "cloning a session must share earlier transcript items, not copy them"
4552 );
4553 }
4554
4555 #[test]
4556 fn appending_a_chunk_replaces_only_the_streaming_tail_item() {
4557 let mut session = MaterializedSession::empty("session-1");
4558 apply_observation(&mut session, agent_chunk("finished", "answer-1"));
4559 apply_observation(&mut session, end_turn());
4560 apply_observation(&mut session, agent_chunk("hel", "answer-2"));
4561 let published = session.clone();
4562
4563 apply_observation(&mut session, agent_chunk("lo", "answer-2"));
4564
4565 assert_eq!(session.transcript.len(), 2);
4566 assert!(
4567 Arc::ptr_eq(&session.transcript[0], &published.transcript[0]),
4568 "finalized items stay shared while the tail streams"
4569 );
4570 assert!(
4571 !Arc::ptr_eq(&session.transcript[1], &published.transcript[1]),
4572 "the streaming tail must be replaced, not mutated in place"
4573 );
4574 assert_eq!(agent_text(&published.transcript[1]), "hel");
4575 assert_eq!(agent_text(&session.transcript[1]), "hello");
4576 }
4577}