use serde::Deserialize;
use serde_json::Value;
use crate::error::Error;
pub(crate) const LINE_LIMIT: usize = 8 * 1024 * 1024;
pub(crate) const REASON_LIMIT: usize = 64 * 1024;
macro_rules! words {
($(#[$m:meta])* $name:ident { $($(#[$vm:meta])* $var:ident = $word:literal,)* }) => {
$(#[$m])*
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum $name {
$($(#[$vm])* $var,)*
Other(String),
}
impl $name {
pub fn as_str(&self) -> &str {
match self {
$(Self::$var => $word,)*
Self::Other(word) => word,
}
}
#[allow(dead_code)]
pub(crate) fn from_wire(word: &str) -> Self {
match word {
$($word => Self::$var,)*
other => Self::Other(other.to_string()),
}
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Ok(Self::from_wire(&String::deserialize(d)?))
}
}
};
}
words! {
UserKind {
Message = "message",
Steer = "steer",
}
}
words! {
ToolStatus {
Ok = "ok",
Failed = "failed",
}
}
words! {
ApprovalDecision {
AllowedOnce = "allowed_once",
AllowedForTurn = "allowed_for_turn",
AllowedForSession = "allowed_for_session",
Denied = "denied",
DeniedAndStopped = "denied_and_stopped",
TimedOut = "timed_out",
Refused = "refused",
}
}
words! {
ApprovalKind {
Gate = "gate",
Protected = "protected",
Removal = "removal",
DecodeAndRun = "decode_and_run",
Credential = "credential",
Escape = "escape",
Mcp = "mcp",
JudgeFlag = "judge_flag",
JudgeUnavailable = "judge_unavailable",
ClientTool = "client_tool",
}
}
words! {
Choice {
AllowOnce = "allow_once",
AllowForTurn = "allow_for_turn",
Deny = "deny",
DenyAndStop = "deny_and_stop",
}
}
words! {
Outcome {
Completed = "completed",
ToolFailed = "tool_failed",
ModelFailed = "model_failed",
MaxIterations = "max_iterations",
Interrupted = "interrupted",
Truncated = "truncated",
PromptBlocked = "prompt_blocked",
}
}
words! {
Disposition {
Started = "started",
Queued = "queued",
Accepted = "accepted",
Dequeued = "dequeued",
Idle = "idle",
Stale = "stale",
Interrupting = "interrupting",
Answered = "answered",
Done = "done",
Set = "set",
Quitting = "quitting",
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Event {
Init(Info),
User(UserMessage),
Reasoning(Text),
Assistant(Text),
ToolUse(ToolUse),
ToolResult(ToolResult),
Approval(Approval),
ApprovalRequest(ApprovalRequest),
Compacted(Compacted),
Result(TurnResult),
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct Info {
pub session_id: String,
pub model: Option<String>,
pub mode: Option<String>,
pub cwd: Option<String>,
pub harness_version: String,
#[serde(default)]
pub commands: Vec<String>,
#[serde(default)]
pub resumed: bool,
#[serde(default)]
pub client_tools: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct UserMessage {
pub text: String,
pub kind: UserKind,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct Text {
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct ToolUse {
pub id: String,
pub tool: String,
pub input: Value,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct ToolResult {
pub id: String,
pub tool: String,
pub status: ToolStatus,
pub output: Option<Value>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct Approval {
pub id: String,
pub tool: String,
pub decision: ApprovalDecision,
pub reason: Option<String>,
pub session: Option<String>,
pub agent: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct ApprovalRequest {
pub id: String,
pub tool_use_id: String,
pub tool: String,
pub kind: ApprovalKind,
pub reason: Option<String>,
pub escape: Option<Escape>,
pub summary: String,
pub input: Value,
pub choices: Vec<Choice>,
pub turn_grant_skips_kind: bool,
pub session: Option<String>,
pub agent: Option<String>,
#[serde(skip)]
pub(crate) responder: Responder,
}
#[derive(Clone, Default)]
pub(crate) struct Responder(pub(crate) Option<std::sync::Weak<crate::client::Shared>>);
impl std::fmt::Debug for Responder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Responder")
}
}
impl PartialEq for Responder {
fn eq(&self, _: &Self) -> bool {
true
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct Escape {
pub kind: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct Compacted {
pub trigger: Option<String>,
pub pre_tokens: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct TurnResult {
pub session_id: String,
pub outcome: Option<Outcome>,
pub is_error: bool,
pub result: Option<String>,
pub error: Option<String>,
pub model: Option<String>,
pub duration_ms: u64,
pub model_calls: u64,
pub usage: Usage,
pub denied: Vec<Denied>,
pub turn_id: Option<String>,
pub prompt_id: Option<String>,
pub cost: Option<Cost>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cached_input_tokens: Option<u64>,
pub reasoning_tokens: u64,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct Denied {
pub tool: String,
pub summary: String,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct Cost {
pub usd: Option<f64>,
pub priced: u64,
pub unpriced: u64,
pub free: u64,
pub bound: Option<String>,
pub basis: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct State {
pub session_id: String,
pub model: Option<String>,
pub thinking: Option<String>,
pub mode: Option<String>,
pub away: bool,
pub turn: TurnState,
pub open_requests: Vec<String>,
pub queued: u64,
pub context: ContextState,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum TurnState {
Idle,
Running {
id: Option<String>,
prompt_id: Option<String>,
},
}
impl<'de> Deserialize<'de> for TurnState {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Running {
id: Option<String>,
prompt_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Word(String),
Running(Running),
}
match Raw::deserialize(d)? {
Raw::Word(w) if w == "idle" => Ok(TurnState::Idle),
Raw::Word(w) => Err(serde::de::Error::custom(format!(
"unknown turn state {w:?}"
))),
Raw::Running(r) => Ok(TurnState::Running {
id: r.id,
prompt_id: r.prompt_id,
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct ContextState {
pub last_prompt_tokens: Option<u64>,
pub window: Option<u64>,
pub percent: Option<u64>,
pub auto_compact_at_percent: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
AllowOnce,
AllowForTurn,
Deny {
reason: Option<String>,
},
DenyAndStop,
}
impl Decision {
pub(crate) fn as_wire(&self) -> &'static str {
match self {
Decision::AllowOnce => "allow_once",
Decision::AllowForTurn => "allow_for_turn",
Decision::Deny { .. } => "deny",
Decision::DenyAndStop => "deny_and_stop",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct Response {
pub id: Option<String>,
pub command: String,
pub ok: bool,
pub disposition: Option<String>,
pub data: Option<Value>,
pub error: Option<(String, String)>,
}
#[derive(Debug)]
pub(crate) enum Line {
Init(Box<Info>, u64),
Event(Box<Event>),
Response(Response),
ClientToolCall(ToolCall),
ClientToolCancel(String),
Other,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ToolCall {
pub id: String,
pub name: String,
pub input: Value,
#[serde(skip)]
pub too_deep: bool,
}
struct WithoutInput(serde_json::Map<String, Value>);
impl<'de> Deserialize<'de> for WithoutInput {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct Visit;
impl<'de> serde::de::Visitor<'de> for Visit {
type Value = WithoutInput;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a JSON object")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<WithoutInput, A::Error> {
let mut out = serde_json::Map::new();
while let Some(key) = map.next_key::<String>()? {
let value = if key == "input" {
map.next_value::<serde::de::IgnoredAny>()?;
Value::Null
} else {
map.next_value::<Value>()?
};
out.insert(key, value);
}
Ok(WithoutInput(out))
}
}
d.deserialize_map(Visit)
}
}
fn field<T: serde::de::DeserializeOwned>(ty: &str, v: Value) -> Result<T, Error> {
serde_json::from_value(v).map_err(|e| Error::Protocol(format!("a `{ty}` line: {e}")))
}
pub(crate) fn parse(line: &[u8]) -> Result<Line, Error> {
let not_json = |e: serde_json::Error| {
Error::Protocol(format!(
"a line that is not JSON ({e}): {}",
String::from_utf8_lossy(&line[..line.len().min(200)])
))
};
let (value, too_deep) = match serde_json::from_slice::<Value>(line) {
Ok(value) => (value, false),
Err(e) if e.to_string().starts_with("recursion limit exceeded") => {
let WithoutInput(map) = serde_json::from_slice(line).map_err(not_json)?;
(Value::Object(map), true)
}
Err(e) => return Err(not_json(e)),
};
let Value::Object(map) = value else {
return Err(Error::Protocol("a line that is not a JSON object".into()));
};
let Some(ty) = map.get("type").and_then(Value::as_str).map(str::to_string) else {
return Err(Error::Protocol("a line with no string `type`".into()));
};
let v = Value::Object(map);
Ok(match ty.as_str() {
"init" => {
let version = v
.get("v")
.and_then(Value::as_u64)
.ok_or_else(|| Error::Protocol("an `init` line with no `v`".into()))?;
Line::Init(Box::new(field(&ty, v)?), version)
}
"user" => Line::Event(Box::new(Event::User(field(&ty, v)?))),
"reasoning" => Line::Event(Box::new(Event::Reasoning(field(&ty, v)?))),
"assistant" => Line::Event(Box::new(Event::Assistant(field(&ty, v)?))),
"tool_use" => Line::Event(Box::new(Event::ToolUse(field(&ty, v)?))),
"tool_result" => Line::Event(Box::new(Event::ToolResult(field(&ty, v)?))),
"approval" => Line::Event(Box::new(Event::Approval(field(&ty, v)?))),
"approval_request" => Line::Event(Box::new(Event::ApprovalRequest(field(&ty, v)?))),
"compacted" => Line::Event(Box::new(Event::Compacted(field(&ty, v)?))),
"result" => Line::Event(Box::new(Event::Result(field(&ty, v)?))),
"client_tool_call" => {
let mut call: ToolCall = field(&ty, v)?;
call.too_deep = too_deep;
Line::ClientToolCall(call)
}
"client_tool_cancel" => {
#[derive(Deserialize)]
struct Cancel {
id: String,
}
Line::ClientToolCancel(field::<Cancel>(&ty, v)?.id)
}
"response" => {
#[derive(Deserialize)]
struct ErrorBody {
code: String,
message: String,
}
#[derive(Deserialize)]
struct Raw {
#[serde(default)]
id: Value,
command: String,
ok: bool,
disposition: Option<String>,
data: Option<Value>,
error: Option<ErrorBody>,
}
let raw: Raw = field(&ty, v)?;
Line::Response(Response {
id: raw.id.as_str().map(str::to_string),
command: raw.command,
ok: raw.ok,
disposition: raw.disposition,
data: raw.data,
error: raw.error.map(|e| (e.code, e.message)),
})
}
_ => Line::Other,
})
}
pub(crate) fn encode(command: &Value) -> Result<String, Error> {
let mut line = serde_json::to_string(command).map_err(|e| Error::Protocol(e.to_string()))?;
if line.len() + 1 > LINE_LIMIT {
return Err(Error::TooLarge);
}
line.push('\n');
Ok(line)
}
pub(crate) fn cut(text: &str, limit: usize) -> &str {
if text.len() <= limit {
return text;
}
let mut end = limit;
while !text.is_char_boundary(end) {
end -= 1;
}
&text[..end]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn an_unknown_type_and_field_are_tolerated() {
let line = parse(br#"{"type":"assistant","text":"hi","new":1}"#).unwrap();
assert!(
matches!(line, Line::Event(e) if matches!(&*e, Event::Assistant(t) if t.text == "hi"))
);
let line = parse(br#"{"type":"brand_new","x":1}"#).unwrap();
assert!(matches!(line, Line::Other));
}
#[test]
fn a_missing_required_field_or_not_json_is_a_protocol_error() {
assert!(matches!(
parse(br#"{"type":"tool_use","id":"a","tool":"t"}"#),
Err(Error::Protocol(_))
));
assert!(matches!(parse(b"not json"), Err(Error::Protocol(_))));
assert!(matches!(parse(b"[1]"), Err(Error::Protocol(_))));
}
#[test]
fn a_line_at_the_limit_fits_and_one_byte_over_does_not() {
let fits = "a".repeat(LINE_LIMIT - 9);
assert!(encode(&json!({ "t": fits })).is_ok());
let over = "a".repeat(LINE_LIMIT - 8);
assert_eq!(encode(&json!({ "t": over })), Err(Error::TooLarge));
}
#[test]
fn the_client_tool_lines_parse_and_a_missing_field_is_a_protocol_error() {
let line = parse(
br#"{"type":"client_tool_call","id":"c","name":"pay","input":{"n":1},"turn_id":"t"}"#,
)
.unwrap();
assert!(
matches!(line, Line::ClientToolCall(c) if c.id == "c" && c.name == "pay" && c.input == json!({"n": 1}))
);
let line =
parse(br#"{"type":"client_tool_cancel","id":"c","reason":"timed_out"}"#).unwrap();
assert!(matches!(line, Line::ClientToolCancel(id) if id == "c"));
assert!(matches!(
parse(br#"{"type":"client_tool_call","id":"c","input":{}}"#),
Err(Error::Protocol(_))
));
}
#[test]
fn lines_with_input_nested_127_deep_are_read_without_it() {
let input = format!("{}1{}", "[".repeat(127), "]".repeat(127));
let call = format!(
r#"{{"type":"client_tool_call","id":"c","name":"pay","input":{input},"turn_id":"t"}}"#
);
match parse(call.as_bytes()).unwrap() {
Line::ClientToolCall(c) => {
assert!(c.too_deep && c.input.is_null() && c.id == "c" && c.name == "pay");
}
other => panic!("{other:?}"),
}
let tool_use = format!(r#"{{"type":"tool_use","id":"u","tool":"pay","input":{input}}}"#);
assert!(matches!(
parse(tool_use.as_bytes()).unwrap(),
Line::Event(e) if matches!(&*e, Event::ToolUse(u) if u.input.is_null() && u.id == "u")
));
let request = format!(
r#"{{"type":"approval_request","id":"r","tool_use_id":"u","tool":"pay","kind":"client_tool","summary":"pay","input":{input},"choices":["deny"],"turn_grant_skips_kind":false}}"#
);
assert!(matches!(
parse(request.as_bytes()).unwrap(),
Line::Event(e) if matches!(&*e, Event::ApprovalRequest(r) if r.input.is_null() && r.id == "r")
));
let shallow = format!(
r#"{{"type":"client_tool_call","id":"c","name":"pay","input":{}1{}}}"#,
"[".repeat(100),
"]".repeat(100)
);
assert!(
matches!(parse(shallow.as_bytes()).unwrap(), Line::ClientToolCall(c) if !c.too_deep)
);
}
#[test]
fn cut_keeps_whole_characters() {
assert_eq!(cut("aé", 2), "a");
assert_eq!(cut("abc", 5), "abc");
}
#[test]
fn get_state_parses() {
let s: State = serde_json::from_value(json!({"away":false,"context":{"auto_compact_at_percent":80,"last_prompt_tokens":null,"percent":null,"window":null},"mode":"manual","model":"m","open_requests":[],"queued":0,"session_id":"s","thinking":"medium","turn":{"id":null,"prompt_id":"p"}})).unwrap();
assert_eq!(
s.turn,
TurnState::Running {
id: None,
prompt_id: Some("p".into())
}
);
}
}
#[cfg(test)]
mod word_tests {
use super::*;
use serde_json::json;
#[test]
fn a_known_word_is_typed_and_an_unknown_one_kept() {
let r: ApprovalRequest = serde_json::from_value(json!({
"id": "r", "tool_use_id": "t", "tool": "pay", "kind": "client_tool",
"summary": "pay", "input": {}, "choices": ["allow_once", "brand_new"],
"turn_grant_skips_kind": false,
}))
.unwrap();
assert_eq!(r.kind, ApprovalKind::ClientTool);
assert_eq!(
r.choices,
vec![Choice::AllowOnce, Choice::Other("brand_new".into())]
);
assert_eq!(Choice::Other("x".into()).as_str(), "x");
assert_eq!(Outcome::from_wire("completed"), Outcome::Completed);
}
}
#[cfg(test)]
mod conformance_tests {
use super::*;
#[test]
fn every_outbound_transcript_line_parses_into_its_type() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../conformance");
let mut files = 0;
let mut types = std::collections::BTreeSet::new();
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "jsonl") {
continue;
}
files += 1;
let name = path.file_stem().unwrap().to_string_lossy().into_owned();
for line in std::fs::read_to_string(&path).unwrap().lines() {
let entry: Value = serde_json::from_str(line).unwrap();
if entry["dir"] != "out" {
continue;
}
assert!(entry.get("invalid").is_none(), "{name}: {entry}");
let ty = entry["line"]["type"].as_str().unwrap().to_string();
let text = entry["line"].to_string();
let got = parse(text.as_bytes()).unwrap_or_else(|e| panic!("{name}: {text}: {e}"));
let ok = match (&got, ty.as_str()) {
(Line::Init(..), "init")
| (Line::Response(_), "response")
| (Line::ClientToolCall(_), "client_tool_call")
| (Line::ClientToolCancel(_), "client_tool_cancel") => true,
(Line::Event(e), ty) => matches!(
(&**e, ty),
(Event::User(_), "user")
| (Event::Reasoning(_), "reasoning")
| (Event::Assistant(_), "assistant")
| (Event::ToolUse(_), "tool_use")
| (Event::ToolResult(_), "tool_result")
| (Event::Approval(_), "approval")
| (Event::ApprovalRequest(_), "approval_request")
| (Event::Compacted(_), "compacted")
| (Event::Result(_), "result")
),
_ => false,
};
assert!(ok, "{name}: `{ty}` was read as {got:?}");
types.insert(ty);
}
}
let scripts = std::fs::read_dir(dir.join("scripts")).unwrap().count();
assert_eq!(files, scripts, "a script without a transcript");
let seen: Vec<&str> = types.iter().map(String::as_str).collect();
assert_eq!(
seen,
[
"approval",
"approval_request",
"assistant",
"client_tool_call",
"client_tool_cancel",
"compacted",
"init",
"reasoning",
"response",
"result",
"tool_result",
"tool_use",
"user"
]
);
}
}