use chrono::{DateTime, Utc};
use crate::assistant_document::{
AssistantDocumentEditError, AssistantDocumentEditOp, apply_document_edits,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(
Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord,
)]
pub struct AssistantSessionId(Uuid);
impl AssistantSessionId {
#[must_use]
pub const fn new(id: Uuid) -> Self {
Self(id)
}
#[must_use]
pub fn new_v4() -> Self {
Self(Uuid::new_v4())
}
#[must_use]
pub const fn as_uuid(&self) -> Uuid {
self.0
}
pub fn parse(text: &str) -> Result<Self, AssistantSessionIdError> {
Uuid::parse_str(text)
.map(Self)
.map_err(|_source| AssistantSessionIdError {
text: text.to_owned(),
})
}
}
impl std::fmt::Display for AssistantSessionId {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(formatter)
}
}
#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
#[error("`{text}` is not an assistant session id (session ids are UUIDs minted by the server)")]
pub struct AssistantSessionIdError {
pub text: String,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum AssistantSessionState {
Live,
Dormant,
Closed,
Ended,
}
impl AssistantSessionState {
#[must_use]
pub const fn is_live(self) -> bool {
matches!(self, Self::Live)
}
#[must_use]
pub const fn is_continuable(self) -> bool {
matches!(self, Self::Live | Self::Dormant | Self::Closed)
}
#[must_use]
pub const fn is_current_candidate(self) -> bool {
matches!(self, Self::Live | Self::Dormant)
}
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct AssistantSessionSummary {
pub session_id: AssistantSessionId,
pub harness: String,
pub account: Option<String>,
pub state: AssistantSessionState,
pub reason: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub turns: u64,
pub title: Option<String>,
pub commands: Vec<AssistantCommand>,
pub config_options: Vec<AssistantConfigOption>,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct AssistantCommand {
pub name: String,
pub description: String,
pub input_hint: Option<String>,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct AssistantConfigChoice {
pub id: String,
pub name: String,
pub description: Option<String>,
pub group: Option<String>,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AssistantConfigValue {
Select {
choices: Vec<AssistantConfigChoice>,
current: String,
},
Toggle {
current: bool,
},
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct AssistantConfigOption {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub value: AssistantConfigValue,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct AssistantCommandInvocation {
pub name: String,
pub input: Option<String>,
}
impl AssistantCommandInvocation {
#[must_use]
pub fn prompt_line(&self) -> String {
match self
.input
.as_deref()
.map(str::trim)
.filter(|input| !input.is_empty())
{
Some(input) => format!("/{} {input}", self.name),
None => format!("/{}", self.name),
}
}
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
pub struct AssistantDocumentPosition {
pub line: u32,
pub column: u32,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
pub struct AssistantDocumentSelection {
pub from: AssistantDocumentPosition,
pub to: AssistantDocumentPosition,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct AssistantDocumentContext {
pub path: String,
pub text: String,
pub selection: Option<AssistantDocumentSelection>,
pub cursor: Option<AssistantDocumentPosition>,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, Default, PartialEq, Eq)]
pub struct AssistantTurnContext {
pub url: Option<String>,
pub concepts: Vec<String>,
pub document: Option<AssistantDocumentContext>,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AssistantToolCallStatus {
Started,
Completed,
Failed,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AssistantPermissionDecision {
AllowOnce,
Deny,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AssistantSessionEvent {
SessionOpened {
acp_session_ref: String,
load_session: bool,
at: DateTime<Utc>,
resumed: bool,
},
ContextShared {
context: AssistantTurnContext,
source: String,
},
Request {
turn_id: String,
text: String,
context: Option<AssistantTurnContext>,
command: Option<AssistantCommandInvocation>,
},
TurnStarted {
turn_id: String,
at: DateTime<Utc>,
prompt: String,
},
AvailableCommands {
commands: Vec<AssistantCommand>,
},
ConfigOptions {
options: Vec<AssistantConfigOption>,
},
Delta {
turn_id: String,
text: String,
},
Thought {
turn_id: String,
text: String,
},
ToolCall {
turn_id: String,
call_id: String,
name: String,
status: AssistantToolCallStatus,
#[ts(type = "unknown")]
input: Option<serde_json::Value>,
#[ts(type = "unknown")]
output: Option<serde_json::Value>,
},
PermissionAsk {
turn_id: String,
#[ts(type = "unknown")]
request: serde_json::Value,
decided: AssistantPermissionDecision,
},
DocumentEdit {
edits: Vec<AssistantDocumentEditOp>,
revision: u64,
},
TurnCompleted {
turn_id: String,
final_message: String,
stop_reason: String,
session_ref: Option<String>,
},
TurnFailed {
turn_id: String,
code: String,
message: String,
},
Raw {
turn_id: Option<String>,
source: String,
#[ts(type = "unknown")]
value: serde_json::Value,
},
State {
state: AssistantSessionState,
reason: Option<String>,
},
Ended {
reason: String,
},
}
impl AssistantSessionEvent {
#[must_use]
pub fn turn_id(&self) -> Option<&str> {
match self {
Self::Request { turn_id, .. }
| Self::TurnStarted { turn_id, .. }
| Self::Delta { turn_id, .. }
| Self::Thought { turn_id, .. }
| Self::ToolCall { turn_id, .. }
| Self::PermissionAsk { turn_id, .. }
| Self::TurnCompleted { turn_id, .. }
| Self::TurnFailed { turn_id, .. } => Some(turn_id),
Self::Raw { turn_id, .. } => turn_id.as_deref(),
Self::SessionOpened { .. }
| Self::ContextShared { .. }
| Self::AvailableCommands { .. }
| Self::ConfigOptions { .. }
| Self::DocumentEdit { .. }
| Self::State { .. }
| Self::Ended { .. } => None,
}
}
#[must_use]
pub fn settles(&self) -> Option<(AssistantSessionState, Option<String>)> {
match self {
Self::State { state, reason } if !state.is_live() => Some((*state, reason.clone())),
Self::Ended { reason } => Some((AssistantSessionState::Ended, Some(reason.clone()))),
_ => None,
}
}
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct AssistantSessionFrame {
pub index: u64,
#[serde(flatten)]
#[ts(flatten)]
pub event: AssistantSessionEvent,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AssistantSessionProjection {
pub settled: Option<(AssistantSessionState, Option<String>)>,
pub acp_session_ref: Option<String>,
pub load_session: bool,
pub turns: u64,
pub first_turn_text: Option<String>,
pub open_turn_id: Option<String>,
pub latest_context: Option<AssistantTurnContext>,
pub document_revision: u64,
pub commands: Vec<AssistantCommand>,
pub config_options: Vec<AssistantConfigOption>,
}
impl AssistantSessionProjection {
#[must_use]
pub fn of<'events>(events: impl IntoIterator<Item = &'events AssistantSessionEvent>) -> Self {
let mut projection = Self::default();
for event in events {
projection.apply(event);
}
projection
}
fn apply(&mut self, event: &AssistantSessionEvent) {
match event {
AssistantSessionEvent::SessionOpened {
acp_session_ref,
load_session,
..
} => {
self.acp_session_ref = Some(acp_session_ref.clone());
self.load_session = *load_session;
if !self.is_ended() {
self.settled = None;
}
}
AssistantSessionEvent::ContextShared { context, .. } => {
self.latest_context = Some(context.clone());
}
AssistantSessionEvent::AvailableCommands { commands } => {
self.commands.clone_from(commands);
}
AssistantSessionEvent::ConfigOptions { options } => {
self.config_options.clone_from(options);
}
AssistantSessionEvent::Request {
turn_id,
text,
context,
..
} => {
self.turns = self.turns.saturating_add(1);
self.open_turn_id = Some(turn_id.clone());
if self.first_turn_text.is_none() {
self.first_turn_text = Some(text.clone());
}
if let Some(context) = context {
self.latest_context = Some(context.clone());
}
}
AssistantSessionEvent::TurnCompleted { turn_id, .. }
| AssistantSessionEvent::TurnFailed { turn_id, .. } => {
if self.open_turn_id.as_deref() == Some(turn_id.as_str()) {
self.open_turn_id = None;
}
if let Some(settled) = event.settles() {
self.settle_to(settled);
}
}
AssistantSessionEvent::DocumentEdit { edits, revision } => {
self.document_revision = self.document_revision.max(*revision);
if let Some(document) = self
.latest_context
.as_mut()
.and_then(|context| context.document.as_mut())
{
match apply_document_edits(&document.text, edits) {
Ok(applied) => document.text = applied,
Err(
AssistantDocumentEditError::EmptyOldString { .. }
| AssistantDocumentEditError::Absent { .. }
| AssistantDocumentEditError::Ambiguous { .. },
) => {}
}
}
}
other => {
if let Some(settled) = other.settles() {
self.settle_to(settled);
}
}
}
}
#[must_use]
pub fn is_ended(&self) -> bool {
matches!(self.settled, Some((AssistantSessionState::Ended, _)))
}
fn settle_to(&mut self, settled: (AssistantSessionState, Option<String>)) {
if self.is_ended() && settled.0 != AssistantSessionState::Ended {
return;
}
self.settled = Some(settled);
}
#[must_use]
pub fn state(
&self,
live: Option<AssistantSessionState>,
) -> (AssistantSessionState, Option<String>) {
if let Some(state) = live
&& !self.is_ended()
{
return (state, None);
}
match &self.settled {
Some((state, cause)) => (*state, cause.clone()),
None => (
AssistantSessionState::Ended,
Some(NO_SETTLING_RECORD.to_owned()),
),
}
}
#[must_use]
pub fn is_resumable(&self) -> bool {
self.load_session && self.acp_session_ref.is_some()
}
#[must_use]
pub fn derived_title(&self) -> Option<String> {
self.first_turn_text.as_ref().map(|text| {
let trimmed = text.trim();
trimmed.chars().take(TITLE_CHARACTERS).collect()
})
}
}
pub const TITLE_CHARACTERS: usize = 80;
pub const NO_SETTLING_RECORD: &str =
"no process is running and the transcript records no stop; treated as ended";
#[cfg(test)]
mod tests {
use super::*;
fn instant() -> Result<DateTime<Utc>, String> {
use chrono::TimeZone;
Utc.with_ymd_and_hms(2026, 8, 29, 6, 0, 0)
.single()
.ok_or_else(|| "the test instant must be valid".to_owned())
}
#[test]
fn a_frame_carries_its_index_beside_the_event_discriminator() -> Result<(), String> {
let frame = AssistantSessionFrame {
index: 3,
event: AssistantSessionEvent::Delta {
turn_id: "t-1".to_owned(),
text: "hello".to_owned(),
},
};
let wire = serde_json::to_value(&frame).map_err(|error| error.to_string())?;
assert_eq!(wire["index"], serde_json::json!(3));
assert_eq!(wire["type"], serde_json::json!("delta"));
assert_eq!(wire["turn_id"], serde_json::json!("t-1"));
assert_eq!(wire["text"], serde_json::json!("hello"));
Ok(())
}
#[test]
fn every_event_shape_round_trips_through_its_wire_form() -> Result<(), String> {
let events = vec![
AssistantSessionEvent::SessionOpened {
acp_session_ref: "sess-1".to_owned(),
load_session: true,
at: instant()?,
resumed: false,
},
AssistantSessionEvent::ContextShared {
context: AssistantTurnContext::default(),
source: "turn".to_owned(),
},
AssistantSessionEvent::Request {
turn_id: "t-1".to_owned(),
text: "fix this".to_owned(),
context: Some(AssistantTurnContext::default()),
command: Some(AssistantCommandInvocation {
name: "compact".to_owned(),
input: Some("keep the plan".to_owned()),
}),
},
AssistantSessionEvent::TurnStarted {
turn_id: "t-1".to_owned(),
at: instant()?,
prompt: "On screen: /studio\n\nfix this".to_owned(),
},
AssistantSessionEvent::AvailableCommands {
commands: vec![AssistantCommand {
name: "compact".to_owned(),
description: "compact the conversation".to_owned(),
input_hint: Some("what to keep".to_owned()),
}],
},
AssistantSessionEvent::DocumentEdit {
edits: vec![crate::assistant_document::AssistantDocumentEditOp {
old_string: "step one".to_owned(),
new_string: "step first".to_owned(),
}],
revision: 1,
},
AssistantSessionEvent::Delta {
turn_id: "t-1".to_owned(),
text: "part".to_owned(),
},
AssistantSessionEvent::Thought {
turn_id: "t-1".to_owned(),
text: "considering".to_owned(),
},
AssistantSessionEvent::ToolCall {
turn_id: "t-1".to_owned(),
call_id: "c-1".to_owned(),
name: "check_document".to_owned(),
status: AssistantToolCallStatus::Completed,
input: Some(serde_json::json!({ "path": "a.awl" })),
output: None,
},
AssistantSessionEvent::PermissionAsk {
turn_id: "t-1".to_owned(),
request: serde_json::json!({ "toolCall": { "toolCallId": "c-1" } }),
decided: AssistantPermissionDecision::Deny,
},
AssistantSessionEvent::TurnCompleted {
turn_id: "t-1".to_owned(),
final_message: "done".to_owned(),
stop_reason: "end_turn".to_owned(),
session_ref: Some("sess-1".to_owned()),
},
AssistantSessionEvent::TurnFailed {
turn_id: "t-2".to_owned(),
code: "auth_required".to_owned(),
message: "the agent requires authentication".to_owned(),
},
AssistantSessionEvent::State {
state: AssistantSessionState::Dormant,
reason: Some("process_exited".to_owned()),
},
AssistantSessionEvent::Ended {
reason: "the operator closed the session".to_owned(),
},
];
for event in events {
let bytes = serde_json::to_vec(&event).map_err(|error| error.to_string())?;
let decoded: AssistantSessionEvent =
serde_json::from_slice(&bytes).map_err(|error| error.to_string())?;
assert_eq!(decoded, event);
}
Ok(())
}
#[test]
fn a_session_id_parses_from_its_own_display_form() {
let id = AssistantSessionId::new(Uuid::from_u128(7));
assert_eq!(AssistantSessionId::parse(&id.to_string()), Ok(id));
let error = AssistantSessionId::parse("not-a-uuid");
assert!(
matches!(&error, Err(failure) if failure.text == "not-a-uuid"),
"a malformed id must be refused naming the text: {error:?}"
);
}
#[test]
fn only_a_running_process_is_live_and_a_dormant_session_is_still_continuable() {
assert!(AssistantSessionState::Live.is_live());
assert!(!AssistantSessionState::Dormant.is_live());
assert!(!AssistantSessionState::Closed.is_live());
assert!(!AssistantSessionState::Ended.is_live());
assert!(AssistantSessionState::Live.is_continuable());
assert!(AssistantSessionState::Dormant.is_continuable());
assert!(AssistantSessionState::Closed.is_continuable());
assert!(!AssistantSessionState::Ended.is_continuable());
assert!(AssistantSessionState::Live.is_current_candidate());
assert!(AssistantSessionState::Dormant.is_current_candidate());
assert!(!AssistantSessionState::Closed.is_current_candidate());
assert!(!AssistantSessionState::Ended.is_current_candidate());
}
#[test]
fn the_three_wire_states_spell_exactly_what_the_console_parses() {
for (state, spelling) in [
(AssistantSessionState::Live, "\"live\""),
(AssistantSessionState::Dormant, "\"dormant\""),
(AssistantSessionState::Closed, "\"closed\""),
(AssistantSessionState::Ended, "\"ended\""),
] {
assert_eq!(
serde_json::to_string(&state).unwrap_or_default(),
spelling,
"{state:?} must spell {spelling} on the wire"
);
}
}
#[test]
fn session_wide_events_belong_to_no_turn() {
assert_eq!(
AssistantSessionEvent::State {
state: AssistantSessionState::Live,
reason: None,
}
.turn_id(),
None
);
assert_eq!(
AssistantSessionEvent::Ended {
reason: "stopped".to_owned(),
}
.turn_id(),
None
);
assert_eq!(
AssistantSessionEvent::Delta {
turn_id: "t-9".to_owned(),
text: String::new(),
}
.turn_id(),
Some("t-9")
);
}
fn opened(load_session: bool, resumed: bool) -> Result<AssistantSessionEvent, String> {
Ok(AssistantSessionEvent::SessionOpened {
acp_session_ref: "sess-1".to_owned(),
load_session,
at: instant()?,
resumed,
})
}
fn turn(text: &str) -> AssistantSessionEvent {
AssistantSessionEvent::Request {
turn_id: "t-1".to_owned(),
text: text.to_owned(),
context: None,
command: None,
}
}
#[test]
fn a_running_process_wins_over_every_settling_record() -> Result<(), String> {
let events = vec![
opened(true, false)?,
AssistantSessionEvent::State {
state: AssistantSessionState::Dormant,
reason: Some("process_exited".to_owned()),
},
opened(true, true)?,
];
let projection = AssistantSessionProjection::of(&events);
assert_eq!(projection.settled, None);
assert_eq!(
projection.state(Some(AssistantSessionState::Live)),
(AssistantSessionState::Live, None)
);
Ok(())
}
#[test]
fn with_no_process_the_last_settling_record_decides() -> Result<(), String> {
let events = vec![
opened(true, false)?,
turn("fix the check"),
AssistantSessionEvent::State {
state: AssistantSessionState::Dormant,
reason: Some("process_exited".to_owned()),
},
];
let projection = AssistantSessionProjection::of(&events);
assert_eq!(
projection.state(None),
(
AssistantSessionState::Dormant,
Some("process_exited".to_owned())
)
);
assert!(projection.is_resumable());
assert_eq!(projection.turns, 1);
assert_eq!(projection.derived_title(), Some("fix the check".to_owned()));
Ok(())
}
#[test]
fn an_agent_that_never_advertised_load_session_is_not_resumable() -> Result<(), String> {
let projection = AssistantSessionProjection::of(&[opened(false, false)?]);
assert!(
!projection.is_resumable(),
"resume is gated on what the agent ACTUALLY advertised"
);
Ok(())
}
#[test]
fn no_process_and_no_settling_record_reads_ended_not_running() -> Result<(), String> {
let projection = AssistantSessionProjection::of(&[opened(true, false)?, turn("hello")]);
let (state, cause) = projection.state(None);
assert_eq!(state, AssistantSessionState::Ended);
assert_eq!(cause.as_deref(), Some(NO_SETTLING_RECORD));
Ok(())
}
#[test]
fn ended_is_absorbing_no_later_open_or_live_record_resurrects_it() -> Result<(), String> {
let events = vec![
AssistantSessionEvent::State {
state: AssistantSessionState::Ended,
reason: Some("deleted by the caller".to_owned()),
},
AssistantSessionEvent::Ended {
reason: "deleted by the caller".to_owned(),
},
opened(true, false)?,
AssistantSessionEvent::State {
state: AssistantSessionState::Live,
reason: None,
},
turn("hello"),
];
let projection = AssistantSessionProjection::of(&events);
assert!(
projection.is_ended(),
"an ended session stays ended: {projection:?}"
);
let (state, cause) = projection.state(None);
assert_eq!(state, AssistantSessionState::Ended);
assert_eq!(cause.as_deref(), Some("deleted by the caller"));
let (state, _) = projection.state(Some(AssistantSessionState::Live));
assert_eq!(
state,
AssistantSessionState::Ended,
"a live process beside an ended record does not outrank it"
);
let restated = AssistantSessionProjection::of(&[
AssistantSessionEvent::State {
state: AssistantSessionState::Ended,
reason: Some("first".to_owned()),
},
AssistantSessionEvent::State {
state: AssistantSessionState::Ended,
reason: Some("second".to_owned()),
},
]);
assert_eq!(restated.state(None).1.as_deref(), Some("second"));
Ok(())
}
#[test]
fn the_latest_shared_context_is_the_one_the_tool_answers_with() {
let first = AssistantTurnContext {
url: Some("/studio".to_owned()),
concepts: vec!["awl.step".to_owned()],
document: None,
};
let second = AssistantTurnContext {
url: Some("/runs".to_owned()),
concepts: Vec::new(),
document: None,
};
let events = vec![
AssistantSessionEvent::ContextShared {
context: first,
source: "turn".to_owned(),
},
AssistantSessionEvent::ContextShared {
context: second.clone(),
source: "push".to_owned(),
},
];
assert_eq!(
AssistantSessionProjection::of(&events).latest_context,
Some(second)
);
}
#[test]
fn a_request_frame_spells_what_the_console_parses() -> Result<(), String> {
let wire = serde_json::to_value(AssistantSessionEvent::Request {
turn_id: "t-7".to_owned(),
text: "fix the check".to_owned(),
context: Some(AssistantTurnContext {
url: Some("/studio".to_owned()),
concepts: vec!["awl.step".to_owned()],
document: None,
}),
command: None,
})
.map_err(|error| error.to_string())?;
assert_eq!(wire["type"], serde_json::json!("request"));
assert_eq!(wire["turn_id"], serde_json::json!("t-7"));
assert_eq!(wire["text"], serde_json::json!("fix the check"));
assert_eq!(wire["context"]["url"], serde_json::json!("/studio"));
Ok(())
}
#[test]
fn a_later_command_advertisement_replaces_the_earlier_one_whole() {
let command = |name: &str| AssistantCommand {
name: name.to_owned(),
description: format!("{name} does something"),
input_hint: None,
};
let events = vec![
AssistantSessionEvent::AvailableCommands {
commands: vec![command("compact"), command("plan")],
},
AssistantSessionEvent::AvailableCommands {
commands: vec![command("compact")],
},
];
let projection = AssistantSessionProjection::of(&events);
assert_eq!(
projection
.commands
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>(),
vec!["compact"],
"`plan` was withdrawn and must not survive as an offer"
);
}
#[test]
fn a_later_config_advertisement_replaces_the_earlier_one_whole() {
let option = |id: &str| AssistantConfigOption {
id: id.to_owned(),
name: id.to_owned(),
description: None,
category: Some("model".to_owned()),
value: AssistantConfigValue::Select {
choices: vec![AssistantConfigChoice {
id: "opus".to_owned(),
name: "Opus".to_owned(),
description: None,
group: None,
}],
current: "opus".to_owned(),
},
};
let events = vec![
AssistantSessionEvent::ConfigOptions {
options: vec![option("model"), option("thinking")],
},
AssistantSessionEvent::ConfigOptions {
options: vec![option("model")],
},
];
let projection = AssistantSessionProjection::of(&events);
assert_eq!(
projection
.config_options
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>(),
vec!["model"],
"`thinking` was withdrawn and must not survive as an offer"
);
}
#[test]
fn a_config_options_frame_serialises_under_its_published_tag() -> Result<(), String> {
let wire = serde_json::to_value(AssistantSessionEvent::ConfigOptions {
options: vec![AssistantConfigOption {
id: "model".to_owned(),
name: "Model".to_owned(),
description: None,
category: Some("model".to_owned()),
value: AssistantConfigValue::Select {
choices: vec![AssistantConfigChoice {
id: "opus".to_owned(),
name: "Opus".to_owned(),
description: Some("the main one".to_owned()),
group: None,
}],
current: "opus".to_owned(),
},
}],
})
.map_err(|error| error.to_string())?;
assert_eq!(wire["type"], serde_json::json!("config_options"));
assert_eq!(wire["options"][0]["id"], serde_json::json!("model"));
assert_eq!(wire["options"][0]["category"], serde_json::json!("model"));
assert_eq!(
wire["options"][0]["value"]["kind"],
serde_json::json!("select")
);
assert_eq!(
wire["options"][0]["value"]["current"],
serde_json::json!("opus")
);
assert_eq!(
wire["options"][0]["value"]["choices"][0]["name"],
serde_json::json!("Opus")
);
Ok(())
}
#[test]
fn a_session_with_no_advertisement_offers_no_commands() {
assert!(
AssistantSessionProjection::default().commands.is_empty(),
"a command list must come from the agent, never from this server"
);
}
#[test]
fn a_command_is_delivered_as_the_slash_line_the_acp_spec_describes() {
assert_eq!(
AssistantCommandInvocation {
name: "compact".to_owned(),
input: Some("keep the plan".to_owned()),
}
.prompt_line(),
"/compact keep the plan"
);
assert_eq!(
AssistantCommandInvocation {
name: "compact".to_owned(),
input: None,
}
.prompt_line(),
"/compact"
);
assert_eq!(
AssistantCommandInvocation {
name: "compact".to_owned(),
input: Some(" ".to_owned()),
}
.prompt_line(),
"/compact"
);
}
#[test]
fn a_turns_own_context_becomes_the_shared_context() {
let context = AssistantTurnContext {
url: Some("/studio/a.awl".to_owned()),
concepts: Vec::new(),
document: None,
};
let projection = AssistantSessionProjection::of(&[AssistantSessionEvent::Request {
turn_id: "t-1".to_owned(),
text: "explain".to_owned(),
context: Some(context.clone()),
command: None,
}]);
assert_eq!(projection.latest_context, Some(context));
assert_eq!(projection.turns, 1);
}
#[test]
fn a_title_is_bounded_at_eighty_characters() -> Result<(), String> {
let long = "x".repeat(200);
let projection = AssistantSessionProjection::of(&[turn(&long)]);
let title = projection
.derived_title()
.ok_or_else(|| "a started turn has a derivable title".to_owned())?;
assert_eq!(title.chars().count(), TITLE_CHARACTERS);
Ok(())
}
#[test]
fn a_request_opens_a_turn_and_its_ending_closes_it() {
let mut projection = AssistantSessionProjection::of(&[turn("first ask")]);
assert_eq!(projection.open_turn_id.as_deref(), Some("t-1"));
projection.apply(&AssistantSessionEvent::TurnFailed {
turn_id: "t-0".to_owned(),
code: "stale".to_owned(),
message: "an earlier turn's ending arrives late".to_owned(),
});
assert_eq!(projection.open_turn_id.as_deref(), Some("t-1"));
projection.apply(&AssistantSessionEvent::TurnCompleted {
turn_id: "t-1".to_owned(),
final_message: "answered".to_owned(),
stop_reason: "end_turn".to_owned(),
session_ref: None,
});
assert_eq!(projection.open_turn_id, None);
}
#[test]
fn a_document_edit_folds_into_the_latest_context() {
let events = vec![
AssistantSessionEvent::ContextShared {
context: AssistantTurnContext {
url: Some("/studio/pipeline.awl".to_owned()),
concepts: Vec::new(),
document: Some(AssistantDocumentContext {
path: "pipeline.awl".to_owned(),
text: "workflow demo\nstep one\n".to_owned(),
selection: None,
cursor: None,
}),
},
source: "turn".to_owned(),
},
AssistantSessionEvent::DocumentEdit {
edits: vec![crate::assistant_document::AssistantDocumentEditOp {
old_string: "step one".to_owned(),
new_string: "step first".to_owned(),
}],
revision: 1,
},
];
let projection = AssistantSessionProjection::of(&events);
assert_eq!(projection.document_revision, 1);
let text = projection
.latest_context
.and_then(|context| context.document)
.map(|document| document.text);
assert_eq!(text, Some("workflow demo\nstep first\n".to_owned()));
}
#[test]
fn a_document_edit_with_no_shared_document_still_advances_the_revision() {
let events = vec![AssistantSessionEvent::DocumentEdit {
edits: vec![crate::assistant_document::AssistantDocumentEditOp {
old_string: "anything".to_owned(),
new_string: "else".to_owned(),
}],
revision: 1,
}];
let projection = AssistantSessionProjection::of(&events);
assert_eq!(projection.document_revision, 1);
assert_eq!(projection.latest_context, None);
}
#[test]
fn a_document_edit_frame_spells_what_the_console_parses() -> Result<(), String> {
let wire = serde_json::to_value(AssistantSessionEvent::DocumentEdit {
edits: vec![crate::assistant_document::AssistantDocumentEditOp {
old_string: "a".to_owned(),
new_string: "b".to_owned(),
}],
revision: 4,
})
.map_err(|error| error.to_string())?;
assert_eq!(wire["type"], serde_json::json!("document_edit"));
assert_eq!(wire["revision"], serde_json::json!(4));
assert_eq!(wire["edits"][0]["old_string"], serde_json::json!("a"));
assert_eq!(wire["edits"][0]["new_string"], serde_json::json!("b"));
Ok(())
}
}