use serde::{Deserialize, Serialize};
use crate::chat::types::{ConversationItem, Lifecycle};
use crate::child_session::{
ChildBodyOutcome, ChildCommandEffect, ChildCommandState, ChildProcessGeneration,
};
use crate::project_session::{ProjectEventKind, ProjectObservation};
use crate::task::{TaskEventKind, TaskObservation};
use crate::wave::playhead::{now_rfc3339, BodyProvenance};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatRole {
User,
Assistant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChildActivitySubject {
Project,
Task,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChildActivityKind {
StateChanged,
ControlApplied,
ControlUncertain,
Directed,
Incorporated,
DecisionRequired,
DecisionResolved,
PrOpened,
Completed,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChildControlActivity {
pub id: String,
pub subject: ChildActivitySubject,
pub subject_id: String,
pub session_id: String,
pub kind: ChildActivityKind,
pub title: String,
pub summary: String,
pub directive_version: Option<u32>,
pub command_id: Option<String>,
pub effect: Option<ChildCommandEffect>,
pub source: Option<crate::child_session::ChildCommandSource>,
pub decision_id: Option<String>,
pub options: Vec<String>,
}
impl ChildControlActivity {
pub fn from_task(observation: &TaskObservation) -> Self {
let fields = task_activity_fields(&observation.event);
Self {
id: observation.inbox_id(),
subject: ChildActivitySubject::Task,
subject_id: observation.issue_identifier.clone(),
session_id: observation.session_id.to_string(),
kind: fields.kind,
title: fields.title,
summary: fields.summary,
directive_version: fields.directive_version,
command_id: fields.command_id,
effect: fields.effect,
source: observation.control_source.clone(),
decision_id: fields.decision_id,
options: fields.options,
}
}
pub fn from_project(observation: &ProjectObservation) -> Self {
let fields = project_activity_fields(&observation.event);
Self {
id: observation.inbox_id(),
subject: ChildActivitySubject::Project,
subject_id: observation.project.clone(),
session_id: observation.session_id.to_string(),
kind: fields.kind,
title: fields.title,
summary: fields.summary,
directive_version: fields.directive_version,
command_id: fields.command_id,
effect: fields.effect,
source: observation.control_source.clone(),
decision_id: fields.decision_id,
options: fields.options,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ChatTurn {
pub id: String,
pub role: ChatRole,
pub text: String,
pub status: Lifecycle,
pub items: Vec<ConversationItem>,
pub created_at: String,
pub from: Option<String>,
pub body: Option<BodyProvenance>,
pub activity: Option<ChildControlActivity>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TurnDelta {
pub turn_id: String,
pub item: ConversationItem,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ChatTurnError {
#[error("child activity entries cannot also carry prose, items, or a provider body")]
MixedActivity,
#[error("child activity entries must be completed attributed user-side entries")]
InvalidActivityEnvelope,
#[error("human turns must be completed and cannot carry provider items or a body")]
InvalidHumanTurn,
}
impl ChatTurn {
pub fn user(id: String, text: String) -> Self {
Self {
id,
role: ChatRole::User,
text,
status: Lifecycle::Completed,
items: Vec::new(),
created_at: now_rfc3339(),
from: None,
body: None,
activity: None,
}
}
pub fn child_activity(
id: String,
created_at: String,
from: String,
activity: ChildControlActivity,
) -> Self {
Self {
id,
role: ChatRole::User,
text: String::new(),
status: Lifecycle::Completed,
items: Vec::new(),
created_at,
from: Some(from),
body: None,
activity: Some(activity),
}
}
pub fn validate(&self) -> Result<(), ChatTurnError> {
if self.activity.is_some() {
if !self.text.is_empty() || !self.items.is_empty() || self.body.is_some() {
return Err(ChatTurnError::MixedActivity);
}
if self.role != ChatRole::User
|| self.status != Lifecycle::Completed
|| self.from.is_none()
{
return Err(ChatTurnError::InvalidActivityEnvelope);
}
} else if self.role == ChatRole::User
&& (self.status != Lifecycle::Completed
|| !self.items.is_empty()
|| self.body.is_some())
{
return Err(ChatTurnError::InvalidHumanTurn);
}
Ok(())
}
pub fn absorb_item(&mut self, item: ConversationItem) {
if let ConversationItem::Message { text, phase, .. } = &item {
match phase.as_deref() {
Some("stream") => {
self.text.push_str(text);
return;
}
Some("commentary") => {}
_ => {
self.push_text(text);
return;
}
}
}
self.items.push(item);
}
pub fn close_body(&mut self, ended_at: String, reason: Option<String>) {
if let Some(body) = self.body.as_mut() {
body.ended_at = Some(ended_at);
body.termination_reason = reason;
}
}
pub fn push_text(&mut self, fragment: &str) {
if !self.text.is_empty() {
self.text.push('\n');
}
self.text.push_str(fragment);
}
}
impl<'de> Deserialize<'de> for ChatTurn {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Wire {
id: String,
role: ChatRole,
text: String,
status: Lifecycle,
items: Vec<ConversationItem>,
created_at: String,
from: Option<String>,
body: Option<BodyProvenance>,
activity: Option<ChildControlActivity>,
}
let wire = Wire::deserialize(deserializer)?;
let turn = Self {
id: wire.id,
role: wire.role,
text: wire.text,
status: wire.status,
items: wire.items,
created_at: wire.created_at,
from: wire.from,
body: wire.body,
activity: wire.activity,
};
turn.validate().map_err(serde::de::Error::custom)?;
Ok(turn)
}
}
struct ActivityFields {
kind: ChildActivityKind,
title: String,
summary: String,
directive_version: Option<u32>,
command_id: Option<String>,
effect: Option<ChildCommandEffect>,
decision_id: Option<String>,
options: Vec<String>,
}
fn task_activity_fields(event: &TaskEventKind) -> ActivityFields {
match event {
TaskEventKind::Started => activity(ChildActivityKind::StateChanged, "Task started", ""),
TaskEventKind::BodyHandedOff { handoff } => activity(
ChildActivityKind::StateChanged,
&format!(
"Task body handed off: {} → {}",
handoff.from_agent, handoff.to_agent
),
&handoff.reason,
),
TaskEventKind::BodyLeaseChanged { process } => body_lease_activity("Task", process),
TaskEventKind::BodyRecoveryAttempted {
attempt, reason, ..
} => activity(
ChildActivityKind::StateChanged,
&format!("Task body recovered automatically (attempt {attempt})"),
reason,
),
TaskEventKind::StatusChanged { to, reason, .. } => activity(
ChildActivityKind::StateChanged,
&format!("Task is {}", to.as_str()),
reason,
),
TaskEventKind::CommandChanged {
command_id,
state,
effect,
error,
} => ActivityFields {
command_id: Some(command_id.to_string()),
effect: *effect,
..activity(
control_activity_kind(*state),
&format!("Control {}", state.as_str()),
error.as_deref().unwrap_or_default(),
)
},
TaskEventKind::DirectiveChanged { version, .. } => ActivityFields {
directive_version: Some(*version),
..activity(
ChildActivityKind::Directed,
&format!("Direction v{version}"),
"Waiting for incorporation",
)
},
TaskEventKind::DirectiveIncorporated {
version, summary, ..
} => ActivityFields {
directive_version: Some(*version),
..activity(
ChildActivityKind::Incorporated,
&format!("Incorporated direction v{version}"),
summary,
)
},
TaskEventKind::DirectiveReconciled {
version, summary, ..
} => ActivityFields {
directive_version: Some(*version),
..activity(
ChildActivityKind::Incorporated,
&format!("Reconciled direction v{version} by out-of-band attestation"),
summary,
)
},
TaskEventKind::DecisionRequested {
decision_id,
prompt,
options,
} => ActivityFields {
decision_id: Some(decision_id.to_string()),
options: options.clone(),
..activity(
ChildActivityKind::DecisionRequired,
"Decision required",
prompt,
)
},
TaskEventKind::DecisionResolved { choice, .. } => activity(
ChildActivityKind::DecisionResolved,
"Decision resolved",
choice,
),
TaskEventKind::Progress { summary } => {
activity(ChildActivityKind::StateChanged, "Task progress", summary)
}
TaskEventKind::InteractionReviewRequested { review } => activity(
ChildActivityKind::DecisionRequired,
&format!("{} review requested", review.step),
&review.reason,
),
TaskEventKind::InteractionReviewMessage { author, text, .. } => activity(
ChildActivityKind::StateChanged,
&format!("Review message from {}", author.as_str()),
text,
),
TaskEventKind::InteractionReviewCompleted {
disposition,
outcome,
..
} => activity(
ChildActivityKind::DecisionResolved,
&format!("Review {}", disposition.as_str()),
outcome,
),
TaskEventKind::PrStarted {
sequence, branch, ..
} => activity(
ChildActivityKind::StateChanged,
&format!("Started PR {sequence}"),
branch,
),
TaskEventKind::PrOpened { number, url, .. } => activity(
ChildActivityKind::PrOpened,
&format!("Opened PR #{number}"),
url,
),
TaskEventKind::PrMerged { number, url, .. } => activity(
ChildActivityKind::StateChanged,
&format!("Merged PR #{number}"),
url,
),
TaskEventKind::Completed { summary, .. } => {
activity(ChildActivityKind::Completed, "Task completed", summary)
}
TaskEventKind::Failed { error, .. } => {
activity(ChildActivityKind::Failed, "Task failed", error)
}
}
}
fn project_activity_fields(event: &ProjectEventKind) -> ActivityFields {
match event {
ProjectEventKind::Started => {
activity(ChildActivityKind::StateChanged, "Project started", "")
}
ProjectEventKind::BodyHandedOff { handoff } => activity(
ChildActivityKind::StateChanged,
&format!(
"Project body handed off: {} → {}",
handoff.from_agent, handoff.to_agent
),
&handoff.reason,
),
ProjectEventKind::BodyLeaseChanged { process } => body_lease_activity("Project", process),
ProjectEventKind::StatusChanged { to, reason, .. } => activity(
ChildActivityKind::StateChanged,
&format!("Project is {}", to.as_str()),
reason,
),
ProjectEventKind::CommandChanged {
command_id,
state,
effect,
error,
} => ActivityFields {
command_id: Some(command_id.to_string()),
effect: *effect,
..activity(
control_activity_kind(*state),
&format!("Control {}", state.as_str()),
error.as_deref().unwrap_or_default(),
)
},
ProjectEventKind::DirectiveChanged { version, .. } => ActivityFields {
directive_version: Some(*version),
..activity(
ChildActivityKind::Directed,
&format!("Direction v{version}"),
"Waiting for incorporation",
)
},
ProjectEventKind::DirectiveIncorporated {
version, summary, ..
} => ActivityFields {
directive_version: Some(*version),
..activity(
ChildActivityKind::Incorporated,
&format!("Incorporated direction v{version}"),
summary,
)
},
ProjectEventKind::TaskObserved { event, .. } => task_activity_fields(event),
ProjectEventKind::DecisionRequested {
decision_id,
prompt,
options,
} => ActivityFields {
decision_id: Some(decision_id.to_string()),
options: options.clone(),
..activity(
ChildActivityKind::DecisionRequired,
"Decision required",
prompt,
)
},
ProjectEventKind::DecisionResolved { choice, .. } => activity(
ChildActivityKind::DecisionResolved,
"Decision resolved",
choice,
),
ProjectEventKind::IterationCompleted { summary, .. } => activity(
ChildActivityKind::StateChanged,
"Project iteration completed",
summary,
),
ProjectEventKind::Completed { summary } => {
activity(ChildActivityKind::Completed, "Project completed", summary)
}
ProjectEventKind::Failed { error, .. } => {
activity(ChildActivityKind::Failed, "Project failed", error)
}
}
}
fn body_lease_activity(subject: &str, process: &ChildProcessGeneration) -> ActivityFields {
let summary = match process.outcome.as_ref() {
Some(ChildBodyOutcome::Completed) => "completed",
Some(ChildBodyOutcome::Interrupted { reason })
| Some(ChildBodyOutcome::Failed { reason })
| Some(ChildBodyOutcome::Lost { reason })
| Some(ChildBodyOutcome::Superseded { reason })
| Some(ChildBodyOutcome::LegacyStopped { reason }) => reason,
None => "",
};
activity(
ChildActivityKind::StateChanged,
&format!(
"{subject} body generation {} is {}",
process.generation,
process.state.as_str()
),
summary,
)
}
fn activity(kind: ChildActivityKind, title: &str, summary: &str) -> ActivityFields {
ActivityFields {
kind,
title: title.to_string(),
summary: summary.to_string(),
directive_version: None,
command_id: None,
effect: None,
decision_id: None,
options: Vec::new(),
}
}
fn control_activity_kind(state: ChildCommandState) -> ChildActivityKind {
if state == ChildCommandState::Uncertain {
ChildActivityKind::ControlUncertain
} else {
ChildActivityKind::ControlApplied
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_turn_round_trips_through_json() {
let turn = ChatTurn::user("turn-0".into(), "please fix the build".into());
let value = serde_json::to_value(&turn).expect("serialize");
let decoded: ChatTurn = serde_json::from_value(value).expect("deserialize");
assert_eq!(decoded, turn);
assert_eq!(decoded.role, ChatRole::User);
}
#[test]
fn attributed_turn_round_trips_and_absent_from_decodes_none() {
let mut turn = ChatTurn::user("turn-1".into(), "worker report".into());
turn.from = Some("worker".to_string());
let value = serde_json::to_value(&turn).expect("serialize");
assert_eq!(value["from"], "worker");
let decoded: ChatTurn = serde_json::from_value(value).expect("deserialize");
assert_eq!(decoded.from.as_deref(), Some("worker"));
let mut value =
serde_json::to_value(ChatTurn::user("turn-2".into(), "hi".into())).expect("serialize");
value.as_object_mut().expect("object").remove("from");
let decoded: ChatTurn = serde_json::from_value(value).expect("deserialize");
assert_eq!(decoded.from, None);
}
#[test]
fn child_activity_envelope_rejects_mixed_conversation_content() {
let activity = ChildControlActivity {
id: "task-ts_example-1".to_string(),
subject: ChildActivitySubject::Task,
subject_id: "INF-123".to_string(),
session_id: "ts_example".to_string(),
kind: ChildActivityKind::StateChanged,
title: "Task started".to_string(),
summary: String::new(),
directive_version: None,
command_id: None,
effect: None,
source: None,
decision_id: None,
options: Vec::new(),
};
let turn = ChatTurn::child_activity(
"turn-activity".to_string(),
"2026-07-13T20:00:00Z".to_string(),
"Task INF-123".to_string(),
activity,
);
let mut value = serde_json::to_value(turn).expect("serialize activity turn");
value["text"] = serde_json::Value::String("mixed prose".to_string());
let error = serde_json::from_value::<ChatTurn>(value).expect_err("reject mixed activity");
assert!(error.to_string().contains("cannot also carry prose"));
}
#[test]
fn turn_delta_fixture_round_trips() {
let fixture = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../tests/fixtures/dto/turn_delta.json"
));
let delta: TurnDelta = serde_json::from_str(fixture).expect("decode turn_delta fixture");
assert_eq!(delta.turn_id, "turn-3");
match &delta.item {
ConversationItem::Message { id, text, phase } => {
assert_eq!(id, "text-7");
assert_eq!(phase.as_deref(), Some("stream"));
assert!(text.contains("edge case"));
}
other => panic!("expected a stream message item, got {other:?}"),
}
let mut turn = ChatTurn::user("turn-3".into(), String::new());
turn.role = ChatRole::Assistant;
turn.push_text("so ");
turn.absorb_item(delta.item.clone());
assert_eq!(turn.text, "so the parser handles the edge case now.");
assert!(
turn.items.is_empty(),
"a stream message joins text, not items"
);
let reencoded = serde_json::to_string(&delta).expect("serialize");
let again: TurnDelta = serde_json::from_str(&reencoded).expect("decode");
assert_eq!(again, delta);
}
#[test]
fn absorb_item_joins_prose_and_appends_the_rest() {
let mut turn = ChatTurn::user("turn-3".into(), String::new());
turn.absorb_item(ConversationItem::Message {
id: "m-1".into(),
text: "first".into(),
phase: None,
});
turn.absorb_item(ConversationItem::Tool {
id: "t-1".into(),
name: "Bash".into(),
status: Lifecycle::Completed,
input: None,
output: None,
});
turn.absorb_item(ConversationItem::Message {
id: "m-2".into(),
text: "second".into(),
phase: None,
});
assert_eq!(turn.text, "first\nsecond");
assert_eq!(turn.items.len(), 1, "prose joins text, tools append");
}
#[test]
fn absorb_item_keeps_commentary_as_a_curatable_item() {
let mut turn = ChatTurn::user("turn-10".into(), String::new());
turn.role = ChatRole::Assistant;
turn.absorb_item(ConversationItem::Message {
id: "m-0".into(),
text: "I'm using `wave_clarify` to audit the plan.".into(),
phase: Some("commentary".into()),
});
turn.absorb_item(ConversationItem::Message {
id: "m-1".into(),
text: "Clarification complete.".into(),
phase: Some("final_answer".into()),
});
assert_eq!(turn.text, "Clarification complete.");
assert_eq!(turn.items.len(), 1);
match &turn.items[0] {
ConversationItem::Message { text, phase, .. } => {
assert_eq!(phase.as_deref(), Some("commentary"));
assert!(text.contains("wave_clarify"));
}
other => panic!("expected the commentary message in items, got {other:?}"),
}
}
#[test]
fn absorb_item_concatenates_stream_fragments_exactly() {
let mut turn = ChatTurn::user("turn-4".into(), String::new());
for text in ["hello", " ", "world"] {
turn.absorb_item(ConversationItem::Message {
id: format!("m-{}", turn.text.len()),
text: text.into(),
phase: Some("stream".into()),
});
}
assert_eq!(turn.text, "hello world");
}
#[test]
fn decision_activity_keeps_options_and_lineage() {
let decision_id = crate::child_session::ChildDecisionId::new();
let observation = TaskObservation {
session_id: crate::task::TaskSessionId::new(),
issue_identifier: "INF-123".to_string(),
event_id: 9,
control_source: None,
event: TaskEventKind::DecisionRequested {
decision_id: decision_id.clone(),
prompt: "Which parser mode?".to_string(),
options: vec!["strict".to_string(), "permissive".to_string()],
},
};
let activity = ChildControlActivity::from_task(&observation);
assert_eq!(activity.kind, ChildActivityKind::DecisionRequired);
assert_eq!(activity.decision_id.as_deref(), Some(decision_id.as_str()));
assert_eq!(activity.options, ["strict", "permissive"]);
}
#[test]
fn command_activity_keeps_the_applied_effect() {
let command_id = crate::child_session::ChildCommandId::new();
let wave_id = crate::id::WaveId::new();
let observation = TaskObservation {
session_id: crate::task::TaskSessionId::new(),
issue_identifier: "INF-123".to_string(),
event_id: 10,
control_source: Some(crate::child_session::ChildCommandSource::Wave(
wave_id.clone(),
)),
event: TaskEventKind::CommandChanged {
command_id: command_id.clone(),
state: crate::child_session::ChildCommandState::Accepted,
effect: Some(ChildCommandEffect::LiveSteer),
error: None,
},
};
let activity = ChildControlActivity::from_task(&observation);
assert_eq!(activity.kind, ChildActivityKind::ControlApplied);
assert_eq!(activity.command_id.as_deref(), Some(command_id.as_str()));
assert_eq!(activity.effect, Some(ChildCommandEffect::LiveSteer));
assert_eq!(activity.directive_version, None);
assert_eq!(
activity.source,
Some(crate::child_session::ChildCommandSource::Wave(wave_id))
);
}
#[test]
fn ambiguous_provider_delivery_is_visibly_uncertain() {
let observation = TaskObservation {
session_id: crate::task::TaskSessionId::new(),
issue_identifier: "INF-123".to_string(),
event_id: 11,
control_source: Some(crate::child_session::ChildCommandSource::System),
event: TaskEventKind::CommandChanged {
command_id: crate::child_session::ChildCommandId::new(),
state: ChildCommandState::Uncertain,
effect: None,
error: Some("provider delivery outcome is unknown".to_string()),
},
};
let activity = ChildControlActivity::from_task(&observation);
assert_eq!(activity.kind, ChildActivityKind::ControlUncertain);
assert_eq!(activity.title, "Control uncertain");
assert!(activity.summary.contains("unknown"));
}
}