Skip to main content

cookie_agent_tui/state/
mod.rs

1//! Disposable UI projections reduced from protocol-10 stored events.
2//!
3//! Assistant attribution is derived only from the frozen `RunStarted` plus
4//! `ModelAttemptStarted`/`ModelTurnCommitted` ownership — never from the
5//! current picker, live agent files, or provider configuration. The visible
6//! assistant header projects the exact canonical `Agent • Model[variant]`.
7
8mod reduce;
9mod runtime;
10
11pub(crate) use reduce::approval_state_from_record;
12use reduce::*;
13
14pub use runtime::{EMPTY_RUNTIME_GUIDANCE, RuntimePhase, RuntimeState};
15
16use std::{
17    collections::{BTreeMap, HashMap, HashSet, VecDeque},
18    time::{Duration, Instant},
19};
20
21use cookie_agent_protocol::{
22    AgentId, ApprovalCapability, ApprovalConstraints, ApprovalEvaluation, ApprovalFinalOutcome,
23    ApprovalId, ApprovalRecord, ApprovalRequest, ApprovalStatus, ApprovalTrigger,
24    AssistantToolCallRef, AttemptId, EventPayload, EventSubscriptionMessage, GoalId,
25    GoalReminderIdentity, GoalState, GoalStatus, ModelErrorSummary, OperationFingerprint,
26    PersistedModelTurn, PreparedApprovalResource, PreparedCapabilityLifetime, ProducerDeliveryMode,
27    ProducerIdempotencyKey, ProducerMessageId, ProducerOwner, ReplayDecision, ReplayDisposition,
28    ResolvedModelRef, RunId, SafeCode, SessionId, SessionTitleChange, Sha256Digest, StoredEvent,
29    ToolAttachment, ToolCallId, ToolTerminationOutcome, Usage, VariantId,
30};
31use serde::Serialize;
32
33use crate::{client::ClientDelivery, markdown::MarkdownDocument};
34
35/// The visible state of a tool invocation, reduced from the exact protocol-10
36/// termination outcome. Failed, cancelled, and interrupted stay distinct.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub enum ToolStatus {
39    Running,
40    Completed,
41    Failed,
42    Cancelled,
43    Interrupted,
44}
45
46/// A tool invocation displayed inside its owning assistant item. The compact
47/// title uses only the persisted `ToolCallPresentation`; raw arguments appear
48/// only in the expanded detail. The persisted display argument is byte-capped
49/// but may still carry control characters, so render sites flatten it.
50#[derive(Clone, Debug)]
51pub struct ToolCallState {
52    pub id: ToolCallId,
53    pub owner: AssistantToolCallRef,
54    pub presentation: cookie_agent_protocol::ToolCallPresentation,
55    /// Durable tool input from the owning committed turn, shown expanded.
56    pub arguments: String,
57    pub status: ToolStatus,
58    pub detail: String,
59    pub has_output_chunks: bool,
60}
61
62impl ToolCallState {
63    /// The exact compact title: the persisted sanitized tool title plus its
64    /// persisted display argument, never reparsed from raw input. The argument is
65    /// byte-capped but not flattened, so a render site must sanitize it and
66    /// abbreviate it to its own budget.
67    pub fn compact_title(&self) -> String {
68        match &self.presentation.primary_argument {
69            Some(argument) => format!("{} {argument}", self.presentation.title),
70            None => self.presentation.title.to_string(),
71        }
72    }
73}
74
75/// One durable approval request projected for internal evaluation and, only
76/// after escalation, possible user interaction.
77#[derive(Clone, Debug)]
78pub struct ApprovalState {
79    pub session_id: SessionId,
80    pub approval_id: ApprovalId,
81    pub request_revision: u64,
82    pub operation_fingerprint: OperationFingerprint,
83    pub trigger: ApprovalTrigger,
84    pub normalized_arguments_digest: Sha256Digest,
85    pub execution_context_digest: Sha256Digest,
86    pub capability_lifetime: PreparedCapabilityLifetime,
87    pub capabilities: Vec<ApprovalCapability>,
88    pub resources: Vec<PreparedApprovalResource>,
89    pub evaluations: Vec<ApprovalEvaluation>,
90    pub constraints: ApprovalConstraints,
91    pub escalated: bool,
92}
93
94impl ApprovalState {
95    /// User-visible/respondable approvals must have a durable escalation and
96    /// must still be within their response lifetime.
97    pub(crate) fn is_visible_user_escalation(&self) -> bool {
98        self.escalated
99            && self
100                .constraints
101                .expires_at
102                .is_none_or(|expires_at| expires_at > jiff::Timestamp::now())
103    }
104}
105
106/// Leveled diagnostic severity for TUI-only event rows. This is a display
107/// projection classification; durable protocol events are unchanged.
108#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
109pub enum EventLevel {
110    Debug,
111    Info,
112    Warning,
113    Error,
114}
115
116impl EventLevel {
117    pub fn badge(self) -> &'static str {
118        match self {
119            Self::Debug => "[D]",
120            Self::Info => "[I]",
121            Self::Warning => "[W]",
122            Self::Error => "[E]",
123        }
124    }
125
126    pub fn name(self) -> &'static str {
127        match self {
128            Self::Debug => "debug",
129            Self::Info => "info",
130            Self::Warning => "warning",
131            Self::Error => "error",
132        }
133    }
134}
135
136/// Frozen producing identity for one assistant attempt/turn, reduced from the
137/// exact protocol-10 attempt and turn ownership events.
138#[derive(Clone, Debug, Eq, PartialEq)]
139pub struct FrozenAssistantAttribution {
140    pub agent: AgentId,
141    pub resolved_model: ResolvedModelRef,
142}
143
144impl FrozenAssistantAttribution {
145    /// The exact visible header `<agent-id> • <provider>/<model-id>[<variant>]`.
146    pub fn header(&self) -> String {
147        format!(
148            "{} • {}[{}]",
149            self.agent,
150            self.resolved_model.selection.model,
151            self.variant_label()
152        )
153    }
154
155    /// The variant retained in structured attribution, rendered as `base`
156    /// when the frozen selection is exact base behavior.
157    pub fn variant_label(&self) -> String {
158        self.resolved_model
159            .selection
160            .variant
161            .as_ref()
162            .map_or_else(|| "base".to_owned(), |variant| variant.to_string())
163    }
164}
165
166/// One steered message the engine admitted into its pending-input lane but
167/// has not yet promoted to the model-facing log. Pure event reduction: the
168/// lane is exactly what `UserInputAdmitted`/`UserInputSubmitted` and the recall
169/// events describe, so replays rebuild it identically.
170#[derive(Clone, Debug)]
171pub struct PendingInput {
172    pub text: String,
173    pub admission_seq: u64,
174    /// Durable admission timestamp from the admitting event.
175    pub admitted_at: jiff::Timestamp,
176}
177
178#[derive(Clone, Copy, Debug, Eq, PartialEq)]
179pub enum ProducerMessageStatus {
180    Pending,
181    Admitted,
182    Claimed,
183    Consumed,
184    Discarded,
185}
186
187/// One rendered conversation item.
188#[derive(Clone, Debug)]
189pub enum TranscriptItem {
190    User {
191        id: u64,
192        version: u64,
193        text: String,
194        /// Physical sequence of the `UserInputSubmitted` event that created
195        /// the row: the revert/fork menu targets it with `through_seq`.
196        seq: u64,
197    },
198    Assistant {
199        id: u64,
200        version: u64,
201        attribution: FrozenAssistantAttribution,
202        committed_turn_seq: Option<u64>,
203        children: Vec<AssistantChild>,
204    },
205    /// A leveled diagnostic row (lifecycle notices, model warnings,
206    /// failures). Never user/assistant/tool content; filtering these rows by
207    /// level cannot hide conversation content or approvals.
208    Event {
209        id: u64,
210        version: u64,
211        level: EventLevel,
212        text: String,
213    },
214    /// A committed context checkpoint rendered inline at its durable event.
215    Compaction {
216        id: u64,
217        version: u64,
218        seq: u64,
219        commit: cookie_agent_protocol::ContextCheckpointCommit,
220    },
221    /// A plugin-injected model message rendered inline at its durable event.
222    PluginMessage {
223        id: u64,
224        version: u64,
225        seq: u64,
226        role: cookie_agent_protocol::ExtensionMessageRole,
227        input: String,
228    },
229    /// AGENTS.md context loaded at a root run start, rendered inline when it
230    /// differs from the previous run's (see `agent_md_previous_run`).
231    AgentMd {
232        id: u64,
233        version: u64,
234        seq: u64,
235        entries: Vec<cookie_agent_protocol::AgentMdEntry>,
236    },
237    /// A skill body loaded into model context, rendered inline at its event.
238    SkillLoaded {
239        id: u64,
240        version: u64,
241        seq: u64,
242        name: String,
243        source_path: String,
244        args: String,
245        rendered_body: String,
246    },
247    Goal {
248        id: u64,
249        seq: u64,
250        /// The activation event projects the accepted goal action, not a user
251        /// prompt sent to the model. Its producer owns the start message.
252        activation: bool,
253        goal: GoalState,
254    },
255    ProducerMessage {
256        id: u64,
257        /// Acceptance sequence for queue ordering. The row itself moves to
258        /// the effective admission boundary in the conversation.
259        seq: u64,
260        /// Durable timestamp of the accepting event, retained independently
261        /// from the pruned generation-timing index for stable queue age.
262        accepted_at: jiff::Timestamp,
263        message_id: ProducerMessageId,
264        producer_owner: ProducerOwner,
265        mode: ProducerDeliveryMode,
266        body: String,
267        /// Backend description or legacy goal summary, frozen at acceptance.
268        summary: Option<String>,
269        reminder: Option<GoalReminderIdentity>,
270        status: ProducerMessageStatus,
271    },
272}
273
274/// One ordered child segment inside an assistant item, owned by the committed
275/// turn/tool ownership events. There are no top-level reasoning or tool items.
276#[derive(Clone, Debug)]
277pub enum AssistantChild {
278    Text {
279        /// Sequence number of the first delta in this consecutive segment.
280        id: u64,
281        version: u64,
282        markdown: MarkdownDocument,
283    },
284    Thinking {
285        /// Sequence number of the first delta in this consecutive segment.
286        id: u64,
287        version: u64,
288        text: String,
289    },
290    Tool {
291        call_id: ToolCallId,
292    },
293    /// Inline producer change within one run-scoped assistant item.
294    Attribution {
295        resolved_model: ResolvedModelRef,
296    },
297    /// A durable tool placeholder from committed turn content, carrying the
298    /// exact content index. A started tool replaces its placeholder through
299    /// `owner.content_index`; an unstarted placeholder renders its committed
300    /// call.
301    CommittedTool {
302        turn_seq: u64,
303        content_index: u32,
304        /// The committed call's tool name, so the placeholder can render a
305        /// pending row instead of an error while execution has not started.
306        name: SafeCode,
307    },
308    /// A durable assistant media part at its exact committed content index.
309    MediaFile {
310        turn_seq: u64,
311        content_index: u32,
312        file: cookie_agent_protocol::PersistedFilePart,
313    },
314}
315
316impl AssistantChild {
317    pub fn id(&self) -> u64 {
318        match self {
319            Self::Text { id, .. } | Self::Thinking { id, .. } => *id,
320            Self::Tool { .. }
321            | Self::Attribution { .. }
322            | Self::CommittedTool { .. }
323            | Self::MediaFile { .. } => 0,
324        }
325    }
326
327    pub fn version(&self) -> u64 {
328        match self {
329            Self::Text { version, .. } | Self::Thinking { version, .. } => *version,
330            Self::Tool { .. }
331            | Self::Attribution { .. }
332            | Self::CommittedTool { .. }
333            | Self::MediaFile { .. } => 0,
334        }
335    }
336}
337
338#[derive(Clone, Copy, Debug, Eq, PartialEq)]
339pub(crate) enum AssistantPartKind {
340    Text,
341    Thinking,
342}
343
344#[derive(Clone, Copy, Debug)]
345pub(crate) struct OpenAssistantProjection {
346    item_id: u64,
347    part_id: u64,
348    kind: AssistantPartKind,
349    /// Durable timestamp of the event that opened this part. Sealing a
350    /// thinking part derives its "thought for Ns" duration from event
351    /// timestamps, so replays reproduce the original elapsed time.
352    opened_at: jiff::Timestamp,
353}
354
355impl TranscriptItem {
356    pub fn id(&self) -> u64 {
357        match self {
358            Self::User { id, .. }
359            | Self::Assistant { id, .. }
360            | Self::Event { id, .. }
361            | Self::Compaction { id, .. }
362            | Self::PluginMessage { id, .. }
363            | Self::AgentMd { id, .. }
364            | Self::SkillLoaded { id, .. }
365            | Self::Goal { id, .. }
366            | Self::ProducerMessage { id, .. } => *id,
367        }
368    }
369
370    pub fn version(&self) -> u64 {
371        match self {
372            Self::User { version, .. }
373            | Self::Assistant { version, .. }
374            | Self::Event { version, .. }
375            | Self::Compaction { version, .. }
376            | Self::PluginMessage { version, .. }
377            | Self::AgentMd { version, .. }
378            | Self::SkillLoaded { version, .. } => *version,
379            Self::Goal { .. } => 0,
380            Self::ProducerMessage { status, .. } => match status {
381                ProducerMessageStatus::Pending => 0,
382                ProducerMessageStatus::Admitted => 1,
383                ProducerMessageStatus::Claimed => 2,
384                ProducerMessageStatus::Consumed => 3,
385                ProducerMessageStatus::Discarded => 4,
386            },
387        }
388    }
389
390    #[cfg(test)]
391    pub fn user(text: impl Into<String>) -> Self {
392        Self::User {
393            id: 1,
394            version: 0,
395            text: text.into(),
396            // Layout fixtures never target the row; any plausible physical
397            // sequence (SessionCreated owns 1) keeps the field inhabited.
398            seq: 2,
399        }
400    }
401
402    #[cfg(test)]
403    pub fn internal(text: impl Into<String>) -> Self {
404        Self::Event {
405            id: 1,
406            version: 0,
407            level: EventLevel::Info,
408            text: text.into(),
409        }
410    }
411}
412
413/// One live streaming attempt, owning one assistant item.
414#[derive(Clone, Debug)]
415pub(crate) struct AttemptProjection {
416    item_id: u64,
417    run_id: Option<RunId>,
418    /// Retained independently of the currently open run segment so a later
419    /// input boundary cannot make this attempt erase older committed children.
420    committed_prefix: usize,
421    attribution_marker: Option<usize>,
422    /// Earlier blocks this attempt streamed into before interleaved event
423    /// rows split them off, each with the committed prefix it keeps. The
424    /// attempt's uncommitted output in those blocks is pruned when the
425    /// attempt abandons or its turn commits (the committed turn rebuilds
426    /// canonically in the newest block).
427    split_segments: Vec<(u64, usize)>,
428}
429
430/// The assistant item accumulating attempts until the run's next input boundary.
431#[derive(Clone, Debug)]
432pub(crate) struct RunAssistantProjection {
433    pub(crate) run_id: RunId,
434    pub(crate) item_id: u64,
435    pub(crate) committed_prefix: usize,
436    pub(crate) current_model: ResolvedModelRef,
437    /// An event row interleaved after this block while a turn was in flight.
438    /// The part streaming at that moment finishes in this block, but the
439    /// next new segment (a part of another kind, a tool call, or a new
440    /// attempt) opens a fresh block after the row.
441    pub(crate) split_pending: bool,
442}
443
444/// Generation metrics accumulated across one assistant block's committed
445/// turns: output tokens and generation wall time summed over exactly the
446/// turns with a known positive span (never mixing measured and unmeasured
447/// generation), plus the total context occupied at the end of the last
448/// committed turn (its `input_tokens + output_tokens`).
449#[derive(Clone, Copy, Debug, Default)]
450pub(crate) struct AssistantTurnMetrics {
451    pub(crate) timed_output_tokens: u64,
452    pub(crate) generation: Duration,
453    pub(crate) timed_turns: u32,
454    pub(crate) context_tokens: Option<u64>,
455    pub(crate) estimated_cost_pico_usd: Option<u64>,
456    cost_unpriced: bool,
457}
458
459impl AssistantTurnMetrics {
460    fn record_cost(&mut self, cost: Option<u64>) {
461        if self.cost_unpriced {
462            return;
463        }
464        let Some(cost) = cost else {
465            self.estimated_cost_pico_usd = None;
466            self.cost_unpriced = true;
467            return;
468        };
469        self.estimated_cost_pico_usd = self.estimated_cost_pico_usd.unwrap_or(0).checked_add(cost);
470        if self.estimated_cost_pico_usd.is_none() {
471            self.cost_unpriced = true;
472        }
473    }
474}
475
476/// A tool start buffered until its committed placeholder exists, linked by
477/// the owning turn's exact content index.
478#[derive(Clone, Copy, Debug)]
479pub(crate) struct PendingToolRow {
480    turn_seq: u64,
481    content_index: u32,
482    call_id: ToolCallId,
483}
484
485#[derive(Clone, Debug)]
486pub(crate) struct IndexedToolCall {
487    name: SafeCode,
488    arguments: String,
489}
490
491/// Projection-only identity composed exclusively from frozen, secret-safe
492/// protocol values. History indices are deliberately excluded so one logical
493/// compatibility transition warns once without altering durable evidence.
494#[derive(Clone, Debug, Eq, Hash, PartialEq)]
495pub(crate) enum ReplayContextTransition {
496    Adapter {
497        found: SafeCode,
498        expected: SafeCode,
499    },
500    ModelSelection {
501        found: cookie_agent_protocol::ModelSelection,
502        expected: cookie_agent_protocol::ModelSelection,
503    },
504    Variant {
505        found: Option<VariantId>,
506        expected: Option<VariantId>,
507    },
508    /// A persisted artifact failed validation for the current target (for
509    /// example cross-adapter history after a fallback). Keyed by reason so
510    /// one logical incompatiblity warns once per run even though every later
511    /// attempt re-evaluates the same history entries.
512    InvalidPayload {
513        reason: String,
514    },
515}
516
517#[derive(Clone, Debug)]
518pub(crate) struct ProducerMessageProjection {
519    transcript_index: usize,
520    producer_owner: ProducerOwner,
521    reminder: Option<GoalReminderIdentity>,
522    accepted_seq: u64,
523    admission: Option<(RunId, u64)>,
524    claims: HashSet<u64>,
525    status: ProducerMessageStatus,
526    discarded_seq: Option<u64>,
527    consumed_run: Option<RunId>,
528    consumption_recorded: bool,
529}
530
531#[derive(Clone, Debug)]
532pub(crate) struct ProducerClaimProjection {
533    pub(crate) run_id: RunId,
534    pub(crate) message_ids: Vec<ProducerMessageId>,
535}
536
537/// Per-session projection of persisted events and live output.
538#[derive(Clone, Debug, Default)]
539pub struct SessionState {
540    /// Changes whenever the visible projection mutates, for UI cache invalidation.
541    pub version: u64,
542    pub generation: u64,
543    pub last_seq: u64,
544    /// Creation-event time, used as the deterministic sibling-order fallback.
545    pub(crate) created_at: Option<jiff::Timestamp>,
546    /// Latest user submission or delegate/steer tool start in this session.
547    pub(crate) last_agent_activity: Option<jiff::Timestamp>,
548    pub active_run: Option<RunId>,
549    pub cwd_identity: Option<cookie_agent_protocol::CwdIdentity>,
550    /// Total context occupied at the end of the latest committed turn
551    /// (`input_tokens + output_tokens`); `None` when the turn reported no
552    /// usage, so the bottom bar hides its context segment.
553    pub context_tokens: Option<u64>,
554    /// Latest authoritative session usage cost fetched from the engine.
555    /// `None` covers both not-yet-fetched and unpriced usage; both hide the
556    /// bottom-bar segment.
557    pub estimated_cost_usd: Option<f64>,
558    /// Frozen producing agent of the latest accepted `RunStarted`.
559    pub run_agent: Option<AgentId>,
560    /// The complete frozen creation snapshot from `SessionCreated`,
561    /// including the exact frozen fallback chain. Delegated draft
562    /// projections derive only from this, never from live descriptors.
563    pub creation_agent: Option<Box<cookie_agent_protocol::AgentSnapshot>>,
564    /// The complete frozen snapshot of the latest accepted `RunStarted`.
565    pub run_snapshot: Option<Box<cookie_agent_protocol::AgentSnapshot>>,
566    /// The authoritative exact suffix from the latest accepted `RunStarted`:
567    /// after any run-selection variant override, this vector — never a
568    /// reconstruction from the agent fallback chain — is what attempts and
569    /// delegated pickers use.
570    pub run_selected_suffix: Option<Vec<cookie_agent_protocol::FrozenModelBinding>>,
571    pub model_selection: cookie_agent_protocol::SessionModelState,
572    pub goal: Option<GoalState>,
573    pub transcript: Vec<TranscriptItem>,
574    /// Durable insertion time per transcript item id. Cross-session rows such
575    /// as aggregated descendant warnings merge into a viewed transcript by
576    /// this time, so mid-conversation breaks render at their chronological
577    /// position instead of the bottom.
578    pub(crate) item_times: HashMap<u64, jiff::Timestamp>,
579    /// The engine's pending-input lane for this session, reduced purely
580    /// from admission/promotion/recall events: steered messages the model
581    /// has not seen yet. Source of the queue strip between the conversation
582    /// pane and the composer.
583    pub pending_inputs: VecDeque<PendingInput>,
584    /// Runs whose initial (non-lane) input has already been submitted.
585    /// Later submissions for the same run are pending-lane promotions.
586    pub(crate) initial_input_submitted: HashSet<RunId>,
587    /// Pending inputs the engine voided at run end (no per-entry events),
588    /// parked here until the UI restores their text into the composer —
589    /// user text is never silently lost. Drained by the UI on sight.
590    pub voided_inputs: Vec<String>,
591    pub(crate) next_transcript_id: u64,
592    pub(crate) open_assistant: Option<OpenAssistantProjection>,
593    /// Elapsed thinking time per sealed thinking part, keyed by
594    /// `(item_id, part_id)` and derived from durable event timestamps.
595    pub thinking_durations: HashMap<(u64, u64), Duration>,
596    /// Durable event timestamps by sequence, pruned at each committed
597    /// turn's input boundary; backs replay-exact generation durations.
598    pub(crate) event_timestamps: BTreeMap<u64, jiff::Timestamp>,
599    /// Generation metrics per assistant item, accumulated from committed
600    /// turns, for the subordinate footer row at the end of the block.
601    pub(crate) assistant_metrics: HashMap<u64, AssistantTurnMetrics>,
602    pub(crate) open_run_assistant: Option<RunAssistantProjection>,
603    /// Assistant blocks whose run ended in `RunInterrupted`; their footer
604    /// reads `… · interrupted`.
605    pub(crate) interrupted_assistant_items: HashSet<u64>,
606    pub(crate) attempts: HashMap<AttemptId, AttemptProjection>,
607    /// The latest attempt until its first delta, commit, or abandonment.
608    pub(crate) pending_attempt: Option<AttemptId>,
609    pub tools: HashMap<ToolCallId, ToolCallState>,
610    /// Buffered tool rows awaiting their committed placeholder, keyed by
611    /// the owning turn's content index so starts/completions cannot reorder.
612    pub(crate) pending_tool_rows: Vec<PendingToolRow>,
613    /// Durable tool input indexed from committed turn content:
614    /// (model_turn_seq, model_call_id) → arguments JSON, for expanded rows.
615    pub(crate) turn_tool_index: HashMap<(u64, String), IndexedToolCall>,
616    /// The assistant item owning each committed model-turn sequence.
617    pub(crate) turn_items: HashMap<u64, u64>,
618    /// User-visible replay compatibility transitions already projected for a
619    /// run. Durable replay/reconnect and later tool-loop attempts may repeat
620    /// request diagnostics without creating another logical transition.
621    pub(crate) replay_context_warnings: HashSet<(RunId, Sha256Digest, ReplayContextTransition)>,
622    /// Adapter warnings already surfaced for a run. After a model fallback the
623    /// same reconstruction warnings regenerate on every committed turn; each
624    /// distinct warning text is shown only once per run.
625    pub(crate) model_turn_warnings: HashSet<(RunId, String)>,
626    pub(crate) goal_revisions: HashMap<GoalId, u64>,
627    pub(crate) producer_messages: HashMap<ProducerMessageId, ProducerMessageProjection>,
628    pub(crate) producer_dedup: HashMap<(ProducerOwner, ProducerIdempotencyKey), ProducerMessageId>,
629    pub(crate) producer_claims: HashMap<u64, ProducerClaimProjection>,
630    pub(crate) terminal_runs: HashSet<RunId>,
631    pub approvals: Vec<ApprovalState>,
632    /// AGENTS.md entries loaded by the latest run (`None` when it loaded
633    /// none) and by the run before it. A row appears only when a run's
634    /// context differs from the previous run's, so turning AGENTS.md off and
635    /// back on shows again even when the files are unchanged.
636    pub(crate) agent_md_latest_run: Option<Vec<cookie_agent_protocol::AgentMdEntry>>,
637    pub(crate) agent_md_previous_run: Option<Vec<cookie_agent_protocol::AgentMdEntry>>,
638}
639
640impl SessionState {
641    pub fn is_open_assistant_part(&self, item_id: u64, part_id: u64) -> bool {
642        self.open_assistant
643            .is_some_and(|open| open.item_id == item_id && open.part_id == part_id)
644    }
645
646    pub fn is_open_thinking(&self, item_id: u64, part_id: u64) -> bool {
647        self.open_assistant.is_some_and(|open| {
648            open.item_id == item_id
649                && open.part_id == part_id
650                && open.kind == AssistantPartKind::Thinking
651        })
652    }
653
654    /// Whether any assistant part is still streaming thinking content.
655    pub fn has_open_thinking(&self) -> bool {
656        self.open_assistant
657            .is_some_and(|open| open.kind == AssistantPartKind::Thinking)
658    }
659
660    /// Whether any tool call in this session is still running.
661    pub fn has_running_tool(&self) -> bool {
662        self.tools
663            .values()
664            .any(|tool| tool.status == ToolStatus::Running)
665    }
666
667    /// Whether any producer message is waiting for a model run to claim it.
668    pub fn has_pending_producers(&self) -> bool {
669        self.transcript.iter().any(|item| {
670            matches!(
671                item,
672                TranscriptItem::ProducerMessage {
673                    status: ProducerMessageStatus::Pending | ProducerMessageStatus::Admitted,
674                    ..
675                }
676            )
677        })
678    }
679
680    /// The sealed elapsed thinking duration for one part, when known.
681    pub fn thinking_duration(&self, item_id: u64, part_id: u64) -> Option<Duration> {
682        self.thinking_durations.get(&(item_id, part_id)).copied()
683    }
684
685    /// Durable insertion time of one transcript item, when tracked.
686    pub(crate) fn item_time(&self, item_id: u64) -> Option<jiff::Timestamp> {
687        self.item_times.get(&item_id).copied()
688    }
689
690    /// Mark the open run block split-pending so the next new segment opens a
691    /// fresh block below an interleaved event row. Shared by in-session
692    /// warning rows and aggregated descendant warnings arriving from another
693    /// session while this one streams.
694    pub(crate) fn mark_event_split_pending(&mut self) {
695        if (self.open_assistant.is_some() || self.pending_attempt.is_some())
696            && let Some(projection) = self.open_run_assistant.as_mut()
697        {
698            projection.split_pending = true;
699        }
700    }
701
702    /// Split the open run block at a committed context checkpoint. A
703    /// checkpoint is durable context history the run's output must not
704    /// straddle, so unlike an interleaved event row — which splits only while
705    /// something streams — it always splits: the next new segment (part, tool
706    /// call, or attempt) opens a fresh block below the compaction row. A block
707    /// that never committed anything (an abandoned attempt's pruned partials)
708    /// has nothing to split off: its index comes back instead — with any split
709    /// an earlier row left pending consumed, exactly as relocating an empty
710    /// block does for a mid-stream row — so the caller can move the block
711    /// itself below the row and the retry keeps using it, rather than leaving
712    /// an empty header stranded on either side of the marker.
713    pub(crate) fn split_run_at_compaction(&mut self) -> Option<usize> {
714        let item_id = self
715            .open_run_assistant
716            .as_ref()
717            .map(|projection| projection.item_id)?;
718        let index = self
719            .transcript
720            .iter()
721            .position(|item| item.id() == item_id)?;
722        if matches!(
723            &self.transcript[index],
724            TranscriptItem::Assistant {
725                children,
726                committed_turn_seq,
727                ..
728            } if children.is_empty() && committed_turn_seq.is_none()
729        ) {
730            // The relocated block is the one the next segment continues in, so
731            // moving it consumes any split left pending by an earlier row
732            // instead of making the retry open a second block around it.
733            let projection = self
734                .open_run_assistant
735                .as_mut()
736                .expect("located run projection");
737            projection.split_pending = false;
738            projection.committed_prefix = 0;
739            return Some(index);
740        }
741        self.open_run_assistant
742            .as_mut()
743            .expect("located run projection")
744            .split_pending = true;
745        None
746    }
747}
748
749/// All currently observed session projections.
750#[derive(Clone, Debug, Default)]
751pub struct StateStore {
752    pub sessions: HashMap<SessionId, SessionState>,
753    physical_events: HashMap<SessionId, Vec<StoredEvent>>,
754    abandoned_output: HashMap<ToolCallId, SessionId>,
755    tool_sessions: HashMap<ToolCallId, SessionId>,
756    quarantined_sessions: HashSet<SessionId>,
757    replays: HashMap<SessionId, ReplayProgress>,
758}
759
760#[derive(Clone, Debug)]
761struct ReplayProgress {
762    generation: u64,
763    final_seq: u64,
764    scratch: SessionState,
765    physical_events: Vec<StoredEvent>,
766    deadline: Instant,
767}
768
769/// Result of reducing one item from the client's ordered delivery stream.
770#[derive(Clone, Copy, Debug, Eq, PartialEq)]
771pub enum DeliveryOutcome {
772    Applied,
773    Gap { session_id: SessionId, cursor: u64 },
774    ReplayFailed { session_id: SessionId },
775}
776
777const REPLAY_END_TIMEOUT: Duration = Duration::from_secs(5);
778
779impl StateStore {
780    /// Reduce every delivery variant from the single client stream. Replay
781    /// events reduce into a per-session scratch projection. The visible
782    /// projection changes only through a validated `ReplayEnd` swap or live
783    /// events after that replay's final sequence. Once a replay for a session
784    /// is abandoned, that session's visible projection is immutable until a
785    /// validated replacement replay ends; all of its output is quarantined.
786    pub fn apply_delivery(&mut self, delivery: ClientDelivery) -> DeliveryOutcome {
787        match delivery {
788            ClientDelivery::Live {
789                message,
790                generation,
791            } => match *message {
792                EventSubscriptionMessage::Event { event } => {
793                    let session_id = event.session_id;
794                    if self.apply_event_for_generation(*event, generation) {
795                        DeliveryOutcome::Applied
796                    } else {
797                        let cursor = self
798                            .sessions
799                            .get(&session_id)
800                            .map_or(0, |state| state.last_seq);
801                        DeliveryOutcome::Gap { session_id, cursor }
802                    }
803                }
804                EventSubscriptionMessage::Gap {
805                    session_id,
806                    last_delivered_seq,
807                } => DeliveryOutcome::Gap {
808                    session_id,
809                    cursor: last_delivered_seq,
810                },
811            },
812            ClientDelivery::ReplayStart {
813                session_id,
814                generation,
815                final_seq,
816                rebuild,
817            } => {
818                let mut scratch = if rebuild {
819                    SessionState {
820                        generation,
821                        ..SessionState::default()
822                    }
823                } else if let Some(state) = self.sessions.get(&session_id)
824                    && state.generation == generation
825                {
826                    state.clone()
827                } else {
828                    self.quarantined_sessions.insert(session_id);
829                    return DeliveryOutcome::ReplayFailed { session_id };
830                };
831                close_open_assistant(&mut scratch, jiff::Timestamp::now());
832                self.replays.insert(
833                    session_id,
834                    ReplayProgress {
835                        generation,
836                        final_seq,
837                        scratch,
838                        physical_events: if rebuild {
839                            Vec::new()
840                        } else {
841                            self.physical_events
842                                .get(&session_id)
843                                .cloned()
844                                .unwrap_or_default()
845                        },
846                        deadline: Instant::now() + REPLAY_END_TIMEOUT,
847                    },
848                );
849                DeliveryOutcome::Applied
850            }
851            ClientDelivery::ReplayEvent {
852                session_id,
853                generation,
854                final_seq,
855                event,
856            } => {
857                let event = *event;
858                let started_call = match &event.payload {
859                    EventPayload::ToolCallStarted { start } => Some(start.tool_call_id),
860                    _ => None,
861                };
862                if let Some(call_id) = started_call {
863                    self.tool_sessions.insert(call_id, session_id);
864                }
865                let valid = self.replays.get_mut(&session_id).is_some_and(|replay| {
866                    if replay.generation != generation || replay.final_seq != final_seq {
867                        return false;
868                    }
869                    if event.seq <= replay.scratch.last_seq {
870                        return true;
871                    }
872                    replay.physical_events.push(event.clone());
873                    if matches!(event.payload, EventPayload::SessionReverted { .. }) {
874                        replay.scratch =
875                            reduce_session_events(session_id, generation, &replay.physical_events);
876                    } else {
877                        replay.scratch.last_seq = event.seq;
878                        reduce_event(
879                            &mut replay.scratch,
880                            event.session_id,
881                            event.run_id,
882                            event.seq,
883                            event.timestamp,
884                            event.payload,
885                        );
886                    }
887                    replay.scratch.version = replay.scratch.version.wrapping_add(1);
888                    true
889                });
890                if !valid {
891                    self.abandon_replay(session_id);
892                    DeliveryOutcome::ReplayFailed { session_id }
893                } else {
894                    DeliveryOutcome::Applied
895                }
896            }
897            ClientDelivery::ReplayEnd {
898                session_id,
899                generation,
900                final_seq,
901            } => {
902                let replay = self.replays.remove(&session_id);
903                let valid = replay.as_ref().is_some_and(|replay| {
904                    replay.generation == generation
905                        && replay.final_seq == final_seq
906                        && replay.scratch.last_seq == final_seq
907                });
908                match replay {
909                    Some(mut replay) if valid => {
910                        self.quarantined_sessions.remove(&session_id);
911                        self.abandoned_output
912                            .retain(|_, output_session| *output_session != session_id);
913                        for call_id in replay.scratch.tools.keys() {
914                            self.abandoned_output.remove(call_id);
915                        }
916                        replay.scratch.version = self
917                            .sessions
918                            .get(&session_id)
919                            .map(|previous| {
920                                previous.version.max(replay.scratch.version).wrapping_add(1)
921                            })
922                            .unwrap_or(replay.scratch.version);
923                        self.physical_events
924                            .insert(session_id, replay.physical_events);
925                        self.sessions.insert(session_id, replay.scratch);
926                        DeliveryOutcome::Applied
927                    }
928                    Some(replay) => {
929                        self.quarantine_replay_output(session_id, &replay);
930                        DeliveryOutcome::ReplayFailed { session_id }
931                    }
932                    None => DeliveryOutcome::ReplayFailed { session_id },
933                }
934            }
935            // Display comes from durable progress/terminal events. Do not retain
936            // raw payloads even when a caller bypasses the display-only client.
937            ClientDelivery::OutputSnapshot(snapshot) => {
938                if let Some(session_id) = self.quarantined_output(snapshot.snapshot.call_id) {
939                    return DeliveryOutcome::ReplayFailed { session_id };
940                }
941                DeliveryOutcome::Applied
942            }
943            ClientDelivery::OutputDelta(delta) => {
944                if let Some(session_id) = self.quarantined_output(delta.call_id) {
945                    return DeliveryOutcome::ReplayFailed { session_id };
946                }
947                DeliveryOutcome::Applied
948            }
949            ClientDelivery::OutputGap(gap) => {
950                if let Some(session_id) = self.quarantined_output(gap.call_id) {
951                    return DeliveryOutcome::ReplayFailed { session_id };
952                }
953                DeliveryOutcome::Applied
954            }
955            ClientDelivery::RecoveryFailed { .. } => DeliveryOutcome::Applied,
956            ClientDelivery::Disconnected { error } => {
957                // A live transport notification, not a durable log event: wall
958                // clock is the only available ordering signal.
959                let timestamp = jiff::Timestamp::now();
960                for state in self.sessions.values_mut() {
961                    push_event(
962                        state,
963                        EventLevel::Error,
964                        format!("connection failed: {error}"),
965                        timestamp,
966                    );
967                }
968                DeliveryOutcome::Applied
969            }
970            ClientDelivery::PluginEvent(_) => DeliveryOutcome::Applied,
971            ClientDelivery::RuntimeChanged(_) => DeliveryOutcome::Applied,
972        }
973    }
974
975    /// Discard incomplete scratch replays. Their currently visible session
976    /// projections remain intact for a full recovery attempt.
977    pub fn abandon_timed_out_replays(&mut self) -> Vec<SessionId> {
978        let now = Instant::now();
979        let expired = self
980            .replays
981            .iter()
982            .filter_map(|(session_id, replay)| (replay.deadline <= now).then_some(*session_id))
983            .collect::<Vec<_>>();
984        for session_id in &expired {
985            self.abandon_replay(*session_id);
986        }
987        expired
988    }
989
990    /// Discard all incomplete scratch replays after a connection closes.
991    pub fn abandon_replays(&mut self) -> Vec<SessionId> {
992        let sessions = self.replays.keys().copied().collect::<Vec<_>>();
993        for session_id in &sessions {
994            self.abandon_replay(*session_id);
995        }
996        sessions
997    }
998
999    fn quarantined_output(&self, call_id: ToolCallId) -> Option<SessionId> {
1000        if self
1001            .replays
1002            .values()
1003            .any(|replay| replay.scratch.tools.contains_key(&call_id))
1004        {
1005            return None;
1006        }
1007        self.tool_sessions
1008            .get(&call_id)
1009            .copied()
1010            .filter(|session_id| {
1011                self.quarantined_sessions.contains(session_id)
1012                    || self.replays.contains_key(session_id)
1013            })
1014            .or_else(|| self.abandoned_output.get(&call_id).copied())
1015    }
1016
1017    fn abandon_replay(&mut self, session_id: SessionId) {
1018        self.quarantined_sessions.insert(session_id);
1019        if let Some(replay) = self.replays.remove(&session_id) {
1020            self.quarantine_replay_output(session_id, &replay);
1021        }
1022    }
1023
1024    fn quarantine_replay_output(&mut self, session_id: SessionId, replay: &ReplayProgress) {
1025        for call_id in replay.scratch.tools.keys() {
1026            self.abandoned_output.insert(*call_id, session_id);
1027        }
1028    }
1029
1030    /// Drain a session's voided inputs for restoration into the composer.
1031    /// Returns them in admission (FIFO) order; empty when nothing is owed.
1032    pub fn take_voided_inputs(&mut self, session_id: SessionId) -> Vec<String> {
1033        self.sessions
1034            .get_mut(&session_id)
1035            .map(|state| std::mem::take(&mut state.voided_inputs))
1036            .unwrap_or_default()
1037    }
1038
1039    /// Park text as voided for a session (e.g. a recall resolved while a
1040    /// different session was being viewed); the UI restores it on sight.
1041    pub fn park_voided_input(&mut self, session_id: SessionId, text: String) {
1042        if let Some(state) = self.sessions.get_mut(&session_id) {
1043            state.voided_inputs.push(text);
1044        }
1045    }
1046
1047    /// Apply a persisted event. Replayed duplicates are ignored by sequence.
1048    pub fn apply_event(&mut self, event: StoredEvent) -> bool {
1049        self.apply_event_for_generation(event, 0)
1050    }
1051
1052    pub fn apply_event_for_generation(&mut self, event: StoredEvent, generation: u64) -> bool {
1053        let started_call = match &event.payload {
1054            EventPayload::ToolCallStarted { start } => Some(start.tool_call_id),
1055            _ => None,
1056        };
1057        if let Some(call_id) = started_call {
1058            self.tool_sessions.insert(call_id, event.session_id);
1059        }
1060        if self.quarantined_sessions.contains(&event.session_id) {
1061            return false;
1062        }
1063        let state = self.sessions.entry(event.session_id).or_default();
1064        if state.generation != generation {
1065            return false;
1066        }
1067        if event.seq <= state.last_seq {
1068            return true;
1069        }
1070        self.physical_events
1071            .entry(event.session_id)
1072            .or_default()
1073            .push(event.clone());
1074        if matches!(event.payload, EventPayload::SessionReverted { .. }) {
1075            let previous_version = state.version;
1076            *state = reduce_session_events(
1077                event.session_id,
1078                generation,
1079                self.physical_events
1080                    .get(&event.session_id)
1081                    .expect("physical event was inserted"),
1082            );
1083            state.version = previous_version.max(state.version).wrapping_add(1);
1084        } else {
1085            state.last_seq = event.seq;
1086            reduce_event(
1087                state,
1088                event.session_id,
1089                event.run_id,
1090                event.seq,
1091                event.timestamp,
1092                event.payload,
1093            );
1094            state.version = state.version.wrapping_add(1);
1095        }
1096        true
1097    }
1098
1099    /// Apply a message from the event subscription stream. A gap is returned
1100    /// to allow callers to surface it; the client independently re-subscribes.
1101    pub fn apply_subscription(&mut self, message: EventSubscriptionMessage) -> Option<u64> {
1102        self.apply_subscription_for_generation(message, 0)
1103    }
1104
1105    pub fn apply_subscription_for_generation(
1106        &mut self,
1107        message: EventSubscriptionMessage,
1108        generation: u64,
1109    ) -> Option<u64> {
1110        match message {
1111            EventSubscriptionMessage::Event { event } => {
1112                let cursor = self
1113                    .sessions
1114                    .get(&event.session_id)
1115                    .map_or(0, |state| state.last_seq);
1116                self.apply_event_for_generation(*event, generation)
1117                    .then_some(())
1118                    .map_or(Some(cursor), |_| None)
1119            }
1120            EventSubscriptionMessage::Gap {
1121                last_delivered_seq, ..
1122            } => Some(last_delivered_seq),
1123        }
1124    }
1125
1126    /// Drop a session projection before a full cursor-zero rebuild.
1127    ///
1128    /// A visible projection is never reset while its replay scratch is active
1129    /// or its session is quarantined; callers receive `false` and must recover
1130    /// through the staged replay path instead.
1131    pub fn reset_session(&mut self, session_id: SessionId, generation: u64) -> bool {
1132        if self.quarantined_sessions.contains(&session_id) || self.replays.contains_key(&session_id)
1133        {
1134            return false;
1135        }
1136        self.sessions.insert(
1137            session_id,
1138            SessionState {
1139                generation,
1140                ..SessionState::default()
1141            },
1142        );
1143        self.physical_events.remove(&session_id);
1144        true
1145    }
1146
1147    /// Replace a session projection only after a complete contiguous replay is
1148    /// available. A failed/incomplete fetch leaves the existing projection intact.
1149    pub fn rebuild_session(
1150        &mut self,
1151        session_id: SessionId,
1152        generation: u64,
1153        events: Vec<StoredEvent>,
1154    ) -> bool {
1155        for (expected, event) in (1..).zip(&events) {
1156            if event.session_id != session_id || event.seq != expected {
1157                return false;
1158            }
1159        }
1160        if self.quarantined_sessions.contains(&session_id) || self.replays.contains_key(&session_id)
1161        {
1162            return false;
1163        }
1164        let state = reduce_session_events(session_id, generation, &events);
1165        self.physical_events.insert(session_id, events);
1166        self.sessions.insert(session_id, state);
1167        true
1168    }
1169}
1170
1171#[cfg(test)]
1172mod tests;