Skip to main content

mj_transcript/
projection.rs

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