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,
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")]
pub enum Status {
Completed,
MaxTurns,
ModelError,
Error,
}
#[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,
}
#[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>,
}
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,
},
Result {
report: Report,
},
Error {
message: String,
},
}
#[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::Result { .. } | Event::Error { .. } => {}
}
}
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(),
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")
);
}
}