use std::collections::BTreeSet;
use lash_core::runtime::QueuedWorkClaimBoundary;
use lash_core::{
AcceptedInjectedTurnInput, CheckpointKind, MessageOrigin, MessageRole, PluginMessage,
PluginRuntimeEvent, TokenUsage, ToolCallOutput, ToolFailure, ToolFailureClass, TurnActivity,
TurnActivityId, TurnCause, TurnEvent,
};
use serde_json::json;
fn expected_type_tag(event: &TurnEvent) -> &'static str {
match event {
TurnEvent::QueuedWorkStarted { .. } => "queued_work_started",
TurnEvent::ModelRequestStarted { .. } => "model_request_started",
TurnEvent::AssistantProseDelta { .. } => "assistant_prose_delta",
TurnEvent::ReasoningDelta { .. } => "reasoning_delta",
TurnEvent::CodeBlockStarted { .. } => "code_block_started",
TurnEvent::CodeBlockCompleted { .. } => "code_block_completed",
TurnEvent::ToolCallStarted { .. } => "tool_call_started",
TurnEvent::ToolCallCompleted { .. } => "tool_call_completed",
TurnEvent::FinalValue { .. } => "final_value",
TurnEvent::ToolValue { .. } => "tool_value",
TurnEvent::Usage { .. } => "usage",
TurnEvent::ChildUsage { .. } => "child_usage",
TurnEvent::RetryStatus { .. } => "retry_status",
TurnEvent::PluginRuntime { .. } => "plugin_runtime",
TurnEvent::QueuedInputAccepted { .. } => "queued_input_accepted",
TurnEvent::QueuedMessagesCommitted { .. } => "queued_messages_committed",
TurnEvent::Error { .. } => "error",
}
}
const ALL_TURN_EVENT_TAGS: &[&str] = &[
"queued_work_started",
"model_request_started",
"assistant_prose_delta",
"reasoning_delta",
"code_block_started",
"code_block_completed",
"tool_call_started",
"tool_call_completed",
"final_value",
"tool_value",
"usage",
"child_usage",
"retry_status",
"plugin_runtime",
"queued_input_accepted",
"queued_messages_committed",
"error",
];
fn token_usage_sample() -> TokenUsage {
TokenUsage {
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 1,
cache_write_input_tokens: 2,
reasoning_output_tokens: 3,
}
}
fn token_usage_json() -> serde_json::Value {
json!({
"input_tokens": 10,
"output_tokens": 5,
"cache_read_input_tokens": 1,
"cache_write_input_tokens": 2,
"reasoning_output_tokens": 3,
})
}
fn sample_events() -> Vec<(&'static str, TurnEvent, serde_json::Value)> {
vec![
(
"queued_work_started",
TurnEvent::QueuedWorkStarted {
boundary: QueuedWorkClaimBoundary::Idle,
batch_ids: vec!["batch-1".to_string()],
causes: vec![TurnCause {
id: "cause-1".to_string(),
event_type: "process_wake".to_string(),
origin: MessageOrigin::Plugin {
plugin_id: "p".to_string(),
transient: false,
},
text: "hello".to_string(),
}],
},
json!({
"type": "queued_work_started",
"boundary": "idle",
"batch_ids": ["batch-1"],
"causes": [{
"id": "cause-1",
"event_type": "process_wake",
"origin": { "kind": "plugin", "plugin_id": "p" },
"text": "hello",
}],
}),
),
(
"model_request_started",
TurnEvent::ModelRequestStarted {
protocol_iteration: 2,
},
json!({ "type": "model_request_started", "protocol_iteration": 2 }),
),
(
"assistant_prose_delta",
TurnEvent::AssistantProseDelta {
text: "hi".to_string(),
},
json!({ "type": "assistant_prose_delta", "text": "hi" }),
),
(
"reasoning_delta",
TurnEvent::ReasoningDelta {
text: "thinking".to_string(),
},
json!({ "type": "reasoning_delta", "text": "thinking" }),
),
(
"code_block_started (graph_key present)",
TurnEvent::CodeBlockStarted {
language: "python".to_string(),
code: "print(1)".to_string(),
graph_key: Some("effect:s:e".to_string()),
},
json!({
"type": "code_block_started",
"language": "python",
"code": "print(1)",
"graph_key": "effect:s:e",
}),
),
(
"code_block_started (graph_key absent)",
TurnEvent::CodeBlockStarted {
language: "python".to_string(),
code: "print(1)".to_string(),
graph_key: None,
},
json!({
"type": "code_block_started",
"language": "python",
"code": "print(1)",
}),
),
(
"code_block_completed (error + graph_key present)",
TurnEvent::CodeBlockCompleted {
language: "python".to_string(),
output: "1".to_string(),
error: Some("boom".to_string()),
success: false,
duration_ms: 5,
tool_call_ids: vec!["call-1".to_string()],
graph_key: Some("effect:s:e".to_string()),
},
json!({
"type": "code_block_completed",
"language": "python",
"output": "1",
"error": "boom",
"success": false,
"duration_ms": 5,
"tool_call_ids": ["call-1"],
"graph_key": "effect:s:e",
}),
),
(
"code_block_completed (error + graph_key absent)",
TurnEvent::CodeBlockCompleted {
language: "python".to_string(),
output: "1".to_string(),
error: None,
success: true,
duration_ms: 5,
tool_call_ids: vec![],
graph_key: None,
},
json!({
"type": "code_block_completed",
"language": "python",
"output": "1",
"success": true,
"duration_ms": 5,
"tool_call_ids": [],
}),
),
(
"tool_call_started (all options present)",
TurnEvent::ToolCallStarted {
call_id: Some("call-1".to_string()),
name: "read_file".to_string(),
args: json!({ "path": "x" }),
graph_key: Some("effect:s:e".to_string()),
parent_call_id: Some("parent-1".to_string()),
},
json!({
"type": "tool_call_started",
"call_id": "call-1",
"name": "read_file",
"args": { "path": "x" },
"graph_key": "effect:s:e",
"parent_call_id": "parent-1",
}),
),
(
"tool_call_started (all options absent)",
TurnEvent::ToolCallStarted {
call_id: None,
name: "read_file".to_string(),
args: json!({ "path": "x" }),
graph_key: None,
parent_call_id: None,
},
json!({
"type": "tool_call_started",
"name": "read_file",
"args": { "path": "x" },
}),
),
(
"tool_call_completed (all options present, success)",
TurnEvent::ToolCallCompleted {
call_id: Some("call-1".to_string()),
name: "read_file".to_string(),
args: json!({ "path": "x" }),
output: ToolCallOutput::success("ok"),
duration_ms: 7,
graph_key: Some("effect:s:e".to_string()),
parent_call_id: Some("parent-1".to_string()),
},
json!({
"type": "tool_call_completed",
"call_id": "call-1",
"name": "read_file",
"args": { "path": "x" },
"output": { "outcome": { "status": "success", "payload": "ok" } },
"duration_ms": 7,
"graph_key": "effect:s:e",
"parent_call_id": "parent-1",
}),
),
(
"tool_call_completed (all options absent, failure vocabulary)",
TurnEvent::ToolCallCompleted {
call_id: None,
name: "read_file".to_string(),
args: json!({ "path": "x" }),
output: ToolCallOutput::failure(ToolFailure::tool(
ToolFailureClass::Execution,
"boom",
"kaboom",
)),
duration_ms: 7,
graph_key: None,
parent_call_id: None,
},
json!({
"type": "tool_call_completed",
"name": "read_file",
"args": { "path": "x" },
"output": {
"outcome": {
"status": "failure",
"payload": {
"class": "execution",
"code": "boom",
"message": "kaboom",
"source": "tool",
"retry": { "type": "never" },
},
},
},
"duration_ms": 7,
}),
),
(
"final_value",
TurnEvent::FinalValue {
value: json!({ "answer": 42 }),
},
json!({ "type": "final_value", "value": { "answer": 42 } }),
),
(
"tool_value",
TurnEvent::ToolValue {
tool_name: "calc".to_string(),
value: json!(3),
},
json!({ "type": "tool_value", "tool_name": "calc", "value": 3 }),
),
(
"usage",
TurnEvent::Usage {
protocol_iteration: 1,
usage: token_usage_sample(),
cumulative: token_usage_sample(),
},
json!({
"type": "usage",
"protocol_iteration": 1,
"usage": token_usage_json(),
"cumulative": token_usage_json(),
}),
),
(
"child_usage",
TurnEvent::ChildUsage {
session_id: "child".to_string(),
source: "delegate".to_string(),
model: "m".to_string(),
protocol_iteration: 1,
usage: token_usage_sample(),
cumulative: token_usage_sample(),
},
json!({
"type": "child_usage",
"session_id": "child",
"source": "delegate",
"model": "m",
"protocol_iteration": 1,
"usage": token_usage_json(),
"cumulative": token_usage_json(),
}),
),
(
"retry_status",
TurnEvent::RetryStatus {
wait_seconds: 3,
attempt: 1,
max_attempts: 5,
reason: "rate_limited".to_string(),
},
json!({
"type": "retry_status",
"wait_seconds": 3,
"attempt": 1,
"max_attempts": 5,
"reason": "rate_limited",
}),
),
(
"plugin_runtime",
TurnEvent::PluginRuntime {
plugin_id: "todo".to_string(),
event: PluginRuntimeEvent::Status {
key: "k".to_string(),
label: "Working".to_string(),
detail: Some("d".to_string()),
},
},
json!({
"type": "plugin_runtime",
"plugin_id": "todo",
"event": {
"kind": "status",
"key": "k",
"label": "Working",
"detail": "d",
},
}),
),
(
"queued_input_accepted",
TurnEvent::QueuedInputAccepted {
checkpoint: CheckpointKind::AfterWork,
inputs: vec![AcceptedInjectedTurnInput {
id: Some("i".to_string()),
message: PluginMessage::text(MessageRole::User, "hi"),
}],
},
json!({
"type": "queued_input_accepted",
"checkpoint": "after_work",
"inputs": [{
"id": "i",
"message": { "role": "User", "content": "hi" },
}],
}),
),
(
"queued_messages_committed",
TurnEvent::QueuedMessagesCommitted {
messages: vec![PluginMessage::text(MessageRole::Assistant, "done")],
checkpoint: CheckpointKind::BeforeCompletion,
},
json!({
"type": "queued_messages_committed",
"messages": [{ "role": "Assistant", "content": "done" }],
"checkpoint": "before_completion",
}),
),
(
"error",
TurnEvent::Error {
message: "boom".to_string(),
},
json!({ "type": "error", "message": "boom" }),
),
]
}
#[test]
fn every_variant_serializes_to_pinned_json() {
for (label, event, expected) in sample_events() {
let actual =
serde_json::to_value(&event).unwrap_or_else(|err| panic!("serialize {label}: {err}"));
assert_eq!(actual, expected, "serialized shape drifted for `{label}`");
assert_eq!(
expected["type"],
expected_type_tag(&event),
"tag disagrees with expected_type_tag for `{label}`"
);
let round_trip: TurnEvent = serde_json::from_value(expected.clone())
.unwrap_or_else(|err| panic!("deserialize {label}: {err}"));
assert_eq!(
serde_json::to_value(&round_trip).unwrap(),
expected,
"round-trip drifted for `{label}`"
);
}
}
#[test]
fn samples_cover_every_variant() {
let sampled: BTreeSet<&str> = sample_events()
.iter()
.map(|(_, event, _)| expected_type_tag(event))
.collect();
let canonical: BTreeSet<&str> = ALL_TURN_EVENT_TAGS.iter().copied().collect();
assert_eq!(
sampled, canonical,
"sample_events must pin exactly one representative per TurnEvent variant"
);
}
#[test]
fn turn_activity_envelope_flattens_event() {
let activity = TurnActivity {
id: TurnActivityId::new("act-1"),
correlation_id: TurnActivityId::new("corr-1"),
event: TurnEvent::Error {
message: "boom".to_string(),
},
};
let json = serde_json::to_value(&activity).expect("serialize activity");
assert_eq!(
json,
json!({
"id": "act-1",
"correlation_id": "corr-1",
"type": "error",
"message": "boom",
})
);
assert!(
json.get("event").is_none(),
"the event payload must be flattened into the envelope, not nested"
);
let round_trip: TurnActivity = serde_json::from_value(json.clone()).expect("deserialize");
assert_eq!(serde_json::to_value(&round_trip).unwrap(), json);
}