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}