use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentChatMessage {
pub role: String,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audio: Option<crate::turn_content::AudioRef>,
}
impl AgentChatMessage {
pub fn text(role: impl Into<String>, content: impl Into<String>) -> Self {
Self {
role: role.into(),
content: content.into(),
audio: None,
}
}
}
pub const GATE_AGENT_ID_META_KEY: &str = "gate_agent_id";
pub fn caller_principal_name(caller_id: &str) -> String {
format!("agent.chat:user:{caller_id}")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentChatParams {
pub messages: Vec<AgentChatMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(default = "default_conv_id")]
pub conv_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caller_id: Option<String>,
}
pub fn default_conv_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("ephemeral-{ts:013}-{n:06}")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentChatToolCall {
pub name: String,
pub arguments_preview: String,
pub result_preview: String,
pub success: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SpawnedTaskSummary {
pub task_id: String,
pub child_conv_id: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GateDenialRecord {
pub tool: String,
pub reason: String,
}
pub const FINISH_REASON_ESCALATE_TO_HUMAN: &str = "escalate_to_human";
pub const FINISH_REASON_DEFERRED: &str = "deferred";
pub const GOVERNANCE_DECISION_ESCALATE_TO_HUMAN: &str = "EscalateToHuman";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EscalateToHumanEvent {
pub decision: String,
pub denial_count: u32,
pub conv_id: String,
pub denials: Vec<GateDenialRecord>,
pub summary: String,
}
impl EscalateToHumanEvent {
pub fn from_denials(conv_id: impl Into<String>, denials: Vec<GateDenialRecord>) -> Self {
let conv_id = conv_id.into();
let denial_count = denials.len() as u32;
let last = denials.last();
let summary = match last {
Some(d) => format!(
"agent: gate denied tool calls {denial_count}x; escalating to human \
(EscalateToHuman). Last: `{}`: {}",
d.tool, d.reason
),
None => format!(
"agent: gate denied tool calls {denial_count}x; escalating to human \
(EscalateToHuman)."
),
};
Self {
decision: GOVERNANCE_DECISION_ESCALATE_TO_HUMAN.to_string(),
denial_count,
conv_id,
denials,
summary,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeferredActionEvent {
pub deferred: bool,
pub reason: String,
pub tool: String,
pub conv_id: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub arguments_preview: String,
pub summary: String,
}
impl DeferredActionEvent {
pub fn new(
conv_id: impl Into<String>,
tool: impl Into<String>,
reason: impl Into<String>,
arguments_preview: impl Into<String>,
) -> Self {
let conv_id = conv_id.into();
let tool = tool.into();
let reason = reason.into();
let arguments_preview = arguments_preview.into();
let summary = format!(
"agent: tool `{tool}` deferred for human review — {reason}. \
Approve, deny, or reply with guidance to resume."
);
Self {
deferred: true,
reason,
tool,
conv_id,
arguments_preview,
summary,
}
}
}
pub const FINISH_REASON_ERROR: &str = "error";
impl AgentChatResult {
pub fn is_error(&self) -> bool {
self.finish_reason == FINISH_REASON_ERROR
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentChatResult {
pub assistant_text: String,
pub tool_calls: Vec<AgentChatToolCall>,
pub finish_reason: String,
pub iterations: u32,
pub prompt_tokens: u32,
pub completion_tokens: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub spawned_tasks: Vec<SpawnedTaskSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub escalation: Option<EscalateToHumanEvent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deferred: Option<DeferredActionEvent>,
}
pub const AGENT_LOOP_RESULT_META_KEY: &str = "agent_loop_result";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentLoopResultMeta {
#[serde(default)]
pub tool_calls: Vec<AgentChatToolCall>,
#[serde(default)]
pub finish_reason: String,
#[serde(default)]
pub iterations: u32,
#[serde(default)]
pub spawned_tasks: Vec<SpawnedTaskSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default)]
pub prompt_tokens: u32,
#[serde(default)]
pub completion_tokens: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity_source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub escalation: Option<EscalateToHumanEvent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deferred: Option<DeferredActionEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordedTurn {
pub role: String,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ts_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub voice_analysis: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentTurnRecordParams {
pub conv_id: String,
#[serde(default)]
pub channel: String,
pub turns: Vec<RecordedTurn>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentTurnRecordResult {
pub recorded: usize,
}
pub fn chat_stream_path(conv_id: &str) -> String {
format!("substrate/_derived/chat/{conv_id}/stream")
}
pub const DEFER_DEFAULT_TIMEOUT_MS: u64 = 120_000;
pub const STREAM_PHASE_AWAITING_DEFER: &str = "awaiting_defer";
pub const DEFER_DECISION_ALLOW: &str = "allow";
pub const DEFER_DECISION_DENY: &str = "deny";
pub const DEFER_DECISION_CANCEL: &str = "cancel";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DeferUserDecision {
Allow,
Deny,
Cancel,
}
impl DeferUserDecision {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
DEFER_DECISION_ALLOW => Some(Self::Allow),
DEFER_DECISION_DENY => Some(Self::Deny),
DEFER_DECISION_CANCEL => Some(Self::Cancel),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Allow => DEFER_DECISION_ALLOW,
Self::Deny => DEFER_DECISION_DENY,
Self::Cancel => DEFER_DECISION_CANCEL,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeferPromptEvent {
pub defer_id: String,
pub conv_id: String,
pub tool: String,
pub reason: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub arguments_preview: String,
#[serde(default = "default_defer_timeout_ms")]
pub timeout_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ts_ms: Option<u64>,
}
fn default_defer_timeout_ms() -> u64 {
DEFER_DEFAULT_TIMEOUT_MS
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentChatDeferDecideParams {
pub conv_id: String,
pub defer_id: String,
pub decision: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AgentChatDeferDecideResult {
pub accepted: bool,
pub defer_id: String,
pub decision: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub fn chat_defer_path(conv_id: &str) -> String {
format!("substrate/_derived/chat/{conv_id}/defer")
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentChatStreamFrame {
#[serde(default)]
pub text: String,
pub phase: String,
pub seq: u64,
#[serde(default)]
pub done: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ts_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub defer: Option<DeferPromptEvent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delta: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speakable: Option<String>,
}
impl AgentChatStreamFrame {
pub fn thinking(seq: u64) -> Self {
Self {
text: String::new(),
phase: "thinking".into(),
seq,
done: false,
tool_name: None,
error: None,
ts_ms: None,
defer: None,
delta: None,
speakable: None,
}
}
pub fn generating(seq: u64, text: impl Into<String>) -> Self {
Self {
text: text.into(),
phase: "generating".into(),
seq,
done: false,
tool_name: None,
error: None,
ts_ms: None,
defer: None,
delta: None,
speakable: None,
}
}
pub fn generating_voice(
seq: u64,
text: impl Into<String>,
delta: impl Into<String>,
speakable: Option<String>,
) -> Self {
let delta = delta.into();
Self {
text: text.into(),
phase: "generating".into(),
seq,
done: false,
tool_name: None,
error: None,
ts_ms: None,
defer: None,
delta: if delta.is_empty() {
None
} else {
Some(delta)
},
speakable,
}
}
pub fn done_ok(seq: u64, text: impl Into<String>) -> Self {
Self {
text: text.into(),
phase: "done".into(),
seq,
done: true,
tool_name: None,
error: None,
ts_ms: None,
defer: None,
delta: None,
speakable: None,
}
}
pub fn done_err(seq: u64, error: impl Into<String>) -> Self {
Self {
text: String::new(),
phase: "error".into(),
seq,
done: true,
tool_name: None,
error: Some(error.into()),
ts_ms: None,
defer: None,
delta: None,
speakable: None,
}
}
pub fn awaiting_defer(seq: u64, prompt: DeferPromptEvent) -> Self {
let tool_name = Some(prompt.tool.clone());
Self {
text: String::new(),
phase: STREAM_PHASE_AWAITING_DEFER.into(),
seq,
done: false,
tool_name,
error: None,
ts_ms: prompt.ts_ms,
defer: Some(prompt),
delta: None,
speakable: None,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct SpeakableTracker {
emitted_upto: usize,
last_text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamStep {
pub text: String,
pub delta: String,
pub speakable: Option<String>,
}
impl SpeakableTracker {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, accumulated: &str) -> StreamStep {
let delta = if accumulated.starts_with(&self.last_text) {
accumulated[self.last_text.len()..].to_string()
} else if self.last_text.is_empty() {
accumulated.to_string()
} else {
accumulated.to_string()
};
self.last_text = accumulated.to_string();
let speakable = self.drain_speakable(accumulated);
StreamStep {
text: accumulated.to_string(),
delta,
speakable,
}
}
pub fn flush(&mut self, accumulated: &str) -> Option<String> {
if self.emitted_upto >= accumulated.len() {
return None;
}
let rest = accumulated[self.emitted_upto..].trim();
self.emitted_upto = accumulated.len();
if rest.is_empty() {
None
} else {
Some(rest.to_string())
}
}
fn drain_speakable(&mut self, accumulated: &str) -> Option<String> {
let bytes = accumulated.as_bytes();
let mut start = self.emitted_upto;
let mut units: Vec<String> = Vec::new();
let mut i = start;
while i < bytes.len() {
let ch = bytes[i] as char;
if matches!(ch, '.' | '!' | '?' | '\n') {
let mut end = i + 1;
while end < bytes.len()
&& matches!(bytes[end] as char, '"' | '\'' | ')' | ']' | '}')
{
end += 1;
}
let ok = end >= bytes.len()
|| (bytes[end] as char).is_whitespace()
|| ch == '\n';
if ok {
let unit = accumulated[start..end].trim();
if !unit.is_empty() {
units.push(unit.to_string());
}
while end < bytes.len() && (bytes[end] as char).is_whitespace() {
end += 1;
}
start = end;
i = end;
continue;
}
}
i += 1;
}
self.emitted_upto = start;
if units.is_empty() {
None
} else {
Some(units.join(" "))
}
}
}
pub fn cascade_stream_frames(full: &str, chunk_chars: usize) -> Vec<AgentChatStreamFrame> {
let chunk = chunk_chars.max(2);
let chars: Vec<char> = full.chars().collect();
let mut tracker = SpeakableTracker::new();
let mut frames = Vec::new();
let mut seq: u64 = 1;
let mut emitted = 0usize;
while emitted < chars.len() {
let mut take = 0usize;
while emitted + take < chars.len() && take < chunk {
take += 1;
if take >= 2 && chars[emitted + take - 1].is_whitespace() {
break;
}
}
emitted += take;
let prefix: String = chars[..emitted].iter().collect();
let step = tracker.push(&prefix);
frames.push(AgentChatStreamFrame::generating_voice(
seq,
step.text,
step.delta,
step.speakable,
));
seq = seq.saturating_add(1);
}
if let Some(tail) = tracker.flush(full)
&& let Some(last) = frames.last_mut()
{
if last.speakable.is_none() {
last.speakable = Some(tail);
} else {
frames.push(AgentChatStreamFrame::generating_voice(
seq,
full,
"",
Some(tail),
));
seq = seq.saturating_add(1);
}
}
frames.push(AgentChatStreamFrame::done_ok(seq, full));
frames
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "error_kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum AgentChatError {
#[error("{message}")]
Timeout {
message: String,
},
#[error("{message}")]
GateDeny {
message: String,
},
#[error("{message}")]
LlmError {
message: String,
},
#[error("{message}")]
Cancelled {
message: String,
},
#[error("{message}")]
BudgetExceeded {
message: String,
},
#[error("{message}")]
ShuttingDown {
message: String,
},
#[error("{message}")]
NotWired {
message: String,
},
#[error("{message}")]
InvalidParams {
message: String,
},
#[error("{message}")]
Internal {
message: String,
},
}
impl AgentChatError {
pub fn error_kind(&self) -> &'static str {
match self {
Self::Timeout { .. } => "timeout",
Self::GateDeny { .. } => "gate_deny",
Self::LlmError { .. } => "llm_error",
Self::Cancelled { .. } => "cancelled",
Self::BudgetExceeded { .. } => "budget_exceeded",
Self::ShuttingDown { .. } => "shutting_down",
Self::NotWired { .. } => "not_wired",
Self::InvalidParams { .. } => "invalid_params",
Self::Internal { .. } => "internal",
}
}
pub fn message(&self) -> &str {
match self {
Self::Timeout { message }
| Self::GateDeny { message }
| Self::LlmError { message }
| Self::Cancelled { message }
| Self::BudgetExceeded { message }
| Self::ShuttingDown { message }
| Self::NotWired { message }
| Self::InvalidParams { message }
| Self::Internal { message } => message.as_str(),
}
}
pub fn legacy_message(&self, method: &str) -> String {
format!("{method}: {}", self.message())
}
pub fn timeout(message: impl Into<String>) -> Self {
Self::Timeout {
message: message.into(),
}
}
pub fn gate_deny(message: impl Into<String>) -> Self {
Self::GateDeny {
message: message.into(),
}
}
pub fn llm_error(message: impl Into<String>) -> Self {
Self::LlmError {
message: message.into(),
}
}
pub fn cancelled(message: impl Into<String>) -> Self {
Self::Cancelled {
message: message.into(),
}
}
pub fn budget_exceeded(message: impl Into<String>) -> Self {
Self::BudgetExceeded {
message: message.into(),
}
}
pub fn shutting_down(message: impl Into<String>) -> Self {
Self::ShuttingDown {
message: message.into(),
}
}
pub fn not_wired(message: impl Into<String>) -> Self {
Self::NotWired {
message: message.into(),
}
}
pub fn invalid_params(message: impl Into<String>) -> Self {
Self::InvalidParams {
message: message.into(),
}
}
pub fn internal(message: impl Into<String>) -> Self {
Self::Internal {
message: message.into(),
}
}
pub fn classify_loop_error(err: &str) -> Self {
let lower = err.to_ascii_lowercase();
if lower.contains("was cancelled") {
return Self::cancelled(err);
}
if lower.contains("timed out") || lower.contains("timeout") {
return Self::timeout(err);
}
if lower.contains("budget exceeded")
|| lower.contains("circuit_open")
|| lower.contains("conversation budget")
{
return Self::budget_exceeded(err);
}
if lower.contains("gate den")
|| lower.contains("permission denied")
|| lower.contains("security violation")
|| lower.contains("not allowed")
|| lower.contains("capability")
{
return Self::gate_deny(err);
}
if lower.contains("provider error")
|| lower.contains("rate limited")
|| lower.contains("llm")
|| lower.contains("openrouter")
|| lower.contains("llama")
{
return Self::llm_error(err);
}
if lower.contains("shutting down") {
return Self::shutting_down(err);
}
if lower.contains("not wired") {
return Self::not_wired(err);
}
Self::internal(err)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_chat_params_omitted_conv_id_gets_default() {
let json = r#"{"messages":[{"role":"user","content":"hi"}]}"#;
let params: AgentChatParams = serde_json::from_str(json).unwrap();
assert!(
params.conv_id.starts_with("ephemeral-"),
"default conv_id must be ephemeral-shaped, got {:?}",
params.conv_id
);
assert_eq!(params.messages.len(), 1);
}
#[test]
fn agent_chat_params_explicit_conv_id_round_trips() {
let json = r#"{"messages":[],"conv_id":"01HQ123ABCXYZ"}"#;
let params: AgentChatParams = serde_json::from_str(json).unwrap();
assert_eq!(params.conv_id, "01HQ123ABCXYZ");
assert!(params.caller_id.is_none());
}
#[test]
fn agent_chat_params_caller_id_round_trips() {
let json = r#"{"messages":[],"conv_id":"c1","caller_id":"alice"}"#;
let params: AgentChatParams = serde_json::from_str(json).unwrap();
assert_eq!(params.caller_id.as_deref(), Some("alice"));
let back = serde_json::to_value(¶ms).unwrap();
assert_eq!(back["caller_id"], "alice");
}
#[test]
fn caller_principal_name_namespaces_user() {
assert_eq!(
caller_principal_name("alice"),
"agent.chat:user:alice"
);
assert_ne!(
caller_principal_name("alice"),
caller_principal_name("bob")
);
}
#[test]
fn agent_chat_params_default_conv_ids_are_distinct() {
let a = default_conv_id();
let b = default_conv_id();
assert_ne!(a, b);
}
#[test]
fn agent_chat_result_skips_optional_fields_when_none() {
let r = AgentChatResult {
assistant_text: "hi".into(),
tool_calls: Vec::new(),
finish_reason: "stop".into(),
iterations: 1,
prompt_tokens: 0,
completion_tokens: 0,
model: None,
identity_source: None,
reasoning: None,
spawned_tasks: Vec::new(),
escalation: None,
deferred: None,
};
let json = serde_json::to_string(&r).unwrap();
assert!(!json.contains("\"model\""));
assert!(!json.contains("\"identity_source\""));
assert!(!json.contains("\"reasoning\""));
assert!(json.contains("\"tool_calls\":[]"));
assert!(!json.contains("\"spawned_tasks\""));
assert!(!json.contains("\"escalation\""));
assert!(!json.contains("\"deferred\""));
}
#[test]
fn chat_stream_path_is_sibling_of_status() {
let p = chat_stream_path("panel-1");
assert_eq!(p, "substrate/_derived/chat/panel-1/stream");
assert!(p.starts_with("substrate/_derived/chat/"));
assert!(p.ends_with("/stream"));
}
#[test]
fn stream_frame_round_trips_and_helpers() {
let f = AgentChatStreamFrame::generating(3, "hel");
let v = serde_json::to_value(&f).unwrap();
assert_eq!(v["phase"], "generating");
assert_eq!(v["text"], "hel");
assert_eq!(v["seq"], 3);
assert_eq!(v["done"], false);
let back: AgentChatStreamFrame = serde_json::from_value(v).unwrap();
assert_eq!(back, f);
let done = AgentChatStreamFrame::done_ok(4, "hello");
assert!(done.done);
assert_eq!(done.phase, "done");
let err = AgentChatStreamFrame::done_err(5, "boom");
assert!(err.done);
assert_eq!(err.error.as_deref(), Some("boom"));
}
#[test]
fn stream_frame_voice_fields_round_trip() {
let f = AgentChatStreamFrame::generating_voice(2, "Hello. ", "Hello. ", Some("Hello.".into()));
let v = serde_json::to_value(&f).unwrap();
assert_eq!(v["delta"], "Hello. ");
assert_eq!(v["speakable"], "Hello.");
let back: AgentChatStreamFrame = serde_json::from_value(v).unwrap();
assert_eq!(back.delta.as_deref(), Some("Hello. "));
assert_eq!(back.speakable.as_deref(), Some("Hello."));
let legacy = r#"{"text":"x","phase":"generating","seq":1,"done":false}"#;
let old: AgentChatStreamFrame = serde_json::from_str(legacy).unwrap();
assert!(old.delta.is_none());
assert!(old.speakable.is_none());
}
#[test]
fn cascade_stream_frames_emits_deltas_and_done() {
let frames = cascade_stream_frames("Hi. Bye.", 8);
assert!(frames.len() >= 2);
assert!(frames.last().unwrap().done);
assert_eq!(frames.last().unwrap().phase, "done");
let deltas: String = frames.iter().filter_map(|f| f.delta.clone()).collect();
assert!(deltas.contains('H') || deltas.contains("Hi"), "deltas={deltas:?}");
let spoken: Vec<_> = frames.iter().filter_map(|f| f.speakable.clone()).collect();
assert!(
spoken.iter().any(|s| s.contains("Hi")),
"speakable units={spoken:?}"
);
}
#[test]
fn agent_chat_result_serializes_spawned_tasks_when_present() {
let r = AgentChatResult {
assistant_text: "kicked off work".into(),
tool_calls: Vec::new(),
finish_reason: "stop".into(),
iterations: 2,
prompt_tokens: 0,
completion_tokens: 0,
model: None,
identity_source: None,
reasoning: None,
spawned_tasks: vec![SpawnedTaskSummary {
task_id: "task-1".into(),
child_conv_id: "sub:P:01HQ".into(),
status: "completed".into(),
}],
escalation: None,
deferred: None,
};
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("\"spawned_tasks\""));
assert!(json.contains("\"task-1\""));
let back: AgentChatResult = serde_json::from_str(&json).unwrap();
assert_eq!(back.spawned_tasks.len(), 1);
assert_eq!(back.spawned_tasks[0].status, "completed");
}
#[test]
fn agent_chat_result_old_client_without_spawned_tasks_deserializes() {
let json = r#"{
"assistant_text": "hi",
"tool_calls": [],
"finish_reason": "stop",
"iterations": 1,
"prompt_tokens": 0,
"completion_tokens": 0
}"#;
let r: AgentChatResult = serde_json::from_str(json).unwrap();
assert!(r.spawned_tasks.is_empty());
}
#[test]
fn agent_loop_result_meta_round_trips_through_metadata_value() {
let meta = AgentLoopResultMeta {
tool_calls: vec![AgentChatToolCall {
name: "agent_spawn".into(),
arguments_preview: "{\"goal\":\"answer 2+2\"}".into(),
result_preview: "{\"status\":\"completed\"}".into(),
success: true,
}],
finish_reason: "stop".into(),
iterations: 3,
spawned_tasks: vec![SpawnedTaskSummary {
task_id: "t1".into(),
child_conv_id: "sub:P:01HQ".into(),
status: "completed".into(),
}],
model: Some("hermes-4.3-36b".into()),
prompt_tokens: 812,
completion_tokens: 96,
reasoning: Some("the user asked for a sum".into()),
identity_source: Some("clawft".into()),
escalation: None,
deferred: None,
};
let value = serde_json::to_value(&meta).unwrap();
let back: AgentLoopResultMeta = serde_json::from_value(value).unwrap();
assert_eq!(back.iterations, 3);
assert_eq!(back.tool_calls.len(), 1);
assert_eq!(back.spawned_tasks.len(), 1);
assert_eq!(back.spawned_tasks[0].task_id, "t1");
assert_eq!(back.model.as_deref(), Some("hermes-4.3-36b"));
assert_eq!(back.prompt_tokens, 812);
assert_eq!(back.completion_tokens, 96);
assert_eq!(back.reasoning.as_deref(), Some("the user asked for a sum"));
assert_eq!(back.identity_source.as_deref(), Some("clawft"));
assert!(back.escalation.is_none());
assert!(back.deferred.is_none());
}
#[test]
fn agent_loop_result_meta_partial_object_uses_defaults() {
let back: AgentLoopResultMeta = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(back.iterations, 0);
assert!(back.finish_reason.is_empty());
assert!(back.tool_calls.is_empty());
assert!(back.spawned_tasks.is_empty());
assert_eq!(back.prompt_tokens, 0);
assert_eq!(back.completion_tokens, 0);
assert!(back.model.is_none());
assert!(back.identity_source.is_none());
assert!(back.escalation.is_none());
assert!(back.deferred.is_none());
}
#[test]
fn escalate_to_human_event_from_denials() {
let event = EscalateToHumanEvent::from_denials(
"conv-1",
vec![
GateDenialRecord {
tool: "write_file".into(),
reason: "policy".into(),
},
GateDenialRecord {
tool: "exec_shell".into(),
reason: "high risk".into(),
},
GateDenialRecord {
tool: "write_file".into(),
reason: "policy".into(),
},
],
);
assert_eq!(event.decision, GOVERNANCE_DECISION_ESCALATE_TO_HUMAN);
assert_eq!(event.denial_count, 3);
assert_eq!(event.conv_id, "conv-1");
assert_eq!(event.denials.len(), 3);
assert!(event.summary.contains("EscalateToHuman"));
assert!(event.summary.contains("3x"));
}
#[test]
fn agent_chat_result_escalation_round_trips() {
let r = AgentChatResult {
assistant_text: "escalated".into(),
tool_calls: vec![],
finish_reason: FINISH_REASON_ESCALATE_TO_HUMAN.into(),
iterations: 3,
prompt_tokens: 10,
completion_tokens: 5,
model: None,
identity_source: None,
reasoning: None,
spawned_tasks: vec![],
escalation: Some(EscalateToHumanEvent::from_denials(
"c",
vec![GateDenialRecord {
tool: "echo".into(),
reason: "blocked".into(),
}],
)),
deferred: None,
};
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("EscalateToHuman"));
let back: AgentChatResult = serde_json::from_str(&json).unwrap();
assert_eq!(back.finish_reason, FINISH_REASON_ESCALATE_TO_HUMAN);
assert_eq!(back.escalation.as_ref().unwrap().decision, "EscalateToHuman");
}
#[test]
fn deferred_action_event_wire_shape() {
let event = DeferredActionEvent::new(
"conv-1",
"write_file",
"policy review pending",
"{\"path\":\"x\"}",
);
assert!(event.deferred);
assert_eq!(event.tool, "write_file");
assert_eq!(event.reason, "policy review pending");
assert!(event.summary.contains("write_file"));
let v = serde_json::to_value(&event).unwrap();
assert_eq!(v["deferred"], true);
assert_eq!(v["reason"], "policy review pending");
}
#[test]
fn agent_chat_result_deferred_round_trips() {
let event = DeferredActionEvent::new("c", "echo", "needs review", "{}");
let r = AgentChatResult {
assistant_text: event.summary.clone(),
tool_calls: vec![],
finish_reason: FINISH_REASON_DEFERRED.into(),
iterations: 1,
prompt_tokens: 5,
completion_tokens: 2,
model: None,
identity_source: None,
reasoning: None,
spawned_tasks: vec![],
escalation: None,
deferred: Some(event),
};
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("\"deferred\":true") || json.contains("\"deferred\": true"));
let back: AgentChatResult = serde_json::from_str(&json).unwrap();
assert_eq!(back.finish_reason, FINISH_REASON_DEFERRED);
let d = back.deferred.expect("deferred event");
assert!(d.deferred);
assert_eq!(d.tool, "echo");
assert_eq!(d.reason, "needs review");
}
#[test]
fn agent_chat_result_deserializes_nonzero_token_and_identity_fields() {
let json = r#"{
"assistant_text": "done",
"tool_calls": [{"name":"echo","arguments_preview":"{}","result_preview":"ok","success":true}],
"finish_reason": "stop",
"iterations": 2,
"prompt_tokens": 40,
"completion_tokens": 22,
"model": "hermes-4.3-36b",
"identity_source": "clawft"
}"#;
let r: AgentChatResult = serde_json::from_str(json).unwrap();
assert_eq!(r.tool_calls.len(), 1);
assert_eq!(r.prompt_tokens, 40);
assert_eq!(r.completion_tokens, 22);
assert_eq!(r.model.as_deref(), Some("hermes-4.3-36b"));
assert_eq!(r.identity_source.as_deref(), Some("clawft"));
}
#[test]
fn defer_user_decision_parses_wire_strings() {
assert_eq!(
DeferUserDecision::parse("allow"),
Some(DeferUserDecision::Allow)
);
assert_eq!(
DeferUserDecision::parse("DENY"),
Some(DeferUserDecision::Deny)
);
assert_eq!(
DeferUserDecision::parse("cancel"),
Some(DeferUserDecision::Cancel)
);
assert!(DeferUserDecision::parse("maybe").is_none());
}
#[test]
fn defer_prompt_and_decide_params_round_trip() {
let prompt = DeferPromptEvent {
defer_id: "d-1".into(),
conv_id: "c-1".into(),
tool: "write_file".into(),
reason: "needs review".into(),
arguments_preview: r#"{"path":"x"}"#.into(),
timeout_ms: DEFER_DEFAULT_TIMEOUT_MS,
ts_ms: Some(1),
};
let frame = AgentChatStreamFrame::awaiting_defer(7, prompt.clone());
assert_eq!(frame.phase, STREAM_PHASE_AWAITING_DEFER);
assert!(!frame.done);
assert_eq!(frame.defer.as_ref().unwrap().defer_id, "d-1");
let v = serde_json::to_value(&frame).unwrap();
let back: AgentChatStreamFrame = serde_json::from_value(v).unwrap();
assert_eq!(back, frame);
let params = AgentChatDeferDecideParams {
conv_id: "c-1".into(),
defer_id: "d-1".into(),
decision: DEFER_DECISION_ALLOW.into(),
};
let json = serde_json::to_string(¶ms).unwrap();
let p2: AgentChatDeferDecideParams = serde_json::from_str(&json).unwrap();
assert_eq!(p2.decision, "allow");
assert_eq!(chat_defer_path("c-1"), "substrate/_derived/chat/c-1/defer");
}
#[test]
fn agent_chat_error_timeout_wire_shape() {
let err = AgentChatError::timeout("operation timed out: llm_call");
let v = serde_json::to_value(&err).unwrap();
assert_eq!(v["error_kind"], "timeout");
assert_eq!(v["message"], "operation timed out: llm_call");
assert_eq!(err.error_kind(), "timeout");
let back: AgentChatError = serde_json::from_value(v).unwrap();
assert_eq!(back, err);
}
#[test]
fn agent_chat_error_gate_deny_wire_shape() {
let err = AgentChatError::gate_deny("permission denied: write_file");
let v = serde_json::to_value(&err).unwrap();
assert_eq!(v["error_kind"], "gate_deny");
assert_eq!(err.error_kind(), "gate_deny");
let back: AgentChatError = serde_json::from_value(v).unwrap();
assert_eq!(back, err);
}
#[test]
fn agent_chat_error_llm_error_wire_shape() {
let err = AgentChatError::llm_error("provider error: 502 bad gateway");
let v = serde_json::to_value(&err).unwrap();
assert_eq!(v["error_kind"], "llm_error");
assert_eq!(err.error_kind(), "llm_error");
let back: AgentChatError = serde_json::from_value(v).unwrap();
assert_eq!(back, err);
}
#[test]
fn agent_chat_error_cancelled_wire_shape() {
let err = AgentChatError::cancelled("conversation `c1` was cancelled");
let v = serde_json::to_value(&err).unwrap();
assert_eq!(v["error_kind"], "cancelled");
let back: AgentChatError = serde_json::from_value(v).unwrap();
assert_eq!(back, err);
}
#[test]
fn agent_chat_error_budget_exceeded_wire_shape() {
let err = AgentChatError::budget_exceeded(
"conversation budget exceeded for `c1`: tokens 100 >= limit 100 (circuit_open until reset_budget)",
);
let v = serde_json::to_value(&err).unwrap();
assert_eq!(v["error_kind"], "budget_exceeded");
let back: AgentChatError = serde_json::from_value(v).unwrap();
assert_eq!(back, err);
}
#[test]
fn agent_chat_error_shutting_down_wire_shape() {
let err = AgentChatError::shutting_down("agent service shutting down");
let v = serde_json::to_value(&err).unwrap();
assert_eq!(v["error_kind"], "shutting_down");
let back: AgentChatError = serde_json::from_value(v).unwrap();
assert_eq!(back, err);
}
#[test]
fn agent_chat_error_not_wired_wire_shape() {
let err = AgentChatError::not_wired(
"agent service not wired (LLM client init failed at boot)",
);
let v = serde_json::to_value(&err).unwrap();
assert_eq!(v["error_kind"], "not_wired");
let back: AgentChatError = serde_json::from_value(v).unwrap();
assert_eq!(back, err);
}
#[test]
fn agent_chat_error_invalid_params_wire_shape() {
let err = AgentChatError::invalid_params("missing field `messages`");
let v = serde_json::to_value(&err).unwrap();
assert_eq!(v["error_kind"], "invalid_params");
let back: AgentChatError = serde_json::from_value(v).unwrap();
assert_eq!(back, err);
}
#[test]
fn agent_chat_error_internal_wire_shape() {
let err = AgentChatError::internal("unexpected state");
let v = serde_json::to_value(&err).unwrap();
assert_eq!(v["error_kind"], "internal");
let back: AgentChatError = serde_json::from_value(v).unwrap();
assert_eq!(back, err);
}
#[test]
fn agent_chat_error_legacy_message_prefix() {
let err = AgentChatError::timeout("operation timed out: llm_call");
assert_eq!(
err.legacy_message("agent.chat"),
"agent.chat: operation timed out: llm_call"
);
assert_eq!(
err.legacy_message("agent.chat_stream"),
"agent.chat_stream: operation timed out: llm_call"
);
}
#[test]
fn agent_chat_error_classify_loop_error_variants() {
assert_eq!(
AgentChatError::classify_loop_error("operation timed out: llm_call").error_kind(),
"timeout"
);
assert_eq!(
AgentChatError::classify_loop_error("security violation: path traversal").error_kind(),
"gate_deny"
);
assert_eq!(
AgentChatError::classify_loop_error("provider error: 500").error_kind(),
"llm_error"
);
assert_eq!(
AgentChatError::classify_loop_error("conversation `x` was cancelled").error_kind(),
"cancelled"
);
assert_eq!(
AgentChatError::classify_loop_error(
"conversation budget exceeded for `c`: tokens 1 >= limit 1 (circuit_open until reset_budget)"
)
.error_kind(),
"budget_exceeded"
);
assert_eq!(
AgentChatError::classify_loop_error("something completely novel").error_kind(),
"internal"
);
}
#[test]
fn agent_chat_error_display_is_message_body() {
let err = AgentChatError::llm_error("provider error: boom");
assert_eq!(err.to_string(), "provider error: boom");
assert_eq!(err.message(), "provider error: boom");
}
}