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    /// Shut by the caller, and reopenable: the harness advertised
116    /// `loadSession` and the conversation has a handle to load, so the next
117    /// turn respawns it exactly as a dormant one — but the caller chose to put
118    /// it away, so it is never the operator's *current* session. Opening it
119    /// from history and typing is what brings it back.
120    Closed,
121    /// Terminal and read-only: its harness cannot reload a prior conversation,
122    /// so there is nothing left to return to — whether the process went on its
123    /// own or the caller shut it.
124    Ended,
125}
126
127impl AssistantSessionState {
128    /// Whether a process is running for this session.
129    #[must_use]
130    pub const fn is_live(self) -> bool {
131        matches!(self, Self::Live)
132    }
133
134    /// Whether another turn can be taken — now, or after a resume.
135    #[must_use]
136    pub const fn is_continuable(self) -> bool {
137        matches!(self, Self::Live | Self::Dormant | Self::Closed)
138    }
139
140    /// Whether this session may be the operator's *current* one: continuable
141    /// AND not put away. A closed session can still be reopened, but only by
142    /// the operator choosing it — it never becomes current on its own.
143    #[must_use]
144    pub const fn is_current_candidate(self) -> bool {
145        matches!(self, Self::Live | Self::Dormant)
146    }
147}
148
149/// One row of the session list.
150#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
151pub struct AssistantSessionSummary {
152    /// The session's identity.
153    pub session_id: AssistantSessionId,
154    /// The configured harness name this session runs.
155    pub harness: String,
156    /// The configured account name, when the harness declares any.
157    pub account: Option<String>,
158    /// What the session is, right now.
159    pub state: AssistantSessionState,
160    /// Why it is in that state, in the server's own words, or `None`.
161    ///
162    /// An ended session carries the reason it ended; a dormant one carries why
163    /// its process went away. A live one carries nothing, because nothing
164    /// decided it.
165    pub reason: Option<String>,
166    /// When the session was created.
167    pub created_at: DateTime<Utc>,
168    /// The last time anything about the session changed.
169    pub updated_at: DateTime<Utc>,
170    /// How many turns have been submitted on it.
171    pub turns: u64,
172    /// The first 80 characters of the first prompt, or `None` before one.
173    pub title: Option<String>,
174    /// The commands the HARNESS most recently advertised, in the order it
175    /// advertised them. Empty when it has advertised none.
176    ///
177    /// A projection over the transcript's
178    /// [`AssistantSessionEvent::AvailableCommands`] records, never a stored
179    /// field, and never a list this server composes: a command a client offers
180    /// that the agent never advertised is a refusal waiting to happen, so the
181    /// only honest source is what the agent itself said.
182    pub commands: Vec<AssistantCommand>,
183
184    /// The configuration options the HARNESS most recently advertised — the
185    /// model picker among them. Same rule and same reasons as `commands`: a
186    /// projection over the transcript's
187    /// [`AssistantSessionEvent::ConfigOptions`] records, replaced whole by
188    /// each advertisement, never a list this server composes.
189    pub config_options: Vec<AssistantConfigOption>,
190}
191
192/// One command the harness advertised on this session.
193///
194/// The three fields ACP's `AvailableCommand` carries that a client needs to
195/// OFFER one: what to send, what it does, and — when the command takes input —
196/// the hint to show before any has been typed. ACP's only input form is
197/// `unstructured` ("all text that was typed after the command name is provided
198/// as input"), so a hint is the whole of what there is to publish about it.
199#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
200pub struct AssistantCommand {
201    /// The command's name, as sent on a turn.
202    pub name: String,
203    /// What the agent says it does.
204    pub description: String,
205    /// The hint to show while the command's input is empty, when it takes one.
206    pub input_hint: Option<String>,
207}
208
209/// One value a select-style configuration option offers.
210#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
211pub struct AssistantConfigChoice {
212    /// The value's identifier — what `session/set_config_option` is sent.
213    pub id: String,
214    /// Human-readable label.
215    pub name: String,
216    /// What the agent says about this value, when it says anything.
217    pub description: Option<String>,
218    /// The label of the group the agent listed it under, when it grouped them.
219    /// Kept so a surface can render the agent's own grouping; flat rendering
220    /// may ignore it without losing a choice.
221    pub group: Option<String>,
222}
223
224/// The type-specific half of one configuration option.
225///
226/// Exactly the two shapes ACP's `SessionConfigKind` publishes today. An option
227/// of a kind this server cannot represent is skipped with a log line — the
228/// rest of the advertisement stands, exactly as a malformed command entry is
229/// handled — so this enum never carries a value it is guessing about.
230#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
231#[serde(tag = "kind", rename_all = "snake_case")]
232pub enum AssistantConfigValue {
233    /// Pick exactly one of the listed choices.
234    Select {
235        /// Every choice the agent offers, in the order it listed them.
236        choices: Vec<AssistantConfigChoice>,
237        /// The id of the currently selected choice.
238        current: String,
239    },
240    /// An on/off switch.
241    Toggle {
242        /// The current value.
243        current: bool,
244    },
245}
246
247/// One configuration option the harness advertised on this session.
248///
249/// The published mirror of ACP's `SessionConfigOption`: what an operator may
250/// configure about the running agent, the model selector chief among them
251/// (`category` is ACP's own semantic label — `"model"`, `"mode"`,
252/// `"thought_level"`, or whatever a future agent says, verbatim). The only
253/// honest source is what the agent itself advertised, exactly as with
254/// [`AssistantCommand`]: an option a client offers that the agent never
255/// advertised is a refusal waiting to happen.
256#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
257pub struct AssistantConfigOption {
258    /// The option's identifier, as sent when setting it.
259    pub id: String,
260    /// Human-readable label.
261    pub name: String,
262    /// What the agent says it does, when it says anything.
263    pub description: Option<String>,
264    /// ACP's semantic category, verbatim, when the agent gave one. UX only:
265    /// nothing decides behaviour on it.
266    pub category: Option<String>,
267    /// The option's shape and current state.
268    pub value: AssistantConfigValue,
269}
270
271/// A harness command a turn invokes.
272///
273/// ACP delivers a command as ORDINARY PROMPT TEXT — `/name` followed by
274/// whatever input was typed after it, which is exactly what the schema's
275/// `unstructured` input form says the agent receives. So this is not an
276/// alternative transport; it is a statement of intent the server turns into the
277/// spec's own delivery form, checks against what the agent advertised, and
278/// records verbatim so the transcript says a command was invoked rather than
279/// leaving a reader to infer it from a leading slash.
280#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
281pub struct AssistantCommandInvocation {
282    /// The advertised command's name, without the leading `/`.
283    pub name: String,
284    /// The text typed after the command name, when any was.
285    pub input: Option<String>,
286}
287
288impl AssistantCommandInvocation {
289    /// The prompt line ACP delivers this invocation as.
290    ///
291    /// `/name` alone, or `/name input` — the one place the delivery form is
292    /// spelled, so the wire tests and the turn path cannot disagree about it.
293    #[must_use]
294    pub fn prompt_line(&self) -> String {
295        match self
296            .input
297            .as_deref()
298            .map(str::trim)
299            .filter(|input| !input.is_empty())
300        {
301            Some(input) => format!("/{} {input}", self.name),
302            None => format!("/{}", self.name),
303        }
304    }
305}
306
307/// A position in a document, in editor coordinates.
308#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
309pub struct AssistantDocumentPosition {
310    /// Zero-based line number, as the editor's own model holds it.
311    ///
312    /// ZERO-based on the wire and ONE-based in the prose: the numbers a
313    /// document model uses and the numbers an operator reads off a gutter are
314    /// different numbers, and converting once — where the prose is composed —
315    /// is what keeps them from drifting.
316    pub line: u32,
317    /// Zero-based column, counted in UTF-16 code units (the editor's own unit).
318    pub column: u32,
319}
320
321/// A selected range in a document.
322#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
323pub struct AssistantDocumentSelection {
324    /// Where the selection starts.
325    pub from: AssistantDocumentPosition,
326    /// Where the selection ends.
327    pub to: AssistantDocumentPosition,
328}
329
330/// The document a turn is asked about.
331#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
332pub struct AssistantDocumentContext {
333    /// The document's workspace-relative path.
334    pub path: String,
335    /// The document's current text, as the editor holds it.
336    pub text: String,
337    /// The selection that scopes the request, when there is one.
338    pub selection: Option<AssistantDocumentSelection>,
339    /// Where the caret is, when the document came from a live editor.
340    ///
341    /// NOT a zero-width selection: a caret is where the operator IS and a
342    /// selection is what they CHOSE, and an agent told a region was selected
343    /// when none was edits the wrong thing. Carried as its own field so the
344    /// prose can say "cursor on line 4" with no selection at all — and so the
345    /// operator's own words never have to carry it (nothing is appended to
346    /// what they typed; Tom, 2026-08-30).
347    pub cursor: Option<AssistantDocumentPosition>,
348}
349
350/// What was on the operator's screen when they asked.
351///
352/// Composed into the prompt as prose by the server, and SHOWN to the operator
353/// before it is sent — the console renders the same prefix the server will
354/// build, and the operator may opt out of it.
355#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, Default, PartialEq, Eq)]
356pub struct AssistantTurnContext {
357    /// The console URL the operator was on.
358    pub url: Option<String>,
359    /// The titles of the explain concepts declared on that screen.
360    pub concepts: Vec<String>,
361    /// The document under the editor's cursor, when the turn came from there.
362    pub document: Option<AssistantDocumentContext>,
363}
364
365/// How a tool call is going.
366#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
367#[serde(rename_all = "snake_case")]
368pub enum AssistantToolCallStatus {
369    /// The agent has begun the call.
370    Started,
371    /// The call finished and its output is on the frame.
372    Completed,
373    /// The call failed; the failure is on the frame's output.
374    Failed,
375}
376
377/// How a permission request was decided.
378///
379/// There is no console prompt in this cut: the configured policy decides, and
380/// the decision is recorded so an operator can see what was asked and what was
381/// answered.
382#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
383#[serde(rename_all = "snake_case")]
384pub enum AssistantPermissionDecision {
385    /// The policy allowed the call, once.
386    AllowOnce,
387    /// The policy denied the call.
388    Deny,
389}
390
391/// One event on a session's transcript, and one frame on its WebSocket.
392///
393/// The SAME value is durably appended and streamed live, so replay and live are
394/// one stream read at two times rather than two encodings that could disagree.
395#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
396#[serde(tag = "type", rename_all = "snake_case")]
397pub enum AssistantSessionEvent {
398    /// A harness process opened (or reopened) the conversation.
399    ///
400    /// Appended once per spawn — so a resumed session has two of these, and the
401    /// LAST one carries the capabilities of the process that is running now. It
402    /// is what makes the resume decision a fact read off the transcript rather
403    /// than a guess: `load_session` is what the agent ACTUALLY advertised at
404    /// `initialize`, not what its kind is assumed to support.
405    SessionOpened {
406        /// The harness's own session handle — what `session/load` resumes.
407        acp_session_ref: String,
408        /// Whether this agent advertised `loadSession` at `initialize`.
409        load_session: bool,
410        /// When it opened.
411        at: DateTime<Utc>,
412        /// Whether this open was a resume of a prior conversation.
413        resumed: bool,
414    },
415    /// The operator's on-screen context, as it stood.
416    ///
417    /// Appended by every turn and by an explicit context push, so the LATEST one
418    /// is the shared context both surfaces read — and the one the harness's
419    /// `assistant_context` tool answers with. The transcript IS the shared
420    /// context; there is no second store.
421    ContextShared {
422        /// What was on screen.
423        context: AssistantTurnContext,
424        /// What put it there: `turn` or `push`.
425        source: String,
426    },
427    /// What the OPERATOR asked, appended the moment the turn was accepted and
428    /// before any frame the harness produces for it.
429    ///
430    /// The record of the question. Without it a reloaded conversation is answers
431    /// with no questions, and the transcript — which is the shared context both
432    /// surfaces read — could not say what was asked. It carries the context the
433    /// turn was sent with, so the request and the screen it was asked from are
434    /// ONE record rather than two that could be appended out of order.
435    Request {
436        /// The turn this frame belongs to.
437        turn_id: String,
438        /// The operator's own words, without the composed context prefix.
439        text: String,
440        /// What was on the operator's screen, when they sent any.
441        context: Option<AssistantTurnContext>,
442        /// The harness command this turn invokes, when it invokes one.
443        ///
444        /// Recorded beside the text rather than folded into it, because a
445        /// transcript that showed only the composed `/compact …` line could not
446        /// say whether the operator pressed a command control or typed a line
447        /// that happened to begin with a slash.
448        command: Option<AssistantCommandInvocation>,
449    },
450    /// A turn was accepted and the prompt was sent.
451    TurnStarted {
452        /// The turn this frame belongs to.
453        turn_id: String,
454        /// When the turn started.
455        at: DateTime<Utc>,
456        /// The prompt exactly as it was sent, context prefix included.
457        ///
458        /// Recorded so the transcript answers "what was the agent actually
459        /// asked" — a transcript that showed only the operator's typing would
460        /// hide the composed context the answer was really shaped by. The
461        /// operator's own words are on the [`Self::Request`] frame that precedes
462        /// this one; they are not repeated here.
463        prompt: String,
464    },
465    /// The harness advertised the commands it serves.
466    ///
467    /// A COMPLETE replacement of whatever it advertised before, exactly as ACP's
468    /// `available_commands_update` is: an agent that drops a command stops
469    /// listing it, and a merge would keep offering a command that would now be
470    /// refused.
471    AvailableCommands {
472        /// Every command the harness serves, in the order it listed them.
473        commands: Vec<AssistantCommand>,
474    },
475    /// The harness advertised its configuration options — the model picker
476    /// among them.
477    ///
478    /// A COMPLETE replacement of whatever it advertised before, exactly as
479    /// ACP's `config_option_update` and its `session/set_config_option`
480    /// response both are: an agent that drops an option stops listing it, and
481    /// a merge would keep offering a value that would now be refused.
482    ConfigOptions {
483        /// Every option, in the order the agent listed them.
484        options: Vec<AssistantConfigOption>,
485    },
486    /// An agent text chunk, in order.
487    Delta {
488        /// The turn this frame belongs to.
489        turn_id: String,
490        /// The chunk.
491        text: String,
492    },
493    /// An agent thought chunk.
494    Thought {
495        /// The turn this frame belongs to.
496        turn_id: String,
497        /// The thought fragment.
498        text: String,
499    },
500    /// A tool call the agent made.
501    ToolCall {
502        /// The turn this frame belongs to.
503        turn_id: String,
504        /// The agent's own id for the call, so updates join to one row.
505        call_id: String,
506        /// The tool's name.
507        name: String,
508        /// How the call is going.
509        status: AssistantToolCallStatus,
510        /// The call's input, when the agent reported one.
511        #[ts(type = "unknown")]
512        input: Option<serde_json::Value>,
513        /// The call's output, when the agent reported one.
514        #[ts(type = "unknown")]
515        output: Option<serde_json::Value>,
516    },
517    /// A permission request, recorded with the decision the policy made.
518    PermissionAsk {
519        /// The turn this frame belongs to.
520        turn_id: String,
521        /// The agent's request, verbatim.
522        #[ts(type = "unknown")]
523        request: serde_json::Value,
524        /// What the configured policy answered.
525        decided: AssistantPermissionDecision,
526    },
527    /// The agent edited the shared document through its
528    /// `assistant_document_edit` tool.
529    ///
530    /// Appended by the assistant MCP route AFTER validating the batch against
531    /// the shared document as the transcript then held it, so a recorded batch
532    /// always applied cleanly in full. The projection folds it into
533    /// `latest_context` — which is how the agent's own next `assistant_context`
534    /// read sees its edits — and the console applies the same operations to the
535    /// live editor buffer, where the operator keeps or reverts them.
536    DocumentEdit {
537        /// The operations, in application order.
538        edits: Vec<AssistantDocumentEditOp>,
539        /// The shared document's revision AFTER this batch — monotonic per
540        /// session and never reset, so a client replaying frames after a
541        /// reconnect can skip batches it already applied.
542        revision: u64,
543    },
544    /// The turn ended with an answer.
545    TurnCompleted {
546        /// The turn this frame belongs to.
547        turn_id: String,
548        /// The agent's last completed message.
549        final_message: String,
550        /// The canonical stop reason.
551        stop_reason: String,
552        /// The harness's own session handle, when it reported one.
553        session_ref: Option<String>,
554    },
555    /// The turn ended without an answer.
556    TurnFailed {
557        /// The turn this frame belongs to.
558        turn_id: String,
559        /// `auth_required` for a `-32000`, otherwise the typed harness-error
560        /// variant name in `snake_case`.
561        code: String,
562        /// The failure, in the harness's own words.
563        message: String,
564    },
565    /// A frame the neutral vocabulary above cannot represent, passed through
566    /// verbatim.
567    ///
568    /// The adapter's own rule — nothing is ever silently dropped — reaching this
569    /// surface. A console renders what it knows and ignores the rest; an
570    /// operator reconstructing what an agent actually did still has every frame.
571    Raw {
572        /// The turn it belongs to, when it belongs to one.
573        turn_id: Option<String>,
574        /// Where the frame came from, as the adapter labelled it.
575        source: String,
576        /// The frame, verbatim.
577        #[ts(type = "unknown")]
578        value: serde_json::Value,
579    },
580    /// The session's state changed.
581    State {
582        /// The state it changed to.
583        state: AssistantSessionState,
584        /// Why — `process_exited`, `deleted`,
585        /// `resume_refused: loadSession not advertised`, `load_failed: …`.
586        /// `None` when nothing decided it.
587        reason: Option<String>,
588    },
589    /// The session is over; nothing further will arrive on this stream.
590    Ended {
591        /// Why it ended.
592        reason: String,
593    },
594}
595
596impl AssistantSessionEvent {
597    /// The turn this event belongs to, when it belongs to one.
598    ///
599    /// `State` and `Ended` are session-wide and belong to no turn; saying so
600    /// with `None` is what keeps a caller from inventing an attribution.
601    #[must_use]
602    pub fn turn_id(&self) -> Option<&str> {
603        match self {
604            Self::Request { turn_id, .. }
605            | Self::TurnStarted { turn_id, .. }
606            | Self::Delta { turn_id, .. }
607            | Self::Thought { turn_id, .. }
608            | Self::ToolCall { turn_id, .. }
609            | Self::PermissionAsk { turn_id, .. }
610            | Self::TurnCompleted { turn_id, .. }
611            | Self::TurnFailed { turn_id, .. } => Some(turn_id),
612            Self::Raw { turn_id, .. } => turn_id.as_deref(),
613            Self::SessionOpened { .. }
614            | Self::ContextShared { .. }
615            | Self::AvailableCommands { .. }
616            | Self::ConfigOptions { .. }
617            | Self::DocumentEdit { .. }
618            | Self::State { .. }
619            | Self::Ended { .. } => None,
620        }
621    }
622
623    /// The state and cause this record SETTLES the session to, if it settles it
624    /// at all.
625    ///
626    /// The lifecycle projection is exactly "the last record for which this
627    /// returns `Some`, unless a process is running". A record that decides
628    /// nothing returns `None`, which is what lets the scan walk backwards and
629    /// stop at the first decision it meets.
630    #[must_use]
631    pub fn settles(&self) -> Option<(AssistantSessionState, Option<String>)> {
632        match self {
633            Self::State { state, reason } if !state.is_live() => Some((*state, reason.clone())),
634            Self::Ended { reason } => Some((AssistantSessionState::Ended, Some(reason.clone()))),
635            _ => None,
636        }
637    }
638}
639
640/// One event with the durable index the store assigned it.
641///
642/// The index is what a reconnecting client passes back as `?after=`, so it is on
643/// every frame rather than only on replayed ones: a client that watched live and
644/// then dropped its socket must be able to resume from what it last saw without
645/// having read the transcript endpoint first.
646#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
647pub struct AssistantSessionFrame {
648    /// The event's dense, store-assigned position in the session's transcript.
649    pub index: u64,
650    /// The event itself, flattened so the frame reads as one object.
651    #[serde(flatten)]
652    #[ts(flatten)]
653    pub event: AssistantSessionEvent,
654}
655
656/// Everything a session's transcript says about it.
657///
658/// The one place the projection rules live, so the list surface, the detail
659/// surface, the resume decision and the `assistant_context` tool all read the
660/// same facts off the same records. Nothing here reads a stored status: there
661/// is none.
662#[derive(Clone, Debug, Default, PartialEq, Eq)]
663pub struct AssistantSessionProjection {
664    /// The last state a record settled the session to, with its cause. `None`
665    /// when no record has settled it — which, for a session with no running
666    /// process, is a transcript the boot sweep has not yet written back to.
667    pub settled: Option<(AssistantSessionState, Option<String>)>,
668    /// The harness's own session handle from the most recent open.
669    pub acp_session_ref: Option<String>,
670    /// Whether the agent advertised `loadSession` at its most recent open.
671    /// `false` until an open has been recorded: a session that never opened
672    /// cannot be resumed either.
673    pub load_session: bool,
674    /// How many turns have started.
675    pub turns: u64,
676    /// What the operator typed on the first turn — the fallback title.
677    pub first_turn_text: Option<String>,
678    /// The turn a `Request` opened that no `TurnCompleted`/`TurnFailed` has
679    /// closed yet. `None` when every asked turn has been answered.
680    ///
681    /// This is how a settle path knows there is a turn to close: a session
682    /// whose process died mid-turn must have that turn FAILED on the record
683    /// before the session settles, or every reader of the transcript folds an
684    /// open turn forever — the console reads it as busy, and a later edit
685    /// batch would be attributed to a turn that ended long ago.
686    pub open_turn_id: Option<String>,
687    /// The most recently shared on-screen context.
688    pub latest_context: Option<AssistantTurnContext>,
689    /// The shared document's revision: how many edit batches have folded into
690    /// `latest_context`. Monotonic and never reset — a fresh context share
691    /// rebases the text but does not rewind the count — so a client can use it
692    /// as an idempotency guard when replaying frames.
693    pub document_revision: u64,
694    /// The commands the harness advertised most recently. A later advertisement
695    /// REPLACES an earlier one whole, as ACP's own update does.
696    pub commands: Vec<AssistantCommand>,
697
698    /// The configuration options the harness advertised most recently. The
699    /// same replacement rule as `commands`, for the same reason.
700    pub config_options: Vec<AssistantConfigOption>,
701}
702
703impl AssistantSessionProjection {
704    /// Project a whole transcript, oldest event first.
705    #[must_use]
706    pub fn of<'events>(events: impl IntoIterator<Item = &'events AssistantSessionEvent>) -> Self {
707        let mut projection = Self::default();
708        for event in events {
709            projection.apply(event);
710        }
711        projection
712    }
713
714    /// Fold one event in, in transcript order.
715    fn apply(&mut self, event: &AssistantSessionEvent) {
716        match event {
717            AssistantSessionEvent::SessionOpened {
718                acp_session_ref,
719                load_session,
720                ..
721            } => {
722                self.acp_session_ref = Some(acp_session_ref.clone());
723                self.load_session = *load_session;
724                // An open un-settles the session: a process is running again —
725                // unless the session has ENDED, which is terminal. An open
726                // recorded after an ended record is a resurrection the server
727                // refuses at the door; a transcript that carries one anyway
728                // (written before that refusal existed) still reads `ended`.
729                if !self.is_ended() {
730                    self.settled = None;
731                }
732            }
733            AssistantSessionEvent::ContextShared { context, .. } => {
734                self.latest_context = Some(context.clone());
735            }
736            AssistantSessionEvent::AvailableCommands { commands } => {
737                // REPLACED, not merged: the agent's advertisement is complete,
738                // and a merge would keep offering a command it has dropped.
739                self.commands.clone_from(commands);
740            }
741            AssistantSessionEvent::ConfigOptions { options } => {
742                // REPLACED, not merged — see `AvailableCommands` above.
743                self.config_options.clone_from(options);
744            }
745            // The REQUEST is what counts a turn, not the start: it is appended
746            // at acceptance and carries the operator's own words, so a turn that
747            // was accepted and then failed to reach the agent is still a turn
748            // that was asked.
749            AssistantSessionEvent::Request {
750                turn_id,
751                text,
752                context,
753                ..
754            } => {
755                self.turns = self.turns.saturating_add(1);
756                self.open_turn_id = Some(turn_id.clone());
757                if self.first_turn_text.is_none() {
758                    self.first_turn_text = Some(text.clone());
759                }
760                if let Some(context) = context {
761                    self.latest_context = Some(context.clone());
762                }
763            }
764            AssistantSessionEvent::TurnCompleted { turn_id, .. }
765            | AssistantSessionEvent::TurnFailed { turn_id, .. } => {
766                if self.open_turn_id.as_deref() == Some(turn_id.as_str()) {
767                    self.open_turn_id = None;
768                }
769                if let Some(settled) = event.settles() {
770                    self.settle_to(settled);
771                }
772            }
773            AssistantSessionEvent::DocumentEdit { edits, revision } => {
774                // `max`, not assignment: revisions are minted monotonically at
775                // append, so on an in-order transcript this IS assignment — the
776                // `max` only guards the fold against a stream a caller hands it
777                // out of order, where a rewind would break every client using
778                // the revision as an idempotency cursor.
779                self.document_revision = self.document_revision.max(*revision);
780                if let Some(document) = self
781                    .latest_context
782                    .as_mut()
783                    .and_then(|context| context.document.as_mut())
784                {
785                    match apply_document_edits(&document.text, edits) {
786                        Ok(applied) => document.text = applied,
787                        // Reachable, and benign. The append path validated this
788                        // batch against the projection AS IT THEN STOOD, but a
789                        // `ContextShared` recorded between validation and this
790                        // fold can rebase `latest_context` to bytes the batch
791                        // no longer matches. The projection stays TOTAL — an
792                        // unappliable batch leaves the text as it stands rather
793                        // than poisoning the fold — and the very next context
794                        // share carries the console's own buffer, edits
795                        // included, so the folded document converges on the
796                        // operator's truth rather than drifting from it.
797                        Err(
798                            AssistantDocumentEditError::EmptyOldString { .. }
799                            | AssistantDocumentEditError::Absent { .. }
800                            | AssistantDocumentEditError::Ambiguous { .. },
801                        ) => {}
802                    }
803                }
804            }
805            other => {
806                if let Some(settled) = other.settles() {
807                    self.settle_to(settled);
808                }
809            }
810        }
811    }
812
813    /// Whether the session has settled `ended`.
814    #[must_use]
815    pub fn is_ended(&self) -> bool {
816        matches!(self.settled, Some((AssistantSessionState::Ended, _)))
817    }
818
819    /// Apply a settling record. Ended is absorbing: once a session has ended,
820    /// a later record may restate `ended` (refreshing the cause) but can never
821    /// move it to another state — the caller who put it down for good must be
822    /// able to trust that it stays down.
823    fn settle_to(&mut self, settled: (AssistantSessionState, Option<String>)) {
824        if self.is_ended() && settled.0 != AssistantSessionState::Ended {
825            return;
826        }
827        self.settled = Some(settled);
828    }
829
830    /// The state and cause to report, given whether a process is running.
831    ///
832    /// A running process ALWAYS wins: it is the one fact a transcript cannot
833    /// contradict. With no process, the last settling record decides. With no
834    /// process and no settling record — a state the boot sweep exists to
835    /// prevent — the answer is [`AssistantSessionState::Ended`] with a cause
836    /// that says exactly that, because a session with no process and no record
837    /// of stopping must never be presented as running.
838    #[must_use]
839    pub fn state(
840        &self,
841        live: Option<AssistantSessionState>,
842    ) -> (AssistantSessionState, Option<String>) {
843        if let Some(state) = live
844            && !self.is_ended()
845        {
846            return (state, None);
847        }
848        match &self.settled {
849            Some((state, cause)) => (*state, cause.clone()),
850            None => (
851                AssistantSessionState::Ended,
852                Some(NO_SETTLING_RECORD.to_owned()),
853            ),
854        }
855    }
856
857    /// Whether a dormant session can be reopened: it has a handle to load and
858    /// the agent said it can load one.
859    #[must_use]
860    pub fn is_resumable(&self) -> bool {
861        self.load_session && self.acp_session_ref.is_some()
862    }
863
864    /// The title a listing shows: the first 80 characters of the first turn's
865    /// own text, when the caller named no title of their own.
866    #[must_use]
867    pub fn derived_title(&self) -> Option<String> {
868        self.first_turn_text.as_ref().map(|text| {
869            let trimmed = text.trim();
870            trimmed.chars().take(TITLE_CHARACTERS).collect()
871        })
872    }
873}
874
875/// How many characters of the first prompt become a session's title.
876pub const TITLE_CHARACTERS: usize = 80;
877
878/// The cause reported for a session with no process and no record of stopping.
879///
880/// Reachable only if the boot sweep did not run or could not write; naming it
881/// keeps that state distinguishable from a session that was genuinely closed.
882pub const NO_SETTLING_RECORD: &str =
883    "no process is running and the transcript records no stop; treated as ended";
884
885#[cfg(test)]
886mod tests {
887    use super::*;
888
889    fn instant() -> Result<DateTime<Utc>, String> {
890        use chrono::TimeZone;
891
892        Utc.with_ymd_and_hms(2026, 8, 29, 6, 0, 0)
893            .single()
894            .ok_or_else(|| "the test instant must be valid".to_owned())
895    }
896
897    #[test]
898    fn a_frame_carries_its_index_beside_the_event_discriminator() -> Result<(), String> {
899        let frame = AssistantSessionFrame {
900            index: 3,
901            event: AssistantSessionEvent::Delta {
902                turn_id: "t-1".to_owned(),
903                text: "hello".to_owned(),
904            },
905        };
906        let wire = serde_json::to_value(&frame).map_err(|error| error.to_string())?;
907        assert_eq!(wire["index"], serde_json::json!(3));
908        assert_eq!(wire["type"], serde_json::json!("delta"));
909        assert_eq!(wire["turn_id"], serde_json::json!("t-1"));
910        assert_eq!(wire["text"], serde_json::json!("hello"));
911        Ok(())
912    }
913
914    #[test]
915    fn every_event_shape_round_trips_through_its_wire_form() -> Result<(), String> {
916        let events = vec![
917            AssistantSessionEvent::SessionOpened {
918                acp_session_ref: "sess-1".to_owned(),
919                load_session: true,
920                at: instant()?,
921                resumed: false,
922            },
923            AssistantSessionEvent::ContextShared {
924                context: AssistantTurnContext::default(),
925                source: "turn".to_owned(),
926            },
927            AssistantSessionEvent::Request {
928                turn_id: "t-1".to_owned(),
929                text: "fix this".to_owned(),
930                context: Some(AssistantTurnContext::default()),
931                command: Some(AssistantCommandInvocation {
932                    name: "compact".to_owned(),
933                    input: Some("keep the plan".to_owned()),
934                }),
935            },
936            AssistantSessionEvent::TurnStarted {
937                turn_id: "t-1".to_owned(),
938                at: instant()?,
939                prompt: "On screen: /studio\n\nfix this".to_owned(),
940            },
941            AssistantSessionEvent::AvailableCommands {
942                commands: vec![AssistantCommand {
943                    name: "compact".to_owned(),
944                    description: "compact the conversation".to_owned(),
945                    input_hint: Some("what to keep".to_owned()),
946                }],
947            },
948            AssistantSessionEvent::DocumentEdit {
949                edits: vec![crate::assistant_document::AssistantDocumentEditOp {
950                    old_string: "step one".to_owned(),
951                    new_string: "step first".to_owned(),
952                }],
953                revision: 1,
954            },
955            AssistantSessionEvent::Delta {
956                turn_id: "t-1".to_owned(),
957                text: "part".to_owned(),
958            },
959            AssistantSessionEvent::Thought {
960                turn_id: "t-1".to_owned(),
961                text: "considering".to_owned(),
962            },
963            AssistantSessionEvent::ToolCall {
964                turn_id: "t-1".to_owned(),
965                call_id: "c-1".to_owned(),
966                name: "check_document".to_owned(),
967                status: AssistantToolCallStatus::Completed,
968                input: Some(serde_json::json!({ "path": "a.awl" })),
969                output: None,
970            },
971            AssistantSessionEvent::PermissionAsk {
972                turn_id: "t-1".to_owned(),
973                request: serde_json::json!({ "toolCall": { "toolCallId": "c-1" } }),
974                decided: AssistantPermissionDecision::Deny,
975            },
976            AssistantSessionEvent::TurnCompleted {
977                turn_id: "t-1".to_owned(),
978                final_message: "done".to_owned(),
979                stop_reason: "end_turn".to_owned(),
980                session_ref: Some("sess-1".to_owned()),
981            },
982            AssistantSessionEvent::TurnFailed {
983                turn_id: "t-2".to_owned(),
984                code: "auth_required".to_owned(),
985                message: "the agent requires authentication".to_owned(),
986            },
987            AssistantSessionEvent::State {
988                state: AssistantSessionState::Dormant,
989                reason: Some("process_exited".to_owned()),
990            },
991            AssistantSessionEvent::Ended {
992                reason: "the operator closed the session".to_owned(),
993            },
994        ];
995        for event in events {
996            let bytes = serde_json::to_vec(&event).map_err(|error| error.to_string())?;
997            let decoded: AssistantSessionEvent =
998                serde_json::from_slice(&bytes).map_err(|error| error.to_string())?;
999            assert_eq!(decoded, event);
1000        }
1001        Ok(())
1002    }
1003
1004    #[test]
1005    fn a_session_id_parses_from_its_own_display_form() {
1006        let id = AssistantSessionId::new(Uuid::from_u128(7));
1007        assert_eq!(AssistantSessionId::parse(&id.to_string()), Ok(id));
1008        let error = AssistantSessionId::parse("not-a-uuid");
1009        assert!(
1010            matches!(&error, Err(failure) if failure.text == "not-a-uuid"),
1011            "a malformed id must be refused naming the text: {error:?}"
1012        );
1013    }
1014
1015    #[test]
1016    fn only_a_running_process_is_live_and_a_dormant_session_is_still_continuable() {
1017        assert!(AssistantSessionState::Live.is_live());
1018        assert!(!AssistantSessionState::Dormant.is_live());
1019        assert!(!AssistantSessionState::Closed.is_live());
1020        assert!(!AssistantSessionState::Ended.is_live());
1021        // The distinction the whole resume path rests on: dormant is not
1022        // running and is not over.
1023        assert!(AssistantSessionState::Live.is_continuable());
1024        assert!(AssistantSessionState::Dormant.is_continuable());
1025        assert!(AssistantSessionState::Closed.is_continuable());
1026        assert!(!AssistantSessionState::Ended.is_continuable());
1027
1028        // Closed is the one state that is continuable yet never current: the
1029        // whole point of the state is that putting a conversation away takes
1030        // it out of the operator's way without taking it away from them.
1031        assert!(AssistantSessionState::Live.is_current_candidate());
1032        assert!(AssistantSessionState::Dormant.is_current_candidate());
1033        assert!(!AssistantSessionState::Closed.is_current_candidate());
1034        assert!(!AssistantSessionState::Ended.is_current_candidate());
1035    }
1036
1037    #[test]
1038    fn the_three_wire_states_spell_exactly_what_the_console_parses() {
1039        // A parser on the other side refuses anything but these three words, so
1040        // a renamed variant must fail HERE rather than at a running console.
1041        for (state, spelling) in [
1042            (AssistantSessionState::Live, "\"live\""),
1043            (AssistantSessionState::Dormant, "\"dormant\""),
1044            (AssistantSessionState::Closed, "\"closed\""),
1045            (AssistantSessionState::Ended, "\"ended\""),
1046        ] {
1047            assert_eq!(
1048                serde_json::to_string(&state).unwrap_or_default(),
1049                spelling,
1050                "{state:?} must spell {spelling} on the wire"
1051            );
1052        }
1053    }
1054
1055    #[test]
1056    fn session_wide_events_belong_to_no_turn() {
1057        assert_eq!(
1058            AssistantSessionEvent::State {
1059                state: AssistantSessionState::Live,
1060                reason: None,
1061            }
1062            .turn_id(),
1063            None
1064        );
1065        assert_eq!(
1066            AssistantSessionEvent::Ended {
1067                reason: "stopped".to_owned(),
1068            }
1069            .turn_id(),
1070            None
1071        );
1072        assert_eq!(
1073            AssistantSessionEvent::Delta {
1074                turn_id: "t-9".to_owned(),
1075                text: String::new(),
1076            }
1077            .turn_id(),
1078            Some("t-9")
1079        );
1080    }
1081
1082    fn opened(load_session: bool, resumed: bool) -> Result<AssistantSessionEvent, String> {
1083        Ok(AssistantSessionEvent::SessionOpened {
1084            acp_session_ref: "sess-1".to_owned(),
1085            load_session,
1086            at: instant()?,
1087            resumed,
1088        })
1089    }
1090
1091    /// The frame a turn is ACCEPTED with — the record of what was asked, which
1092    /// is also what the projection counts and titles from.
1093    fn turn(text: &str) -> AssistantSessionEvent {
1094        AssistantSessionEvent::Request {
1095            turn_id: "t-1".to_owned(),
1096            text: text.to_owned(),
1097            context: None,
1098            command: None,
1099        }
1100    }
1101
1102    #[test]
1103    fn a_running_process_wins_over_every_settling_record() -> Result<(), String> {
1104        let events = vec![
1105            opened(true, false)?,
1106            AssistantSessionEvent::State {
1107                state: AssistantSessionState::Dormant,
1108                reason: Some("process_exited".to_owned()),
1109            },
1110            opened(true, true)?,
1111        ];
1112        let projection = AssistantSessionProjection::of(&events);
1113        // The second open cleared the settlement: a reopened session is not
1114        // dormant because it once was.
1115        assert_eq!(projection.settled, None);
1116        assert_eq!(
1117            projection.state(Some(AssistantSessionState::Live)),
1118            (AssistantSessionState::Live, None)
1119        );
1120        Ok(())
1121    }
1122
1123    #[test]
1124    fn with_no_process_the_last_settling_record_decides() -> Result<(), String> {
1125        let events = vec![
1126            opened(true, false)?,
1127            turn("fix the check"),
1128            AssistantSessionEvent::State {
1129                state: AssistantSessionState::Dormant,
1130                reason: Some("process_exited".to_owned()),
1131            },
1132        ];
1133        let projection = AssistantSessionProjection::of(&events);
1134        assert_eq!(
1135            projection.state(None),
1136            (
1137                AssistantSessionState::Dormant,
1138                Some("process_exited".to_owned())
1139            )
1140        );
1141        assert!(projection.is_resumable());
1142        assert_eq!(projection.turns, 1);
1143        assert_eq!(projection.derived_title(), Some("fix the check".to_owned()));
1144        Ok(())
1145    }
1146
1147    #[test]
1148    fn an_agent_that_never_advertised_load_session_is_not_resumable() -> Result<(), String> {
1149        let projection = AssistantSessionProjection::of(&[opened(false, false)?]);
1150        assert!(
1151            !projection.is_resumable(),
1152            "resume is gated on what the agent ACTUALLY advertised"
1153        );
1154        Ok(())
1155    }
1156
1157    #[test]
1158    fn no_process_and_no_settling_record_reads_ended_not_running() -> Result<(), String> {
1159        let projection = AssistantSessionProjection::of(&[opened(true, false)?, turn("hello")]);
1160        let (state, cause) = projection.state(None);
1161        assert_eq!(state, AssistantSessionState::Ended);
1162        assert_eq!(cause.as_deref(), Some(NO_SETTLING_RECORD));
1163        Ok(())
1164    }
1165
1166    /// Ended is terminal, both directions: an open recorded after an ended
1167    /// record does not un-settle it, a later `live` state record does not
1168    /// replace it, and a live process reported beside it does not outrank it.
1169    /// The transcript this pins is real — a session the caller deleted before
1170    /// its first turn, whose turn then spawned an agent and wrote `live`.
1171    #[test]
1172    fn ended_is_absorbing_no_later_open_or_live_record_resurrects_it() -> Result<(), String> {
1173        let events = vec![
1174            AssistantSessionEvent::State {
1175                state: AssistantSessionState::Ended,
1176                reason: Some("deleted by the caller".to_owned()),
1177            },
1178            AssistantSessionEvent::Ended {
1179                reason: "deleted by the caller".to_owned(),
1180            },
1181            opened(true, false)?,
1182            AssistantSessionEvent::State {
1183                state: AssistantSessionState::Live,
1184                reason: None,
1185            },
1186            turn("hello"),
1187        ];
1188        let projection = AssistantSessionProjection::of(&events);
1189        assert!(
1190            projection.is_ended(),
1191            "an ended session stays ended: {projection:?}"
1192        );
1193        let (state, cause) = projection.state(None);
1194        assert_eq!(state, AssistantSessionState::Ended);
1195        assert_eq!(cause.as_deref(), Some("deleted by the caller"));
1196        let (state, _) = projection.state(Some(AssistantSessionState::Live));
1197        assert_eq!(
1198            state,
1199            AssistantSessionState::Ended,
1200            "a live process beside an ended record does not outrank it"
1201        );
1202        // Restating `ended` with a fresh cause is allowed: same state, newer word.
1203        let restated = AssistantSessionProjection::of(&[
1204            AssistantSessionEvent::State {
1205                state: AssistantSessionState::Ended,
1206                reason: Some("first".to_owned()),
1207            },
1208            AssistantSessionEvent::State {
1209                state: AssistantSessionState::Ended,
1210                reason: Some("second".to_owned()),
1211            },
1212        ]);
1213        assert_eq!(restated.state(None).1.as_deref(), Some("second"));
1214        Ok(())
1215    }
1216
1217    #[test]
1218    fn the_latest_shared_context_is_the_one_the_tool_answers_with() {
1219        let first = AssistantTurnContext {
1220            url: Some("/studio".to_owned()),
1221            concepts: vec!["awl.step".to_owned()],
1222            document: None,
1223        };
1224        let second = AssistantTurnContext {
1225            url: Some("/runs".to_owned()),
1226            concepts: Vec::new(),
1227            document: None,
1228        };
1229        let events = vec![
1230            AssistantSessionEvent::ContextShared {
1231                context: first,
1232                source: "turn".to_owned(),
1233            },
1234            AssistantSessionEvent::ContextShared {
1235                context: second.clone(),
1236                source: "push".to_owned(),
1237            },
1238        ];
1239        assert_eq!(
1240            AssistantSessionProjection::of(&events).latest_context,
1241            Some(second)
1242        );
1243    }
1244
1245    /// The console parses `request { turn_id, text, context }` by those exact
1246    /// names (`wire.ts:161`). A renamed field is a frame it drops, and a
1247    /// transcript with no request block claims nothing rather than being wrong —
1248    /// which is precisely why the drift has to fail HERE.
1249    #[test]
1250    fn a_request_frame_spells_what_the_console_parses() -> Result<(), String> {
1251        let wire = serde_json::to_value(AssistantSessionEvent::Request {
1252            turn_id: "t-7".to_owned(),
1253            text: "fix the check".to_owned(),
1254            context: Some(AssistantTurnContext {
1255                url: Some("/studio".to_owned()),
1256                concepts: vec!["awl.step".to_owned()],
1257                document: None,
1258            }),
1259            command: None,
1260        })
1261        .map_err(|error| error.to_string())?;
1262        assert_eq!(wire["type"], serde_json::json!("request"));
1263        assert_eq!(wire["turn_id"], serde_json::json!("t-7"));
1264        assert_eq!(wire["text"], serde_json::json!("fix the check"));
1265        assert_eq!(wire["context"]["url"], serde_json::json!("/studio"));
1266        Ok(())
1267    }
1268
1269    /// A later advertisement REPLACES an earlier one whole. A merge would keep
1270    /// offering a command the agent has dropped, and the turn invoking it would
1271    /// be refused by the agent instead of by the surface that offered it.
1272    #[test]
1273    fn a_later_command_advertisement_replaces_the_earlier_one_whole() {
1274        let command = |name: &str| AssistantCommand {
1275            name: name.to_owned(),
1276            description: format!("{name} does something"),
1277            input_hint: None,
1278        };
1279        let events = vec![
1280            AssistantSessionEvent::AvailableCommands {
1281                commands: vec![command("compact"), command("plan")],
1282            },
1283            AssistantSessionEvent::AvailableCommands {
1284                commands: vec![command("compact")],
1285            },
1286        ];
1287        let projection = AssistantSessionProjection::of(&events);
1288        assert_eq!(
1289            projection
1290                .commands
1291                .iter()
1292                .map(|entry| entry.name.as_str())
1293                .collect::<Vec<_>>(),
1294            vec!["compact"],
1295            "`plan` was withdrawn and must not survive as an offer"
1296        );
1297    }
1298
1299    /// The exact rule `commands` follows, pinned separately for options: a
1300    /// later advertisement replaces the earlier one whole.
1301    #[test]
1302    fn a_later_config_advertisement_replaces_the_earlier_one_whole() {
1303        let option = |id: &str| AssistantConfigOption {
1304            id: id.to_owned(),
1305            name: id.to_owned(),
1306            description: None,
1307            category: Some("model".to_owned()),
1308            value: AssistantConfigValue::Select {
1309                choices: vec![AssistantConfigChoice {
1310                    id: "opus".to_owned(),
1311                    name: "Opus".to_owned(),
1312                    description: None,
1313                    group: None,
1314                }],
1315                current: "opus".to_owned(),
1316            },
1317        };
1318        let events = vec![
1319            AssistantSessionEvent::ConfigOptions {
1320                options: vec![option("model"), option("thinking")],
1321            },
1322            AssistantSessionEvent::ConfigOptions {
1323                options: vec![option("model")],
1324            },
1325        ];
1326        let projection = AssistantSessionProjection::of(&events);
1327        assert_eq!(
1328            projection
1329                .config_options
1330                .iter()
1331                .map(|entry| entry.id.as_str())
1332                .collect::<Vec<_>>(),
1333            vec!["model"],
1334            "`thinking` was withdrawn and must not survive as an offer"
1335        );
1336    }
1337
1338    /// The frame's wire tag and field shape, pinned where every other frame's
1339    /// is: the console parses this by hand, so the tag is a published contract.
1340    #[test]
1341    fn a_config_options_frame_serialises_under_its_published_tag() -> Result<(), String> {
1342        let wire = serde_json::to_value(AssistantSessionEvent::ConfigOptions {
1343            options: vec![AssistantConfigOption {
1344                id: "model".to_owned(),
1345                name: "Model".to_owned(),
1346                description: None,
1347                category: Some("model".to_owned()),
1348                value: AssistantConfigValue::Select {
1349                    choices: vec![AssistantConfigChoice {
1350                        id: "opus".to_owned(),
1351                        name: "Opus".to_owned(),
1352                        description: Some("the main one".to_owned()),
1353                        group: None,
1354                    }],
1355                    current: "opus".to_owned(),
1356                },
1357            }],
1358        })
1359        .map_err(|error| error.to_string())?;
1360        assert_eq!(wire["type"], serde_json::json!("config_options"));
1361        assert_eq!(wire["options"][0]["id"], serde_json::json!("model"));
1362        assert_eq!(wire["options"][0]["category"], serde_json::json!("model"));
1363        assert_eq!(
1364            wire["options"][0]["value"]["kind"],
1365            serde_json::json!("select")
1366        );
1367        assert_eq!(
1368            wire["options"][0]["value"]["current"],
1369            serde_json::json!("opus")
1370        );
1371        assert_eq!(
1372            wire["options"][0]["value"]["choices"][0]["name"],
1373            serde_json::json!("Opus")
1374        );
1375        Ok(())
1376    }
1377
1378    /// A session nobody has advertised commands on offers none — the negative
1379    /// control for the cell above, which an always-populated list would pass.
1380    #[test]
1381    fn a_session_with_no_advertisement_offers_no_commands() {
1382        assert!(
1383            AssistantSessionProjection::default().commands.is_empty(),
1384            "a command list must come from the agent, never from this server"
1385        );
1386    }
1387
1388    /// ACP delivers a command as ordinary prompt text: `/name` and the input
1389    /// after it. One spelling, here, so the turn path and the wire pins cannot
1390    /// disagree about the form the agent actually receives.
1391    #[test]
1392    fn a_command_is_delivered_as_the_slash_line_the_acp_spec_describes() {
1393        assert_eq!(
1394            AssistantCommandInvocation {
1395                name: "compact".to_owned(),
1396                input: Some("keep the plan".to_owned()),
1397            }
1398            .prompt_line(),
1399            "/compact keep the plan"
1400        );
1401        assert_eq!(
1402            AssistantCommandInvocation {
1403                name: "compact".to_owned(),
1404                input: None,
1405            }
1406            .prompt_line(),
1407            "/compact"
1408        );
1409        // Whitespace-only input is no input: a trailing space on the wire must
1410        // not become a trailing space in what the agent parses.
1411        assert_eq!(
1412            AssistantCommandInvocation {
1413                name: "compact".to_owned(),
1414                input: Some("   ".to_owned()),
1415            }
1416            .prompt_line(),
1417            "/compact"
1418        );
1419    }
1420
1421    /// A turn's context reaches the shared-context projection through the
1422    /// REQUEST frame, so a turn asked from a screen updates what the
1423    /// `assistant_context` tool answers with without a second record.
1424    #[test]
1425    fn a_turns_own_context_becomes_the_shared_context() {
1426        let context = AssistantTurnContext {
1427            url: Some("/studio/a.awl".to_owned()),
1428            concepts: Vec::new(),
1429            document: None,
1430        };
1431        let projection = AssistantSessionProjection::of(&[AssistantSessionEvent::Request {
1432            turn_id: "t-1".to_owned(),
1433            text: "explain".to_owned(),
1434            context: Some(context.clone()),
1435            command: None,
1436        }]);
1437        assert_eq!(projection.latest_context, Some(context));
1438        assert_eq!(projection.turns, 1);
1439    }
1440
1441    #[test]
1442    fn a_title_is_bounded_at_eighty_characters() -> Result<(), String> {
1443        let long = "x".repeat(200);
1444        let projection = AssistantSessionProjection::of(&[turn(&long)]);
1445        let title = projection
1446            .derived_title()
1447            .ok_or_else(|| "a started turn has a derivable title".to_owned())?;
1448        assert_eq!(title.chars().count(), TITLE_CHARACTERS);
1449        Ok(())
1450    }
1451
1452    /// A `Request` opens a turn and only ITS `TurnCompleted`/`TurnFailed`
1453    /// closes it — this is what a settle path reads to know a dying process
1454    /// leaves a question on the record that will never be answered.
1455    #[test]
1456    fn a_request_opens_a_turn_and_its_ending_closes_it() {
1457        let mut projection = AssistantSessionProjection::of(&[turn("first ask")]);
1458        assert_eq!(projection.open_turn_id.as_deref(), Some("t-1"));
1459        // An ending for a DIFFERENT turn does not close this one.
1460        projection.apply(&AssistantSessionEvent::TurnFailed {
1461            turn_id: "t-0".to_owned(),
1462            code: "stale".to_owned(),
1463            message: "an earlier turn's ending arrives late".to_owned(),
1464        });
1465        assert_eq!(projection.open_turn_id.as_deref(), Some("t-1"));
1466        projection.apply(&AssistantSessionEvent::TurnCompleted {
1467            turn_id: "t-1".to_owned(),
1468            final_message: "answered".to_owned(),
1469            stop_reason: "end_turn".to_owned(),
1470            session_ref: None,
1471        });
1472        assert_eq!(projection.open_turn_id, None);
1473    }
1474
1475    /// The projection folds a recorded edit batch into the shared document, so
1476    /// the agent's next `assistant_context` read sees its own edits — the
1477    /// transcript IS the shared document, with no second store to catch up.
1478    #[test]
1479    fn a_document_edit_folds_into_the_latest_context() {
1480        let events = vec![
1481            AssistantSessionEvent::ContextShared {
1482                context: AssistantTurnContext {
1483                    url: Some("/studio/pipeline.awl".to_owned()),
1484                    concepts: Vec::new(),
1485                    document: Some(AssistantDocumentContext {
1486                        path: "pipeline.awl".to_owned(),
1487                        text: "workflow demo\nstep one\n".to_owned(),
1488                        selection: None,
1489                        cursor: None,
1490                    }),
1491                },
1492                source: "turn".to_owned(),
1493            },
1494            AssistantSessionEvent::DocumentEdit {
1495                edits: vec![crate::assistant_document::AssistantDocumentEditOp {
1496                    old_string: "step one".to_owned(),
1497                    new_string: "step first".to_owned(),
1498                }],
1499                revision: 1,
1500            },
1501        ];
1502        let projection = AssistantSessionProjection::of(&events);
1503        assert_eq!(projection.document_revision, 1);
1504        let text = projection
1505            .latest_context
1506            .and_then(|context| context.document)
1507            .map(|document| document.text);
1508        assert_eq!(text, Some("workflow demo\nstep first\n".to_owned()));
1509    }
1510
1511    /// An edit recorded with no shared document still advances the revision —
1512    /// the batch was appended, and a client's idempotency guard counts appended
1513    /// batches, not applicable ones — and the fold stays total rather than
1514    /// failing over a document that is not there.
1515    #[test]
1516    fn a_document_edit_with_no_shared_document_still_advances_the_revision() {
1517        let events = vec![AssistantSessionEvent::DocumentEdit {
1518            edits: vec![crate::assistant_document::AssistantDocumentEditOp {
1519                old_string: "anything".to_owned(),
1520                new_string: "else".to_owned(),
1521            }],
1522            revision: 1,
1523        }];
1524        let projection = AssistantSessionProjection::of(&events);
1525        assert_eq!(projection.document_revision, 1);
1526        assert_eq!(projection.latest_context, None);
1527    }
1528
1529    /// The console parses `document_edit { edits, revision }` by those exact
1530    /// names. A renamed field is a frame the editor never applies, so the drift
1531    /// has to fail HERE.
1532    #[test]
1533    fn a_document_edit_frame_spells_what_the_console_parses() -> Result<(), String> {
1534        let wire = serde_json::to_value(AssistantSessionEvent::DocumentEdit {
1535            edits: vec![crate::assistant_document::AssistantDocumentEditOp {
1536                old_string: "a".to_owned(),
1537                new_string: "b".to_owned(),
1538            }],
1539            revision: 4,
1540        })
1541        .map_err(|error| error.to_string())?;
1542        assert_eq!(wire["type"], serde_json::json!("document_edit"));
1543        assert_eq!(wire["revision"], serde_json::json!(4));
1544        assert_eq!(wire["edits"][0]["old_string"], serde_json::json!("a"));
1545        assert_eq!(wire["edits"][0]["new_string"], serde_json::json!("b"));
1546        Ok(())
1547    }
1548}