use serde::{Deserialize, Serialize};
use crate::activity_event::StopKind;
use crate::payload::{ContentType, Payload};
impl StopKind {
#[must_use]
pub fn canonical_stop_reason(&self) -> &'static str {
match self {
Self::EndTurn => "end_turn",
Self::ToolUse => "tool_use",
Self::LimitReached => "limit_reached",
Self::Cancelled => "cancelled",
Self::Error { .. } => "error",
Self::Other { .. } => "other",
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum AgentOutcomeError {
#[error(
"the agent-outcome record's mandatory `session_id` is empty — every harness fills it \
with the handle of the conversation that produced this outcome, and an empty handle \
names no conversation"
)]
EmptySessionId,
#[error("the agent-outcome record is not encodable: {0}")]
Encode(#[from] serde_json::Error),
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct AgentOutcome {
pub text: String,
pub final_message: String,
pub stop_reason: String,
pub session_id: String,
}
impl AgentOutcome {
#[must_use]
pub fn new(
text: impl Into<String>,
final_message: impl Into<String>,
stop: &StopKind,
session_id: impl Into<String>,
) -> Self {
Self {
text: text.into(),
final_message: final_message.into(),
stop_reason: stop.canonical_stop_reason().to_owned(),
session_id: session_id.into(),
}
}
pub fn into_payload(self) -> Result<Payload, AgentOutcomeError> {
if self.session_id.is_empty() {
return Err(AgentOutcomeError::EmptySessionId);
}
let bytes = serde_json::to_vec(&self)?;
Ok(Payload::new(ContentType::Json, bytes))
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{AgentOutcome, AgentOutcomeError, StopKind};
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[test]
fn every_stop_kind_has_its_canonical_snake_case_spelling() {
let cases = [
(StopKind::EndTurn, "end_turn"),
(StopKind::ToolUse, "tool_use"),
(StopKind::LimitReached, "limit_reached"),
(StopKind::Cancelled, "cancelled"),
(
StopKind::Error {
message: "boom".to_owned(),
},
"error",
),
(
StopKind::Other {
reason: "teleported".to_owned(),
},
"other",
),
];
for (kind, expected) in cases {
assert_eq!(kind.canonical_stop_reason(), expected);
}
}
#[test]
fn the_record_serializes_as_the_four_snake_case_fields() -> TestResult {
let outcome = AgentOutcome::new(
"narration\nthe answer",
"the answer",
&StopKind::EndTurn,
"sess-7",
);
assert_eq!(
serde_json::to_value(&outcome)?,
json!({
"text": "narration\nthe answer",
"final_message": "the answer",
"stop_reason": "end_turn",
"session_id": "sess-7",
})
);
Ok(())
}
#[test]
fn the_record_round_trips_through_its_payload() -> TestResult {
let outcome = AgentOutcome::new("", "", &StopKind::EndTurn, "sess-7");
let payload = outcome.clone().into_payload()?;
assert_eq!(payload.content_type(), &super::ContentType::Json);
let decoded: AgentOutcome = serde_json::from_slice(payload.bytes())?;
assert_eq!(decoded, outcome);
assert_eq!(
decoded.text, "",
"an empty text on a completed run is honest and survives the wire"
);
assert_eq!(
decoded.final_message, "",
"an empty final_message is honest and survives the wire without refusal"
);
Ok(())
}
#[test]
fn an_empty_session_id_is_refused_by_name() -> Result<(), String> {
let error = AgentOutcome::new("the answer", "the answer", &StopKind::EndTurn, "")
.into_payload()
.err()
.ok_or("an empty session_id must not serialize")?;
assert!(
matches!(error, AgentOutcomeError::EmptySessionId),
"the refusal is the named vacancy, got {error:?}"
);
assert!(
error.to_string().contains("session_id"),
"the refusal names the field: {error}"
);
Ok(())
}
#[test]
fn a_three_field_payload_no_longer_decodes() {
let stale = json!({
"text": "the answer",
"stop_reason": "end_turn",
"session_id": "sess-7",
});
let decoded: Result<AgentOutcome, _> = serde_json::from_value(stale);
assert!(
decoded.is_err(),
"the pre-#241 shape must be refused, not defaulted"
);
}
}