use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize};
use crate::id::Id;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventMsg {
pub timestamp: DateTime<Utc>,
pub id: Id,
pub event: Evt,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<Id>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpMsg {
pub op: Op,
pub id: Id,
}
pub fn op_msg(op: Op) -> OpMsg {
OpMsg { op, id: Id::op() }
}
pub fn event_msg(event: Evt, parent: Option<Id>) -> EventMsg {
EventMsg { timestamp: Utc::now(), id: Id::evt(), event, parent }
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub enum Op {
StartSession(SessionRequest),
UpdateSession(SessionUpdate),
Interrupt,
UserInput(String),
ShellInput(String),
Steer(String),
ApprovalResponse {
turn_id: Id,
responses: Vec<ToolDecision>,
},
QuestionResponse {
turn_id: Id,
tool_use_id: String,
reply: QuestionReply,
},
SlashCommand {
name: String,
args: String,
},
ResumeSession {
session_id: Id,
#[serde(default)]
unattended: bool,
},
RegisterLocalProvider {
port: u16,
model: Option<ModelSpec>,
},
RestoreLocalProvider,
Compact {
#[serde(default, skip_serializing_if = "Option::is_none")]
instructions: Option<String>,
},
ContextReport,
RewindSession {
to: Id,
},
ForkSession {
#[serde(default, skip_serializing_if = "Option::is_none")]
at: Option<Id>,
},
Goal(GoalCommand),
AmbientPhrase {
draft: String,
req_id: u64,
},
AmbientSuggestion {
recent_user: String,
recent_agent: String,
req_id: u64,
},
Shutdown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum GoalCommand {
Set(String),
Clear,
Status,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AmbientKind {
ThinkingPhrase,
PromptSuggestion,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Evt {
SessionStart(Box<SessionInfo>),
SessionUpdated(Box<SessionInfo>),
ExtensionRefreshed(Box<ExtensionRefreshed>),
SessionEnd {
session_id: Id,
reason: SessionEndReason,
usage: Usage,
},
UserInput(String),
ShellOutput {
command: String,
stdout: String,
stderr: String,
exit_code: Option<i32>,
},
AgentMessage(String),
Thinking(String),
MessageDelta(String),
ThinkingDelta(String),
Info(String),
InfoBlockStart {
id: String,
header: String,
#[serde(default)]
loading: bool,
},
InfoBlockAppend {
id: String,
detail: String,
},
Error(String),
ToolStart(ToolUse),
ToolUpdate(ToolUpdate),
ToolEnd(ToolEnd),
TaskEnd {
tool_use_id: String,
exit_code: Option<i32>,
},
CompactStart,
CompactEnd {
#[serde(default)]
summary: Option<String>,
},
TurnStart {
turn_id: Id,
},
TurnPause {
turn_id: Id,
reason: TurnPauseReason,
},
TurnResume {
turn_id: Id,
},
TurnEnd {
turn_id: Id,
status: TurnEndStatus,
#[serde(default)]
steps: usize,
},
UsageUpdate {
usage: Usage,
#[serde(default, skip_serializing_if = "Option::is_none")]
context: Option<ContextWindow>,
},
ContextReport(ContextBreakdown),
SessionRewound {
to: Id,
},
SessionForked {
forked_from: Id,
session_id: Id,
#[serde(default, skip_serializing_if = "Option::is_none")]
at: Option<Id>,
},
Ambient {
kind: AmbientKind,
req_id: u64,
text: String,
},
Goodbye,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TurnPauseReason {
Approval {
tools: Vec<ToolUse>,
message: String,
},
Question {
tool_use_id: String,
questions: Vec<QuestionSpec>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QuestionSpec {
pub header: String,
pub question: String,
#[serde(default)]
pub multi_select: bool,
pub options: Vec<QuestionOption>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QuestionOption {
pub label: String,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QuestionAnswer {
pub selected: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum QuestionReply {
Answered(Vec<QuestionAnswer>),
Dismissed,
Discuss {
#[serde(default, skip_serializing_if = "Option::is_none")]
message: Option<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SessionEndReason {
Replaced,
Shutdown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TurnEndStatus {
Completed,
Interrupted {
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
Error {
#[serde(default, skip_serializing_if = "Option::is_none")]
kind: Option<String>,
headline: String,
details: Vec<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolUpdate {
pub tool_use_id: String,
pub seq: u64,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToolEndStatus {
Completed,
Cancelled,
Denied,
Failed,
}
impl ToolEndStatus {
pub fn is_error(self) -> bool {
!matches!(self, ToolEndStatus::Completed)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolEnd {
pub tool_use_id: String,
#[serde(default)]
pub tool_name: String,
pub status: ToolEndStatus,
pub result_json: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReviewDecision {
Accept,
Deny,
AcceptForSession,
AcceptAlways,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolDecision {
pub tool_use_id: String,
pub decision: ReviewDecision,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillMetadata {
pub name: String,
pub description: Option<String>,
pub scope: Scope,
pub argument_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubagentMetadata {
pub name: String,
pub description: String,
pub scope: Scope,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProviderSpec {
#[serde(alias = "name")]
pub id: String,
pub display_name: String,
pub base_url: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionInfo {
pub model: ModelSpec,
pub provider: ProviderSpec,
pub session_id: Id,
pub cwd: PathBuf,
pub permission_mode: PermissionMode,
#[serde(default)]
pub skills: Vec<SkillMetadata>,
#[serde(default)]
pub subagents: Vec<SubagentMetadata>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forked_from: Option<Id>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionUpdate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<ModelSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<PermissionMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionRefreshed {
pub session_id: Id,
pub skills: Vec<SkillMetadata>,
pub subagents: Vec<SubagentMetadata>,
#[serde(default)]
pub mcp_servers: Vec<McpServerInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerInfo {
pub name: String,
pub command: String,
pub args: Vec<String>,
pub tools: Vec<McpToolInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolInfo {
pub name: String,
pub qualified_name: String,
pub description: String,
pub parameters: Vec<McpToolParam>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolParam {
pub name: String,
pub param_type: String,
pub required: bool,
pub description: String,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SessionRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<PermissionMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub append_system_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_tools: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclude_tools: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<Effort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enable_auto_memory: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub short_prompt: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_skills: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_skills: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclude_skills: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub save_session: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unattended: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
impl SessionRequest {
pub fn patched(self, patch: SessionRequest) -> SessionRequest {
let SessionRequest {
model,
provider,
permission_mode,
system_prompt,
append_system_prompt,
tools,
include_tools,
exclude_tools,
cwd,
effort,
enable_auto_memory,
short_prompt,
no_skills,
include_skills,
exclude_skills,
save_session,
unattended,
title,
} = patch;
SessionRequest {
model: model.or(self.model),
provider: provider.or(self.provider),
permission_mode: permission_mode.or(self.permission_mode),
system_prompt: system_prompt.or(self.system_prompt),
append_system_prompt: append_system_prompt.or(self.append_system_prompt),
tools: tools.or(self.tools),
include_tools: include_tools.or(self.include_tools),
exclude_tools: exclude_tools.or(self.exclude_tools),
cwd: cwd.or(self.cwd),
effort: effort.or(self.effort),
enable_auto_memory: enable_auto_memory.or(self.enable_auto_memory),
short_prompt: short_prompt.or(self.short_prompt),
no_skills: no_skills.or(self.no_skills),
include_skills: include_skills.or(self.include_skills),
exclude_skills: exclude_skills.or(self.exclude_skills),
save_session: save_session.or(self.save_session),
unattended: unattended.or(self.unattended),
title: title.or(self.title),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MalformedToolArgs {
pub raw: String,
pub error: String,
}
pub const MISSING_TOOL_NAME: &str = "missing_function_name";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolUse {
pub id: String,
pub name: String,
pub args: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub malformed_args: Option<MalformedToolArgs>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
impl ToolUse {
pub fn new(id: impl Into<String>, name: impl Into<String>, args: serde_json::Value) -> Self {
Self { id: id.into(), name: name.into(), args, malformed_args: None, signature: None }
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ModelSpec {
#[serde(alias = "name")]
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_k: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_sequences: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<Effort>,
#[serde(skip_serializing_if = "Option::is_none")]
pub supported_efforts: Option<Vec<Effort>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub support_vision: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub weight_class: Option<WeightClass>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(rename_all = "lowercase")]
pub enum Effort {
Min,
Low,
Medium,
High,
XHigh,
Max,
}
impl Effort {
pub const ALL: [Effort; 6] =
[Effort::Min, Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max];
pub fn as_str(self) -> &'static str {
match self {
Effort::Min => "min",
Effort::Low => "low",
Effort::Medium => "medium",
Effort::High => "high",
Effort::XHigh => "xhigh",
Effort::Max => "max",
}
}
}
impl std::fmt::Display for Effort {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for Effort {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Effort::ALL.into_iter().find(|e| e.as_str() == s).ok_or_else(|| {
format!("unknown effort `{s}` (expected min, low, medium, high, xhigh, max)")
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum WeightClass {
Feather,
Middle,
Heavy,
}
impl<'de> Deserialize<'de> for WeightClass {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
match value.to_ascii_lowercase().as_str() {
"feather" => Ok(Self::Feather),
"middle" => Ok(Self::Middle),
"heavy" => Ok(Self::Heavy),
_ => Err(serde::de::Error::unknown_variant(&value, &["feather", "middle", "heavy"])),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, Copy)]
#[serde(default)]
pub struct Usage {
pub input_tokens: u32,
pub output_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_creation_tokens: Option<u32>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextWindow {
pub used_tokens: u32,
pub limit_tokens: u32,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ContextBreakdown {
pub system_prompt_tokens: u32,
pub system_tools_tokens: u32,
pub mcp_tools_tokens: u32,
pub memory_tokens: u32,
pub skills_tokens: u32,
pub messages_tokens: u32,
pub used_tokens: u32,
pub limit_tokens: Option<u32>,
pub compact_buffer_tokens: u32,
}
impl Usage {
pub fn new(input_tokens: u32, output_tokens: u32) -> Self {
Self { input_tokens, output_tokens, cache_read_tokens: None, cache_creation_tokens: None }
}
pub fn total(&self) -> u32 {
self.input_tokens.saturating_add(self.output_tokens)
}
}
impl std::ops::Add<Usage> for Usage {
type Output = Usage;
fn add(self, other: Usage) -> Usage {
Usage {
input_tokens: self.input_tokens.saturating_add(other.input_tokens),
output_tokens: self.output_tokens.saturating_add(other.output_tokens),
cache_read_tokens: add_optional_u32(self.cache_read_tokens, other.cache_read_tokens),
cache_creation_tokens: add_optional_u32(
self.cache_creation_tokens,
other.cache_creation_tokens,
),
}
}
}
fn add_optional_u32(a: Option<u32>, b: Option<u32>) -> Option<u32> {
match (a, b) {
(None, None) => None,
_ => Some(a.unwrap_or(0).saturating_add(b.unwrap_or(0))),
}
}
impl std::ops::AddAssign<Usage> for Usage {
fn add_assign(&mut self, other: Usage) {
*self = *self + other;
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum PermissionMode {
#[default]
Strict,
Auto,
Yolo,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub enum Scope {
Project,
User,
System,
}
#[cfg(test)]
mod tests {
use super::{
Effort, Evt, ExtensionRefreshed, Id, ModelSpec, Op, PermissionMode, ProviderSpec,
ReviewDecision, SessionInfo, SessionRequest, SessionUpdate, ToolDecision, ToolEnd,
ToolEndStatus, ToolUse, Usage, WeightClass, event_msg, op_msg,
};
use std::path::PathBuf;
#[test]
fn op_msg_assigns_runtime_id() {
let msg = op_msg(Op::Interrupt);
assert!(msg.id.to_string().starts_with("op_"));
assert!(matches!(msg.op, Op::Interrupt));
}
#[test]
fn event_msg_assigns_runtime_metadata() {
let event = event_msg(Evt::Info("hello".to_string()), None);
assert!(event.id.to_string().starts_with("evt_"));
assert!(matches!(event.event, Evt::Info(message) if message == "hello"));
assert!(event.parent.is_none());
}
fn model_spec(name: &str) -> ModelSpec {
ModelSpec {
id: name.to_string(),
display_name: None,
description: None,
temperature: None,
top_p: None,
top_k: None,
max_tokens: None,
stop_sequences: None,
context_limit: None,
effort: None,
supported_efforts: None,
support_vision: None,
weight_class: None,
}
}
#[test]
fn tool_use_without_malformed_args_remains_backward_compatible() {
let tool_use: ToolUse = serde_json::from_value(serde_json::json!({
"id": "call-1",
"name": "Read",
"args": { "file_path": "README.md" }
}))
.unwrap();
assert!(tool_use.malformed_args.is_none());
let encoded = serde_json::to_value(tool_use).unwrap();
assert!(encoded.get("malformed_args").is_none());
}
#[test]
fn tool_end_without_name_remains_backward_compatible() {
let legacy = serde_json::json!({
"tool_use_id": "call-1",
"status": "Denied",
"result_json": "Tool call denied by policy and was not executed."
});
let mut end: ToolEnd = serde_json::from_value(legacy.clone()).unwrap();
assert!(end.tool_name.is_empty());
assert_eq!(end.status, ToolEndStatus::Denied);
end.tool_name = "Bash".to_string();
let mut expected = legacy;
expected["tool_name"] = serde_json::json!("Bash");
assert_eq!(serde_json::to_value(end).unwrap(), expected);
}
#[test]
fn task_end_round_trips_with_observed_or_unobserved_exit() {
for exit_code in [Some(2), None] {
let json = serde_json::json!({
"TaskEnd": { "tool_use_id": "bash-1", "exit_code": exit_code }
});
let event: super::Evt = serde_json::from_value(json.clone()).unwrap();
assert!(matches!(&event, super::Evt::TaskEnd { tool_use_id, exit_code: code }
if tool_use_id == "bash-1" && *code == exit_code));
assert_eq!(serde_json::to_value(event).unwrap(), json);
}
}
#[test]
fn turn_end_error_kind_is_optional_on_the_wire() {
let old: super::TurnEndStatus = serde_json::from_value(serde_json::json!({
"Error": { "headline": "authentication error", "details": [] }
}))
.unwrap();
let super::TurnEndStatus::Error { kind, .. } = &old else {
panic!("expected Error variant");
};
assert!(kind.is_none());
let json = serde_json::to_value(&old).unwrap();
assert!(json["Error"].get("kind").is_none());
let with = super::TurnEndStatus::Error {
kind: Some("oauth".to_string()),
headline: "OAuth sign-in required".to_string(),
details: vec![],
};
let json = serde_json::to_value(&with).unwrap();
assert_eq!(json["Error"]["kind"], "oauth");
}
#[test]
fn turn_end_steps_default_for_older_events() {
let event = super::Evt::TurnEnd {
turn_id: super::Id::new("turn"),
status: super::TurnEndStatus::Completed,
steps: 3,
};
let mut json = serde_json::to_value(&event).unwrap();
json["TurnEnd"].as_object_mut().unwrap().remove("steps");
let old: super::Evt = serde_json::from_value(json).unwrap();
assert!(matches!(old, super::Evt::TurnEnd { steps: 0, .. }));
let json = serde_json::to_value(event).unwrap();
assert_eq!(json["TurnEnd"]["steps"], 3);
}
#[test]
fn effort_serializes_as_lowercase_tokens() {
let mut spec = model_spec("m");
spec.effort = Some(super::Effort::XHigh);
let json = serde_json::to_value(&spec).unwrap();
assert_eq!(json["effort"], "xhigh");
let json_none = serde_json::to_value(model_spec("m")).unwrap();
assert!(json_none.get("effort").is_none());
for level in super::Effort::ALL {
let parsed: ModelSpec =
serde_json::from_value(serde_json::json!({"id": "m", "effort": level.as_str()}))
.unwrap();
assert_eq!(parsed.effort, Some(level), "round-trip {level}");
}
}
#[test]
fn effort_orders_min_lowest_to_max_highest() {
let mut sorted = super::Effort::ALL;
sorted.sort();
assert_eq!(sorted, super::Effort::ALL);
assert!(super::Effort::Min < super::Effort::Low);
assert!(super::Effort::XHigh < super::Effort::Max);
}
#[test]
fn session_overrides_effort_round_trips() {
let parsed: super::SessionRequest =
serde_json::from_value(serde_json::json!({"effort": "max"})).unwrap();
assert_eq!(parsed.effort, Some(super::Effort::Max));
let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(parsed.effort, None);
}
#[test]
fn short_prompt_round_trips_and_defaults_to_unset() {
let parsed: super::SessionRequest =
serde_json::from_value(serde_json::json!({"short_prompt": true})).unwrap();
assert_eq!(parsed.short_prompt, Some(true));
let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(parsed.short_prompt, None);
}
#[test]
fn skill_filters_are_optional_on_the_wire_and_preserve_empty_overrides() {
let absent: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(absent.include_skills, None);
assert_eq!(absent.exclude_skills, None);
let encoded = serde_json::to_value(absent).unwrap();
assert!(encoded.get("include_skills").is_none());
assert!(encoded.get("exclude_skills").is_none());
let json = serde_json::json!({"include_skills": ["Review"], "exclude_skills": []});
let parsed: SessionRequest = serde_json::from_value(json.clone()).unwrap();
assert_eq!(parsed.include_skills, Some(vec!["Review".to_string()]));
assert_eq!(parsed.exclude_skills, Some(Vec::new()));
assert_eq!(serde_json::to_value(parsed).unwrap(), json);
}
fn pinned_request() -> SessionRequest {
SessionRequest {
model: Some("base-model".to_string()),
provider: Some("anthropic".to_string()),
permission_mode: Some(PermissionMode::Strict),
system_prompt: Some("base prompt".to_string()),
cwd: Some(std::path::PathBuf::from("/base")),
effort: Some(Effort::Medium),
enable_auto_memory: Some(true),
short_prompt: Some(true),
no_skills: Some(true),
include_skills: Some(vec!["review".to_string()]),
exclude_skills: Some(vec!["noisy".to_string()]),
save_session: Some(true),
unattended: Some(true),
title: Some("pinned title".to_string()),
..Default::default()
}
}
#[test]
fn patched_with_an_empty_patch_keeps_every_field() {
assert_eq!(pinned_request().patched(SessionRequest::default()), pinned_request());
}
#[test]
fn patched_overwrites_only_the_patch_set_fields() {
let patched = pinned_request().patched(SessionRequest {
model: Some("new-model".to_string()),
permission_mode: Some(PermissionMode::Yolo),
enable_auto_memory: Some(false),
short_prompt: Some(false),
include_skills: Some(Vec::new()),
..Default::default()
});
assert_eq!(patched.model.as_deref(), Some("new-model"));
assert_eq!(patched.permission_mode, Some(PermissionMode::Yolo));
assert_eq!(patched.enable_auto_memory, Some(false));
assert_eq!(patched.short_prompt, Some(false));
assert_eq!(patched.include_skills, Some(Vec::new()));
assert_eq!(patched.provider.as_deref(), Some("anthropic"));
assert_eq!(patched.system_prompt.as_deref(), Some("base prompt"));
assert_eq!(patched.effort, Some(Effort::Medium));
assert_eq!(patched.save_session, Some(true));
assert_eq!(patched.unattended, Some(true));
assert_eq!(patched.title.as_deref(), Some("pinned title"));
assert_eq!(patched.exclude_skills, Some(vec!["noisy".to_string()]));
}
#[test]
fn rewind_names_its_input_as_to() {
let to = Id::op();
let json = serde_json::to_value(Op::RewindSession { to }).unwrap();
assert_eq!(json, serde_json::json!({ "RewindSession": { "to": to } }));
let op: Op = serde_json::from_value(json).unwrap();
assert!(matches!(op, Op::RewindSession { to: decoded } if decoded == to));
let json = serde_json::to_value(Evt::SessionRewound { to }).unwrap();
assert_eq!(json, serde_json::json!({ "SessionRewound": { "to": to } }));
}
#[test]
fn fork_at_is_optional_on_the_wire() {
let whole: Op = serde_json::from_value(serde_json::json!({ "ForkSession": {} })).unwrap();
assert!(matches!(whole, Op::ForkSession { at: None }));
let at = Id::op();
let json = serde_json::to_value(Op::ForkSession { at: Some(at) }).unwrap();
assert_eq!(json, serde_json::json!({ "ForkSession": { "at": at } }));
let op: Op = serde_json::from_value(json).unwrap();
assert!(matches!(op, Op::ForkSession { at: Some(decoded) } if decoded == at));
let (forked_from, session_id) = (Id::ses(), Id::ses());
let forked = Evt::SessionForked { forked_from, session_id, at: None };
assert_eq!(
serde_json::to_value(forked).unwrap(),
serde_json::json!({
"SessionForked": { "forked_from": forked_from, "session_id": session_id }
})
);
}
#[test]
fn forked_from_is_optional_on_the_wire() {
let source = Id::ses();
let info = SessionInfo { forked_from: Some(source), ..Default::default() };
let mut json = serde_json::to_value(&info).unwrap();
assert_eq!(json["forked_from"], serde_json::json!(source));
let decoded: SessionInfo = serde_json::from_value(json.clone()).unwrap();
assert_eq!(decoded.forked_from, Some(source));
json.as_object_mut().unwrap().remove("forked_from");
let plain: SessionInfo = serde_json::from_value(json).unwrap();
assert_eq!(plain.forked_from, None);
assert!(serde_json::to_value(&plain).unwrap().get("forked_from").is_none());
}
#[test]
fn unattended_is_optional_on_the_wire() {
let request: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(request.unattended, None);
let op = Op::ResumeSession { session_id: Id::new("ses"), unattended: true };
let mut json = serde_json::to_value(&op).unwrap();
json["ResumeSession"].as_object_mut().unwrap().remove("unattended");
let decoded: Op = serde_json::from_value(json).unwrap();
assert!(matches!(decoded, Op::ResumeSession { unattended: false, .. }));
}
#[test]
fn session_initialized_title_is_optional_on_the_wire() {
let mut payload = SessionInfo {
model: model_spec("m"),
provider: provider_spec("p"),
session_id: Id::new("ses"),
cwd: PathBuf::from("/tmp"),
permission_mode: PermissionMode::default(),
skills: vec![],
subagents: vec![],
title: None,
forked_from: None,
};
let json = serde_json::to_value(&payload).unwrap();
assert!(json.get("title").is_none(), "an unset title is omitted: {json}");
let decoded: SessionInfo = serde_json::from_value(json).unwrap();
assert_eq!(decoded.title, None);
payload.title = Some("fix".to_string());
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(json["title"], "fix");
}
#[test]
fn weight_class_serializes_lowercase_and_is_omitted_when_none() {
let mut spec = model_spec("m");
spec.weight_class = Some(WeightClass::Heavy);
let json = serde_json::to_value(&spec).unwrap();
assert_eq!(json["weight_class"], "heavy");
let json_none = serde_json::to_value(model_spec("m")).unwrap();
assert!(json_none.get("weight_class").is_none());
let parsed: ModelSpec =
serde_json::from_value(serde_json::json!({"id": "m", "weight_class": "feather"}))
.unwrap();
assert_eq!(parsed.weight_class, Some(WeightClass::Feather));
}
#[test]
fn weight_class_deserializes_case_insensitively() {
for (value, expected) in [
("Feather", WeightClass::Feather),
("MIDDLE", WeightClass::Middle),
("hEaVy", WeightClass::Heavy),
] {
let parsed: ModelSpec =
serde_json::from_value(serde_json::json!({"id": "m", "weight_class": value}))
.unwrap();
assert_eq!(parsed.weight_class, Some(expected));
}
}
#[test]
fn weight_class_orders_feather_lightest_to_heavy_heaviest() {
assert!(WeightClass::Feather < WeightClass::Middle);
assert!(WeightClass::Middle < WeightClass::Heavy);
}
fn provider_spec(name: &str) -> ProviderSpec {
ProviderSpec {
id: name.to_string(),
display_name: name.to_string(),
base_url: format!("https://api.{name}.test/v1"),
}
}
#[test]
fn compact_events_serde_roundtrip() {
let compact_start =
serde_json::to_string(&Evt::CompactStart).expect("serialize CompactStart");
let compact_end =
serde_json::to_string(&Evt::CompactEnd { summary: Some("the summary".to_string()) })
.expect("serialize CompactEnd");
assert_eq!(compact_start, "\"CompactStart\"");
assert_eq!(compact_end, r#"{"CompactEnd":{"summary":"the summary"}}"#);
assert!(matches!(
serde_json::from_str::<Evt>(&compact_start).expect("deserialize CompactStart"),
Evt::CompactStart
));
assert!(matches!(
serde_json::from_str::<Evt>(&compact_end).expect("deserialize CompactEnd"),
Evt::CompactEnd { summary: Some(s) } if s == "the summary"
));
assert!(matches!(
serde_json::from_str::<Evt>(r#"{"CompactEnd":{}}"#)
.expect("deserialize CompactEnd without summary"),
Evt::CompactEnd { summary: None }
));
}
#[test]
fn compact_op_serde_roundtrip() {
let plain = serde_json::to_string(&Op::Compact { instructions: None })
.expect("serialize bare Compact");
assert_eq!(plain, r#"{"Compact":{}}"#);
assert!(matches!(
serde_json::from_str::<Op>(&plain).expect("deserialize bare Compact"),
Op::Compact { instructions: None }
));
let steered =
serde_json::to_string(&Op::Compact { instructions: Some("keep dates".to_string()) })
.expect("serialize steered Compact");
assert_eq!(steered, r#"{"Compact":{"instructions":"keep dates"}}"#);
assert!(matches!(
serde_json::from_str::<Op>(&steered).expect("deserialize steered Compact"),
Op::Compact { instructions: Some(text) } if text == "keep dates"
));
}
#[test]
fn session_end_and_turn_resume_serde_roundtrip() {
let session_id = Id::new("ses");
let end = Evt::SessionEnd {
session_id,
reason: super::SessionEndReason::Shutdown,
usage: Usage::new(10, 5),
};
let json = serde_json::to_string(&end).expect("serialize SessionEnd");
let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionEnd");
assert!(matches!(
decoded,
Evt::SessionEnd { session_id: id, reason: super::SessionEndReason::Shutdown, usage }
if id == session_id && usage.total() == 15
));
let turn_id = Id::new("op");
let resume = Evt::TurnResume { turn_id };
let json = serde_json::to_string(&resume).expect("serialize TurnResume");
let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize TurnResume");
assert!(matches!(decoded, Evt::TurnResume { turn_id: id } if id == turn_id));
}
#[test]
fn extension_refreshed_serde_roundtrip() {
let event = Evt::ExtensionRefreshed(Box::new(ExtensionRefreshed {
session_id: Id::new("ses"),
skills: Vec::new(),
subagents: Vec::new(),
mcp_servers: Vec::new(),
}));
let json = serde_json::to_string(&event).expect("serialize ExtensionRefreshed");
let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize ExtensionRefreshed");
assert!(matches!(
decoded,
Evt::ExtensionRefreshed(payload)
if payload.skills.is_empty() && payload.subagents.is_empty()
));
}
#[test]
fn session_update_op_serde_roundtrip() {
let op = Op::UpdateSession(SessionUpdate {
provider: Some("openai".to_string()),
model: Some(ModelSpec {
temperature: Some(0.2),
effort: Some(super::Effort::High),
..model_spec("gpt-5.4")
}),
permission_mode: Some(PermissionMode::Yolo),
title: Some("renamed".to_string()),
});
let json = serde_json::to_string(&op).expect("serialize UpdateSession");
let decoded = serde_json::from_str::<Op>(&json).expect("deserialize UpdateSession");
assert!(matches!(
decoded,
Op::UpdateSession(SessionUpdate {
provider: Some(provider),
model: Some(model),
permission_mode: Some(PermissionMode::Yolo),
title: Some(title),
})
if provider == "openai"
&& model.id == "gpt-5.4"
&& model.temperature == Some(0.2)
&& model.effort == Some(super::Effort::High)
&& title == "renamed"
));
}
#[test]
fn approval_response_uses_named_tool_decisions() {
let turn_id = Id::new("turn");
let op = Op::ApprovalResponse {
turn_id,
responses: vec![ToolDecision {
tool_use_id: "call-1".to_string(),
decision: ReviewDecision::Deny,
message: Some("use the read-only endpoint".to_string()),
}],
};
let json = serde_json::to_value(&op).expect("serialize ApprovalResponse");
assert_eq!(
json["ApprovalResponse"]["responses"],
serde_json::json!([{
"tool_use_id": "call-1",
"decision": "Deny",
"message": "use the read-only endpoint"
}])
);
let decoded = serde_json::from_value::<Op>(json).expect("deserialize ApprovalResponse");
assert!(matches!(
decoded,
Op::ApprovalResponse { turn_id: id, responses }
if id == turn_id
&& responses == vec![ToolDecision {
tool_use_id: "call-1".to_string(),
decision: ReviewDecision::Deny,
message: Some("use the read-only endpoint".to_string()),
}]
));
}
#[test]
fn session_updated_event_serde_roundtrip() {
let session_id = Id::new("ses");
let event = Evt::SessionUpdated(Box::new(SessionInfo {
model: model_spec("claude-sonnet-4-6"),
provider: provider_spec("anthropic"),
session_id,
cwd: PathBuf::from("/tmp/session-updated"),
permission_mode: PermissionMode::default(),
skills: vec![],
subagents: vec![],
title: None,
forked_from: None,
}));
let json = serde_json::to_string(&event).expect("serialize SessionUpdated");
let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionUpdated");
assert!(matches!(
decoded,
Evt::SessionUpdated(payload)
if payload.model.id == "claude-sonnet-4-6"
&& payload.provider.id == "anthropic"
&& payload.provider.base_url == "https://api.anthropic.test/v1"
&& payload.session_id == session_id
&& payload.cwd == std::path::Path::new("/tmp/session-updated")
));
}
#[test]
fn provider_spec_ignores_the_dropped_model_list() {
let spec: ProviderSpec = serde_json::from_value(serde_json::json!({
"id": "anthropic",
"display_name": "Anthropic",
"base_url": "https://api.anthropic.test/v1",
"preferred_models": [{ "id": "claude-sonnet-4-6" }],
}))
.unwrap();
assert_eq!(spec.id, "anthropic");
assert_eq!(spec.display_name, "Anthropic");
assert_eq!(spec.base_url, "https://api.anthropic.test/v1");
let encoded = serde_json::to_value(&spec).unwrap();
assert!(encoded.get("preferred_models").is_none());
}
#[test]
fn context_report_serde_roundtrip() {
let breakdown = super::ContextBreakdown {
system_prompt_tokens: 1200,
system_tools_tokens: 3400,
mcp_tools_tokens: 0,
memory_tokens: 800,
skills_tokens: 150,
messages_tokens: 42_000,
used_tokens: 47_550,
limit_tokens: Some(200_000),
compact_buffer_tokens: 20_000,
};
let json = serde_json::to_value(Evt::ContextReport(breakdown)).expect("serialize");
assert_eq!(
json,
serde_json::json!({
"ContextReport": {
"system_prompt_tokens": 1200,
"system_tools_tokens": 3400,
"mcp_tools_tokens": 0,
"memory_tokens": 800,
"skills_tokens": 150,
"messages_tokens": 42000,
"used_tokens": 47550,
"limit_tokens": 200000,
"compact_buffer_tokens": 20000
}
})
);
let decoded = serde_json::from_value::<Evt>(json).expect("deserialize");
assert!(matches!(decoded, Evt::ContextReport(b) if b == breakdown));
let sparse: super::ContextBreakdown =
serde_json::from_value(serde_json::json!({"used_tokens": 10})).unwrap();
assert_eq!(sparse.used_tokens, 10);
assert_eq!(sparse.limit_tokens, None);
let op = serde_json::to_value(Op::ContextReport).expect("serialize op");
assert_eq!(op, serde_json::json!("ContextReport"));
assert!(matches!(serde_json::from_value::<Op>(op).unwrap(), Op::ContextReport));
}
#[test]
fn question_pause_serde_roundtrip() {
let turn_id = Id::new("op");
let pause = Evt::TurnPause {
turn_id,
reason: super::TurnPauseReason::Question {
tool_use_id: "toolu_1".to_string(),
questions: vec![super::QuestionSpec {
header: "Auth method".to_string(),
question: "Which auth method should we use?".to_string(),
multi_select: false,
options: vec![
super::QuestionOption {
label: "JWT (Recommended)".to_string(),
description: "Stateless tokens".to_string(),
preview: None,
},
super::QuestionOption {
label: "Sessions".to_string(),
description: "Server-side sessions".to_string(),
preview: Some("fn login() {}".to_string()),
},
],
}],
},
};
let json = serde_json::to_value(&pause).expect("serialize question pause");
let spec = &json["TurnPause"]["reason"]["Question"]["questions"][0];
assert!(spec["options"][0].get("preview").is_none());
assert_eq!(spec["options"][1]["preview"], "fn login() {}");
let decoded: Evt = serde_json::from_value(json).expect("deserialize question pause");
let Evt::TurnPause {
reason: super::TurnPauseReason::Question { tool_use_id, questions },
..
} = decoded
else {
panic!("expected Question pause");
};
assert_eq!(tool_use_id, "toolu_1");
assert_eq!(questions.len(), 1);
assert!(!questions[0].multi_select);
let sparse: super::QuestionSpec = serde_json::from_value(serde_json::json!({
"header": "Scope",
"question": "How broad?",
"options": [],
}))
.unwrap();
assert!(!sparse.multi_select);
}
#[test]
fn question_response_serde_roundtrip() {
let turn_id = Id::new("op");
let op = Op::QuestionResponse {
turn_id,
tool_use_id: "toolu_1".to_string(),
reply: super::QuestionReply::Answered(vec![super::QuestionAnswer {
selected: vec!["JWT".to_string(), "Sessions".to_string()],
note: None,
}]),
};
let json = serde_json::to_string(&op).expect("serialize QuestionResponse");
let decoded: Op = serde_json::from_str(&json).expect("deserialize QuestionResponse");
assert!(matches!(
decoded,
Op::QuestionResponse { tool_use_id, reply: super::QuestionReply::Answered(answers), .. }
if tool_use_id == "toolu_1" && answers[0].selected.len() == 2
));
for (reply, expected) in [
(super::QuestionReply::Dismissed, serde_json::json!("Dismissed")),
(super::QuestionReply::Discuss { message: None }, serde_json::json!({ "Discuss": {} })),
(
super::QuestionReply::Discuss { message: Some("later".to_string()) },
serde_json::json!({ "Discuss": { "message": "later" } }),
),
] {
let json = serde_json::to_value(&reply).expect("serialize reply");
assert_eq!(json, expected);
let decoded: super::QuestionReply =
serde_json::from_value(json).expect("deserialize reply");
assert_eq!(decoded, reply);
}
}
#[test]
fn usage_adds_cache_fields_without_overflowing() {
let mut usage = Usage {
input_tokens: 10,
output_tokens: 20,
cache_read_tokens: Some(3),
cache_creation_tokens: None,
};
usage += Usage {
input_tokens: 5,
output_tokens: 6,
cache_read_tokens: Some(4),
cache_creation_tokens: Some(8),
};
assert_eq!(usage.input_tokens, 15);
assert_eq!(usage.output_tokens, 26);
assert_eq!(usage.total(), 41);
assert_eq!(usage.cache_read_tokens, Some(7));
assert_eq!(usage.cache_creation_tokens, Some(8));
}
}