Skip to main content

aion_core/
assistant_session.rs

1//! The assistant-session vocabulary: identity, state, the summary a listing
2//! carries, the context a turn is asked with, and the frames a session streams.
3//!
4//! An assistant session is ONE live agent-harness process the server owns on a
5//! caller's behalf — not a workflow run. It has no history to replay and no
6//! determinism boundary; what it has is a durable transcript (every request and
7//! every event, in order) and a process that dies with the server.
8//!
9//! These types live in `aion-core` rather than in `aion-server` for the reason
10//! every other wire record does: the store persists them, the server serves
11//! them, and the ops console's TypeScript types are generated from this crate.
12//! One declaration, three consumers, no hand-kept copy.
13//!
14//! # The frames are the transcript
15//!
16//! [`AssistantSessionEvent`] is exactly what the WebSocket streams AND exactly
17//! what is appended to the durable transcript, so replaying the transcript and
18//! watching live are the same stream seen at two times. The index a frame
19//! carries ([`AssistantSessionFrame`]) is assigned by the store, densely, and is
20//! what a reconnecting client passes back as `?after=`.
21
22use chrono::{DateTime, Utc};
23
24use crate::assistant_document::{
25    AssistantDocumentEditError, AssistantDocumentEditOp, apply_document_edits,
26};
27use serde::{Deserialize, Serialize};
28use uuid::Uuid;
29
30/// Identifier for one assistant session.
31///
32/// A UUID rather than an operator-chosen name: a session is minted by the
33/// server, is never addressed by a name a human types, and a UUID keys a
34/// fixed-width durable record without an escaping rule.
35#[derive(
36    Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord,
37)]
38pub struct AssistantSessionId(Uuid);
39
40impl AssistantSessionId {
41    /// Creates a session identifier from an existing UUID.
42    #[must_use]
43    pub const fn new(id: Uuid) -> Self {
44        Self(id)
45    }
46
47    /// Mints a fresh session identifier.
48    #[must_use]
49    pub fn new_v4() -> Self {
50        Self(Uuid::new_v4())
51    }
52
53    /// Returns the UUID backing this identifier.
54    #[must_use]
55    pub const fn as_uuid(&self) -> Uuid {
56        self.0
57    }
58
59    /// Parses a session identifier from its canonical textual form.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`AssistantSessionIdError`] naming the text that is not a UUID.
64    /// A caller-supplied path segment is parsed here rather than pattern-matched
65    /// somewhere downstream, so a malformed id is one refusal with one message.
66    pub fn parse(text: &str) -> Result<Self, AssistantSessionIdError> {
67        Uuid::parse_str(text)
68            .map(Self)
69            .map_err(|_source| AssistantSessionIdError {
70                text: text.to_owned(),
71            })
72    }
73}
74
75impl std::fmt::Display for AssistantSessionId {
76    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        self.0.fmt(formatter)
78    }
79}
80
81/// A session identifier that could not be parsed from its textual form.
82#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
83#[error("`{text}` is not an assistant session id (session ids are UUIDs minted by the server)")]
84pub struct AssistantSessionIdError {
85    /// The text that was offered as a session id.
86    pub text: String,
87}
88
89/// What a session is, as the wire reports it — three answers and no more.
90///
91/// A PROJECTION, never a stored field, exactly as `WorkflowStatus` is: it is
92/// derived from the session's appended transcript records plus the live-process
93/// registry. Nothing writes a state; things append records, and this is read off
94/// them.
95///
96/// The states a UI might expect and will not find here are deliberate:
97///
98/// - **"busy" is not a state.** Whether a turn is open is what the transcript
99///   already says, so a session cannot be busy on the wire and idle in its
100///   frames. The refusal a caller needs — a second turn while one is open — is
101///   a `409` on the turn, not a state to render.
102/// - **"not logged in" is not a state.** It is the `auth_required` code on the
103///   turn that hit it, which names the turn it failed on; a session-wide state
104///   would claim the whole conversation was unusable when one turn was.
105#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
106#[serde(rename_all = "snake_case")]
107pub enum AssistantSessionState {
108    /// A harness process is running for this session right now.
109    Live,
110    /// No process — but the harness advertised `loadSession`, so the
111    /// conversation is the harness's OWN and can be reopened. The next turn
112    /// respawns it and opens with `session/load`, so a dormant session is
113    /// continuable and is never an error.
114    Dormant,
115    /// Terminal and read-only: it was deleted, or its harness cannot reload a
116    /// prior conversation, so there is nothing left to return to.
117    Ended,
118}
119
120impl AssistantSessionState {
121    /// Whether a process is running for this session.
122    #[must_use]
123    pub const fn is_live(self) -> bool {
124        matches!(self, Self::Live)
125    }
126
127    /// Whether another turn can be taken — now, or after a resume.
128    #[must_use]
129    pub const fn is_continuable(self) -> bool {
130        matches!(self, Self::Live | Self::Dormant)
131    }
132}
133
134/// One row of the session list.
135#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
136pub struct AssistantSessionSummary {
137    /// The session's identity.
138    pub session_id: AssistantSessionId,
139    /// The configured harness name this session runs.
140    pub harness: String,
141    /// The configured account name, when the harness declares any.
142    pub account: Option<String>,
143    /// What the session is, right now.
144    pub state: AssistantSessionState,
145    /// Why it is in that state, in the server's own words, or `None`.
146    ///
147    /// An ended session carries the reason it ended; a dormant one carries why
148    /// its process went away. A live one carries nothing, because nothing
149    /// decided it.
150    pub reason: Option<String>,
151    /// When the session was created.
152    pub created_at: DateTime<Utc>,
153    /// The last time anything about the session changed.
154    pub updated_at: DateTime<Utc>,
155    /// How many turns have been submitted on it.
156    pub turns: u64,
157    /// The first 80 characters of the first prompt, or `None` before one.
158    pub title: Option<String>,
159    /// The commands the HARNESS most recently advertised, in the order it
160    /// advertised them. Empty when it has advertised none.
161    ///
162    /// A projection over the transcript's
163    /// [`AssistantSessionEvent::AvailableCommands`] records, never a stored
164    /// field, and never a list this server composes: a command a client offers
165    /// that the agent never advertised is a refusal waiting to happen, so the
166    /// only honest source is what the agent itself said.
167    pub commands: Vec<AssistantCommand>,
168}
169
170/// One command the harness advertised on this session.
171///
172/// The three fields ACP's `AvailableCommand` carries that a client needs to
173/// OFFER one: what to send, what it does, and — when the command takes input —
174/// the hint to show before any has been typed. ACP's only input form is
175/// `unstructured` ("all text that was typed after the command name is provided
176/// as input"), so a hint is the whole of what there is to publish about it.
177#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
178pub struct AssistantCommand {
179    /// The command's name, as sent on a turn.
180    pub name: String,
181    /// What the agent says it does.
182    pub description: String,
183    /// The hint to show while the command's input is empty, when it takes one.
184    pub input_hint: Option<String>,
185}
186
187/// A harness command a turn invokes.
188///
189/// ACP delivers a command as ORDINARY PROMPT TEXT — `/name` followed by
190/// whatever input was typed after it, which is exactly what the schema's
191/// `unstructured` input form says the agent receives. So this is not an
192/// alternative transport; it is a statement of intent the server turns into the
193/// spec's own delivery form, checks against what the agent advertised, and
194/// records verbatim so the transcript says a command was invoked rather than
195/// leaving a reader to infer it from a leading slash.
196#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
197pub struct AssistantCommandInvocation {
198    /// The advertised command's name, without the leading `/`.
199    pub name: String,
200    /// The text typed after the command name, when any was.
201    pub input: Option<String>,
202}
203
204impl AssistantCommandInvocation {
205    /// The prompt line ACP delivers this invocation as.
206    ///
207    /// `/name` alone, or `/name input` — the one place the delivery form is
208    /// spelled, so the wire tests and the turn path cannot disagree about it.
209    #[must_use]
210    pub fn prompt_line(&self) -> String {
211        match self
212            .input
213            .as_deref()
214            .map(str::trim)
215            .filter(|input| !input.is_empty())
216        {
217            Some(input) => format!("/{} {input}", self.name),
218            None => format!("/{}", self.name),
219        }
220    }
221}
222
223/// A position in a document, in editor coordinates.
224#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
225pub struct AssistantDocumentPosition {
226    /// Zero-based line number, as the editor's own model holds it.
227    ///
228    /// ZERO-based on the wire and ONE-based in the prose: the numbers a
229    /// document model uses and the numbers an operator reads off a gutter are
230    /// different numbers, and converting once — where the prose is composed —
231    /// is what keeps them from drifting.
232    pub line: u32,
233    /// Zero-based column, counted in UTF-16 code units (the editor's own unit).
234    pub column: u32,
235}
236
237/// A selected range in a document.
238#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
239pub struct AssistantDocumentSelection {
240    /// Where the selection starts.
241    pub from: AssistantDocumentPosition,
242    /// Where the selection ends.
243    pub to: AssistantDocumentPosition,
244}
245
246/// The document a turn is asked about.
247#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
248pub struct AssistantDocumentContext {
249    /// The document's workspace-relative path.
250    pub path: String,
251    /// The document's current text, as the editor holds it.
252    pub text: String,
253    /// The selection that scopes the request, when there is one.
254    pub selection: Option<AssistantDocumentSelection>,
255    /// Where the caret is, when the document came from a live editor.
256    ///
257    /// NOT a zero-width selection: a caret is where the operator IS and a
258    /// selection is what they CHOSE, and an agent told a region was selected
259    /// when none was edits the wrong thing. Carried as its own field so the
260    /// prose can say "cursor on line 4" with no selection at all — and so the
261    /// operator's own words never have to carry it (nothing is appended to
262    /// what they typed; Tom, 2026-08-30).
263    pub cursor: Option<AssistantDocumentPosition>,
264}
265
266/// What was on the operator's screen when they asked.
267///
268/// Composed into the prompt as prose by the server, and SHOWN to the operator
269/// before it is sent — the console renders the same prefix the server will
270/// build, and the operator may opt out of it.
271#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, Default, PartialEq, Eq)]
272pub struct AssistantTurnContext {
273    /// The console URL the operator was on.
274    pub url: Option<String>,
275    /// The titles of the explain concepts declared on that screen.
276    pub concepts: Vec<String>,
277    /// The document under the editor's cursor, when the turn came from there.
278    pub document: Option<AssistantDocumentContext>,
279}
280
281/// How a tool call is going.
282#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
283#[serde(rename_all = "snake_case")]
284pub enum AssistantToolCallStatus {
285    /// The agent has begun the call.
286    Started,
287    /// The call finished and its output is on the frame.
288    Completed,
289    /// The call failed; the failure is on the frame's output.
290    Failed,
291}
292
293/// How a permission request was decided.
294///
295/// There is no console prompt in this cut: the configured policy decides, and
296/// the decision is recorded so an operator can see what was asked and what was
297/// answered.
298#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
299#[serde(rename_all = "snake_case")]
300pub enum AssistantPermissionDecision {
301    /// The policy allowed the call, once.
302    AllowOnce,
303    /// The policy denied the call.
304    Deny,
305}
306
307/// One event on a session's transcript, and one frame on its WebSocket.
308///
309/// The SAME value is durably appended and streamed live, so replay and live are
310/// one stream read at two times rather than two encodings that could disagree.
311#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
312#[serde(tag = "type", rename_all = "snake_case")]
313pub enum AssistantSessionEvent {
314    /// A harness process opened (or reopened) the conversation.
315    ///
316    /// Appended once per spawn — so a resumed session has two of these, and the
317    /// LAST one carries the capabilities of the process that is running now. It
318    /// is what makes the resume decision a fact read off the transcript rather
319    /// than a guess: `load_session` is what the agent ACTUALLY advertised at
320    /// `initialize`, not what its kind is assumed to support.
321    SessionOpened {
322        /// The harness's own session handle — what `session/load` resumes.
323        acp_session_ref: String,
324        /// Whether this agent advertised `loadSession` at `initialize`.
325        load_session: bool,
326        /// When it opened.
327        at: DateTime<Utc>,
328        /// Whether this open was a resume of a prior conversation.
329        resumed: bool,
330    },
331    /// The operator's on-screen context, as it stood.
332    ///
333    /// Appended by every turn and by an explicit context push, so the LATEST one
334    /// is the shared context both surfaces read — and the one the harness's
335    /// `assistant_context` tool answers with. The transcript IS the shared
336    /// context; there is no second store.
337    ContextShared {
338        /// What was on screen.
339        context: AssistantTurnContext,
340        /// What put it there: `turn` or `push`.
341        source: String,
342    },
343    /// What the OPERATOR asked, appended the moment the turn was accepted and
344    /// before any frame the harness produces for it.
345    ///
346    /// The record of the question. Without it a reloaded conversation is answers
347    /// with no questions, and the transcript — which is the shared context both
348    /// surfaces read — could not say what was asked. It carries the context the
349    /// turn was sent with, so the request and the screen it was asked from are
350    /// ONE record rather than two that could be appended out of order.
351    Request {
352        /// The turn this frame belongs to.
353        turn_id: String,
354        /// The operator's own words, without the composed context prefix.
355        text: String,
356        /// What was on the operator's screen, when they sent any.
357        context: Option<AssistantTurnContext>,
358        /// The harness command this turn invokes, when it invokes one.
359        ///
360        /// Recorded beside the text rather than folded into it, because a
361        /// transcript that showed only the composed `/compact …` line could not
362        /// say whether the operator pressed a command control or typed a line
363        /// that happened to begin with a slash.
364        command: Option<AssistantCommandInvocation>,
365    },
366    /// A turn was accepted and the prompt was sent.
367    TurnStarted {
368        /// The turn this frame belongs to.
369        turn_id: String,
370        /// When the turn started.
371        at: DateTime<Utc>,
372        /// The prompt exactly as it was sent, context prefix included.
373        ///
374        /// Recorded so the transcript answers "what was the agent actually
375        /// asked" — a transcript that showed only the operator's typing would
376        /// hide the composed context the answer was really shaped by. The
377        /// operator's own words are on the [`Self::Request`] frame that precedes
378        /// this one; they are not repeated here.
379        prompt: String,
380    },
381    /// The harness advertised the commands it serves.
382    ///
383    /// A COMPLETE replacement of whatever it advertised before, exactly as ACP's
384    /// `available_commands_update` is: an agent that drops a command stops
385    /// listing it, and a merge would keep offering a command that would now be
386    /// refused.
387    AvailableCommands {
388        /// Every command the harness serves, in the order it listed them.
389        commands: Vec<AssistantCommand>,
390    },
391    /// An agent text chunk, in order.
392    Delta {
393        /// The turn this frame belongs to.
394        turn_id: String,
395        /// The chunk.
396        text: String,
397    },
398    /// An agent thought chunk.
399    Thought {
400        /// The turn this frame belongs to.
401        turn_id: String,
402        /// The thought fragment.
403        text: String,
404    },
405    /// A tool call the agent made.
406    ToolCall {
407        /// The turn this frame belongs to.
408        turn_id: String,
409        /// The agent's own id for the call, so updates join to one row.
410        call_id: String,
411        /// The tool's name.
412        name: String,
413        /// How the call is going.
414        status: AssistantToolCallStatus,
415        /// The call's input, when the agent reported one.
416        #[ts(type = "unknown")]
417        input: Option<serde_json::Value>,
418        /// The call's output, when the agent reported one.
419        #[ts(type = "unknown")]
420        output: Option<serde_json::Value>,
421    },
422    /// A permission request, recorded with the decision the policy made.
423    PermissionAsk {
424        /// The turn this frame belongs to.
425        turn_id: String,
426        /// The agent's request, verbatim.
427        #[ts(type = "unknown")]
428        request: serde_json::Value,
429        /// What the configured policy answered.
430        decided: AssistantPermissionDecision,
431    },
432    /// The agent edited the shared document through its
433    /// `assistant_document_edit` tool.
434    ///
435    /// Appended by the assistant MCP route AFTER validating the batch against
436    /// the shared document as the transcript then held it, so a recorded batch
437    /// always applied cleanly in full. The projection folds it into
438    /// `latest_context` — which is how the agent's own next `assistant_context`
439    /// read sees its edits — and the console applies the same operations to the
440    /// live editor buffer, where the operator keeps or reverts them.
441    DocumentEdit {
442        /// The operations, in application order.
443        edits: Vec<AssistantDocumentEditOp>,
444        /// The shared document's revision AFTER this batch — monotonic per
445        /// session and never reset, so a client replaying frames after a
446        /// reconnect can skip batches it already applied.
447        revision: u64,
448    },
449    /// The turn ended with an answer.
450    TurnCompleted {
451        /// The turn this frame belongs to.
452        turn_id: String,
453        /// The agent's last completed message.
454        final_message: String,
455        /// The canonical stop reason.
456        stop_reason: String,
457        /// The harness's own session handle, when it reported one.
458        session_ref: Option<String>,
459    },
460    /// The turn ended without an answer.
461    TurnFailed {
462        /// The turn this frame belongs to.
463        turn_id: String,
464        /// `auth_required` for a `-32000`, otherwise the typed harness-error
465        /// variant name in `snake_case`.
466        code: String,
467        /// The failure, in the harness's own words.
468        message: String,
469    },
470    /// A frame the neutral vocabulary above cannot represent, passed through
471    /// verbatim.
472    ///
473    /// The adapter's own rule — nothing is ever silently dropped — reaching this
474    /// surface. A console renders what it knows and ignores the rest; an
475    /// operator reconstructing what an agent actually did still has every frame.
476    Raw {
477        /// The turn it belongs to, when it belongs to one.
478        turn_id: Option<String>,
479        /// Where the frame came from, as the adapter labelled it.
480        source: String,
481        /// The frame, verbatim.
482        #[ts(type = "unknown")]
483        value: serde_json::Value,
484    },
485    /// The session's state changed.
486    State {
487        /// The state it changed to.
488        state: AssistantSessionState,
489        /// Why — `process_exited`, `deleted`,
490        /// `resume_refused: loadSession not advertised`, `load_failed: …`.
491        /// `None` when nothing decided it.
492        reason: Option<String>,
493    },
494    /// The session is over; nothing further will arrive on this stream.
495    Ended {
496        /// Why it ended.
497        reason: String,
498    },
499}
500
501impl AssistantSessionEvent {
502    /// The turn this event belongs to, when it belongs to one.
503    ///
504    /// `State` and `Ended` are session-wide and belong to no turn; saying so
505    /// with `None` is what keeps a caller from inventing an attribution.
506    #[must_use]
507    pub fn turn_id(&self) -> Option<&str> {
508        match self {
509            Self::Request { turn_id, .. }
510            | Self::TurnStarted { turn_id, .. }
511            | Self::Delta { turn_id, .. }
512            | Self::Thought { turn_id, .. }
513            | Self::ToolCall { turn_id, .. }
514            | Self::PermissionAsk { turn_id, .. }
515            | Self::TurnCompleted { turn_id, .. }
516            | Self::TurnFailed { turn_id, .. } => Some(turn_id),
517            Self::Raw { turn_id, .. } => turn_id.as_deref(),
518            Self::SessionOpened { .. }
519            | Self::ContextShared { .. }
520            | Self::AvailableCommands { .. }
521            | Self::DocumentEdit { .. }
522            | Self::State { .. }
523            | Self::Ended { .. } => None,
524        }
525    }
526
527    /// The state and cause this record SETTLES the session to, if it settles it
528    /// at all.
529    ///
530    /// The lifecycle projection is exactly "the last record for which this
531    /// returns `Some`, unless a process is running". A record that decides
532    /// nothing returns `None`, which is what lets the scan walk backwards and
533    /// stop at the first decision it meets.
534    #[must_use]
535    pub fn settles(&self) -> Option<(AssistantSessionState, Option<String>)> {
536        match self {
537            Self::State { state, reason } if !state.is_live() => Some((*state, reason.clone())),
538            Self::Ended { reason } => Some((AssistantSessionState::Ended, Some(reason.clone()))),
539            _ => None,
540        }
541    }
542}
543
544/// One event with the durable index the store assigned it.
545///
546/// The index is what a reconnecting client passes back as `?after=`, so it is on
547/// every frame rather than only on replayed ones: a client that watched live and
548/// then dropped its socket must be able to resume from what it last saw without
549/// having read the transcript endpoint first.
550#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
551pub struct AssistantSessionFrame {
552    /// The event's dense, store-assigned position in the session's transcript.
553    pub index: u64,
554    /// The event itself, flattened so the frame reads as one object.
555    #[serde(flatten)]
556    #[ts(flatten)]
557    pub event: AssistantSessionEvent,
558}
559
560/// Everything a session's transcript says about it.
561///
562/// The one place the projection rules live, so the list surface, the detail
563/// surface, the resume decision and the `assistant_context` tool all read the
564/// same facts off the same records. Nothing here reads a stored status: there
565/// is none.
566#[derive(Clone, Debug, Default, PartialEq, Eq)]
567pub struct AssistantSessionProjection {
568    /// The last state a record settled the session to, with its cause. `None`
569    /// when no record has settled it — which, for a session with no running
570    /// process, is a transcript the boot sweep has not yet written back to.
571    pub settled: Option<(AssistantSessionState, Option<String>)>,
572    /// The harness's own session handle from the most recent open.
573    pub acp_session_ref: Option<String>,
574    /// Whether the agent advertised `loadSession` at its most recent open.
575    /// `false` until an open has been recorded: a session that never opened
576    /// cannot be resumed either.
577    pub load_session: bool,
578    /// How many turns have started.
579    pub turns: u64,
580    /// What the operator typed on the first turn — the fallback title.
581    pub first_turn_text: Option<String>,
582    /// The turn a `Request` opened that no `TurnCompleted`/`TurnFailed` has
583    /// closed yet. `None` when every asked turn has been answered.
584    ///
585    /// This is how a settle path knows there is a turn to close: a session
586    /// whose process died mid-turn must have that turn FAILED on the record
587    /// before the session settles, or every reader of the transcript folds an
588    /// open turn forever — the console reads it as busy, and a later edit
589    /// batch would be attributed to a turn that ended long ago.
590    pub open_turn_id: Option<String>,
591    /// The most recently shared on-screen context.
592    pub latest_context: Option<AssistantTurnContext>,
593    /// The shared document's revision: how many edit batches have folded into
594    /// `latest_context`. Monotonic and never reset — a fresh context share
595    /// rebases the text but does not rewind the count — so a client can use it
596    /// as an idempotency guard when replaying frames.
597    pub document_revision: u64,
598    /// The commands the harness advertised most recently. A later advertisement
599    /// REPLACES an earlier one whole, as ACP's own update does.
600    pub commands: Vec<AssistantCommand>,
601}
602
603impl AssistantSessionProjection {
604    /// Project a whole transcript, oldest event first.
605    #[must_use]
606    pub fn of<'events>(events: impl IntoIterator<Item = &'events AssistantSessionEvent>) -> Self {
607        let mut projection = Self::default();
608        for event in events {
609            projection.apply(event);
610        }
611        projection
612    }
613
614    /// Fold one event in, in transcript order.
615    fn apply(&mut self, event: &AssistantSessionEvent) {
616        match event {
617            AssistantSessionEvent::SessionOpened {
618                acp_session_ref,
619                load_session,
620                ..
621            } => {
622                self.acp_session_ref = Some(acp_session_ref.clone());
623                self.load_session = *load_session;
624                // An open un-settles the session: a process is running again.
625                self.settled = None;
626            }
627            AssistantSessionEvent::ContextShared { context, .. } => {
628                self.latest_context = Some(context.clone());
629            }
630            AssistantSessionEvent::AvailableCommands { commands } => {
631                // REPLACED, not merged: the agent's advertisement is complete,
632                // and a merge would keep offering a command it has dropped.
633                self.commands.clone_from(commands);
634            }
635            // The REQUEST is what counts a turn, not the start: it is appended
636            // at acceptance and carries the operator's own words, so a turn that
637            // was accepted and then failed to reach the agent is still a turn
638            // that was asked.
639            AssistantSessionEvent::Request {
640                turn_id,
641                text,
642                context,
643                ..
644            } => {
645                self.turns = self.turns.saturating_add(1);
646                self.open_turn_id = Some(turn_id.clone());
647                if self.first_turn_text.is_none() {
648                    self.first_turn_text = Some(text.clone());
649                }
650                if let Some(context) = context {
651                    self.latest_context = Some(context.clone());
652                }
653            }
654            AssistantSessionEvent::TurnCompleted { turn_id, .. }
655            | AssistantSessionEvent::TurnFailed { turn_id, .. } => {
656                if self.open_turn_id.as_deref() == Some(turn_id.as_str()) {
657                    self.open_turn_id = None;
658                }
659                if let Some(settled) = event.settles() {
660                    self.settled = Some(settled);
661                }
662            }
663            AssistantSessionEvent::DocumentEdit { edits, revision } => {
664                // `max`, not assignment: revisions are minted monotonically at
665                // append, so on an in-order transcript this IS assignment — the
666                // `max` only guards the fold against a stream a caller hands it
667                // out of order, where a rewind would break every client using
668                // the revision as an idempotency cursor.
669                self.document_revision = self.document_revision.max(*revision);
670                if let Some(document) = self
671                    .latest_context
672                    .as_mut()
673                    .and_then(|context| context.document.as_mut())
674                {
675                    match apply_document_edits(&document.text, edits) {
676                        Ok(applied) => document.text = applied,
677                        // Reachable, and benign. The append path validated this
678                        // batch against the projection AS IT THEN STOOD, but a
679                        // `ContextShared` recorded between validation and this
680                        // fold can rebase `latest_context` to bytes the batch
681                        // no longer matches. The projection stays TOTAL — an
682                        // unappliable batch leaves the text as it stands rather
683                        // than poisoning the fold — and the very next context
684                        // share carries the console's own buffer, edits
685                        // included, so the folded document converges on the
686                        // operator's truth rather than drifting from it.
687                        Err(
688                            AssistantDocumentEditError::EmptyOldString { .. }
689                            | AssistantDocumentEditError::Absent { .. }
690                            | AssistantDocumentEditError::Ambiguous { .. },
691                        ) => {}
692                    }
693                }
694            }
695            other => {
696                if let Some(settled) = other.settles() {
697                    self.settled = Some(settled);
698                }
699            }
700        }
701    }
702
703    /// The state and cause to report, given whether a process is running.
704    ///
705    /// A running process ALWAYS wins: it is the one fact a transcript cannot
706    /// contradict. With no process, the last settling record decides. With no
707    /// process and no settling record — a state the boot sweep exists to
708    /// prevent — the answer is [`AssistantSessionState::Ended`] with a cause
709    /// that says exactly that, because a session with no process and no record
710    /// of stopping must never be presented as running.
711    #[must_use]
712    pub fn state(
713        &self,
714        live: Option<AssistantSessionState>,
715    ) -> (AssistantSessionState, Option<String>) {
716        if let Some(state) = live {
717            return (state, None);
718        }
719        match &self.settled {
720            Some((state, cause)) => (*state, cause.clone()),
721            None => (
722                AssistantSessionState::Ended,
723                Some(NO_SETTLING_RECORD.to_owned()),
724            ),
725        }
726    }
727
728    /// Whether a dormant session can be reopened: it has a handle to load and
729    /// the agent said it can load one.
730    #[must_use]
731    pub fn is_resumable(&self) -> bool {
732        self.load_session && self.acp_session_ref.is_some()
733    }
734
735    /// The title a listing shows: the first 80 characters of the first turn's
736    /// own text, when the caller named no title of their own.
737    #[must_use]
738    pub fn derived_title(&self) -> Option<String> {
739        self.first_turn_text.as_ref().map(|text| {
740            let trimmed = text.trim();
741            trimmed.chars().take(TITLE_CHARACTERS).collect()
742        })
743    }
744}
745
746/// How many characters of the first prompt become a session's title.
747pub const TITLE_CHARACTERS: usize = 80;
748
749/// The cause reported for a session with no process and no record of stopping.
750///
751/// Reachable only if the boot sweep did not run or could not write; naming it
752/// keeps that state distinguishable from a session that was genuinely closed.
753pub const NO_SETTLING_RECORD: &str =
754    "no process is running and the transcript records no stop; treated as ended";
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759
760    fn instant() -> Result<DateTime<Utc>, String> {
761        use chrono::TimeZone;
762
763        Utc.with_ymd_and_hms(2026, 8, 29, 6, 0, 0)
764            .single()
765            .ok_or_else(|| "the test instant must be valid".to_owned())
766    }
767
768    #[test]
769    fn a_frame_carries_its_index_beside_the_event_discriminator() -> Result<(), String> {
770        let frame = AssistantSessionFrame {
771            index: 3,
772            event: AssistantSessionEvent::Delta {
773                turn_id: "t-1".to_owned(),
774                text: "hello".to_owned(),
775            },
776        };
777        let wire = serde_json::to_value(&frame).map_err(|error| error.to_string())?;
778        assert_eq!(wire["index"], serde_json::json!(3));
779        assert_eq!(wire["type"], serde_json::json!("delta"));
780        assert_eq!(wire["turn_id"], serde_json::json!("t-1"));
781        assert_eq!(wire["text"], serde_json::json!("hello"));
782        Ok(())
783    }
784
785    #[test]
786    fn every_event_shape_round_trips_through_its_wire_form() -> Result<(), String> {
787        let events = vec![
788            AssistantSessionEvent::SessionOpened {
789                acp_session_ref: "sess-1".to_owned(),
790                load_session: true,
791                at: instant()?,
792                resumed: false,
793            },
794            AssistantSessionEvent::ContextShared {
795                context: AssistantTurnContext::default(),
796                source: "turn".to_owned(),
797            },
798            AssistantSessionEvent::Request {
799                turn_id: "t-1".to_owned(),
800                text: "fix this".to_owned(),
801                context: Some(AssistantTurnContext::default()),
802                command: Some(AssistantCommandInvocation {
803                    name: "compact".to_owned(),
804                    input: Some("keep the plan".to_owned()),
805                }),
806            },
807            AssistantSessionEvent::TurnStarted {
808                turn_id: "t-1".to_owned(),
809                at: instant()?,
810                prompt: "On screen: /studio\n\nfix this".to_owned(),
811            },
812            AssistantSessionEvent::AvailableCommands {
813                commands: vec![AssistantCommand {
814                    name: "compact".to_owned(),
815                    description: "compact the conversation".to_owned(),
816                    input_hint: Some("what to keep".to_owned()),
817                }],
818            },
819            AssistantSessionEvent::DocumentEdit {
820                edits: vec![crate::assistant_document::AssistantDocumentEditOp {
821                    old_string: "step one".to_owned(),
822                    new_string: "step first".to_owned(),
823                }],
824                revision: 1,
825            },
826            AssistantSessionEvent::Delta {
827                turn_id: "t-1".to_owned(),
828                text: "part".to_owned(),
829            },
830            AssistantSessionEvent::Thought {
831                turn_id: "t-1".to_owned(),
832                text: "considering".to_owned(),
833            },
834            AssistantSessionEvent::ToolCall {
835                turn_id: "t-1".to_owned(),
836                call_id: "c-1".to_owned(),
837                name: "check_document".to_owned(),
838                status: AssistantToolCallStatus::Completed,
839                input: Some(serde_json::json!({ "path": "a.awl" })),
840                output: None,
841            },
842            AssistantSessionEvent::PermissionAsk {
843                turn_id: "t-1".to_owned(),
844                request: serde_json::json!({ "toolCall": { "toolCallId": "c-1" } }),
845                decided: AssistantPermissionDecision::Deny,
846            },
847            AssistantSessionEvent::TurnCompleted {
848                turn_id: "t-1".to_owned(),
849                final_message: "done".to_owned(),
850                stop_reason: "end_turn".to_owned(),
851                session_ref: Some("sess-1".to_owned()),
852            },
853            AssistantSessionEvent::TurnFailed {
854                turn_id: "t-2".to_owned(),
855                code: "auth_required".to_owned(),
856                message: "the agent requires authentication".to_owned(),
857            },
858            AssistantSessionEvent::State {
859                state: AssistantSessionState::Dormant,
860                reason: Some("process_exited".to_owned()),
861            },
862            AssistantSessionEvent::Ended {
863                reason: "the operator closed the session".to_owned(),
864            },
865        ];
866        for event in events {
867            let bytes = serde_json::to_vec(&event).map_err(|error| error.to_string())?;
868            let decoded: AssistantSessionEvent =
869                serde_json::from_slice(&bytes).map_err(|error| error.to_string())?;
870            assert_eq!(decoded, event);
871        }
872        Ok(())
873    }
874
875    #[test]
876    fn a_session_id_parses_from_its_own_display_form() {
877        let id = AssistantSessionId::new(Uuid::from_u128(7));
878        assert_eq!(AssistantSessionId::parse(&id.to_string()), Ok(id));
879        let error = AssistantSessionId::parse("not-a-uuid");
880        assert!(
881            matches!(&error, Err(failure) if failure.text == "not-a-uuid"),
882            "a malformed id must be refused naming the text: {error:?}"
883        );
884    }
885
886    #[test]
887    fn only_a_running_process_is_live_and_a_dormant_session_is_still_continuable() {
888        assert!(AssistantSessionState::Live.is_live());
889        assert!(!AssistantSessionState::Dormant.is_live());
890        assert!(!AssistantSessionState::Ended.is_live());
891        // The distinction the whole resume path rests on: dormant is not
892        // running and is not over.
893        assert!(AssistantSessionState::Live.is_continuable());
894        assert!(AssistantSessionState::Dormant.is_continuable());
895        assert!(!AssistantSessionState::Ended.is_continuable());
896    }
897
898    #[test]
899    fn the_three_wire_states_spell_exactly_what_the_console_parses() {
900        // A parser on the other side refuses anything but these three words, so
901        // a renamed variant must fail HERE rather than at a running console.
902        for (state, spelling) in [
903            (AssistantSessionState::Live, "\"live\""),
904            (AssistantSessionState::Dormant, "\"dormant\""),
905            (AssistantSessionState::Ended, "\"ended\""),
906        ] {
907            assert_eq!(
908                serde_json::to_string(&state).unwrap_or_default(),
909                spelling,
910                "{state:?} must spell {spelling} on the wire"
911            );
912        }
913    }
914
915    #[test]
916    fn session_wide_events_belong_to_no_turn() {
917        assert_eq!(
918            AssistantSessionEvent::State {
919                state: AssistantSessionState::Live,
920                reason: None,
921            }
922            .turn_id(),
923            None
924        );
925        assert_eq!(
926            AssistantSessionEvent::Ended {
927                reason: "stopped".to_owned(),
928            }
929            .turn_id(),
930            None
931        );
932        assert_eq!(
933            AssistantSessionEvent::Delta {
934                turn_id: "t-9".to_owned(),
935                text: String::new(),
936            }
937            .turn_id(),
938            Some("t-9")
939        );
940    }
941
942    fn opened(load_session: bool, resumed: bool) -> Result<AssistantSessionEvent, String> {
943        Ok(AssistantSessionEvent::SessionOpened {
944            acp_session_ref: "sess-1".to_owned(),
945            load_session,
946            at: instant()?,
947            resumed,
948        })
949    }
950
951    /// The frame a turn is ACCEPTED with — the record of what was asked, which
952    /// is also what the projection counts and titles from.
953    fn turn(text: &str) -> AssistantSessionEvent {
954        AssistantSessionEvent::Request {
955            turn_id: "t-1".to_owned(),
956            text: text.to_owned(),
957            context: None,
958            command: None,
959        }
960    }
961
962    #[test]
963    fn a_running_process_wins_over_every_settling_record() -> Result<(), String> {
964        let events = vec![
965            opened(true, false)?,
966            AssistantSessionEvent::State {
967                state: AssistantSessionState::Dormant,
968                reason: Some("process_exited".to_owned()),
969            },
970            opened(true, true)?,
971        ];
972        let projection = AssistantSessionProjection::of(&events);
973        // The second open cleared the settlement: a reopened session is not
974        // dormant because it once was.
975        assert_eq!(projection.settled, None);
976        assert_eq!(
977            projection.state(Some(AssistantSessionState::Live)),
978            (AssistantSessionState::Live, None)
979        );
980        Ok(())
981    }
982
983    #[test]
984    fn with_no_process_the_last_settling_record_decides() -> Result<(), String> {
985        let events = vec![
986            opened(true, false)?,
987            turn("fix the check"),
988            AssistantSessionEvent::State {
989                state: AssistantSessionState::Dormant,
990                reason: Some("process_exited".to_owned()),
991            },
992        ];
993        let projection = AssistantSessionProjection::of(&events);
994        assert_eq!(
995            projection.state(None),
996            (
997                AssistantSessionState::Dormant,
998                Some("process_exited".to_owned())
999            )
1000        );
1001        assert!(projection.is_resumable());
1002        assert_eq!(projection.turns, 1);
1003        assert_eq!(projection.derived_title(), Some("fix the check".to_owned()));
1004        Ok(())
1005    }
1006
1007    #[test]
1008    fn an_agent_that_never_advertised_load_session_is_not_resumable() -> Result<(), String> {
1009        let projection = AssistantSessionProjection::of(&[opened(false, false)?]);
1010        assert!(
1011            !projection.is_resumable(),
1012            "resume is gated on what the agent ACTUALLY advertised"
1013        );
1014        Ok(())
1015    }
1016
1017    #[test]
1018    fn no_process_and_no_settling_record_reads_ended_not_running() -> Result<(), String> {
1019        let projection = AssistantSessionProjection::of(&[opened(true, false)?, turn("hello")]);
1020        let (state, cause) = projection.state(None);
1021        assert_eq!(state, AssistantSessionState::Ended);
1022        assert_eq!(cause.as_deref(), Some(NO_SETTLING_RECORD));
1023        Ok(())
1024    }
1025
1026    #[test]
1027    fn the_latest_shared_context_is_the_one_the_tool_answers_with() {
1028        let first = AssistantTurnContext {
1029            url: Some("/studio".to_owned()),
1030            concepts: vec!["awl.step".to_owned()],
1031            document: None,
1032        };
1033        let second = AssistantTurnContext {
1034            url: Some("/runs".to_owned()),
1035            concepts: Vec::new(),
1036            document: None,
1037        };
1038        let events = vec![
1039            AssistantSessionEvent::ContextShared {
1040                context: first,
1041                source: "turn".to_owned(),
1042            },
1043            AssistantSessionEvent::ContextShared {
1044                context: second.clone(),
1045                source: "push".to_owned(),
1046            },
1047        ];
1048        assert_eq!(
1049            AssistantSessionProjection::of(&events).latest_context,
1050            Some(second)
1051        );
1052    }
1053
1054    /// The console parses `request { turn_id, text, context }` by those exact
1055    /// names (`wire.ts:161`). A renamed field is a frame it drops, and a
1056    /// transcript with no request block claims nothing rather than being wrong —
1057    /// which is precisely why the drift has to fail HERE.
1058    #[test]
1059    fn a_request_frame_spells_what_the_console_parses() -> Result<(), String> {
1060        let wire = serde_json::to_value(AssistantSessionEvent::Request {
1061            turn_id: "t-7".to_owned(),
1062            text: "fix the check".to_owned(),
1063            context: Some(AssistantTurnContext {
1064                url: Some("/studio".to_owned()),
1065                concepts: vec!["awl.step".to_owned()],
1066                document: None,
1067            }),
1068            command: None,
1069        })
1070        .map_err(|error| error.to_string())?;
1071        assert_eq!(wire["type"], serde_json::json!("request"));
1072        assert_eq!(wire["turn_id"], serde_json::json!("t-7"));
1073        assert_eq!(wire["text"], serde_json::json!("fix the check"));
1074        assert_eq!(wire["context"]["url"], serde_json::json!("/studio"));
1075        Ok(())
1076    }
1077
1078    /// A later advertisement REPLACES an earlier one whole. A merge would keep
1079    /// offering a command the agent has dropped, and the turn invoking it would
1080    /// be refused by the agent instead of by the surface that offered it.
1081    #[test]
1082    fn a_later_command_advertisement_replaces_the_earlier_one_whole() {
1083        let command = |name: &str| AssistantCommand {
1084            name: name.to_owned(),
1085            description: format!("{name} does something"),
1086            input_hint: None,
1087        };
1088        let events = vec![
1089            AssistantSessionEvent::AvailableCommands {
1090                commands: vec![command("compact"), command("plan")],
1091            },
1092            AssistantSessionEvent::AvailableCommands {
1093                commands: vec![command("compact")],
1094            },
1095        ];
1096        let projection = AssistantSessionProjection::of(&events);
1097        assert_eq!(
1098            projection
1099                .commands
1100                .iter()
1101                .map(|entry| entry.name.as_str())
1102                .collect::<Vec<_>>(),
1103            vec!["compact"],
1104            "`plan` was withdrawn and must not survive as an offer"
1105        );
1106    }
1107
1108    /// A session nobody has advertised commands on offers none — the negative
1109    /// control for the cell above, which an always-populated list would pass.
1110    #[test]
1111    fn a_session_with_no_advertisement_offers_no_commands() {
1112        assert!(
1113            AssistantSessionProjection::default().commands.is_empty(),
1114            "a command list must come from the agent, never from this server"
1115        );
1116    }
1117
1118    /// ACP delivers a command as ordinary prompt text: `/name` and the input
1119    /// after it. One spelling, here, so the turn path and the wire pins cannot
1120    /// disagree about the form the agent actually receives.
1121    #[test]
1122    fn a_command_is_delivered_as_the_slash_line_the_acp_spec_describes() {
1123        assert_eq!(
1124            AssistantCommandInvocation {
1125                name: "compact".to_owned(),
1126                input: Some("keep the plan".to_owned()),
1127            }
1128            .prompt_line(),
1129            "/compact keep the plan"
1130        );
1131        assert_eq!(
1132            AssistantCommandInvocation {
1133                name: "compact".to_owned(),
1134                input: None,
1135            }
1136            .prompt_line(),
1137            "/compact"
1138        );
1139        // Whitespace-only input is no input: a trailing space on the wire must
1140        // not become a trailing space in what the agent parses.
1141        assert_eq!(
1142            AssistantCommandInvocation {
1143                name: "compact".to_owned(),
1144                input: Some("   ".to_owned()),
1145            }
1146            .prompt_line(),
1147            "/compact"
1148        );
1149    }
1150
1151    /// A turn's context reaches the shared-context projection through the
1152    /// REQUEST frame, so a turn asked from a screen updates what the
1153    /// `assistant_context` tool answers with without a second record.
1154    #[test]
1155    fn a_turns_own_context_becomes_the_shared_context() {
1156        let context = AssistantTurnContext {
1157            url: Some("/studio/a.awl".to_owned()),
1158            concepts: Vec::new(),
1159            document: None,
1160        };
1161        let projection = AssistantSessionProjection::of(&[AssistantSessionEvent::Request {
1162            turn_id: "t-1".to_owned(),
1163            text: "explain".to_owned(),
1164            context: Some(context.clone()),
1165            command: None,
1166        }]);
1167        assert_eq!(projection.latest_context, Some(context));
1168        assert_eq!(projection.turns, 1);
1169    }
1170
1171    #[test]
1172    fn a_title_is_bounded_at_eighty_characters() -> Result<(), String> {
1173        let long = "x".repeat(200);
1174        let projection = AssistantSessionProjection::of(&[turn(&long)]);
1175        let title = projection
1176            .derived_title()
1177            .ok_or_else(|| "a started turn has a derivable title".to_owned())?;
1178        assert_eq!(title.chars().count(), TITLE_CHARACTERS);
1179        Ok(())
1180    }
1181
1182    /// A `Request` opens a turn and only ITS `TurnCompleted`/`TurnFailed`
1183    /// closes it — this is what a settle path reads to know a dying process
1184    /// leaves a question on the record that will never be answered.
1185    #[test]
1186    fn a_request_opens_a_turn_and_its_ending_closes_it() {
1187        let mut projection = AssistantSessionProjection::of(&[turn("first ask")]);
1188        assert_eq!(projection.open_turn_id.as_deref(), Some("t-1"));
1189        // An ending for a DIFFERENT turn does not close this one.
1190        projection.apply(&AssistantSessionEvent::TurnFailed {
1191            turn_id: "t-0".to_owned(),
1192            code: "stale".to_owned(),
1193            message: "an earlier turn's ending arrives late".to_owned(),
1194        });
1195        assert_eq!(projection.open_turn_id.as_deref(), Some("t-1"));
1196        projection.apply(&AssistantSessionEvent::TurnCompleted {
1197            turn_id: "t-1".to_owned(),
1198            final_message: "answered".to_owned(),
1199            stop_reason: "end_turn".to_owned(),
1200            session_ref: None,
1201        });
1202        assert_eq!(projection.open_turn_id, None);
1203    }
1204
1205    /// The projection folds a recorded edit batch into the shared document, so
1206    /// the agent's next `assistant_context` read sees its own edits — the
1207    /// transcript IS the shared document, with no second store to catch up.
1208    #[test]
1209    fn a_document_edit_folds_into_the_latest_context() {
1210        let events = vec![
1211            AssistantSessionEvent::ContextShared {
1212                context: AssistantTurnContext {
1213                    url: Some("/studio/pipeline.awl".to_owned()),
1214                    concepts: Vec::new(),
1215                    document: Some(AssistantDocumentContext {
1216                        path: "pipeline.awl".to_owned(),
1217                        text: "workflow demo\nstep one\n".to_owned(),
1218                        selection: None,
1219                        cursor: None,
1220                    }),
1221                },
1222                source: "turn".to_owned(),
1223            },
1224            AssistantSessionEvent::DocumentEdit {
1225                edits: vec![crate::assistant_document::AssistantDocumentEditOp {
1226                    old_string: "step one".to_owned(),
1227                    new_string: "step first".to_owned(),
1228                }],
1229                revision: 1,
1230            },
1231        ];
1232        let projection = AssistantSessionProjection::of(&events);
1233        assert_eq!(projection.document_revision, 1);
1234        let text = projection
1235            .latest_context
1236            .and_then(|context| context.document)
1237            .map(|document| document.text);
1238        assert_eq!(text, Some("workflow demo\nstep first\n".to_owned()));
1239    }
1240
1241    /// An edit recorded with no shared document still advances the revision —
1242    /// the batch was appended, and a client's idempotency guard counts appended
1243    /// batches, not applicable ones — and the fold stays total rather than
1244    /// failing over a document that is not there.
1245    #[test]
1246    fn a_document_edit_with_no_shared_document_still_advances_the_revision() {
1247        let events = vec![AssistantSessionEvent::DocumentEdit {
1248            edits: vec![crate::assistant_document::AssistantDocumentEditOp {
1249                old_string: "anything".to_owned(),
1250                new_string: "else".to_owned(),
1251            }],
1252            revision: 1,
1253        }];
1254        let projection = AssistantSessionProjection::of(&events);
1255        assert_eq!(projection.document_revision, 1);
1256        assert_eq!(projection.latest_context, None);
1257    }
1258
1259    /// The console parses `document_edit { edits, revision }` by those exact
1260    /// names. A renamed field is a frame the editor never applies, so the drift
1261    /// has to fail HERE.
1262    #[test]
1263    fn a_document_edit_frame_spells_what_the_console_parses() -> Result<(), String> {
1264        let wire = serde_json::to_value(AssistantSessionEvent::DocumentEdit {
1265            edits: vec![crate::assistant_document::AssistantDocumentEditOp {
1266                old_string: "a".to_owned(),
1267                new_string: "b".to_owned(),
1268            }],
1269            revision: 4,
1270        })
1271        .map_err(|error| error.to_string())?;
1272        assert_eq!(wire["type"], serde_json::json!("document_edit"));
1273        assert_eq!(wire["revision"], serde_json::json!(4));
1274        assert_eq!(wire["edits"][0]["old_string"], serde_json::json!("a"));
1275        assert_eq!(wire["edits"][0]["new_string"], serde_json::json!("b"));
1276        Ok(())
1277    }
1278}