use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Conversation {
pub messages: Vec<Message>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Message {
pub role: Role,
pub content: Vec<ContentBlock>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
System,
Developer,
User,
Assistant,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ContentBlock {
Text {
text: String,
},
Image {
source: ImageSource,
},
Reasoning {
format: ReasoningFormat,
text: String,
signature: Option<String>,
payload: Option<Value>,
},
ToolUse {
id: String,
name: String,
input: Value,
},
ToolResult {
tool_use_id: String,
content: Vec<ResultChunk>,
is_error: bool,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResultChunk {
Text {
text: String,
},
Image {
source: ImageSource,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ImageSource {
Base64 {
media_type: String,
data: String,
},
Url {
url: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReasoningFormat {
Anthropic,
AnthropicRedacted,
OpenAiResponses,
TextOnly,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Report {
pub schema_version: u32,
pub status: Status,
pub harness: String,
pub api_schema: String,
pub final_message: Option<String>,
pub structured_output: Option<Value>,
pub turns: u32,
pub tool_calls: Vec<ToolCallRecord>,
pub usage: Usage,
#[serde(default)]
pub context_usage: Usage,
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Status {
Completed,
MaxTurns,
ModelError,
Error,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCallRecord {
pub id: String,
pub name: String,
pub kind: String,
pub args: Value,
pub ok: bool,
pub output: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub denial_reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_tokens: Option<u64>,
pub cache_creation_tokens: Option<u64>,
pub reasoning_tokens: Option<u64>,
}
impl Usage {
#[must_use]
pub fn context_tokens(&self) -> u64 {
self.input_tokens
+ self.cache_read_tokens.unwrap_or(0)
+ self.cache_creation_tokens.unwrap_or(0)
+ self.output_tokens
}
}
fn add_opt(a: Option<u64>, b: Option<u64>) -> Option<u64> {
match (a, b) {
(Some(x), Some(y)) => Some(x + y),
(Some(x), None) | (None, Some(x)) => Some(x),
(None, None) => None,
}
}
impl std::ops::AddAssign for Usage {
fn add_assign(&mut self, rhs: Self) {
self.input_tokens += rhs.input_tokens;
self.output_tokens += rhs.output_tokens;
self.cache_read_tokens = add_opt(self.cache_read_tokens, rhs.cache_read_tokens);
self.cache_creation_tokens = add_opt(self.cache_creation_tokens, rhs.cache_creation_tokens);
self.reasoning_tokens = add_opt(self.reasoning_tokens, rhs.reasoning_tokens);
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub input: ToolInputFormat,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolInputFormat {
JsonSchema {
parameters: Value,
},
Freeform {
syntax: GrammarSyntax,
definition: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GrammarSyntax {
Lark,
Regex,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Event {
Init {
session_id: String,
harness: String,
api_schema: String,
model: String,
cwd: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_turns: Option<u32>,
preamble: Vec<Message>,
tools: Vec<Value>,
},
Message {
message: Message,
},
MessageDelta {
text: String,
},
Result {
report: Report,
},
Error {
message: String,
},
Approval {
tool_use_id: String,
tool_name: String,
decision: String,
wait_ms: u64,
},
}
#[must_use]
pub fn reconstruct_conversation(events: &[Event]) -> Conversation {
let mut messages = Vec::new();
for event in events {
match event {
Event::Init { preamble, .. } => messages.extend(preamble.iter().cloned()),
Event::Message { message } => messages.push(message.clone()),
Event::MessageDelta { .. }
| Event::Result { .. }
| Event::Error { .. }
| Event::Approval { .. } => {}
}
}
Conversation { messages }
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn conversation_round_trips_all_roles_and_tool_pairing() {
let call_id = "call_42";
let conversation = Conversation {
messages: vec![
Message {
role: Role::System,
content: vec![ContentBlock::Text {
text: "You are locode.".into(),
}],
},
Message {
role: Role::Developer,
content: vec![ContentBlock::Text {
text: "Available tools: run_terminal_command.".into(),
}],
},
Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "run echo hi".into(),
}],
},
Message {
role: Role::Assistant,
content: vec![
ContentBlock::Text {
text: "Sure.".into(),
},
ContentBlock::ToolUse {
id: call_id.into(),
name: "run_terminal_command".into(),
input: json!({ "command": "echo hi" }),
},
],
},
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: call_id.into(),
content: vec![ResultChunk::Text {
text: "hi\n".into(),
}],
is_error: false,
}],
},
],
};
let wire = serde_json::to_string(&conversation).expect("serialize");
let back: Conversation = serde_json::from_str(&wire).expect("deserialize");
assert_eq!(
conversation, back,
"conversation did not round-trip losslessly"
);
let ContentBlock::ToolUse { id, .. } = &conversation.messages[3].content[1] else {
panic!("expected a tool_use block");
};
let ContentBlock::ToolResult { tool_use_id, .. } = &conversation.messages[4].content[0]
else {
panic!("expected a tool_result block");
};
assert_eq!(id, tool_use_id);
}
#[test]
fn content_block_uses_anthropic_style_type_tags() {
let block = ContentBlock::Text { text: "hi".into() };
assert_eq!(
serde_json::to_value(&block).unwrap(),
json!({ "type": "text", "text": "hi" })
);
}
#[test]
fn status_serializes_to_adr_0009_strings() {
let cases = [
(Status::Completed, "completed"),
(Status::MaxTurns, "max_turns"),
(Status::ModelError, "model_error"),
(Status::Error, "error"),
];
for (status, want) in cases {
assert_eq!(serde_json::to_value(status).unwrap(), json!(want));
}
}
fn minimal_report() -> Report {
Report {
schema_version: 1,
status: Status::Completed,
harness: "grok".into(),
api_schema: "anthropic".into(),
final_message: Some("done".into()),
structured_output: None,
turns: 1,
tool_calls: vec![],
usage: Usage::default(),
context_usage: Usage::default(),
session_id: "sess-1".into(),
stop_reason: None,
error: None,
}
}
#[test]
fn events_reconstruct_full_conversation() {
let system = Message {
role: Role::System,
content: vec![ContentBlock::Text {
text: "base".into(),
}],
};
let developer = Message {
role: Role::Developer,
content: vec![ContentBlock::Text {
text: "capabilities".into(),
}],
};
let user = Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "run echo hi".into(),
}],
};
let assistant = Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
id: "c1".into(),
name: "run_terminal_command".into(),
input: json!({ "command": "echo hi" }),
}],
};
let tool_result = Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: "c1".into(),
content: vec![ResultChunk::Text {
text: "hi\n".into(),
}],
is_error: false,
}],
};
let events = vec![
Event::Init {
session_id: "sess-1".into(),
harness: "grok".into(),
api_schema: "anthropic".into(),
model: "claude-opus-4-8".into(),
cwd: "/repo".into(),
max_turns: Some(30),
preamble: vec![system.clone(), developer.clone()],
tools: vec![json!({ "name": "run_terminal_command" })],
},
Event::Message {
message: user.clone(),
},
Event::Message {
message: assistant.clone(),
},
Event::Message {
message: tool_result.clone(),
},
Event::Result {
report: minimal_report(),
},
];
let jsonl = events
.iter()
.map(|e| serde_json::to_string(e).unwrap())
.collect::<Vec<_>>()
.join("\n");
let parsed: Vec<Event> = jsonl
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(parsed, events, "events did not round-trip through JSONL");
let rebuilt = reconstruct_conversation(&parsed);
assert_eq!(
rebuilt,
Conversation {
messages: vec![system, developer, user, assistant, tool_result]
}
);
}
#[test]
fn event_uses_snake_case_type_tags() {
let event = Event::Message {
message: Message {
role: Role::User,
content: vec![],
},
};
assert_eq!(
serde_json::to_value(&event).unwrap()["type"],
json!("message")
);
}
#[test]
fn message_delta_round_trips_as_jsonl() {
let event = Event::MessageDelta {
text: "hello ".into(),
};
let value = serde_json::to_value(&event).unwrap();
assert_eq!(value["type"], json!("message_delta"), "{value}");
assert_eq!(value["text"], json!("hello "));
let back: Event = serde_json::from_value(value).unwrap();
assert_eq!(back, event);
}
#[test]
fn message_delta_is_not_part_of_reconstructed_history() {
let assistant = Message {
role: Role::Assistant,
content: vec![ContentBlock::Text {
text: "hello world".into(),
}],
};
let with_deltas = vec![
Event::MessageDelta {
text: "hello ".into(),
},
Event::MessageDelta {
text: "world".into(),
},
Event::Message {
message: assistant.clone(),
},
];
let without_deltas = vec![Event::Message {
message: assistant.clone(),
}];
assert_eq!(
reconstruct_conversation(&with_deltas),
reconstruct_conversation(&without_deltas),
"deltas must not affect reconstruction"
);
assert_eq!(
reconstruct_conversation(&with_deltas).messages,
vec![assistant]
);
}
#[test]
fn tool_call_record_denial_reason_is_additive() {
let record = ToolCallRecord {
id: "c1".into(),
name: "shell".into(),
kind: "shell".into(),
args: json!({}),
ok: false,
output: Value::Null,
denial_reason: None,
};
let value = serde_json::to_value(&record).unwrap();
assert!(
!value.as_object().unwrap().contains_key("denial_reason"),
"None must not appear on the wire: {value}"
);
let denied = ToolCallRecord {
denial_reason: Some("not allowed".into()),
..record
};
let value = serde_json::to_value(&denied).unwrap();
assert_eq!(value["denial_reason"], json!("not allowed"));
let back: ToolCallRecord = serde_json::from_value(value).unwrap();
assert_eq!(back, denied);
let old = json!({
"id": "c1", "name": "shell", "kind": "shell",
"args": {}, "ok": true, "output": null
});
let back: ToolCallRecord = serde_json::from_value(old).unwrap();
assert_eq!(back.denial_reason, None);
}
#[test]
fn cancelled_status_wire_string() {
assert_eq!(
serde_json::to_value(Status::Cancelled).unwrap(),
json!("cancelled")
);
let back: Status = serde_json::from_value(json!("cancelled")).unwrap();
assert_eq!(back, Status::Cancelled);
}
#[test]
fn approval_event_shape_and_reconstruction() {
let event = Event::Approval {
tool_use_id: "c1".into(),
tool_name: "run_terminal_cmd".into(),
decision: "deny".into(),
wait_ms: 1234,
};
let value = serde_json::to_value(&event).unwrap();
assert_eq!(
value,
json!({
"type": "approval",
"tool_use_id": "c1",
"tool_name": "run_terminal_cmd",
"decision": "deny",
"wait_ms": 1234
})
);
let back: Event = serde_json::from_value(value).unwrap();
assert_eq!(back, event);
let conversation = reconstruct_conversation(&[event]);
assert!(
conversation.messages.is_empty(),
"approval events are run metadata, not history"
);
}
}