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