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}