use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StopReason {
Stop,
ToolUse,
Length,
Error,
Aborted,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ContentBlock {
Text {
text: String,
},
Thinking {
text: String,
},
ToolCall {
id: String,
name: String,
arguments: Value,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct AssistantMessage {
pub content: Vec<ContentBlock>,
pub stop_reason: StopReason,
pub error_message: Option<String>,
}
impl AssistantMessage {
pub fn new(content: Vec<ContentBlock>, stop_reason: StopReason) -> Self {
Self {
content,
stop_reason,
error_message: None,
}
}
#[allow(dead_code)]
pub fn tool_calls(&self) -> impl Iterator<Item = (&str, &str, &Value)> {
self.content.iter().filter_map(|b| match b {
ContentBlock::ToolCall {
id,
name,
arguments,
} => Some((id.as_str(), name.as_str(), arguments)),
_ => None,
})
}
}
#[derive(Debug, Clone)]
pub enum StreamEvent {
Start { partial: AssistantMessage },
Delta {
partial: AssistantMessage,
phase: DeltaPhase,
},
Done {
reason: StopReason,
message: AssistantMessage,
usage: Option<TokenUsage>,
},
Error { error: String },
Retry {
attempt: u32,
delay_ms: u64,
error: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TokenUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_input_tokens: u64,
pub cache_creation_input_tokens: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeltaPhase {
TextStart,
TextDelta,
#[allow(dead_code)]
TextEnd,
ThinkingStart,
ThinkingDelta,
ThinkingEnd,
ToolCallStart,
ToolCallDelta,
ToolCallEnd,
}
#[derive(Debug, Clone, PartialEq)]
pub struct UserMessage {
pub content: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolResultMessage {
pub tool_call_id: String,
pub tool_name: String,
pub content: Vec<ContentBlock>,
pub details: Value,
pub is_error: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum LoopMessage {
User(UserMessage),
Assistant(AssistantMessage),
ToolResult(ToolResultMessage),
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
Custom(Value),
}
impl LoopMessage {
#[allow(dead_code)]
pub fn role(&self) -> &'static str {
match self {
LoopMessage::User(_) => "user",
LoopMessage::Assistant(_) => "assistant",
LoopMessage::ToolResult(_) => "toolResult",
LoopMessage::Custom(_) => "custom",
}
}
}
pub fn assistant_to_value(a: &AssistantMessage) -> Value {
serde_json::json!({
"role": "assistant",
"content": a.content,
"stopReason": a.stop_reason,
"errorMessage": a.error_message,
})
}
pub fn tool_result_to_value(t: &ToolResultMessage) -> Value {
serde_json::json!({
"role": "toolResult",
"toolCallId": t.tool_call_id,
"toolName": t.tool_name,
"content": t.content,
"details": t.details,
"isError": t.is_error,
})
}
pub fn loop_message_to_value(msg: &LoopMessage) -> Value {
match msg {
LoopMessage::User(u) => serde_json::json!({
"role": "user",
"content": u.content,
}),
LoopMessage::Assistant(a) => assistant_to_value(a),
LoopMessage::ToolResult(t) => tool_result_to_value(t),
LoopMessage::Custom(v) => v.clone(),
}
}
pub fn canonical_json(v: &Value) -> String {
match v {
Value::Object(m) => {
let mut keys: Vec<&String> = m.keys().collect();
keys.sort();
let mut s = String::from("{");
for (i, k) in keys.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&serde_json::to_string(k).unwrap_or_default());
s.push(':');
s.push_str(&canonical_json(&m[*k]));
}
s.push('}');
s
}
Value::Array(a) => {
let mut s = String::from("[");
for (i, e) in a.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&canonical_json(e));
}
s.push(']');
s
}
Value::Number(n) => {
if let Some(i) = n.as_i64() {
i.to_string()
} else if let Some(f) = n.as_f64() {
if f.fract() == 0.0 && f.is_finite() {
(f as i64).to_string()
} else {
f.to_string()
}
} else {
n.to_string()
}
}
other => serde_json::to_string(other).unwrap_or_default(),
}
}
#[derive(Debug, Clone)]
pub enum LoopEvent {
MessageStart { message: LoopMessage },
MessageUpdate {
message: AssistantMessage,
phase: DeltaPhase,
},
MessageEnd { message: LoopMessage },
ToolExecutionStart {
tool_call_id: String,
tool_name: String,
args: Value,
},
ToolExecutionUpdate {
#[allow(dead_code)]
tool_call_id: String,
#[allow(dead_code)]
tool_name: String,
#[allow(dead_code)]
args: Value,
#[allow(dead_code)]
partial_result: super::result::LoopToolResult,
},
ToolExecutionEnd {
tool_call_id: String,
#[allow(dead_code)]
tool_name: String,
result: super::result::LoopToolResult,
#[allow(dead_code)]
is_error: bool,
},
AgentStart,
AgentEnd { messages: Vec<LoopMessage> },
TurnStart,
TurnEnd {
#[allow(dead_code)]
message: AssistantMessage,
#[allow(dead_code)]
tool_results: Vec<ToolResultMessage>,
},
Usage { usage: TokenUsage },
CompactionStarted { tokens_before: u64 },
ContextCompacted {
new_session_id: String,
tokens_before: u64,
tokens_after: u64,
summary: String,
first_kept_index: usize,
compaction_kind: crate::event::CompactionKind,
summary_model: Option<String>,
},
CheckpointRefresh { summary: String },
RetryNotice {
attempt: u32,
delay_ms: u64,
error: String,
},
SystemNotice { content: String },
RepairStats {
snapshot: super::tool_input_repair::RepairStatsSnapshot,
},
EscalationActivated {
provider: String,
reason: EscalationReason,
},
}
#[derive(Debug, Clone)]
pub enum EscalationReason {
RepairExhausted { tool: String },
SyntacticFailure { tool: String, path: String },
}
impl EscalationReason {
pub fn summary(&self) -> String {
match self {
EscalationReason::RepairExhausted { tool } => {
format!("repair exhausted for {tool}")
}
EscalationReason::SyntacticFailure { tool, path } => {
format!("syntax check failed in {tool} ({path})")
}
}
}
}
impl LoopEvent {
#[allow(dead_code)]
pub fn kind(&self) -> &'static str {
match self {
LoopEvent::MessageStart { .. } => "message_start",
LoopEvent::MessageUpdate { .. } => "message_update",
LoopEvent::MessageEnd { .. } => "message_end",
LoopEvent::ToolExecutionStart { .. } => "tool_execution_start",
LoopEvent::ToolExecutionUpdate { .. } => "tool_execution_update",
LoopEvent::ToolExecutionEnd { .. } => "tool_execution_end",
LoopEvent::AgentStart => "agent_start",
LoopEvent::AgentEnd { .. } => "agent_end",
LoopEvent::TurnStart => "turn_start",
LoopEvent::TurnEnd { .. } => "turn_end",
LoopEvent::Usage { .. } => "usage",
LoopEvent::CompactionStarted { .. } => "compaction_started",
LoopEvent::ContextCompacted { .. } => "context_compacted",
LoopEvent::CheckpointRefresh { .. } => "checkpoint_refresh",
LoopEvent::RetryNotice { .. } => "retry_notice",
LoopEvent::SystemNotice { .. } => "system_notice",
LoopEvent::RepairStats { .. } => "repair_stats",
LoopEvent::EscalationActivated { .. } => "escalation_activated",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loop_message_to_value_shapes() {
let user = loop_message_to_value(&LoopMessage::User(UserMessage {
content: "hello".to_string(),
}));
assert_eq!(
user,
serde_json::json!({"role": "user", "content": "hello"})
);
let asst = AssistantMessage {
content: vec![ContentBlock::Text {
text: "hi".to_string(),
}],
stop_reason: StopReason::Stop,
error_message: None,
};
let asst_val = loop_message_to_value(&LoopMessage::Assistant(asst.clone()));
assert_eq!(asst_val, assistant_to_value(&asst));
assert_eq!(asst_val["role"], "assistant");
assert_eq!(asst_val["stopReason"], "stop");
assert!(asst_val.get("errorMessage").is_some());
let tr = ToolResultMessage {
tool_call_id: "c1".to_string(),
tool_name: "read".to_string(),
content: vec![],
details: serde_json::json!({"k": "v"}),
is_error: false,
};
let tr_val = loop_message_to_value(&LoopMessage::ToolResult(tr.clone()));
assert_eq!(tr_val, tool_result_to_value(&tr));
assert_eq!(tr_val["role"], "toolResult");
assert_eq!(tr_val["toolCallId"], "c1");
assert_eq!(tr_val["isError"], false);
let custom = serde_json::json!({"role": "custom", "x": 1});
assert_eq!(
loop_message_to_value(&LoopMessage::Custom(custom.clone())),
custom
);
}
#[test]
fn canonical_json_is_order_and_number_stable() {
let a = serde_json::json!({"a": 1, "b": 2});
let b = serde_json::json!({"b": 2, "a": 1});
assert_eq!(canonical_json(&a), canonical_json(&b));
assert_eq!(canonical_json(&a), "{\"a\":1,\"b\":2}");
let int = serde_json::json!({"limit": 1});
let float = serde_json::json!({"limit": 1.0});
assert_eq!(canonical_json(&int), canonical_json(&float));
let nested = serde_json::json!({"z": [{"y": 2.5, "x": 1}], "a": "s"});
assert_eq!(
canonical_json(&nested),
"{\"a\":\"s\",\"z\":[{\"x\":1,\"y\":2.5}]}"
);
}
#[test]
fn stop_reason_wire_format() {
for (variant, wire) in [
(StopReason::Stop, "\"stop\""),
(StopReason::ToolUse, "\"toolUse\""),
(StopReason::Length, "\"length\""),
(StopReason::Error, "\"error\""),
(StopReason::Aborted, "\"aborted\""),
] {
assert_eq!(serde_json::to_string(&variant).unwrap(), wire);
assert_eq!(serde_json::from_str::<StopReason>(wire).unwrap(), variant);
}
}
#[test]
fn content_block_wire_format() {
let text = ContentBlock::Text {
text: "hi".to_string(),
};
let encoded = serde_json::to_string(&text).unwrap();
assert!(encoded.contains("\"type\":\"text\""), "got: {encoded}");
let tool = ContentBlock::ToolCall {
id: "call_1".to_string(),
name: "read".to_string(),
arguments: serde_json::json!({"path": "/tmp/x"}),
};
let encoded = serde_json::to_string(&tool).unwrap();
assert!(encoded.contains("\"type\":\"toolCall\""), "got: {encoded}");
assert!(encoded.contains("\"id\":\"call_1\""));
assert!(encoded.contains("\"name\":\"read\""));
}
#[test]
fn assistant_message_tool_calls_iterator() {
let msg = AssistantMessage::new(
vec![
ContentBlock::Text {
text: "thinking…".to_string(),
},
ContentBlock::ToolCall {
id: "c1".to_string(),
name: "read".to_string(),
arguments: serde_json::json!({}),
},
ContentBlock::Text {
text: "more text".to_string(),
},
ContentBlock::ToolCall {
id: "c2".to_string(),
name: "write".to_string(),
arguments: serde_json::json!({"path": "x"}),
},
],
StopReason::ToolUse,
);
let calls: Vec<_> = msg.tool_calls().map(|(id, name, _)| (id, name)).collect();
assert_eq!(calls, vec![("c1", "read"), ("c2", "write")]);
}
#[test]
fn loop_event_kind_strings() {
let empty = AssistantMessage::new(vec![], StopReason::Stop);
let assistant_msg = LoopMessage::Assistant(empty.clone());
assert_eq!(
LoopEvent::MessageStart {
message: assistant_msg.clone(),
}
.kind(),
"message_start"
);
assert_eq!(
LoopEvent::MessageEnd {
message: assistant_msg,
}
.kind(),
"message_end"
);
assert_eq!(
LoopEvent::MessageUpdate {
message: empty,
phase: DeltaPhase::TextDelta,
}
.kind(),
"message_update"
);
assert_eq!(
LoopEvent::ToolExecutionStart {
tool_call_id: "1".to_string(),
tool_name: "echo".to_string(),
args: Value::Null,
}
.kind(),
"tool_execution_start"
);
assert_eq!(
LoopEvent::ToolExecutionUpdate {
tool_call_id: "1".to_string(),
tool_name: "echo".to_string(),
args: Value::Null,
partial_result: Default::default(),
}
.kind(),
"tool_execution_update"
);
assert_eq!(
LoopEvent::ToolExecutionEnd {
tool_call_id: "1".to_string(),
tool_name: "echo".to_string(),
result: Default::default(),
is_error: false,
}
.kind(),
"tool_execution_end"
);
}
#[test]
fn loop_message_role_strings() {
assert_eq!(
LoopMessage::User(UserMessage {
content: "hi".to_string()
})
.role(),
"user"
);
assert_eq!(
LoopMessage::Assistant(AssistantMessage::new(vec![], StopReason::Stop)).role(),
"assistant"
);
assert_eq!(
LoopMessage::ToolResult(ToolResultMessage {
tool_call_id: "1".to_string(),
tool_name: "echo".to_string(),
content: vec![],
details: Value::Null,
is_error: false,
})
.role(),
"toolResult"
);
assert_eq!(LoopMessage::Custom(Value::Null).role(), "custom");
}
}