use std::fmt;
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub trait SessionChannel: Send + Sync {
fn emit(&self, event: &SessionEvent) -> Result<(), SessionChannelError>;
fn receive(&self, timeout: Duration) -> Result<Option<HumanInput>, SessionChannelError>;
fn channel_id(&self) -> &str;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event_type", rename_all = "snake_case")]
pub enum SessionEvent {
AgentOutput {
stream: OutputStream,
content: String,
},
DraftReady {
draft_id: Uuid,
summary: String,
artifact_count: usize,
},
GoalComplete { goal_id: Uuid },
WaitingForInput { prompt: String },
StatusUpdate { message: String },
}
impl fmt::Display for SessionEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SessionEvent::AgentOutput { stream, content } => {
write!(f, "[{}] {}", stream, content)
}
SessionEvent::DraftReady {
draft_id,
summary,
artifact_count,
} => {
write!(
f,
"Draft ready: {} ({} artifacts) — {}",
draft_id, artifact_count, summary
)
}
SessionEvent::GoalComplete { goal_id } => {
write!(f, "Goal complete: {}", goal_id)
}
SessionEvent::WaitingForInput { prompt } => {
write!(f, "Waiting for input: {}", prompt)
}
SessionEvent::StatusUpdate { message } => {
write!(f, "Status: {}", message)
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OutputStream {
StdOut,
StdErr,
}
impl fmt::Display for OutputStream {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OutputStream::StdOut => write!(f, "stdout"),
OutputStream::StdErr => write!(f, "stderr"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "input_type", rename_all = "snake_case")]
pub enum HumanInput {
Message { text: String },
Approve {
draft_id: Uuid,
artifact_uri: Option<String>,
},
Reject {
draft_id: Uuid,
artifact_uri: Option<String>,
reason: String,
},
Abort,
}
impl fmt::Display for HumanInput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HumanInput::Message { text } => write!(f, "Message: {}", text),
HumanInput::Approve {
draft_id,
artifact_uri,
} => {
if let Some(uri) = artifact_uri {
write!(f, "Approve {} in draft {}", uri, draft_id)
} else {
write!(f, "Approve draft {}", draft_id)
}
}
HumanInput::Reject {
draft_id,
artifact_uri,
reason,
} => {
if let Some(uri) = artifact_uri {
write!(f, "Reject {} in draft {}: {}", uri, draft_id, reason)
} else {
write!(f, "Reject draft {}: {}", draft_id, reason)
}
}
HumanInput::Abort => write!(f, "Abort session"),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SessionChannelError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("channel closed")]
ChannelClosed,
#[error("session error: {0}")]
Other(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InteractiveSession {
pub session_id: Uuid,
pub goal_id: Uuid,
pub channel_id: String,
pub agent_id: String,
pub state: InteractiveSessionState,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub messages: Vec<SessionMessage>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub draft_ids: Vec<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum InteractiveSessionState {
Active,
Paused,
Completed,
Aborted,
}
impl fmt::Display for InteractiveSessionState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
InteractiveSessionState::Active => write!(f, "active"),
InteractiveSessionState::Paused => write!(f, "paused"),
InteractiveSessionState::Completed => write!(f, "completed"),
InteractiveSessionState::Aborted => write!(f, "aborted"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMessage {
pub sender: String,
pub content: String,
pub timestamp: DateTime<Utc>,
}
impl InteractiveSession {
pub fn new(goal_id: Uuid, channel_id: String, agent_id: String) -> Self {
let now = Utc::now();
Self {
session_id: Uuid::new_v4(),
goal_id,
channel_id,
agent_id,
state: InteractiveSessionState::Active,
created_at: now,
updated_at: now,
messages: Vec::new(),
draft_ids: Vec::new(),
}
}
pub fn log_message(&mut self, sender: &str, content: &str) {
self.messages.push(SessionMessage {
sender: sender.to_string(),
content: content.to_string(),
timestamp: Utc::now(),
});
self.updated_at = Utc::now();
}
pub fn add_draft(&mut self, draft_id: Uuid) {
if !self.draft_ids.contains(&draft_id) {
self.draft_ids.push(draft_id);
}
self.updated_at = Utc::now();
}
pub fn transition(
&mut self,
new_state: InteractiveSessionState,
) -> Result<(), SessionChannelError> {
let valid = matches!(
(&self.state, &new_state),
(
InteractiveSessionState::Active,
InteractiveSessionState::Paused
) | (
InteractiveSessionState::Active,
InteractiveSessionState::Completed
) | (
InteractiveSessionState::Active,
InteractiveSessionState::Aborted
) | (
InteractiveSessionState::Paused,
InteractiveSessionState::Active
) | (
InteractiveSessionState::Paused,
InteractiveSessionState::Aborted
)
);
if !valid {
return Err(SessionChannelError::Other(format!(
"invalid session transition from {} to {}",
self.state, new_state
)));
}
self.state = new_state;
self.updated_at = Utc::now();
Ok(())
}
pub fn is_alive(&self) -> bool {
matches!(
self.state,
InteractiveSessionState::Active | InteractiveSessionState::Paused
)
}
pub fn elapsed(&self) -> chrono::Duration {
Utc::now() - self.created_at
}
pub fn elapsed_display(&self) -> String {
let dur = self.elapsed();
let secs = dur.num_seconds();
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InteractiveConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_output_capture")]
pub output_capture: String,
#[serde(default = "default_true")]
pub allow_human_input: bool,
#[serde(default)]
pub auto_exit_on: Option<String>,
#[serde(default)]
pub resume_cmd: Option<String>,
}
fn default_output_capture() -> String {
"pipe".to_string()
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_interactive_session_is_active() {
let session = InteractiveSession::new(
Uuid::new_v4(),
"cli:tty0".to_string(),
"claude-code".to_string(),
);
assert_eq!(session.state, InteractiveSessionState::Active);
assert!(session.messages.is_empty());
assert!(session.draft_ids.is_empty());
assert!(session.is_alive());
}
#[test]
fn log_message_adds_to_history() {
let mut session = InteractiveSession::new(
Uuid::new_v4(),
"cli:tty0".to_string(),
"claude-code".to_string(),
);
session.log_message("human", "Focus on the auth module");
session.log_message("agent", "Understood, working on auth");
assert_eq!(session.messages.len(), 2);
assert_eq!(session.messages[0].sender, "human");
assert_eq!(session.messages[1].sender, "agent");
}
#[test]
fn add_draft_deduplicates() {
let mut session = InteractiveSession::new(
Uuid::new_v4(),
"cli:tty0".to_string(),
"claude-code".to_string(),
);
let draft_id = Uuid::new_v4();
session.add_draft(draft_id);
session.add_draft(draft_id);
assert_eq!(session.draft_ids.len(), 1);
}
#[test]
fn valid_transitions() {
let mut session = InteractiveSession::new(
Uuid::new_v4(),
"cli:tty0".to_string(),
"claude-code".to_string(),
);
session.transition(InteractiveSessionState::Paused).unwrap();
assert_eq!(session.state, InteractiveSessionState::Paused);
session.transition(InteractiveSessionState::Active).unwrap();
assert_eq!(session.state, InteractiveSessionState::Active);
session
.transition(InteractiveSessionState::Completed)
.unwrap();
assert_eq!(session.state, InteractiveSessionState::Completed);
}
#[test]
fn invalid_transition_returns_error() {
let mut session = InteractiveSession::new(
Uuid::new_v4(),
"cli:tty0".to_string(),
"claude-code".to_string(),
);
session
.transition(InteractiveSessionState::Completed)
.unwrap();
let result = session.transition(InteractiveSessionState::Active);
assert!(result.is_err());
}
#[test]
fn abort_from_active() {
let mut session = InteractiveSession::new(
Uuid::new_v4(),
"cli:tty0".to_string(),
"claude-code".to_string(),
);
session
.transition(InteractiveSessionState::Aborted)
.unwrap();
assert!(!session.is_alive());
}
#[test]
fn abort_from_paused() {
let mut session = InteractiveSession::new(
Uuid::new_v4(),
"cli:tty0".to_string(),
"claude-code".to_string(),
);
session.transition(InteractiveSessionState::Paused).unwrap();
session
.transition(InteractiveSessionState::Aborted)
.unwrap();
assert!(!session.is_alive());
}
#[test]
fn session_serialization_round_trip() {
let mut session = InteractiveSession::new(
Uuid::new_v4(),
"cli:tty0".to_string(),
"claude-code".to_string(),
);
session.log_message("human", "Test message");
session.add_draft(Uuid::new_v4());
let json = serde_json::to_string(&session).unwrap();
let restored: InteractiveSession = serde_json::from_str(&json).unwrap();
assert_eq!(restored.session_id, session.session_id);
assert_eq!(restored.goal_id, session.goal_id);
assert_eq!(restored.channel_id, session.channel_id);
assert_eq!(restored.agent_id, session.agent_id);
assert_eq!(restored.messages.len(), 1);
assert_eq!(restored.draft_ids.len(), 1);
}
#[test]
fn session_event_display() {
let event = SessionEvent::AgentOutput {
stream: OutputStream::StdOut,
content: "Hello world".to_string(),
};
assert_eq!(format!("{}", event), "[stdout] Hello world");
let event = SessionEvent::WaitingForInput {
prompt: "What next?".to_string(),
};
assert_eq!(format!("{}", event), "Waiting for input: What next?");
}
#[test]
fn human_input_display() {
let input = HumanInput::Message {
text: "Focus on auth".to_string(),
};
assert_eq!(format!("{}", input), "Message: Focus on auth");
let input = HumanInput::Abort;
assert_eq!(format!("{}", input), "Abort session");
}
#[test]
fn output_stream_display() {
assert_eq!(format!("{}", OutputStream::StdOut), "stdout");
assert_eq!(format!("{}", OutputStream::StdErr), "stderr");
}
#[test]
fn interactive_config_defaults() {
let config: InteractiveConfig = serde_json::from_str("{}").unwrap();
assert!(!config.enabled);
assert_eq!(config.output_capture, "pipe");
assert!(config.allow_human_input);
assert!(config.auto_exit_on.is_none());
assert!(config.resume_cmd.is_none());
}
#[test]
fn interactive_config_from_yaml() {
let yaml = r#"
enabled: true
output_capture: pty
allow_human_input: true
auto_exit_on: "idle_timeout: 300s"
resume_cmd: "claude --resume {session_id}"
"#;
let config: InteractiveConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.enabled);
assert_eq!(config.output_capture, "pty");
assert!(config.allow_human_input);
assert_eq!(config.auto_exit_on.as_deref(), Some("idle_timeout: 300s"));
assert_eq!(
config.resume_cmd.as_deref(),
Some("claude --resume {session_id}")
);
}
#[test]
fn elapsed_display_formatting() {
let mut session = InteractiveSession::new(
Uuid::new_v4(),
"cli:tty0".to_string(),
"claude-code".to_string(),
);
let display = session.elapsed_display();
assert!(display.ends_with('s'));
session.created_at = Utc::now() - chrono::Duration::minutes(5);
let display = session.elapsed_display();
assert!(display.contains('m'));
}
#[test]
fn session_event_serialization_round_trip() {
let event = SessionEvent::DraftReady {
draft_id: Uuid::new_v4(),
summary: "Test draft".to_string(),
artifact_count: 3,
};
let json = serde_json::to_string(&event).unwrap();
let restored: SessionEvent = serde_json::from_str(&json).unwrap();
if let SessionEvent::DraftReady { artifact_count, .. } = restored {
assert_eq!(artifact_count, 3);
} else {
panic!("Expected DraftReady variant");
}
}
#[test]
fn human_input_serialization_round_trip() {
let input = HumanInput::Reject {
draft_id: Uuid::new_v4(),
artifact_uri: Some("fs://workspace/main.rs".to_string()),
reason: "needs error handling".to_string(),
};
let json = serde_json::to_string(&input).unwrap();
let restored: HumanInput = serde_json::from_str(&json).unwrap();
if let HumanInput::Reject { reason, .. } = restored {
assert_eq!(reason, "needs error handling");
} else {
panic!("Expected Reject variant");
}
}
}