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 native_continuity_lost: false,
2329 },
2330 );
2331 let mutation = project_relay_event(&session, &resumed).unwrap().mutation;
2332 assert!(mutation.transcript.is_empty());
2333 assert_eq!(mutation.pending_elicitations, Some(Vec::new()));
2334
2335 let started = event(
2336 &session,
2337 RelayObservation::SessionOpened {
2338 native_session_id: "native".into(),
2339 resumed: false,
2340 native_continuity_lost: false,
2341 },
2342 );
2343 assert_eq!(
2344 project_relay_event(&session, &started)
2345 .unwrap()
2346 .mutation
2347 .transcript
2348 .len(),
2349 1
2350 );
2351 }
2352
2353 #[test]
2354 fn a_completed_prompt_records_its_stop_reason_and_clears_the_running_turn() {
2355 let mut session = MaterializedSession::empty("session");
2356 apply_observation(
2357 &mut session,
2358 RelayObservation::CommandQueued {
2359 command_id: "prompt-1".into(),
2360 command: RelayCommand::Prompt {
2361 prompt: vec![agent_client_protocol::schema::v1::ContentBlock::from("go")],
2362 },
2363 created_at_ms: 10,
2364 },
2365 );
2366 let accepted = session.applied_event_ordinal;
2367 assert_eq!(
2368 session.queued_prompts[0].accepted_ordinal,
2369 Some(accepted),
2370 "a queue entry remembers the ordinal its caller was told"
2371 );
2372
2373 apply_observation(
2374 &mut session,
2375 RelayObservation::CommandStarted {
2376 command_id: "prompt-1".into(),
2377 started_at_ms: 20,
2378 },
2379 );
2380 let turn = session.active_turn.clone().expect("a running turn");
2381 assert_eq!(turn.command_id, "prompt-1");
2382 assert_eq!(turn.accepted_ordinal, Some(accepted));
2383 assert_eq!(turn.turn_start_position, session.applied_event_ordinal);
2384 assert_eq!(turn.started_at_ms, 20);
2385
2386 apply_observation(
2387 &mut session,
2388 RelayObservation::CommandCompleted {
2389 command_id: "prompt-1".into(),
2390 outcome: RelayCommandOutcome::Prompt {
2391 diagnostic: None,
2392 stop_reason: "EndTurn".into(),
2393 usage: None,
2394 },
2395 },
2396 );
2397 assert!(session.active_turn.is_none());
2398 let outcome = session.last_turn_outcome.clone().expect("an outcome");
2399 assert_eq!(outcome.command_id, "prompt-1");
2400 assert_eq!(outcome.accepted_ordinal, Some(accepted));
2401 assert_eq!(outcome.turn_start_position, Some(turn.turn_start_position));
2402 assert_eq!(outcome.completed_ordinal, session.applied_event_ordinal);
2403 assert_eq!(
2404 outcome.outcome,
2405 TurnOutcomeKind::Completed {
2406 stop_reason: "EndTurn".into()
2407 }
2408 );
2409 }
2410
2411 #[test]
2412 fn a_rejected_queued_prompt_records_its_acceptance_ordinal_without_a_turn_start() {
2413 let mut session = MaterializedSession::empty("session");
2414 apply_observation(
2415 &mut session,
2416 RelayObservation::CommandQueued {
2417 command_id: "prompt-1".into(),
2418 command: RelayCommand::Prompt {
2419 prompt: vec![agent_client_protocol::schema::v1::ContentBlock::from("go")],
2420 },
2421 created_at_ms: 10,
2422 },
2423 );
2424 let accepted = session.applied_event_ordinal;
2425
2426 apply_observation(
2427 &mut session,
2428 RelayObservation::CommandRejected {
2429 command_id: "prompt-1".into(),
2430 command: RelayCommandKind::Prompt,
2431 message: "transport failed".into(),
2432 },
2433 );
2434
2435 assert!(session.active_turn.is_none());
2436 assert!(session.queued_prompts.is_empty());
2437 let outcome = session.last_turn_outcome.clone().expect("an outcome");
2438 assert_eq!(outcome.accepted_ordinal, Some(accepted));
2439 assert_eq!(
2440 outcome.turn_start_position, None,
2441 "a prompt that never started has no turn in the transcript"
2442 );
2443 assert_eq!(
2444 outcome.outcome,
2445 TurnOutcomeKind::Rejected {
2446 message: "transport failed".into()
2447 }
2448 );
2449 }
2450
2451 #[test]
2452 fn queued_prompts_keep_their_own_acceptance_ordinals_through_their_turns() {
2453 let mut session = MaterializedSession::empty("session");
2454 let mut accepted = Vec::new();
2455 for command_id in ["prompt-a", "prompt-b"] {
2456 apply_observation(
2457 &mut session,
2458 RelayObservation::CommandQueued {
2459 command_id: command_id.into(),
2460 command: RelayCommand::Prompt {
2461 prompt: vec![agent_client_protocol::schema::v1::ContentBlock::from("go")],
2462 },
2463 created_at_ms: 10,
2464 },
2465 );
2466 accepted.push(session.applied_event_ordinal);
2467 }
2468 assert!(accepted[1] > accepted[0]);
2471
2472 for (index, command_id) in ["prompt-a", "prompt-b"].into_iter().enumerate() {
2473 apply_observation(
2474 &mut session,
2475 RelayObservation::CommandStarted {
2476 command_id: command_id.into(),
2477 started_at_ms: 20,
2478 },
2479 );
2480 apply_observation(
2481 &mut session,
2482 RelayObservation::CommandCompleted {
2483 command_id: command_id.into(),
2484 outcome: RelayCommandOutcome::Prompt {
2485 diagnostic: None,
2486 stop_reason: "EndTurn".into(),
2487 usage: None,
2488 },
2489 },
2490 );
2491 assert_eq!(
2492 session
2493 .last_turn_outcome
2494 .as_ref()
2495 .and_then(|outcome| outcome.accepted_ordinal),
2496 Some(accepted[index]),
2497 "{command_id} must report the ordinal its own submission returned"
2498 );
2499 }
2500 }
2501
2502 #[test]
2503 fn a_harness_turn_runs_the_session_and_marks_where_it_began() {
2504 let mut session = MaterializedSession::empty("session");
2505
2506 apply_observation(
2507 &mut session,
2508 RelayObservation::HarnessTurnStarted {
2509 started_at_ms: 4_200,
2510 },
2511 );
2512
2513 assert_eq!(
2514 session.execution,
2515 MaterializedExecutionState::Running {
2516 started_at_ms: 4_200
2517 }
2518 );
2519 let marker = session.transcript.last().expect("a marker item");
2520 assert_eq!(
2521 marker.stable_id,
2522 format!("{}1", crate::transcript::HARNESS_TURN_ITEM_PREFIX)
2523 );
2524 assert!(marker.is_turn_start());
2525 assert!(matches!(
2526 &marker.body,
2527 TranscriptBody::System { text } if text == crate::transcript::HARNESS_TURN_TEXT
2528 ));
2529
2530 apply_observation(
2531 &mut session,
2532 agent_chunk("picking this back up", "answer-1"),
2533 );
2534 assert!(
2535 session.transcript.iter().any(
2536 |item| matches!(&item.body, TranscriptBody::Agent { streaming, .. } if *streaming)
2537 ),
2538 "output inside the turn streams into a fresh item"
2539 );
2540
2541 apply_observation(
2542 &mut session,
2543 RelayObservation::HarnessTurnSettled {
2544 origin: Some("task-notification".into()),
2545 prompt_in_flight: false,
2546 },
2547 );
2548
2549 assert_eq!(session.execution, MaterializedExecutionState::Idle);
2550 assert!(
2551 !session.transcript.iter().any(|item| matches!(
2552 &item.body,
2553 TranscriptBody::Agent { streaming, .. } if *streaming
2554 )),
2555 "settling closes the streams a canonical export refuses to hold open"
2556 );
2557 assert_eq!(
2558 mj_core::state::latest_completed_turn_ordinal(&session),
2559 Some(1),
2560 "the finished turn is covered from the marker that began it"
2561 );
2562 assert_eq!(
2563 mj_core::state::ProjectionWindow::of(&session).latest_turn_start_position,
2564 Some(1)
2565 );
2566 }
2567
2568 #[test]
2569 fn a_turn_that_settles_under_an_in_flight_prompt_keeps_the_session_running() {
2570 let mut session = MaterializedSession::empty("session");
2571 apply_observation(
2572 &mut session,
2573 RelayObservation::HarnessTurnStarted {
2574 started_at_ms: 4_200,
2575 },
2576 );
2577 apply_observation(
2580 &mut session,
2581 RelayObservation::CommandQueued {
2582 command_id: "prompt-1".into(),
2583 command: RelayCommand::Prompt {
2584 prompt: vec![agent_client_protocol::schema::v1::ContentBlock::from("go")],
2585 },
2586 created_at_ms: 10,
2587 },
2588 );
2589 apply_observation(
2590 &mut session,
2591 RelayObservation::CommandStarted {
2592 command_id: "prompt-1".into(),
2593 started_at_ms: 20,
2594 },
2595 );
2596 apply_observation(&mut session, agent_chunk("still writing", "answer-1"));
2597
2598 apply_observation(
2599 &mut session,
2600 RelayObservation::HarnessTurnSettled {
2601 origin: Some("task-notification".into()),
2602 prompt_in_flight: true,
2603 },
2604 );
2605
2606 assert!(
2607 matches!(
2608 session.execution,
2609 MaterializedExecutionState::Running { .. }
2610 ),
2611 "the prompt is still running, so the session is not idle"
2612 );
2613 assert!(
2614 session.transcript.iter().any(
2615 |item| matches!(&item.body, TranscriptBody::Agent { streaming, .. } if *streaming)
2616 ),
2617 "the prompt's own answer keeps streaming into its item"
2618 );
2619
2620 apply_observation(
2621 &mut session,
2622 RelayObservation::CommandCompleted {
2623 command_id: "prompt-1".into(),
2624 outcome: RelayCommandOutcome::Prompt {
2625 diagnostic: None,
2626 stop_reason: "end_turn".into(),
2627 usage: None,
2628 },
2629 },
2630 );
2631
2632 assert_eq!(session.execution, MaterializedExecutionState::Idle);
2633 assert!(!session.transcript.iter().any(
2634 |item| matches!(&item.body, TranscriptBody::Agent { streaming, .. } if *streaming)
2635 ));
2636 }
2637
2638 #[test]
2639 fn finishing_an_acp_prompt_preserves_a_later_native_goal_stream() {
2640 let mut session = MaterializedSession::empty("session");
2641 apply_observation(
2642 &mut session,
2643 RelayObservation::HarnessTurnStarted {
2644 started_at_ms: 4200,
2645 },
2646 );
2647 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()) });
2648 apply_observation(&mut session, agent_chunk("autonomous work", "later-answer"));
2649 apply_observation(
2650 &mut session,
2651 RelayObservation::CommandCompleted {
2652 command_id: "initial-prompt".into(),
2653 outcome: RelayCommandOutcome::Prompt {
2654 diagnostic: None,
2655 stop_reason: "end_turn".into(),
2656 usage: None,
2657 },
2658 },
2659 );
2660 assert!(matches!(
2661 session.execution,
2662 MaterializedExecutionState::Running { .. }
2663 ));
2664 assert!(session.transcript.iter().any(|item| matches!(
2665 &item.body,
2666 TranscriptBody::Agent {
2667 streaming: true,
2668 ..
2669 }
2670 )));
2671 apply_observation(
2672 &mut session,
2673 RelayObservation::HarnessTurnSettled {
2674 origin: Some("codex".into()),
2675 prompt_in_flight: false,
2676 },
2677 );
2678 assert_eq!(session.execution, MaterializedExecutionState::Idle);
2679 }
2680
2681 #[test]
2682 fn a_restart_during_a_harness_turn_leaves_an_idle_session_with_no_open_streams() {
2683 let mut session = MaterializedSession::empty("session");
2684 apply_observation(
2685 &mut session,
2686 RelayObservation::HarnessTurnStarted {
2687 started_at_ms: 4_200,
2688 },
2689 );
2690 apply_observation(&mut session, agent_chunk("half a sentence", "answer-1"));
2691
2692 apply_observation(&mut session, RelayObservation::SessionRestarted);
2693
2694 assert_eq!(session.execution, MaterializedExecutionState::Idle);
2695 assert!(!session.transcript.iter().any(|item| matches!(
2696 &item.body,
2697 TranscriptBody::Agent { streaming, .. } | TranscriptBody::Thought { streaming, .. }
2698 if *streaming
2699 )));
2700 canonical_session_from_materialized(&session)
2701 .expect("a restarted session exports without open streams");
2702 }
2703
2704 #[test]
2705 fn a_plan_from_a_harness_turn_does_not_overwrite_the_previous_turns_plan() {
2706 let plan = |content: &str| RelayObservation::SessionUpdate {
2707 update: Box::new(SessionUpdate::Plan(
2708 agent_client_protocol::schema::v1::Plan::new(vec![
2709 agent_client_protocol::schema::v1::PlanEntry::new(
2710 content,
2711 agent_client_protocol::schema::v1::PlanEntryPriority::High,
2712 agent_client_protocol::schema::v1::PlanEntryStatus::InProgress,
2713 ),
2714 ]),
2715 )),
2716 };
2717 let mut session = MaterializedSession::empty("session");
2718 apply_observation(
2719 &mut session,
2720 RelayObservation::CommandQueued {
2721 command_id: "prompt-1".into(),
2722 command: RelayCommand::Prompt {
2723 prompt: vec![agent_client_protocol::schema::v1::ContentBlock::from("go")],
2724 },
2725 created_at_ms: 10,
2726 },
2727 );
2728 apply_observation(
2729 &mut session,
2730 RelayObservation::CommandStarted {
2731 command_id: "prompt-1".into(),
2732 started_at_ms: 20,
2733 },
2734 );
2735 apply_observation(&mut session, plan("first turn plan"));
2736 apply_observation(
2737 &mut session,
2738 RelayObservation::CommandCompleted {
2739 command_id: "prompt-1".into(),
2740 outcome: RelayCommandOutcome::Prompt {
2741 diagnostic: None,
2742 stop_reason: "end_turn".into(),
2743 usage: None,
2744 },
2745 },
2746 );
2747
2748 apply_observation(
2749 &mut session,
2750 RelayObservation::HarnessTurnStarted { started_at_ms: 30 },
2751 );
2752 apply_observation(&mut session, plan("second turn plan"));
2753
2754 let plans: Vec<&TranscriptItem> = session
2755 .transcript
2756 .iter()
2757 .filter(|item| matches!(item.body, TranscriptBody::Plan { .. }))
2758 .map(std::convert::AsRef::as_ref)
2759 .collect();
2760 assert_eq!(
2761 plans.len(),
2762 2,
2763 "the self-started turn keeps its own plan instead of rewriting the last one"
2764 );
2765 }
2766
2767 #[test]
2768 fn session_restarts_project_as_distinct_durable_system_lines() {
2769 let mut session = MaterializedSession::empty("session");
2770 apply_observation(&mut session, RelayObservation::SessionRestarted);
2771 apply_observation(&mut session, RelayObservation::SessionRestarted);
2772
2773 assert_eq!(session.transcript.len(), 2);
2774 assert!(
2775 session
2776 .transcript
2777 .iter()
2778 .all(|item| item.is_session_restart())
2779 );
2780 assert_eq!(session.unread_session_restarts_after(0), 2);
2781 assert!(session.transcript.iter().all(|item| matches!(
2782 &item.body,
2783 TranscriptBody::System { text }
2784 if text == crate::transcript::SESSION_RESTART_TEXT
2785 )));
2786 assert_ne!(
2787 session.transcript[0].stable_id,
2788 session.transcript[1].stable_id
2789 );
2790
2791 let canonical = canonical_session_from_materialized(&session).unwrap();
2792 let restored = materialized_session_from_canonical("session", &canonical).unwrap();
2793 assert_eq!(restored.unread_session_restarts_after(0), 2);
2794 assert!(
2795 restored
2796 .transcript
2797 .iter()
2798 .all(|item| item.is_session_restart())
2799 );
2800 }
2801
2802 #[test]
2803 fn shell_output_updates_one_durable_transcript_item() {
2804 let mut session = MaterializedSession::empty("session-1");
2805 apply_observation(
2806 &mut session,
2807 RelayObservation::CommandQueued {
2808 command_id: "shell-1".into(),
2809 command: RelayCommand::RunUserShell {
2810 command: "cargo test".into(),
2811 },
2812 created_at_ms: 100,
2813 },
2814 );
2815 apply_observation(
2816 &mut session,
2817 RelayObservation::CommandStarted {
2818 command_id: "shell-1".into(),
2819 started_at_ms: 200,
2820 },
2821 );
2822 apply_observation(
2823 &mut session,
2824 RelayObservation::UserShellOutput {
2825 command_id: "shell-1".into(),
2826 command: "cargo test".into(),
2827 stdout: "running tests".into(),
2828 stderr: String::new(),
2829 stdout_truncated: false,
2830 stderr_truncated: false,
2831 },
2832 );
2833 assert_eq!(session.transcript.len(), 1);
2834 assert!(matches!(
2835 &session.transcript[0].body,
2836 TranscriptBody::System { text }
2837 if text.contains("Shell · running") && text.contains("running tests")
2838 ));
2839
2840 apply_observation(
2841 &mut session,
2842 RelayObservation::CommandCompleted {
2843 command_id: "shell-1".into(),
2844 outcome: RelayCommandOutcome::UserShell {
2845 result: UserShellResult {
2846 command: "cargo test".into(),
2847 stdout: "all green".into(),
2848 stderr: String::new(),
2849 stdout_truncated: false,
2850 stderr_truncated: false,
2851 exit_code: Some(0),
2852 signal: None,
2853 duration_ms: 321,
2854 status: UserShellStatus::Exited,
2855 error: None,
2856 },
2857 },
2858 },
2859 );
2860 assert_eq!(session.transcript.len(), 1);
2861 assert_eq!(session.transcript[0].stable_id, "shell:shell-1");
2862 assert!(matches!(
2863 &session.transcript[0].body,
2864 TranscriptBody::System { text }
2865 if text.contains("Shell · done · 321 ms") && text.contains("all green")
2866 ));
2867 }
2868
2869 #[test]
2870 fn elicitation_projection_keeps_only_pending_request_metadata() {
2871 let mut session = MaterializedSession::empty("session-1");
2872 let request = mj_core::elicitation::ElicitationRequest {
2873 id: "elicitation-1".into(),
2874 message: "Choose one".into(),
2875 title: None,
2876 description: None,
2877 fields: Vec::new(),
2878 };
2879 apply_observation(
2880 &mut session,
2881 RelayObservation::ElicitationRequested {
2882 request: request.clone(),
2883 },
2884 );
2885 assert_eq!(session.pending_elicitations, vec![request]);
2886
2887 apply_observation(
2888 &mut session,
2889 RelayObservation::ElicitationResolved {
2890 elicitation_id: "elicitation-1".into(),
2891 action: "accept".into(),
2892 },
2893 );
2894 assert!(session.pending_elicitations.is_empty());
2895 assert!(session.transcript.is_empty());
2896 }
2897
2898 #[test]
2899 fn a_plan_decision_also_becomes_a_durable_proposal_item() {
2900 let mut session = MaterializedSession::empty("session-1");
2901 let plan = "1. Read the code\n2. Change it";
2902 let request = mj_core::acp::normalized_plan_review(
2903 "plan-review-3".into(),
2904 &serde_json::json!({ "plan": plan }),
2905 );
2906 apply_observation(
2907 &mut session,
2908 RelayObservation::ElicitationRequested {
2909 request: request.clone(),
2910 },
2911 );
2912
2913 assert_eq!(session.pending_elicitations, vec![request]);
2914 assert_eq!(session.transcript.len(), 1);
2915 let item = &session.transcript[0];
2916 assert_eq!(item.stable_id, plan_proposal_item_id(1));
2917 assert_eq!(item.position, 1);
2918 assert_eq!(
2919 item.body,
2920 TranscriptBody::PlanProposal {
2921 proposal_id: "plan-review-3".into(),
2922 plan: plan.into(),
2923 }
2924 );
2925
2926 apply_observation(
2928 &mut session,
2929 RelayObservation::ElicitationResolved {
2930 elicitation_id: "plan-review-3".into(),
2931 action: "accept".into(),
2932 },
2933 );
2934 assert!(session.pending_elicitations.is_empty());
2935 assert_eq!(session.transcript.len(), 1);
2936 }
2937
2938 #[test]
2939 fn a_captured_proposal_keeps_its_place_after_the_conversation_that_produced_it() {
2940 let mut session = MaterializedSession::empty("session-1");
2941 apply_observation(&mut session, untagged_agent_chunk("here is my plan"));
2942 apply_observation(
2943 &mut session,
2944 RelayObservation::ElicitationRequested {
2945 request: mj_core::acp::normalized_plan_review(
2946 "plan-review-1".into(),
2947 &serde_json::json!({ "plan": "do the work" }),
2948 ),
2949 },
2950 );
2951 apply_observation(&mut session, untagged_agent_chunk("starting now"));
2952
2953 let bodies = session
2954 .transcript
2955 .iter()
2956 .map(|item| match &item.body {
2957 TranscriptBody::Agent { .. } => "agent",
2958 TranscriptBody::PlanProposal { .. } => "proposal",
2959 _ => "other",
2960 })
2961 .collect::<Vec<_>>();
2962 assert_eq!(bodies, vec!["agent", "proposal", "agent"]);
2963 }
2964
2965 fn untagged_agent_chunk(text: &str) -> RelayObservation {
2967 RelayObservation::SessionUpdate {
2968 update: Box::new(SessionUpdate::AgentMessageChunk(
2969 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
2970 TextContent::new(text),
2971 )),
2972 )),
2973 }
2974 }
2975
2976 fn untagged_thought_chunk(text: &str) -> RelayObservation {
2978 RelayObservation::SessionUpdate {
2979 update: Box::new(SessionUpdate::AgentThoughtChunk(
2980 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
2981 TextContent::new(text),
2982 )),
2983 )),
2984 }
2985 }
2986
2987 #[test]
2988 fn streamed_chunks_are_one_unread_logical_agent_message() {
2989 let mut session = MaterializedSession::empty("session-1");
2990 apply_observation(
2991 &mut session,
2992 RelayObservation::SessionUpdate {
2993 update: Box::new(SessionUpdate::AgentMessageChunk(
2994 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
2995 TextContent::new("hel"),
2996 ))
2997 .message_id("answer-1"),
2998 )),
2999 },
3000 );
3001 assert_eq!(session.transcript[0].latest_content_event_ordinal, Some(1));
3002 assert_eq!(session.unread_agent_messages_after(0), 1);
3003 assert_eq!(session.unread_agent_messages_after(1), 0);
3004
3005 apply_observation(
3006 &mut session,
3007 RelayObservation::SessionUpdate {
3008 update: Box::new(SessionUpdate::AgentMessageChunk(
3009 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
3010 TextContent::new("lo"),
3011 ))
3012 .message_id("answer-1"),
3013 )),
3014 },
3015 );
3016 assert_eq!(session.unread_agent_messages_after(0), 1);
3017 assert_eq!(session.unread_agent_messages_after(1), 1);
3018 assert!(matches!(
3019 &session.transcript[0].body,
3020 TranscriptBody::Agent { chunks, .. }
3021 if crate::transcript::materialized_chunks_text(chunks) == "hello"
3022 ));
3023 assert_eq!(session.transcript[0].position, 1);
3024 assert_eq!(session.transcript[0].latest_content_event_ordinal, Some(2));
3025
3026 apply_observation(
3027 &mut session,
3028 RelayObservation::CommandCompleted {
3029 command_id: "prompt-1".into(),
3030 outcome: RelayCommandOutcome::Prompt {
3031 diagnostic: None,
3032 stop_reason: "end_turn".into(),
3033 usage: None,
3034 },
3035 },
3036 );
3037 assert_eq!(session.transcript[0].latest_content_event_ordinal, Some(2));
3038 assert_eq!(session.unread_agent_messages_after(2), 0);
3039 }
3040
3041 #[test]
3042 fn agent_chunk_while_idle_is_recorded_closed() {
3043 let mut session = MaterializedSession::empty("session-1");
3044 session.execution = MaterializedExecutionState::Idle;
3045 apply_observation(
3046 &mut session,
3047 RelayObservation::SessionUpdate {
3048 update: Box::new(SessionUpdate::AgentMessageChunk(
3049 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
3050 TextContent::new("trailing"),
3051 ))
3052 .message_id("msg-1"),
3053 )),
3054 },
3055 );
3056 let item = session
3057 .transcript
3058 .iter()
3059 .find(|item| item.stable_id == "agent:msg-1")
3060 .expect("trailing chunk recorded");
3061 assert!(matches!(
3062 &item.body,
3063 TranscriptBody::Agent { chunks, streaming }
3064 if !*streaming
3065 && crate::transcript::materialized_chunks_text(chunks) == "trailing"
3066 ));
3067 }
3068
3069 #[test]
3070 fn thought_chunk_while_idle_is_recorded_closed() {
3071 let mut session = MaterializedSession::empty("session-1");
3072 session.execution = MaterializedExecutionState::Idle;
3073 apply_observation(
3074 &mut session,
3075 RelayObservation::SessionUpdate {
3076 update: Box::new(SessionUpdate::AgentThoughtChunk(
3077 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
3078 TextContent::new("late thought"),
3079 ))
3080 .message_id("msg-1"),
3081 )),
3082 },
3083 );
3084 let item = session
3085 .transcript
3086 .iter()
3087 .find(|item| item.stable_id == "thought:msg-1")
3088 .expect("trailing thought recorded");
3089 assert!(matches!(
3090 &item.body,
3091 TranscriptBody::Thought { streaming, .. } if !*streaming
3092 ));
3093 }
3094
3095 #[test]
3096 fn agent_chunk_while_running_still_streams() {
3097 let mut session = MaterializedSession::empty("session-1");
3098 session.execution = MaterializedExecutionState::Running { started_at_ms: 1 };
3099 apply_observation(
3100 &mut session,
3101 RelayObservation::SessionUpdate {
3102 update: Box::new(SessionUpdate::AgentMessageChunk(
3103 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
3104 TextContent::new("live"),
3105 ))
3106 .message_id("msg-1"),
3107 )),
3108 },
3109 );
3110 let item = session
3111 .transcript
3112 .iter()
3113 .find(|item| item.stable_id == "agent:msg-1")
3114 .expect("live chunk recorded");
3115 assert!(matches!(
3116 &item.body,
3117 TranscriptBody::Agent { chunks, streaming }
3118 if *streaming && crate::transcript::materialized_chunks_text(chunks) == "live"
3119 ));
3120 }
3121
3122 #[test]
3123 fn idle_untagged_agent_chunks_coalesce_into_one_closed_item() {
3124 let mut session = MaterializedSession::empty("session-1");
3125 session.execution = MaterializedExecutionState::Idle;
3126 for word in ["Grok ", "streams ", "one ", "word ", "at ", "a ", "time"] {
3127 apply_observation(&mut session, untagged_agent_chunk(word));
3128 }
3129 assert_eq!(session.transcript.len(), 1);
3130 let item = &session.transcript[0];
3131 assert!(matches!(
3132 &item.body,
3133 TranscriptBody::Agent { chunks, streaming }
3134 if !*streaming
3135 && crate::transcript::materialized_chunks_text(chunks)
3136 == "Grok streams one word at a time"
3137 ));
3138 }
3139
3140 #[test]
3141 fn idle_untagged_thought_chunks_coalesce_into_one_closed_item() {
3142 let mut session = MaterializedSession::empty("session-1");
3143 session.execution = MaterializedExecutionState::Idle;
3144 for word in ["thinking ", "in ", "small ", "pieces"] {
3145 apply_observation(&mut session, untagged_thought_chunk(word));
3146 }
3147 assert_eq!(session.transcript.len(), 1);
3148 let item = &session.transcript[0];
3149 assert!(matches!(
3150 &item.body,
3151 TranscriptBody::Thought { chunks, streaming }
3152 if !*streaming
3153 && crate::transcript::materialized_chunks_text(chunks)
3154 == "thinking in small pieces"
3155 ));
3156 }
3157
3158 #[test]
3159 fn idle_untagged_thought_then_agent_chunks_split_into_two_items() {
3160 let mut session = MaterializedSession::empty("session-1");
3161 session.execution = MaterializedExecutionState::Idle;
3162 apply_observation(&mut session, untagged_thought_chunk("pondering "));
3163 apply_observation(&mut session, untagged_thought_chunk("the goal"));
3164 apply_observation(&mut session, untagged_agent_chunk("here's "));
3165 apply_observation(&mut session, untagged_agent_chunk("the plan"));
3166
3167 assert_eq!(session.transcript.len(), 2);
3168 assert!(matches!(
3169 &session.transcript[0].body,
3170 TranscriptBody::Thought { chunks, streaming }
3171 if !*streaming
3172 && crate::transcript::materialized_chunks_text(chunks) == "pondering the goal"
3173 ));
3174 assert!(matches!(
3175 &session.transcript[1].body,
3176 TranscriptBody::Agent { chunks, streaming }
3177 if !*streaming
3178 && crate::transcript::materialized_chunks_text(chunks) == "here's the plan"
3179 ));
3180 }
3181
3182 #[test]
3183 fn idle_untagged_agent_chunks_split_around_an_intervening_tool_call() {
3184 let mut session = MaterializedSession::empty("session-1");
3185 session.execution = MaterializedExecutionState::Idle;
3186 apply_observation(&mut session, untagged_agent_chunk("checking "));
3187 apply_observation(&mut session, untagged_agent_chunk("the repo"));
3188 apply_observation(
3189 &mut session,
3190 RelayObservation::SessionUpdate {
3191 update: Box::new(SessionUpdate::ToolCall(ToolCall::new("call-1", "grep"))),
3192 },
3193 );
3194 apply_observation(&mut session, untagged_agent_chunk("found "));
3195 apply_observation(&mut session, untagged_agent_chunk("it"));
3196
3197 let agent_items: Vec<&TranscriptItem> = session
3198 .transcript
3199 .iter()
3200 .filter(|item| matches!(item.body, TranscriptBody::Agent { .. }))
3201 .map(|item| item.as_ref())
3202 .collect();
3203 assert_eq!(agent_items.len(), 2, "transcript: {:?}", session.transcript);
3204 assert!(matches!(
3205 &agent_items[0].body,
3206 TranscriptBody::Agent { chunks, streaming }
3207 if !*streaming
3208 && crate::transcript::materialized_chunks_text(chunks) == "checking the repo"
3209 ));
3210 assert!(matches!(
3211 &agent_items[1].body,
3212 TranscriptBody::Agent { chunks, streaming }
3213 if !*streaming
3214 && crate::transcript::materialized_chunks_text(chunks) == "found it"
3215 ));
3216 assert!(
3217 session
3218 .transcript
3219 .iter()
3220 .any(|item| matches!(&item.body, TranscriptBody::Tool { .. })),
3221 "the tool call item survives between the two agent items"
3222 );
3223 }
3224
3225 #[test]
3226 fn running_untagged_agent_chunks_still_merge_into_one_open_stream() {
3227 let mut session = MaterializedSession::empty("session-1");
3228 session.execution = MaterializedExecutionState::Running { started_at_ms: 1 };
3229 for word in ["live ", "streaming ", "text"] {
3230 apply_observation(&mut session, untagged_agent_chunk(word));
3231 }
3232 assert_eq!(session.transcript.len(), 1);
3233 let item = &session.transcript[0];
3234 assert!(matches!(
3235 &item.body,
3236 TranscriptBody::Agent { chunks, streaming }
3237 if *streaming
3238 && crate::transcript::materialized_chunks_text(chunks) == "live streaming text"
3239 ));
3240 }
3241
3242 #[test]
3243 fn backward_relay_clock_never_regresses_transcript_change_times() {
3244 let mut session = MaterializedSession::empty("session-1");
3245 let mut first = event(
3246 &session,
3247 RelayObservation::SessionUpdate {
3248 update: Box::new(SessionUpdate::AgentMessageChunk(
3249 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
3250 TextContent::new("first"),
3251 ))
3252 .message_id("answer-1"),
3253 )),
3254 },
3255 );
3256 first.recorded_at_ms = 1_000;
3257 first.digest = relay_event_digest(&first).unwrap();
3258 apply(&mut session, first);
3259
3260 let mut backward = event(
3261 &session,
3262 RelayObservation::SessionUpdate {
3263 update: Box::new(SessionUpdate::AgentMessageChunk(
3264 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
3265 TextContent::new(" second"),
3266 ))
3267 .message_id("answer-1"),
3268 )),
3269 },
3270 );
3271 backward.recorded_at_ms = 500;
3272 backward.digest = relay_event_digest(&backward).unwrap();
3273 apply(&mut session, backward);
3274 assert_eq!(session.transcript[0].last_changed_at_ms, 1_000);
3275
3276 let mut completion = event(
3277 &session,
3278 RelayObservation::CommandCompleted {
3279 command_id: "prompt-1".into(),
3280 outcome: RelayCommandOutcome::Prompt {
3281 diagnostic: None,
3282 stop_reason: "end_turn".into(),
3283 usage: None,
3284 },
3285 },
3286 );
3287 completion.recorded_at_ms = 250;
3288 completion.digest = relay_event_digest(&completion).unwrap();
3289 apply(&mut session, completion);
3290 assert_eq!(session.transcript[0].last_changed_at_ms, 1_000);
3291 assert_eq!(session.last_activity_at_ms(), Some(1_000));
3292 }
3293
3294 #[test]
3295 fn tool_update_without_an_initial_call_is_ignored_and_advances_the_frontier() {
3296 let mut session = MaterializedSession::empty("session-1");
3297 let update = SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3298 "missing-tool",
3299 ToolCallUpdateFields::new().title("updated"),
3300 ));
3301 let relay_event = event(
3302 &session,
3303 RelayObservation::SessionUpdate {
3304 update: Box::new(update),
3305 },
3306 );
3307
3308 let projected = project_relay_event(&session, &relay_event)
3309 .expect("a delayed pre-resume tool update is an observable no-op");
3310 apply_committed_projection_event(&mut session, &relay_event, projected.mutation)
3311 .expect("the no-op still advances the committed relay frontier");
3312
3313 assert!(session.transcript.is_empty());
3314 assert_eq!(session.applied_event_ordinal, 1);
3315 }
3316
3317 #[test]
3318 fn metadata_only_tool_update_without_an_initial_call_is_ignored() {
3319 let mut session = MaterializedSession::empty("session-1");
3320 let update = SessionUpdate::ToolCallUpdate(
3321 ToolCallUpdate::new("pre-resume-tool", ToolCallUpdateFields::new()).meta(
3322 serde_json::Map::from_iter([(
3323 "terminal_output_delta".into(),
3324 json!({"data": "late output"}),
3325 )]),
3326 ),
3327 );
3328 let relay_event = event(
3329 &session,
3330 RelayObservation::SessionUpdate {
3331 update: Box::new(update),
3332 },
3333 );
3334
3335 let projected = project_relay_event(&session, &relay_event)
3336 .expect("private metadata cannot change the transcript projection");
3337 apply_committed_projection_event(&mut session, &relay_event, projected.mutation)
3338 .expect("the no-op still advances the committed relay frontier");
3339
3340 assert!(session.transcript.is_empty());
3341 assert_eq!(session.applied_event_ordinal, 1);
3342 }
3343
3344 #[test]
3345 fn resent_tool_call_keeps_identity_and_replaces_the_call_payload() {
3346 let mut session = MaterializedSession::empty("session-1");
3347 apply_observation(
3348 &mut session,
3349 RelayObservation::SessionUpdate {
3350 update: Box::new(SessionUpdate::ToolCall(ToolCall::new(
3351 "call-1",
3352 "read file",
3353 ))),
3354 },
3355 );
3356 let created = TranscriptItem::clone(&session.transcript[0]);
3357 assert_eq!(created.position, 1);
3358 assert_eq!(created.created_at_ms, 100);
3359
3360 let resend = event(
3361 &session,
3362 RelayObservation::SessionUpdate {
3363 update: Box::new(SessionUpdate::ToolCall(
3364 ToolCall::new("call-1", "read file again")
3365 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed),
3366 )),
3367 },
3368 );
3369 let projected = project_relay_event(&session, &resend).unwrap();
3370 let TranscriptMutation::Upsert(item) = projected
3371 .mutation
3372 .transcript
3373 .iter()
3374 .find(|mutation| {
3375 matches!(mutation, TranscriptMutation::Upsert(item) if item.stable_id == "tool:call-1")
3376 })
3377 .expect("the re-sent tool call upserts its existing item")
3378 .clone()
3379 else {
3380 unreachable!("matched an upsert above");
3381 };
3382 assert_eq!(item.position, created.position);
3383 assert_eq!(item.created_at_ms, created.created_at_ms);
3384 assert_eq!(item.last_changed_at_ms, resend.recorded_at_ms);
3385 assert_eq!(
3386 item.latest_content_event_ordinal,
3387 created.latest_content_event_ordinal
3388 );
3389 let TranscriptBody::Tool { call, .. } = &item.body else {
3390 panic!("re-sent tool call stayed a tool item");
3391 };
3392 assert_eq!(call["title"], json!("read file again"));
3393
3394 apply_committed_projection_event(&mut session, &resend, projected.mutation)
3395 .expect("the merged item passes the projection integrity checks");
3396 assert_eq!(session.transcript.len(), 1);
3397 assert_eq!(session.transcript[0].position, created.position);
3398 }
3399
3400 #[test]
3401 fn tool_call_update_then_resent_tool_call_survives_the_projection() {
3402 let mut session = MaterializedSession::empty("session-1");
3403 apply_observation(
3404 &mut session,
3405 RelayObservation::SessionUpdate {
3406 update: Box::new(SessionUpdate::ToolCall(ToolCall::new("call-1", "shell"))),
3407 },
3408 );
3409 apply_observation(
3410 &mut session,
3411 RelayObservation::SessionUpdate {
3412 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3413 "call-1",
3414 ToolCallUpdateFields::new()
3415 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed),
3416 ))),
3417 },
3418 );
3419 apply_observation(
3420 &mut session,
3421 RelayObservation::SessionUpdate {
3422 update: Box::new(SessionUpdate::ToolCall(ToolCall::new(
3423 "call-1",
3424 "shell (retried)",
3425 ))),
3426 },
3427 );
3428
3429 assert_eq!(session.transcript.len(), 1);
3430 let item = &session.transcript[0];
3431 assert_eq!(item.position, 1);
3432 assert_eq!(item.created_at_ms, 100);
3433 assert_eq!(item.last_changed_at_ms, 300);
3434 let TranscriptBody::Tool { call, .. } = &item.body else {
3435 panic!("the item stayed a tool item");
3436 };
3437 assert_eq!(call["title"], json!("shell (retried)"));
3438 }
3439
3440 fn terminal_tool_call(call_id: &'static str, terminal_id: &'static str) -> RelayObservation {
3443 RelayObservation::SessionUpdate {
3444 update: Box::new(SessionUpdate::ToolCall(
3445 ToolCall::new(call_id, "shell").content(vec![ToolCallContent::Terminal(
3446 agent_client_protocol::schema::v1::Terminal::new(terminal_id),
3447 )]),
3448 )),
3449 }
3450 }
3451
3452 fn terminal_output(terminal_id: &str) -> RelayObservation {
3453 RelayObservation::TerminalOutput {
3454 terminal_id: terminal_id.into(),
3455 output: "build finished\n".into(),
3456 truncated: false,
3457 exit_code: Some(0),
3458 signal: None,
3459 }
3460 }
3461
3462 fn fallback_terminal_tool(terminal_id: &str, command: &str) -> RelayObservation {
3463 RelayObservation::SessionUpdate {
3464 update: Box::new(SessionUpdate::ToolCall(
3465 mj_core::acp::fallback_terminal_tool_call(terminal_id, command.into()),
3466 )),
3467 }
3468 }
3469
3470 fn attached_terminal_outputs(item: &TranscriptItem) -> &[TerminalOutputRecord] {
3471 let TranscriptBody::Tool {
3472 terminal_outputs, ..
3473 } = &item.body
3474 else {
3475 panic!("expected a tool item, got {:?}", item.body);
3476 };
3477 terminal_outputs
3478 }
3479
3480 #[test]
3481 fn fallback_terminal_tool_completes_in_place_instead_of_parking_output() {
3482 let mut session = MaterializedSession::empty("session-1");
3483 apply_observation(&mut session, fallback_terminal_tool("term-1", "cargo test"));
3484 apply_observation(&mut session, terminal_output("term-1"));
3485
3486 assert_eq!(session.transcript.len(), 1);
3487 let item = &session.transcript[0];
3488 assert_eq!(item.stable_id, "tool:hel-terminal:term-1");
3489 assert_eq!(item.position, 1, "the terminal retains its start order");
3490 assert_eq!(attached_terminal_outputs(item).len(), 1);
3491 let TranscriptBody::Tool { call, .. } = &item.body else {
3492 panic!("the fallback stays a tool");
3493 };
3494 let call: ToolCall = serde_json::from_value(call.clone()).unwrap();
3495 assert_eq!(call.status, ToolCallStatus::Completed);
3496 }
3497
3498 #[test]
3499 fn real_tool_call_replaces_fallback_and_keeps_its_start_order() {
3500 let mut session = MaterializedSession::empty("session-1");
3501 apply_observation(&mut session, fallback_terminal_tool("term-1", "cargo test"));
3502 apply_observation(&mut session, terminal_tool_call("call-1", "term-1"));
3503 apply_observation(&mut session, terminal_output("term-1"));
3504
3505 assert_eq!(session.transcript.len(), 1, "the fallback was consumed");
3506 let item = &session.transcript[0];
3507 assert_eq!(item.stable_id, "tool:call-1");
3508 assert_eq!(item.position, 1);
3509 assert_eq!(attached_terminal_outputs(item).len(), 1);
3510 }
3511
3512 #[test]
3513 fn fallback_is_suppressed_when_real_tool_already_claims_terminal() {
3514 let mut session = MaterializedSession::empty("session-1");
3515 apply_observation(&mut session, terminal_tool_call("call-1", "term-1"));
3516 apply_observation(&mut session, fallback_terminal_tool("term-1", "cargo test"));
3517 apply_observation(&mut session, terminal_output("term-1"));
3518
3519 assert_eq!(session.transcript.len(), 1);
3520 assert_eq!(session.transcript[0].stable_id, "tool:call-1");
3521 assert_eq!(attached_terminal_outputs(&session.transcript[0]).len(), 1);
3522 }
3523
3524 fn kimi_raw_tool_update(call_id: &'static str, output: &'static str) -> RelayObservation {
3525 RelayObservation::SessionUpdate {
3526 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3527 call_id,
3528 ToolCallUpdateFields::new()
3529 .status(ToolCallStatus::Completed)
3530 .raw_output(json!({
3531 "type": "Bash",
3532 "output": output.as_bytes(),
3533 "exit_code": 0,
3534 "command": "cargo test"
3535 })),
3536 ))),
3537 }
3538 }
3539
3540 #[test]
3541 fn raw_result_before_terminal_close_claims_the_fallback() {
3542 let mut session = MaterializedSession::empty("session-1");
3543 apply_observation(&mut session, fallback_terminal_tool("term-1", "cargo test"));
3544 apply_observation(
3545 &mut session,
3546 RelayObservation::SessionUpdate {
3547 update: Box::new(SessionUpdate::ToolCall(ToolCall::new(
3548 "call-1",
3549 "Execute `cargo test`",
3550 ))),
3551 },
3552 );
3553 apply_observation(
3554 &mut session,
3555 kimi_raw_tool_update("call-1", "build finished\n"),
3556 );
3557 apply_observation(&mut session, terminal_output("term-1"));
3558
3559 assert_eq!(session.transcript.len(), 1);
3560 assert_eq!(session.transcript[0].stable_id, "tool:call-1");
3561 assert_eq!(session.transcript[0].position, 2);
3562 assert_eq!(attached_terminal_outputs(&session.transcript[0]).len(), 1);
3563 }
3564
3565 #[test]
3566 fn raw_result_after_terminal_close_claims_the_fallback() {
3567 let mut session = MaterializedSession::empty("session-1");
3568 apply_observation(&mut session, fallback_terminal_tool("term-1", "cargo test"));
3569 apply_observation(
3570 &mut session,
3571 RelayObservation::SessionUpdate {
3572 update: Box::new(SessionUpdate::ToolCall(ToolCall::new(
3573 "call-1",
3574 "Execute `cargo test`",
3575 ))),
3576 },
3577 );
3578 apply_observation(&mut session, terminal_output("term-1"));
3579 apply_observation(
3580 &mut session,
3581 kimi_raw_tool_update("call-1", "build finished\n"),
3582 );
3583
3584 assert_eq!(session.transcript.len(), 1);
3585 assert_eq!(session.transcript[0].stable_id, "tool:call-1");
3586 assert_eq!(session.transcript[0].position, 2);
3587 assert_eq!(attached_terminal_outputs(&session.transcript[0]).len(), 1);
3588 }
3589
3590 #[test]
3591 fn late_fallback_claims_output_from_a_fast_terminal() {
3592 let mut session = MaterializedSession::empty("session-1");
3593 apply_observation(&mut session, terminal_output("term-1"));
3594 apply_observation(&mut session, fallback_terminal_tool("term-1", "true"));
3595
3596 assert_eq!(session.transcript.len(), 1);
3597 assert_eq!(session.transcript[0].stable_id, "tool:hel-terminal:term-1");
3598 let TranscriptBody::Tool { call, .. } = &session.transcript[0].body else {
3599 panic!("the parked output became a fallback tool");
3600 };
3601 let call: ToolCall = serde_json::from_value(call.clone()).unwrap();
3602 assert_eq!(call.status, ToolCallStatus::Completed);
3603 }
3604
3605 #[test]
3606 fn terminal_output_after_the_tool_call_attaches_to_the_tool_item() {
3607 let mut session = MaterializedSession::empty("session-1");
3608 apply_observation(&mut session, terminal_tool_call("call-1", "term-1"));
3609 apply_observation(&mut session, terminal_output("term-1"));
3610
3611 assert_eq!(session.transcript.len(), 1, "no standalone item is left");
3612 let outputs = attached_terminal_outputs(&session.transcript[0]);
3613 assert_eq!(outputs.len(), 1);
3614 assert_eq!(outputs[0].terminal_id, "term-1");
3615 assert_eq!(outputs[0].output, "build finished\n");
3616 assert_eq!(outputs[0].exit_code, Some(0));
3617 assert_eq!(session.transcript[0].last_changed_at_ms, 200);
3618 }
3619
3620 #[test]
3621 fn indexed_page_projection_tracks_terminal_and_tool_replacements() {
3622 let mut session = MaterializedSession::empty("session-1");
3623 let mut index = ProjectionIndex::new(&session);
3624 apply_indexed_observation(&mut session, &mut index, terminal_output("term-1"));
3625 apply_indexed_observation(
3626 &mut session,
3627 &mut index,
3628 terminal_tool_call("call-1", "term-1"),
3629 );
3630 apply_indexed_observation(&mut session, &mut index, terminal_output("term-1"));
3631
3632 assert_eq!(session.transcript.len(), 1, "parked output was consumed");
3633 assert_eq!(session.transcript[0].stable_id, "tool:call-1");
3634 let outputs = attached_terminal_outputs(&session.transcript[0]);
3635 assert_eq!(outputs.len(), 1);
3636 assert_eq!(outputs[0].terminal_id, "term-1");
3637 }
3638
3639 #[test]
3640 fn terminal_output_before_the_tool_call_attaches_when_the_call_arrives() {
3641 let mut session = MaterializedSession::empty("session-1");
3642 apply_observation(&mut session, terminal_output("term-1"));
3643
3644 assert_eq!(session.transcript.len(), 1);
3647 assert_eq!(session.transcript[0].stable_id, "terminal:term-1");
3648 assert!(matches!(
3649 &session.transcript[0].body,
3650 TranscriptBody::TerminalOutput { record } if record.terminal_id == "term-1"
3651 ));
3652
3653 apply_observation(&mut session, terminal_tool_call("call-1", "term-1"));
3654
3655 assert_eq!(
3656 session.transcript.len(),
3657 1,
3658 "the tool call consumes the parked item: {:?}",
3659 session.transcript
3660 );
3661 assert_eq!(session.transcript[0].stable_id, "tool:call-1");
3662 let outputs = attached_terminal_outputs(&session.transcript[0]);
3663 assert_eq!(outputs.len(), 1);
3664 assert_eq!(outputs[0].output, "build finished\n");
3665
3666 let mut reversed = MaterializedSession::empty("session-1");
3668 apply_observation(&mut reversed, terminal_tool_call("call-1", "term-1"));
3669 apply_observation(&mut reversed, terminal_output("term-1"));
3670 assert_eq!(
3671 attached_terminal_outputs(&reversed.transcript[0]),
3672 outputs,
3673 "output arriving before or after the call must read the same"
3674 );
3675 }
3676
3677 #[test]
3678 fn kimi_raw_result_claims_its_unreferenced_terminal_output() {
3679 const OUTPUT: &str = "toolchain inventory\n";
3680 let mut session = MaterializedSession::empty("session-1");
3681 apply_observation(
3682 &mut session,
3683 RelayObservation::SessionUpdate {
3684 update: Box::new(SessionUpdate::ToolCall(ToolCall::new(
3685 "call-1",
3686 "Execute `inspect toolchain`",
3687 ))),
3688 },
3689 );
3690 apply_observation(
3691 &mut session,
3692 RelayObservation::TerminalOutput {
3693 terminal_id: "term-1".into(),
3694 output: OUTPUT.into(),
3695 truncated: false,
3696 exit_code: Some(1),
3697 signal: None,
3698 },
3699 );
3700 apply_observation(
3701 &mut session,
3702 RelayObservation::SessionUpdate {
3703 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3704 "call-1",
3705 ToolCallUpdateFields::new()
3706 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed)
3707 .content(vec![ToolCallContent::from(ContentBlock::Text(
3708 TextContent::new(OUTPUT),
3709 ))])
3710 .raw_output(json!({
3711 "type": "Bash",
3712 "output": OUTPUT.as_bytes(),
3713 "exit_code": 1,
3714 "command": "inspect toolchain"
3715 })),
3716 ))),
3717 },
3718 );
3719
3720 assert_eq!(
3721 session.transcript.len(),
3722 1,
3723 "the completed tool consumes the duplicate standalone item"
3724 );
3725 let TranscriptBody::Tool {
3726 terminal_outputs,
3727 terminal_refs,
3728 ..
3729 } = &session.transcript[0].body
3730 else {
3731 panic!("the surviving item is the tool call");
3732 };
3733 assert_eq!(terminal_refs, &["term-1"]);
3734 assert_eq!(terminal_outputs.len(), 1);
3735 assert_eq!(terminal_outputs[0].output, OUTPUT);
3736 assert_eq!(terminal_outputs[0].exit_code, Some(1));
3737 }
3738
3739 #[test]
3740 fn mismatched_raw_result_does_not_hide_a_genuine_orphan_failure() {
3741 let mut session = MaterializedSession::empty("session-1");
3742 apply_observation(
3743 &mut session,
3744 RelayObservation::SessionUpdate {
3745 update: Box::new(SessionUpdate::ToolCall(ToolCall::new("call-1", "Execute"))),
3746 },
3747 );
3748 apply_observation(
3749 &mut session,
3750 RelayObservation::TerminalOutput {
3751 terminal_id: "term-1".into(),
3752 output: "orphan failure\n".into(),
3753 truncated: false,
3754 exit_code: Some(1),
3755 signal: None,
3756 },
3757 );
3758 apply_observation(
3759 &mut session,
3760 RelayObservation::SessionUpdate {
3761 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3762 "call-1",
3763 ToolCallUpdateFields::new()
3764 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed)
3765 .raw_output(json!({
3766 "output": b"different output",
3767 "exit_code": 1
3768 })),
3769 ))),
3770 },
3771 );
3772
3773 assert_eq!(session.transcript.len(), 2);
3774 assert!(session.transcript.iter().any(|item| matches!(
3775 &item.body,
3776 TranscriptBody::TerminalOutput { record }
3777 if record.output == "orphan failure\n"
3778 )));
3779 }
3780
3781 #[test]
3782 fn identical_orphan_results_are_not_assigned_arbitrarily() {
3783 const OUTPUT: &str = "same output\n";
3784 let mut session = MaterializedSession::empty("session-1");
3785 apply_observation(
3786 &mut session,
3787 RelayObservation::SessionUpdate {
3788 update: Box::new(SessionUpdate::ToolCall(ToolCall::new("call-1", "Execute"))),
3789 },
3790 );
3791 for terminal_id in ["term-1", "term-2"] {
3792 apply_observation(
3793 &mut session,
3794 RelayObservation::TerminalOutput {
3795 terminal_id: terminal_id.into(),
3796 output: OUTPUT.into(),
3797 truncated: false,
3798 exit_code: Some(1),
3799 signal: None,
3800 },
3801 );
3802 }
3803 apply_observation(
3804 &mut session,
3805 RelayObservation::SessionUpdate {
3806 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3807 "call-1",
3808 ToolCallUpdateFields::new()
3809 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed)
3810 .raw_output(json!({
3811 "output": OUTPUT.as_bytes(),
3812 "exit_code": 1
3813 })),
3814 ))),
3815 },
3816 );
3817
3818 assert_eq!(
3819 session
3820 .transcript
3821 .iter()
3822 .filter(|item| matches!(item.body, TranscriptBody::TerminalOutput { .. }))
3823 .count(),
3824 2,
3825 "identical concurrent results need an explicit reference"
3826 );
3827 assert!(attached_terminal_outputs(&session.transcript[0]).is_empty());
3828 }
3829
3830 #[test]
3831 fn wholesale_tool_call_update_keeps_the_attached_terminal_output() {
3832 let mut session = MaterializedSession::empty("session-1");
3833 apply_observation(&mut session, terminal_tool_call("call-1", "term-1"));
3834 apply_observation(&mut session, terminal_output("term-1"));
3835 apply_observation(
3838 &mut session,
3839 RelayObservation::SessionUpdate {
3840 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3841 "call-1",
3842 ToolCallUpdateFields::new()
3843 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed)
3844 .content(vec![ToolCallContent::Terminal(
3845 agent_client_protocol::schema::v1::Terminal::new("term-1"),
3846 )]),
3847 ))),
3848 },
3849 );
3850
3851 assert_eq!(session.transcript.len(), 1);
3852 let outputs = attached_terminal_outputs(&session.transcript[0]);
3853 assert_eq!(outputs.len(), 1);
3854 assert_eq!(outputs[0].output, "build finished\n");
3855 let TranscriptBody::Tool { call, .. } = &session.transcript[0].body else {
3856 panic!("the item stayed a tool item");
3857 };
3858 assert_eq!(call["status"], json!("completed"));
3859 }
3860
3861 #[test]
3865 fn a_tool_call_that_dropped_its_terminal_reference_still_attaches_the_output() {
3866 let mut session = MaterializedSession::empty("session-1");
3867 apply_observation(&mut session, terminal_tool_call("call-1", "term-1"));
3868 apply_observation(
3869 &mut session,
3870 RelayObservation::SessionUpdate {
3871 update: Box::new(SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
3872 "call-1",
3873 ToolCallUpdateFields::new()
3874 .status(agent_client_protocol::schema::v1::ToolCallStatus::Completed)
3875 .content(vec![ToolCallContent::from(ContentBlock::Text(
3876 TextContent::new("ran the build"),
3877 ))]),
3878 ))),
3879 },
3880 );
3881 apply_observation(&mut session, terminal_output("term-1"));
3882
3883 assert_eq!(
3884 session.transcript.len(),
3885 1,
3886 "the output attaches instead of parking in its own item: {:?}",
3887 session.transcript
3888 );
3889 assert_eq!(session.transcript[0].stable_id, "tool:call-1");
3890 let outputs = attached_terminal_outputs(&session.transcript[0]);
3891 assert_eq!(outputs.len(), 1);
3892 assert_eq!(outputs[0].output, "build finished\n");
3893 let TranscriptBody::Tool {
3894 call,
3895 terminal_refs,
3896 ..
3897 } = &session.transcript[0].body
3898 else {
3899 panic!("the item stayed a tool item");
3900 };
3901 assert_eq!(terminal_refs, &["term-1".to_owned()]);
3902 assert_eq!(
3903 tool_call_terminal_ids(call),
3904 Vec::<String>::new(),
3905 "the final call really did drop the reference"
3906 );
3907 }
3908
3909 #[test]
3910 fn queued_prompt_becomes_user_message_only_when_started() {
3911 let mut session = MaterializedSession::empty("session-1");
3912 apply_observation(
3913 &mut session,
3914 RelayObservation::CommandQueued {
3915 command_id: "prompt-1".into(),
3916 command: RelayCommand::Prompt {
3917 prompt: vec![ContentBlock::Text(TextContent::new("hello"))],
3918 },
3919 created_at_ms: 100,
3920 },
3921 );
3922 assert!(session.transcript.is_empty());
3923 assert_eq!(session.queued_prompts.len(), 1);
3924
3925 apply_observation(
3926 &mut session,
3927 RelayObservation::CommandStarted {
3928 command_id: "prompt-1".into(),
3929 started_at_ms: 200,
3930 },
3931 );
3932 assert!(session.queued_prompts.is_empty());
3933 assert!(matches!(
3934 session.transcript[0].body,
3935 TranscriptBody::User { .. }
3936 ));
3937
3938 apply_observation(
3939 &mut session,
3940 RelayObservation::CommandCompleted {
3941 command_id: "prompt-1".into(),
3942 outcome: RelayCommandOutcome::Prompt {
3943 diagnostic: None,
3944 stop_reason: "end_turn".into(),
3945 usage: None,
3946 },
3947 },
3948 );
3949 assert_eq!(session.execution, MaterializedExecutionState::Idle);
3950 }
3951
3952 #[test]
3953 fn first_queued_prompt_seeds_a_provisional_session_title() {
3954 let mut session = MaterializedSession::empty("session-1");
3955 apply_observation(
3956 &mut session,
3957 RelayObservation::CommandQueued {
3958 command_id: "prompt-1".into(),
3959 command: RelayCommand::Prompt {
3960 prompt: vec![ContentBlock::Text(TextContent::new(
3961 " fix the flaky\nresume test ",
3962 ))],
3963 },
3964 created_at_ms: 100,
3965 },
3966 );
3967
3968 assert_eq!(
3969 session.session_title.as_deref(),
3970 Some("fix the flaky resume test")
3971 );
3972 }
3973
3974 #[test]
3975 fn harness_title_replaces_the_provisional_title() {
3976 let mut session = MaterializedSession::empty("session-1");
3977 apply_observation(
3978 &mut session,
3979 RelayObservation::CommandQueued {
3980 command_id: "prompt-1".into(),
3981 command: RelayCommand::Prompt {
3982 prompt: vec![ContentBlock::Text(TextContent::new("first prompt"))],
3983 },
3984 created_at_ms: 100,
3985 },
3986 );
3987 apply_observation(
3988 &mut session,
3989 RelayObservation::CommandQueued {
3990 command_id: "prompt-2".into(),
3991 command: RelayCommand::Prompt {
3992 prompt: vec![ContentBlock::Text(TextContent::new("second prompt"))],
3993 },
3994 created_at_ms: 200,
3995 },
3996 );
3997 assert_eq!(session.session_title.as_deref(), Some("first prompt"));
3998
3999 apply_observation(
4000 &mut session,
4001 RelayObservation::SessionUpdate {
4002 update: Box::new(SessionUpdate::SessionInfoUpdate(
4003 agent_client_protocol::schema::v1::SessionInfoUpdate::new()
4004 .title("Agent-generated title"),
4005 )),
4006 },
4007 );
4008
4009 assert_eq!(
4010 session.session_title.as_deref(),
4011 Some("Agent-generated title")
4012 );
4013 }
4014
4015 #[test]
4016 fn session_info_update_without_title_preserves_the_provisional_title() {
4017 let mut session = MaterializedSession::empty("session-1");
4018 apply_observation(
4019 &mut session,
4020 RelayObservation::CommandQueued {
4021 command_id: "prompt-1".into(),
4022 command: RelayCommand::Prompt {
4023 prompt: vec![ContentBlock::Text(TextContent::new("first prompt"))],
4024 },
4025 created_at_ms: 100,
4026 },
4027 );
4028
4029 apply_observation(
4030 &mut session,
4031 RelayObservation::SessionUpdate {
4032 update: Box::new(SessionUpdate::SessionInfoUpdate(
4033 agent_client_protocol::schema::v1::SessionInfoUpdate::new()
4034 .updated_at("2026-08-31T12:00:00Z"),
4035 )),
4036 },
4037 );
4038
4039 assert_eq!(session.session_title.as_deref(), Some("first prompt"));
4040 }
4041
4042 #[test]
4043 fn explicit_session_title_clear_restores_the_prompt_fallback() {
4044 let mut session = MaterializedSession::empty("session-1");
4045 apply_observation(
4046 &mut session,
4047 RelayObservation::CommandQueued {
4048 command_id: "prompt-1".into(),
4049 command: RelayCommand::Prompt {
4050 prompt: vec![ContentBlock::Text(TextContent::new("first prompt"))],
4051 },
4052 created_at_ms: 100,
4053 },
4054 );
4055
4056 apply_observation(
4057 &mut session,
4058 RelayObservation::SessionUpdate {
4059 update: Box::new(SessionUpdate::SessionInfoUpdate(
4060 agent_client_protocol::schema::v1::SessionInfoUpdate::new().title(None),
4061 )),
4062 },
4063 );
4064
4065 assert_eq!(session.session_title, None);
4066 assert_eq!(session.resolved_title().as_deref(), Some("first prompt"));
4067 }
4068
4069 #[test]
4070 fn next_prompt_backfills_an_existing_untitled_session_from_its_first_prompt() {
4071 let mut session = MaterializedSession::empty("session-1");
4072 session.transcript.push(Arc::new(TranscriptItem {
4073 stable_id: "user:prompt-1".into(),
4074 position: 1,
4075 latest_content_event_ordinal: None,
4076 created_at_ms: 100,
4077 last_changed_at_ms: 100,
4078 body: TranscriptBody::User {
4079 content: vec![
4080 serde_json::to_value(ContentBlock::Text(TextContent::new("original task")))
4081 .unwrap(),
4082 ],
4083 },
4084 }));
4085 assert_eq!(session.resolved_title().as_deref(), Some("original task"));
4086
4087 apply_observation(
4088 &mut session,
4089 RelayObservation::CommandQueued {
4090 command_id: "prompt-2".into(),
4091 command: RelayCommand::Prompt {
4092 prompt: vec![ContentBlock::Text(TextContent::new("follow-up task"))],
4093 },
4094 created_at_ms: 200,
4095 },
4096 );
4097
4098 assert_eq!(session.session_title.as_deref(), Some("original task"));
4099 }
4100
4101 #[test]
4102 fn queued_config_change_starts_without_becoming_a_turn() {
4103 let mut session = MaterializedSession::empty("session-1");
4104 apply_observation(
4105 &mut session,
4106 RelayObservation::CommandQueued {
4107 command_id: "config-1".into(),
4108 command: RelayCommand::SetConfig {
4109 key: "model".into(),
4110 value: "sonnet".into(),
4111 },
4112 created_at_ms: 100,
4113 },
4114 );
4115 assert_eq!(session.queued_prompts.len(), 1);
4116 assert_eq!(
4117 session.queued_prompts[0].kind,
4118 QueuedCommandKind::SetConfig {
4119 key: "model".into(),
4120 value: "sonnet".into(),
4121 }
4122 );
4123 assert_eq!(
4124 crate::transcript::materialized_content_text(&session.queued_prompts[0].content),
4125 "/model sonnet"
4126 );
4127
4128 apply_observation(
4129 &mut session,
4130 RelayObservation::CommandStarted {
4131 command_id: "config-1".into(),
4132 started_at_ms: 200,
4133 },
4134 );
4135 assert!(session.queued_prompts.is_empty());
4136 assert!(session.transcript.is_empty());
4137 assert_eq!(session.execution, MaterializedExecutionState::Idle);
4138
4139 apply_observation(
4140 &mut session,
4141 RelayObservation::CommandCompleted {
4142 command_id: "config-1".into(),
4143 outcome: RelayCommandOutcome::Configured,
4144 },
4145 );
4146 assert_eq!(session.execution, MaterializedExecutionState::Idle);
4147 assert!(session.transcript.is_empty());
4148 }
4149
4150 #[test]
4151 fn queue_changes_project_only_from_their_completion_events() {
4152 let mut session = MaterializedSession::empty("session-1");
4153 session.queued_prompts.push(MaterializedQueuedPrompt {
4154 accepted_ordinal: None,
4155 command_id: "queued-1".into(),
4156 kind: QueuedCommandKind::Prompt,
4157 content: vec![json!({"type": "text", "text": "later"})],
4158 queued_at_ms: 10,
4159 });
4160
4161 apply_observation(
4162 &mut session,
4163 RelayObservation::CommandQueued {
4164 command_id: "remove-1".into(),
4165 command: RelayCommand::RemoveQueuedPrompt {
4166 queued_command_id: "queued-1".into(),
4167 },
4168 created_at_ms: 100,
4169 },
4170 );
4171 assert_eq!(session.queued_prompts.len(), 1);
4172
4173 apply_observation(
4174 &mut session,
4175 RelayObservation::CommandCompleted {
4176 command_id: "remove-1".into(),
4177 outcome: RelayCommandOutcome::QueueChanged {
4178 removed_command_ids: vec!["queued-1".into()],
4179 },
4180 },
4181 );
4182 assert!(session.queued_prompts.is_empty());
4183
4184 session.queued_prompts.extend([
4185 MaterializedQueuedPrompt {
4186 accepted_ordinal: None,
4187 command_id: "queued-2".into(),
4188 kind: QueuedCommandKind::Prompt,
4189 content: vec![json!({"type": "text", "text": "two"})],
4190 queued_at_ms: 20,
4191 },
4192 MaterializedQueuedPrompt {
4193 accepted_ordinal: None,
4194 command_id: "queued-3".into(),
4195 kind: QueuedCommandKind::Prompt,
4196 content: vec![json!({"type": "text", "text": "three"})],
4197 queued_at_ms: 30,
4198 },
4199 ]);
4200 apply_observation(
4201 &mut session,
4202 RelayObservation::CommandQueued {
4203 command_id: "clear-1".into(),
4204 command: RelayCommand::ClearQueuedPrompts,
4205 created_at_ms: 200,
4206 },
4207 );
4208 assert_eq!(session.queued_prompts.len(), 2);
4209
4210 apply_observation(
4211 &mut session,
4212 RelayObservation::CommandCompleted {
4213 command_id: "clear-1".into(),
4214 outcome: RelayCommandOutcome::QueueChanged {
4215 removed_command_ids: vec!["queued-2".into(), "queued-3".into()],
4216 },
4217 },
4218 );
4219 assert!(session.queued_prompts.is_empty());
4220 }
4221
4222 #[test]
4223 fn rejected_close_rolls_closing_projection_back_to_idle() {
4224 let mut session = MaterializedSession::empty("session-1");
4225 apply_observation(
4226 &mut session,
4227 RelayObservation::CommandQueued {
4228 command_id: "close-1".into(),
4229 command: RelayCommand::Close {
4230 barrier_command_id: "barrier-1".into(),
4231 expected: mj_core::relay::RelayCursor {
4232 ordinal: 0,
4233 digest: "0".repeat(64),
4234 },
4235 },
4236 created_at_ms: 100,
4237 },
4238 );
4239 assert_eq!(session.execution, MaterializedExecutionState::Closing);
4240
4241 apply_observation(
4242 &mut session,
4243 RelayObservation::CommandRejected {
4244 command_id: "close-1".into(),
4245 command: RelayCommandKind::Close,
4246 message: "ACP close failed".into(),
4247 },
4248 );
4249 assert_eq!(session.execution, MaterializedExecutionState::Idle);
4250 }
4251
4252 #[test]
4253 fn control_command_outcomes_do_not_end_an_active_prompt() {
4254 let mut session = MaterializedSession::empty("session-1");
4255 session.applied_event_ordinal = 2;
4256 session.applied_event_digest = "a".repeat(64);
4257 session.execution = MaterializedExecutionState::Running { started_at_ms: 100 };
4258 session.transcript.push(Arc::new(TranscriptItem {
4259 stable_id: "agent:answer-1".into(),
4260 position: 2,
4261 latest_content_event_ordinal: Some(2),
4262 created_at_ms: 200,
4263 last_changed_at_ms: 200,
4264 body: TranscriptBody::Agent {
4265 chunks: vec![json!({
4266 "content": {"type": "text", "text": "working"}
4267 })],
4268 streaming: true,
4269 },
4270 }));
4271
4272 apply_observation(
4273 &mut session,
4274 RelayObservation::CommandCompleted {
4275 command_id: "config-1".into(),
4276 outcome: RelayCommandOutcome::Configured,
4277 },
4278 );
4279 assert!(matches!(
4280 session.execution,
4281 MaterializedExecutionState::Running { .. }
4282 ));
4283 assert!(matches!(
4284 session.transcript[0].body,
4285 TranscriptBody::Agent {
4286 streaming: true,
4287 ..
4288 }
4289 ));
4290
4291 apply_observation(
4292 &mut session,
4293 RelayObservation::CommandRejected {
4294 command_id: "cancel-1".into(),
4295 command: RelayCommandKind::Cancel,
4296 message: "not cancellable".into(),
4297 },
4298 );
4299 assert!(matches!(
4300 session.execution,
4301 MaterializedExecutionState::Running { .. }
4302 ));
4303 assert!(matches!(
4304 session.transcript[0].body,
4305 TranscriptBody::Agent {
4306 streaming: true,
4307 ..
4308 }
4309 ));
4310 }
4311
4312 #[test]
4313 fn canonical_round_trip_preserves_cursor_and_logical_positions() {
4314 let mut session = MaterializedSession::empty("session-1");
4315 session.applied_event_ordinal = 4;
4316 session.applied_event_digest = "a".repeat(64);
4317 session.last_activity_at_ms = Some(40);
4318 session.session_title = Some("Build it".into());
4319 session.transcript.push(Arc::new(TranscriptItem {
4320 stable_id: "agent:a".into(),
4321 position: 2,
4322 latest_content_event_ordinal: Some(4),
4323 created_at_ms: 20,
4324 last_changed_at_ms: 40,
4325 body: TranscriptBody::Agent {
4326 chunks: vec![json!({
4327 "content": {"type": "text", "text": "done"},
4328 "messageId": "a",
4329 "_meta": {"provider": "test"}
4330 })],
4331 streaming: false,
4332 },
4333 }));
4334 session.transcript.push(Arc::new(TranscriptItem {
4335 stable_id: "thought:t".into(),
4336 position: 3,
4337 latest_content_event_ordinal: None,
4338 created_at_ms: 30,
4339 last_changed_at_ms: 30,
4340 body: TranscriptBody::Thought {
4341 chunks: vec![json!({
4342 "content": {
4343 "type": "text",
4344 "text": "reasoning",
4345 "_meta": {"contentProvider": "test"}
4346 },
4347 "messageId": "t",
4348 "_meta": {"chunkProvider": "test"}
4349 })],
4350 streaming: false,
4351 },
4352 }));
4353 session.transcript.push(Arc::new(TranscriptItem {
4354 stable_id: "tool:call-1".into(),
4355 position: 4,
4356 latest_content_event_ordinal: None,
4357 created_at_ms: 40,
4358 last_changed_at_ms: 40,
4359 body: TranscriptBody::Tool {
4360 call: json!({
4361 "toolCallId": "call-1",
4362 "title": "Read file",
4363 "kind": "read",
4364 "status": "completed",
4365 "content": [{"type": "terminal", "terminalId": "term-1"}],
4366 "rawInput": {"path": "README.md"},
4367 "rawOutput": {"bytes": 42},
4368 "_meta": {"provider": "test"}
4369 }),
4370 terminal_outputs: vec![TerminalOutputRecord {
4371 terminal_id: "term-1".into(),
4372 output: "ok\n".into(),
4373 truncated: true,
4374 exit_code: Some(0),
4375 signal: None,
4376 }],
4377 terminal_refs: vec!["term-1".into(), "term-3".into()],
4380 presentation: Some(Box::new(crate::transcript::ToolCallPresentation {
4381 summary: "Read".into(),
4382 source: "Read file".into(),
4383 source_kind: crate::transcript::ToolSummarySourceKind::Title,
4384 tool_kind: agent_client_protocol::schema::v1::ToolKind::Read,
4385 summary_version: crate::transcript::TOOL_SUMMARY_VERSION,
4386 })),
4387 },
4388 }));
4389 session.transcript.push(Arc::new(TranscriptItem {
4390 stable_id: "terminal:term-2".into(),
4391 position: 4,
4392 latest_content_event_ordinal: None,
4393 created_at_ms: 40,
4394 last_changed_at_ms: 40,
4395 body: TranscriptBody::TerminalOutput {
4396 record: TerminalOutputRecord {
4397 terminal_id: "term-2".into(),
4398 output: "orphaned output\n".into(),
4399 truncated: false,
4400 exit_code: None,
4401 signal: Some("SIGKILL".into()),
4402 },
4403 },
4404 }));
4405 session.transcript.push(Arc::new(TranscriptItem {
4406 stable_id: "plan:4".into(),
4407 position: 4,
4408 latest_content_event_ordinal: None,
4409 created_at_ms: 40,
4410 last_changed_at_ms: 40,
4411 body: TranscriptBody::Plan {
4412 plan: json!({
4413 "entries": [{
4414 "content": "finish",
4415 "priority": "high",
4416 "status": "in_progress",
4417 "_meta": {"entryProvider": "test"}
4418 }],
4419 "_meta": {"planProvider": "test"}
4420 }),
4421 },
4422 }));
4423 session.transcript.push(Arc::new(TranscriptItem {
4424 stable_id: plan_proposal_item_id(4),
4425 position: 4,
4426 latest_content_event_ordinal: None,
4427 created_at_ms: 40,
4428 last_changed_at_ms: 40,
4429 body: TranscriptBody::PlanProposal {
4430 proposal_id: "plan-review-1".into(),
4431 plan: "1. Read the code\n2. Change it".into(),
4432 },
4433 }));
4434 session.queued_prompts.push(MaterializedQueuedPrompt {
4435 accepted_ordinal: None,
4436 command_id: "queued-config".into(),
4437 kind: QueuedCommandKind::SetConfig {
4438 key: "model".into(),
4439 value: "sonnet".into(),
4440 },
4441 content: vec![json!({"type": "text", "text": "/model sonnet"})],
4442 queued_at_ms: 50,
4443 });
4444 let canonical = canonical_session_from_materialized(&session).unwrap();
4445 canonical.validate().unwrap();
4446 assert_eq!(
4447 canonical.queued_prompts[0].kind,
4448 CanonicalQueuedCommandKind::SetConfig {
4449 key: "model".into(),
4450 value: "sonnet".into(),
4451 }
4452 );
4453 let restored = materialized_session_from_canonical("session-1", &canonical).unwrap();
4454 assert_eq!(restored.applied_event_ordinal, 4);
4455 assert_eq!(restored.transcript[0].position, 2);
4456 assert_eq!(restored.unread_agent_messages_after(1), 1);
4457 assert_eq!(restored, session);
4458 }
4459
4460 #[test]
4461 fn one_chunk_projects_only_the_touched_logical_item() {
4462 let mut session = MaterializedSession::empty("session-1");
4463 session.applied_event_ordinal = 10_000;
4464 session.applied_event_digest = "a".repeat(64);
4465 session.last_activity_at_ms = Some(10_000);
4466 session.transcript = (1..=10_000)
4467 .map(|position| {
4468 Arc::new(TranscriptItem {
4469 stable_id: format!("system:{position}"),
4470 position,
4471 latest_content_event_ordinal: None,
4472 created_at_ms: i64::try_from(position).unwrap(),
4473 last_changed_at_ms: i64::try_from(position).unwrap(),
4474 body: TranscriptBody::System {
4475 text: format!("event {position}"),
4476 },
4477 })
4478 })
4479 .collect();
4480 let next = event(
4481 &session,
4482 RelayObservation::SessionUpdate {
4483 update: Box::new(SessionUpdate::AgentMessageChunk(
4484 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
4485 TextContent::new("answer"),
4486 ))
4487 .message_id("answer-1"),
4488 )),
4489 },
4490 );
4491
4492 let projected = project_relay_event(&session, &next).unwrap();
4493
4494 assert_eq!(projected.mutation.transcript.len(), 1);
4495 assert!(projected.mutation.configuration.is_none());
4496 assert!(projected.mutation.queued_prompts.is_none());
4497 assert_eq!(session.transcript.len(), 10_000);
4498 apply_committed_projection_event(&mut session, &next, projected.mutation).unwrap();
4499 assert_eq!(session.transcript.len(), 10_001);
4500 }
4501
4502 fn agent_chunk(text: &str, message_id: &str) -> RelayObservation {
4503 RelayObservation::SessionUpdate {
4504 update: Box::new(SessionUpdate::AgentMessageChunk(
4505 agent_client_protocol::schema::v1::ContentChunk::new(ContentBlock::Text(
4506 TextContent::new(text),
4507 ))
4508 .message_id(message_id),
4509 )),
4510 }
4511 }
4512
4513 fn end_turn() -> RelayObservation {
4514 RelayObservation::CommandCompleted {
4515 command_id: "prompt-1".into(),
4516 outcome: RelayCommandOutcome::Prompt {
4517 diagnostic: None,
4518 stop_reason: "end_turn".into(),
4519 usage: None,
4520 },
4521 }
4522 }
4523
4524 fn agent_text(item: &TranscriptItem) -> String {
4525 let TranscriptBody::Agent { chunks, .. } = &item.body else {
4526 panic!("expected an agent message, got {:?}", item.body);
4527 };
4528 crate::transcript::materialized_chunks_text(chunks)
4529 }
4530
4531 #[test]
4532 fn appending_a_transcript_item_leaves_earlier_items_shared_with_older_snapshots() {
4533 let mut session = MaterializedSession::empty("session-1");
4534 apply_observation(&mut session, agent_chunk("answer", "answer-1"));
4535 apply_observation(&mut session, end_turn());
4536 let published = session.clone();
4537
4538 apply_observation(
4539 &mut session,
4540 RelayObservation::Warning {
4541 message: "disk is nearly full".into(),
4542 },
4543 );
4544
4545 assert_eq!(published.transcript.len(), 1);
4546 assert_eq!(session.transcript.len(), 2);
4547 assert!(matches!(
4548 &session.transcript[1].body,
4549 TranscriptBody::System { text } if text == "warning: disk is nearly full"
4550 ));
4551 assert!(
4552 Arc::ptr_eq(&session.transcript[0], &published.transcript[0]),
4553 "cloning a session must share earlier transcript items, not copy them"
4554 );
4555 }
4556
4557 #[test]
4558 fn appending_a_chunk_replaces_only_the_streaming_tail_item() {
4559 let mut session = MaterializedSession::empty("session-1");
4560 apply_observation(&mut session, agent_chunk("finished", "answer-1"));
4561 apply_observation(&mut session, end_turn());
4562 apply_observation(&mut session, agent_chunk("hel", "answer-2"));
4563 let published = session.clone();
4564
4565 apply_observation(&mut session, agent_chunk("lo", "answer-2"));
4566
4567 assert_eq!(session.transcript.len(), 2);
4568 assert!(
4569 Arc::ptr_eq(&session.transcript[0], &published.transcript[0]),
4570 "finalized items stay shared while the tail streams"
4571 );
4572 assert!(
4573 !Arc::ptr_eq(&session.transcript[1], &published.transcript[1]),
4574 "the streaming tail must be replaced, not mutated in place"
4575 );
4576 assert_eq!(agent_text(&published.transcript[1]), "hel");
4577 assert_eq!(agent_text(&session.transcript[1]), "hello");
4578 }
4579}