//! The conversation itself, normalised out of a harness's transcript.
//!
//! Everything else cctop reads a transcript for is a number: tokens, costs,
//! how full the window is, which tool failed how often. None of that keeps the
//! words, and the words are what someone away from their desk actually wants —
//! what they asked for, what the agent said back, what it edited on the way.
//! [`crate::serve::report`] answers "where did the afternoon go"; this answers
//! "what is it *doing*".
//!
//! # Why this is not the extraction path
//!
//! [`SessionData`](crate::session::SessionData) is built for the table: it is
//! cached, it is loaded for every row on the machine, and it deliberately drops
//! message text — keeping it would multiply the cache by the size of every
//! conversation on disk to serve a panel that shows one. So this is a separate
//! read, on one session, on request, on the route that asked for it, and it
//! keeps nothing.
//!
//! # What bounds it
//!
//! A transcript is unbounded and a browser is not, so every axis is capped:
//! [`MAX_TURNS`] from the end, [`MAX_TEXT_CHARS`] per message,
//! [`MAX_RESULT_CHARS`] per tool result, [`MAX_DIFF_LINES`] per patch. The tail
//! rather than the head, because a conversation is read from where it got to.
//! Older turns are counted and reported as a number rather than sent, which is
//! how the page can say "312 earlier turns" instead of implying the session
//! began where the scroll does.
//!
//! # Claude Code and Codex only
//!
//! Those two write JSONL that says, per entry, who spoke and what they said.
//! The rest do not, in different ways and to different degrees: Cursor's native
//! transcripts carry no roles cctop can trust, and OpenCode and Windsurf pack
//! whole workspaces into SQLite with schemas that move between releases. Rather
//! than half-read those into a view that looks authoritative and is not, a
//! session on one of them comes back [`unsupported`](Conversation::supported)
//! with the reason attached, and the page keeps showing the tool log and the
//! diffs, which every provider does have.
//!
//! ponytail: subagent sidechains are skipped rather than nested. Claude writes
//! them interleaved into the same file, and threading them into the transcript
//! they branch from is a display problem this does not solve; the report's
//! subagent section already names them and what they cost.
use crate::pricing::Provider;
use crate::session::{Delta, Session, devin, extract};
use crate::util;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::borrow::Cow;
use std::collections::{HashMap, VecDeque};
use std::path::Path;
/// How many of the newest turns are sent.
///
/// A long session runs to thousands, and a page that renders all of them is a
/// page that locks the tab it was opened in. This is several screens of scroll
/// past what anyone reads in one sitting.
const MAX_TURNS: usize = 200;
/// The most text one message contributes.
///
/// Generous, because a pasted stack trace or a plan is exactly the message
/// someone opens this to re-read, and a message cut off at a tweet's length is
/// worse than useless — it looks like the agent said only that.
const MAX_TEXT_CHARS: usize = 6000;
/// The most of one tool result that is kept.
///
/// Shorter than a message on purpose: a result is shown to confirm what came
/// back, not to be read in full. The whole of it is in the transcript, and the
/// report's call log is where the argument that produced it lives.
const MAX_RESULT_CHARS: usize = 800;
/// The most tool calls attributed to one turn.
///
/// A turn issuing more than this is a fan-out, and the tail of it says nothing
/// the first sixty-four did not.
const MAX_TOOLS_PER_TURN: usize = 64;
/// The most diff lines carried for one edit.
const MAX_DIFF_LINES: usize = 200;
/// One session's conversation, as much of it as is sent.
///
/// `Deserialize` because the same document is the wire format between two
/// cctops: a remote row's conversation is this, read off an ssh pipe rather
/// than off a transcript — see [`crate::fleet`].
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Conversation {
/// Whether this harness has a reader at all. False carries a `note` saying
/// why, and is not an error: the rest of the report is still true.
pub supported: bool,
/// Turns oldest-first, which is the order they are read in.
pub turns: Vec<Turn>,
/// Turns the transcript holds that came before the ones sent.
pub earlier: usize,
/// Why this is empty or short, when there is a reason worth saying.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub note: Option<String>,
}
/// One thing that was said, and what it caused.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Turn {
/// The turn's place in the whole transcript, oldest counting from zero.
///
/// Sent because the page needs a name for a turn that survives windowing:
/// `?before=` paging and `#chat/turn-N` links both speak in sequence
/// numbers, and a position in the returned window is neither — empty
/// turns are filtered out of `turns`, so position and sequence part ways.
pub seq: usize,
/// `user`, `assistant`, or `system` for the harness speaking for itself.
///
/// A `Cow` rather than `&'static str` because a turn read back over ssh is
/// owned; every writer below still passes a literal, so nothing here
/// allocates.
pub role: Cow<'static, str>,
/// `message` ordinarily; `reasoning` for a thinking summary, `compaction`
/// for the summary a harness writes when it reclaims the window. The page
/// styles them differently because they are read differently — a compaction
/// is a seam in the conversation, not a thing anybody said.
pub kind: Cow<'static, str>,
pub ts: String,
pub text: String,
/// Whether `text` was cut to [`MAX_TEXT_CHARS`].
#[serde(skip_serializing_if = "std::ops::Not::not", default)]
pub clipped: bool,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub tools: Vec<ToolUse>,
}
impl Turn {
fn new(role: &'static str, kind: &'static str, ts: &str) -> Turn {
Turn {
// Numbered by `push`, the only place that knows the count.
seq: 0,
role: Cow::Borrowed(role),
kind: Cow::Borrowed(kind),
ts: ts.to_string(),
text: String::new(),
clipped: false,
tools: Vec::new(),
}
}
fn set_text(&mut self, text: &str) {
let trimmed = text.trim();
self.clipped = trimmed.chars().count() > MAX_TEXT_CHARS;
self.text = match self.clipped {
true => trimmed.chars().take(MAX_TEXT_CHARS).collect(),
false => trimmed.to_string(),
};
}
/// Add more text to a turn that already has some.
///
/// A cap that was reached stays reached: a run of entries must not be able
/// to grow one turn past [`MAX_TEXT_CHARS`] a block at a time.
fn append_text(&mut self, text: &str) {
let trimmed = text.trim();
if trimmed.is_empty() || self.clipped {
return;
}
if self.text.is_empty() {
return self.set_text(trimmed);
}
let joined = format!("{}\n\n{trimmed}", self.text);
self.set_text(&joined);
}
fn is_empty(&self) -> bool {
self.text.is_empty() && self.tools.is_empty()
}
}
/// One tool call, with whatever came back from it.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct ToolUse {
/// The name as the transcript spelled it, with an MCP server's prefix made
/// readable — `mcp__linear__list_issues` is `linear: list issues` on screen
/// and nowhere else.
pub name: String,
/// The one-line form: the path, the command, the pattern.
pub detail: String,
/// The argument in full, when it differs from `detail`.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub full: Option<String>,
/// The head of what the tool returned, or `None` while it is still running —
/// which is what makes the last call of a live session visibly pending.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub result: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not", default)]
pub failed: bool,
#[serde(skip_serializing_if = "is_zero", default)]
pub added: u32,
#[serde(skip_serializing_if = "is_zero", default)]
pub removed: u32,
/// Unified-diff lines, when the harness recorded the patch it applied.
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub diff: Vec<String>,
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
/// Read `session`'s conversation, as far as its harness allows.
///
/// `before` pages backwards through the turns: when it is `Some(seq)`, the
/// conversation returned ends just before that turn's sequence number instead
/// of at the latest one — same [`MAX_TURNS`] window, reached from the other
/// side. The parse still reads the whole transcript either way, because a tool
/// result near the end of the file can belong to a call inside the window.
pub fn build(session: &Session, before: Option<usize>) -> Conversation {
let Some(path) = session.data_file.as_ref() else {
return unsupported("this session has no transcript file on this machine");
};
let mut sink = Sink {
before,
..Sink::default()
};
let read = match session.provider {
Provider::Claude => extract::for_each_jsonl(path, |item| sink.claude(item)),
Provider::Codex => extract::for_each_jsonl(path, |item| sink.codex(item)),
Provider::Devin => read_devin(path, &mut sink),
_ => {
return unsupported(&format!(
"cctop cannot read a {} conversation yet — the tool calls, \
diffs and costs below come from the same transcript and are complete",
session.surface.label(session.provider)
));
}
};
if let Err(e) = read {
return unsupported(&format!("could not read the transcript: {e}"));
}
sink.finish()
}
/// Devin's transcript is one ATIF document rather than a line stream, so it
/// cannot go through [`extract::for_each_jsonl`]: read it whole and feed each
/// step through the same sink the other readers use.
fn read_devin(path: &Path, sink: &mut Sink) -> std::io::Result<()> {
let content = std::fs::read_to_string(path)?;
let doc: Value = serde_json::from_str(&content)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
// A call's outcome is not in the transcript — the step records the call,
// the database records how it ended.
let statuses = devin::tool_statuses(doc.get("session_id").and_then(Value::as_str));
if let Some(steps) = doc.get("steps").and_then(Value::as_array) {
for step in steps {
sink.devin(step, &statuses);
}
}
Ok(())
}
fn unsupported(why: &str) -> Conversation {
Conversation {
supported: false,
note: Some(why.to_string()),
..Conversation::default()
}
}
/// Turns as they are read, with the oldest dropped once there are too many.
///
/// A tool result arrives in a later entry than the call it belongs to, so the
/// call has to stay reachable by id. Keeping the window as a deque and the index
/// in *sequence* numbers rather than positions is what makes that survive the
/// dropping: an id whose turn has already fallen off the front resolves to
/// nothing and its result is discarded, instead of landing on whichever turn
/// happens to sit at that position now.
#[derive(Default)]
struct Sink {
turns: VecDeque<Turn>,
/// Sequence number of the turn at the front of `turns`.
first: usize,
/// Sequence number the next turn will get.
next: usize,
/// When set, the window stops at this sequence number: turns at or past it
/// still count (`next` keeps moving, so a later window's `earlier` is
/// unchanged) but are not kept. Their records are still read — a tool
/// result can arrive entries after the boundary and still needs to land on
/// a call inside the window.
before: Option<usize>,
/// `tool_use` id -> (turn sequence, index within that turn's tools).
index: HashMap<String, (usize, usize)>,
/// Codex repeats an entry when a turn is retried; the second copy of a
/// `call_id` is the same call, not another one.
seen_calls: std::collections::HashSet<String>,
/// The assistant turn still being added to, if there is one.
///
/// Both harnesses write one reply as several records — the text, then each
/// call — so a turn per record makes one answer into four boxes, three of
/// them holding nothing but a tool name. Merging every consecutive record
/// instead collapses a whole session into two boxes with sixty calls each.
/// A tool result is the seam: it means the model has been asked again, and
/// what it says next is a new turn. This is the fallback rule, used where a
/// harness gives nothing better.
run: Option<usize>,
/// The API request the open turn belongs to, where the transcript says.
///
/// Claude stamps every record of one response with the same `requestId`,
/// which is the exact answer the rule above approximates: an entry carrying
/// a request id already seen is part of that reply, however many thinking
/// blocks and parallel tool calls it was written as.
run_request: Option<(String, usize)>,
}
impl Sink {
fn push(&mut self, turn: Turn) -> usize {
// Anything pushed directly ends the run: a user turn, a compaction, a
// block of reasoning. Only `open_assistant` reopens one.
self.run = None;
let seq = self.next;
self.next += 1;
// Past the `before` boundary the turn is numbered but not kept: its
// sequence has to exist so `first` still counts it, but the window a
// paged-back request is answering ends before it.
if self.before.is_none_or(|before| seq < before) {
let mut turn = turn;
turn.seq = seq;
self.turns.push_back(turn);
while self.turns.len() > MAX_TURNS {
self.turns.pop_front();
self.first += 1;
}
}
seq
}
/// The assistant turn more of one reply belongs to, opening a new one when
/// this record starts a different reply.
///
/// `request` is the harness's own name for the response this record came
/// from, where it has one. With it the grouping is exact; without it, the
/// run rule in [`Sink::run`] stands in.
fn open_assistant(&mut self, ts: &str, request: Option<&str>) -> usize {
let roomy = |sink: &mut Sink, seq: usize| {
sink.turn_mut(seq)
.is_some_and(|turn| turn.tools.len() < MAX_TOOLS_PER_TURN)
};
if let Some(id) = request {
if let Some((open, seq)) = self.run_request.clone()
&& open == id
&& roomy(self, seq)
{
self.run = Some(seq);
return seq;
}
} else if let Some(seq) = self.run
&& roomy(self, seq)
{
return seq;
}
let seq = self.push(Turn::new("assistant", "message", ts));
self.run = Some(seq);
if let Some(id) = request {
self.run_request = Some((id.to_string(), seq));
}
seq
}
fn turn_mut(&mut self, seq: usize) -> Option<&mut Turn> {
let position = seq.checked_sub(self.first)?;
self.turns.get_mut(position)
}
fn add_tool(&mut self, seq: usize, id: Option<&str>, tool: ToolUse) {
let Some(turn) = self.turn_mut(seq) else {
return;
};
if turn.tools.len() >= MAX_TOOLS_PER_TURN {
return;
}
let at = turn.tools.len();
turn.tools.push(tool);
if let Some(id) = id {
self.index.insert(id.to_string(), (seq, at));
}
}
/// Attach a result to the call it came back from, if that call is still in
/// the window.
fn resolve(&mut self, id: &str, result: Option<String>, failed: bool, delta: Option<Delta>) {
// Whatever the model says after this is a new reply, whether or not the
// call it answers is still in the window.
self.run = None;
let Some((seq, at)) = self.index.remove(id) else {
return;
};
let Some(tool) = self.turn_mut(seq).and_then(|t| t.tools.get_mut(at)) else {
return;
};
// A result is recorded even when it is empty, because the presence of
// one is what distinguishes a finished call from a running one.
tool.result = Some(result.unwrap_or_default());
tool.failed = failed;
if let Some(delta) = delta {
tool.added = delta.added;
tool.removed = delta.removed;
tool.diff = delta.hunks.into_iter().take(MAX_DIFF_LINES).collect();
}
}
fn finish(self) -> Conversation {
Conversation {
supported: true,
earlier: self.first,
turns: self
.turns
.into_iter()
.filter(|turn| !turn.is_empty())
.collect(),
note: None,
}
}
// --- Claude Code ---
fn claude(&mut self, item: &Value) {
// A subagent's turns are a different conversation that happens to share
// a file. See the module docs.
if item.get("isSidechain").and_then(Value::as_bool) == Some(true) {
return;
}
let ts = item.get("timestamp").and_then(Value::as_str).unwrap_or("");
match item.get("type").and_then(Value::as_str) {
Some("user") => self.claude_user(item, ts),
Some("assistant") => self.claude_assistant(item, ts),
_ => {}
}
}
fn claude_user(&mut self, item: &Value, ts: &str) {
let content = item.get("message").and_then(|m| m.get("content"));
// The patch an edit applied is recorded on the entry carrying its
// result, not on the call, so it is read once here and handed to
// whichever `tool_result` block claims it.
let mut delta = claude_delta(item);
let mut text = String::new();
let mut had_result = false;
match content {
Some(Value::String(s)) => text.push_str(s),
Some(Value::Array(blocks)) => {
for block in blocks {
if let Value::String(s) = block {
push_text(&mut text, s);
continue;
}
match block.get("type").and_then(Value::as_str) {
Some("tool_result") => {
had_result = true;
let Some(id) = block.get("tool_use_id").and_then(Value::as_str) else {
continue;
};
let failed =
block.get("is_error").and_then(Value::as_bool) == Some(true);
let body = flatten_content(block.get("content"));
self.resolve(id, Some(body), failed, delta.take());
}
_ => {
if let Some(t) = block.get("text").and_then(Value::as_str) {
push_text(&mut text, t);
}
}
}
}
}
_ => {}
}
if text.trim().is_empty() {
return;
}
// A tool result and a typed message can share one entry when someone
// types while a tool is running. The result has already been attached;
// what is left is a person talking.
let compaction = item.get("isCompactSummary").and_then(Value::as_bool) == Some(true);
let (role, kind) = match compaction {
true => ("system", "compaction"),
// A `<command-name>` block or a hook's output is the harness
// speaking through the user's turn, and reads wrong attributed to
// the person.
false if is_harness_text(&text) || (had_result && item_is_meta(item)) => {
("system", "message")
}
false => ("user", "message"),
};
// The harness writes for a parser, not for a reader. What it says is
// worth keeping; the tags around it are not, and a turn that is only
// tags says nothing at all.
let text = match role {
"system" if kind == "message" => tidy_harness_text(&text),
_ => text,
};
if text.trim().is_empty() {
return;
}
let mut turn = Turn::new(role, kind, ts);
turn.set_text(&text);
self.push(turn);
}
fn claude_assistant(&mut self, item: &Value, ts: &str) {
let request = item.get("requestId").and_then(Value::as_str);
let Some(blocks) = item
.get("message")
.and_then(|m| m.get("content"))
.and_then(Value::as_array)
else {
return;
};
let mut text = String::new();
let mut thinking = String::new();
let mut calls: Vec<(Option<String>, ToolUse)> = Vec::new();
for block in blocks {
match block.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(t) = block.get("text").and_then(Value::as_str) {
push_text(&mut text, t);
}
}
Some("thinking") => {
if let Some(t) = block.get("thinking").and_then(Value::as_str) {
push_text(&mut thinking, t);
}
}
Some("tool_use") => {
let name = block.get("name").and_then(Value::as_str).unwrap_or("tool");
let input = block.get("input").cloned().unwrap_or(Value::Null);
let (short, full) = extract::tool_detail(name, &input);
calls.push((
block.get("id").and_then(Value::as_str).map(str::to_string),
ToolUse {
name: util::pretty_mcp_name(name),
detail: short,
full,
..ToolUse::default()
},
));
}
_ => {}
}
}
// Thinking is its own turn rather than a prefix of the reply: it is
// shown differently, and folding it into the text would mean either
// hiding what the agent said or leading with all of its reasoning.
//
// Pushing it does not end the reply it belongs to — `run_request` is
// what reopens that — which matters because a harness with extended
// thinking writes a thinking block into most records, and closing the
// reply on each one puts every tool call in a box of its own.
if !thinking.trim().is_empty() {
let mut turn = Turn::new("assistant", "reasoning", ts);
turn.set_text(&thinking);
self.push(turn);
}
if text.trim().is_empty() && calls.is_empty() {
return;
}
// One reply, however many entries it took. Claude writes a turn's text
// and each of its tool calls as separate records, so a turn shown per
// record is one answer split across four boxes with three of them
// holding nothing but a tool name. Everything between two user turns is
// one thing the agent said, which is how its own interface reads it.
let seq = self.open_assistant(ts, request);
if let Some(turn) = self.turn_mut(seq) {
turn.append_text(&text);
}
for (id, tool) in calls {
self.add_tool(seq, id.as_deref(), tool);
}
}
// --- Codex ---
fn codex(&mut self, item: &Value) {
let ts = item.get("timestamp").and_then(Value::as_str).unwrap_or("");
let kind = item.get("type").and_then(Value::as_str).unwrap_or("");
let Some(payload) = item.get("payload") else {
return;
};
// A rollout writes the same shapes either at the top level or wrapped
// in a `response_item`, exactly as the extraction path finds them.
let effective = match kind {
"response_item" => payload
.get("type")
.and_then(Value::as_str)
.unwrap_or_default(),
other => other,
};
match effective {
"message" => self.codex_message(payload, ts),
"reasoning" => {
let text = codex_summary(payload);
if !text.trim().is_empty() {
let mut turn = Turn::new("assistant", "reasoning", ts);
turn.set_text(&text);
self.push(turn);
}
}
"function_call" | "custom_tool_call" => self.codex_call(payload, ts),
"function_call_output" | "custom_tool_call_output" => {
let Some(id) = payload.get("call_id").and_then(Value::as_str) else {
return;
};
let output = payload.get("output");
let failed = codex_output_failed(output);
self.resolve(id, Some(flatten_content(output)), failed, None);
}
_ => {}
}
}
fn codex_message(&mut self, payload: &Value, ts: &str) {
let role = match payload.get("role").and_then(Value::as_str) {
Some("user") => "user",
Some("assistant") => "assistant",
// `system` and `developer` are both the harness talking: the
// instructions, the environment block, the wrapper around a slash
// command.
_ => "system",
};
let text = codex_text(payload);
if text.trim().is_empty() {
return;
}
let mut turn = Turn::new(role, "message", ts);
turn.set_text(&text);
let seq = self.push(turn);
// The calls this reply makes are written as their own entries after it,
// so the reply stays open for them until a result comes back.
if role == "assistant" {
self.run = Some(seq);
}
}
fn codex_call(&mut self, payload: &Value, ts: &str) {
if let Some(id) = payload.get("call_id").and_then(Value::as_str)
&& !self.seen_calls.insert(id.to_string())
{
return;
}
let name = payload
.get("name")
.and_then(Value::as_str)
.unwrap_or("tool");
// `arguments` is a JSON-encoded string on a `function_call` and `input`
// on a `custom_tool_call`, and `apply_patch` sends a raw patch through
// either — so the argument is parsed if it parses and shown verbatim if
// it does not, which is what the extraction path does with the same
// entries.
let raw_field = payload.get("arguments").or_else(|| payload.get("input"));
let raw = raw_field.and_then(Value::as_str);
let args: Value = match raw_field {
Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(Value::Null),
Some(other) => other.clone(),
None => Value::Null,
};
let mut tool = ToolUse {
name: util::pretty_mcp_name(name),
..ToolUse::default()
};
if let Some(patch) = raw.filter(|_| name == "apply_patch" || args.is_null()) {
match name {
"apply_patch" => {
let (summary, delta) = extract::parse_apply_patch(patch);
tool.detail = summary;
tool.full = Some(patch.to_string());
tool.added = delta.added;
tool.removed = delta.removed;
tool.diff = delta.hunks.into_iter().take(MAX_DIFF_LINES).collect();
}
_ => {
tool.detail = extract::flatten_public(patch, 300);
tool.full = Some(patch.to_string());
}
}
} else {
let (short, full) = extract::tool_detail(name, &args);
tool.detail = short;
tool.full = full;
}
let seq = self.open_assistant(ts, None);
let id = payload.get("call_id").and_then(Value::as_str);
self.add_tool(seq, id, tool);
}
// --- Devin ---
/// One ATIF step: the source says who is speaking, and an agent step is a
/// whole model response — reasoning, reply text, tool calls, and the
/// results they produced, which Devin records on the same step's
/// `observation` rather than as the later entry Claude and Codex use.
fn devin(&mut self, step: &Value, statuses: &HashMap<String, String>) {
let ts = step.get("timestamp").and_then(Value::as_str).unwrap_or("");
match step.get("source").and_then(Value::as_str) {
Some("user") => {
let Some(text) = step.get("message").and_then(Value::as_str) else {
return;
};
if text.trim().is_empty() {
return;
}
let mut turn = Turn::new("user", "message", ts);
turn.set_text(text);
self.push(turn);
}
Some("agent") => self.devin_agent(step, ts, statuses),
Some("system") => {
// Most `system` steps are the prompt being assembled — the
// `sysprompt` and `rules` telemetry sources say so directly, and
// the rest are context blocks re-injected at each turn:
// `<available_skills>`, `<system_info>`, loose chunks of the
// prompt. What is worth a turn is an *event* — a tagged block
// reporting that something happened, like a backgrounded
// subagent finishing or the user editing a file mid-run.
match step
.pointer("/extra/telemetry/source")
.and_then(Value::as_str)
{
Some("sysprompt") | Some("rules") => return,
_ => {}
}
let Some(text) = step.get("message").and_then(Value::as_str) else {
return;
};
match text
.trim_start()
.strip_prefix('<')
.and_then(|r| r.split(['>', ' ', '\n', '\t', '/']).next())
{
Some(tag)
if !tag.is_empty()
&& tag
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
&& !matches!(tag, "available_skills" | "system_info") => {}
_ => return,
}
let mut turn = Turn::new("system", "message", ts);
turn.set_text(&tidy_devin_event(text));
self.push(turn);
}
_ => {}
}
}
fn devin_agent(&mut self, step: &Value, ts: &str, statuses: &HashMap<String, String>) {
if let Some(thinking) = step.get("reasoning_content").and_then(Value::as_str)
&& !thinking.trim().is_empty()
{
let mut turn = Turn::new("assistant", "reasoning", ts);
turn.set_text(thinking);
self.push(turn);
}
let text = step.get("message").and_then(Value::as_str).unwrap_or("");
let empty: Vec<Value> = Vec::new();
let calls = step
.get("tool_calls")
.and_then(Value::as_array)
.unwrap_or(&empty);
if text.trim().is_empty() && calls.is_empty() {
return;
}
// `step_id` names the response: every step gets a fresh id, so one
// step is one turn and nothing is ever folded into the reply before.
// It is a number in ATIF, not a string.
let request = step.get("step_id").map(|id| match id {
Value::String(s) => s.clone(),
other => other.to_string(),
});
let seq = self.open_assistant(ts, request.as_deref());
if let Some(turn) = self.turn_mut(seq) {
turn.append_text(text);
}
for call in calls {
let name = call
.get("function_name")
.and_then(Value::as_str)
.unwrap_or("tool");
let args = call.get("arguments").cloned().unwrap_or(Value::Null);
let (short, full) = extract::tool_detail(name, &args);
let mut tool = ToolUse {
name: util::pretty_mcp_name(name),
detail: short,
full,
..ToolUse::default()
};
if matches!(name, "edit" | "write")
&& let Some(delta) = extract::edit_delta(&args)
{
tool.added = delta.added;
tool.removed = delta.removed;
tool.diff = delta.hunks.into_iter().take(MAX_DIFF_LINES).collect();
}
let id = call.get("tool_call_id").and_then(Value::as_str);
self.add_tool(seq, id, tool);
}
for result in step
.get("observation")
.and_then(|o| o.get("results"))
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let Some(id) = result.get("source_call_id").and_then(Value::as_str) else {
continue;
};
let body = flatten_content(result.get("content"));
let failed = statuses.get(id).is_some_and(|s| s != "completed");
self.resolve(id, Some(body), failed, None);
}
}
}
/// The diff a Claude edit reported, from the entry carrying its result.
fn claude_delta(item: &Value) -> Option<Delta> {
let hunks = item
.get("toolUseResult")
.and_then(|r| r.get("structuredPatch"))
.and_then(Value::as_array)?;
let mut delta = Delta::default();
for hunk in hunks {
let Some(lines) = hunk.get("lines").and_then(Value::as_array) else {
continue;
};
for line in lines.iter().filter_map(Value::as_str) {
if line.starts_with('+') {
delta.added += 1;
} else if line.starts_with('-') {
delta.removed += 1;
}
if delta.hunks.len() < MAX_DIFF_LINES {
delta.hunks.push(line.to_string());
}
}
}
(delta.added > 0 || delta.removed > 0).then_some(delta)
}
/// Text out of a Codex message payload's content blocks.
fn codex_text(payload: &Value) -> String {
let mut out = String::new();
match payload.get("content") {
Some(Value::String(s)) => out.push_str(s),
Some(Value::Array(blocks)) => {
for block in blocks {
match block {
Value::String(s) => push_text(&mut out, s),
_ => {
if let Some(t) = block.get("text").and_then(Value::as_str) {
push_text(&mut out, t);
}
}
}
}
}
_ => {}
}
out
}
/// The reasoning summary Codex records, which is a list of its own blocks.
fn codex_summary(payload: &Value) -> String {
let mut out = String::new();
for block in payload
.get("summary")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(t) = block.get("text").and_then(Value::as_str) {
push_text(&mut out, t);
}
}
out
}
/// Whether a Codex tool output says the call failed.
///
/// The field is not always there and not always a bool: a shell call reports an
/// exit status inside its output text instead, so both are checked and neither
/// is required.
fn codex_output_failed(output: Option<&Value>) -> bool {
let Some(output) = output else {
return false;
};
if output.get("success").and_then(Value::as_bool) == Some(false) {
return true;
}
let text = match output {
Value::String(s) => s.clone(),
other => other
.get("content")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
};
let head: String = text.chars().take(400).collect();
head.contains("exit code 1")
|| head.contains("Error:")
|| head.contains("command not found")
|| head.contains("No such file or directory")
}
/// A tool result's content, whatever shape it arrived in, cut to size.
fn flatten_content(content: Option<&Value>) -> String {
let mut out = String::new();
match content {
Some(Value::String(s)) => out.push_str(s),
Some(Value::Array(blocks)) => {
for block in blocks {
match block {
Value::String(s) => push_text(&mut out, s),
_ => {
if let Some(t) = block.get("text").and_then(Value::as_str) {
push_text(&mut out, t);
} else if block.get("type").and_then(Value::as_str) == Some("image") {
// The bytes are megabytes of base64 and the page has
// nothing to do with them, but a result that was an
// image should not read as an empty one.
push_text(&mut out, "[image]");
}
}
}
}
}
Some(Value::Object(map)) => {
// Codex's `output` is an object with the text under one of a few
// keys depending on the tool.
for key in ["content", "output", "stdout", "text"] {
if let Some(t) = map.get(key).and_then(Value::as_str) {
push_text(&mut out, t);
}
}
if out.is_empty() {
out = content.map(|c| c.to_string()).unwrap_or_default();
}
}
Some(other) => out = other.to_string(),
None => {}
}
let trimmed = out.trim();
match trimmed.chars().count() > MAX_RESULT_CHARS {
true => trimmed.chars().take(MAX_RESULT_CHARS).collect::<String>() + "…",
false => trimmed.to_string(),
}
}
/// Append with a blank line between blocks, so two text blocks do not run into
/// one word.
fn push_text(out: &mut String, text: &str) {
if text.is_empty() {
return;
}
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(text);
}
/// Whether a user turn is really the harness talking.
///
/// Claude Code writes several of its own things into `user` entries — the
/// expansion of a slash command, a hook's output, the reminder blocks it
/// injects — and showing those as something a person typed is the difference
/// between a transcript someone recognises and one they do not.
///
/// The named prefixes stay because those blocks are also written on one line,
/// where the shape below cannot see them. The shape is what catches the rest:
/// the list of names was a list that had to be kept up to date and was not, and
/// `<task-notification>` reached a handoff brief quoted as the user's own words
/// because nothing had added it. A person opening a message with a bare tag and
/// nothing else on the line is the rarer mistake to make.
fn is_harness_text(text: &str) -> bool {
let head = text.trim_start();
head.starts_with("<command-name>")
|| head.starts_with("<local-command")
|| head.starts_with("<system-reminder>")
|| head.starts_with("<user-prompt-submit-hook>")
|| head.starts_with("Caveat:")
|| opens_with_bare_tag(head)
}
/// Whether the first line is `<name>` and nothing else — the shape every
/// injected block shares, and the shape prose does not.
fn opens_with_bare_tag(text: &str) -> bool {
let Some(line) = text.lines().next().map(str::trim) else {
return false;
};
let Some(name) = line.strip_prefix('<').and_then(|l| l.strip_suffix('>')) else {
return false;
};
// No attributes and no closing tag on the same line: `<p>hi</p>` is
// somebody pasting markup, and `<b>` alone on a line is not a sentence.
!name.is_empty()
&& !name.starts_with('/')
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
/// The text inside `<tag>…</tag>`, the first time it appears.
fn tagged<'a>(text: &'a str, tag: &str) -> Option<&'a str> {
let open = format!("<{tag}>");
let rest = &text[text.find(&open)? + open.len()..];
Some(&rest[..rest.find(&format!("</{tag}>"))?])
}
/// A harness turn as something to read.
///
/// Claude Code records a slash command as the three tags it parsed it into —
/// `<command-name>`, `<command-message>`, `<command-args>` — and the output as
/// a fourth. Shown raw, a `/clear` fills four lines with markup and buries the
/// one token that matters. So it comes back out as the command someone typed,
/// with whatever it printed beneath it.
///
/// Anything else the harness writes keeps its text and loses its wrapper: a
/// reminder still reads as a reminder without the tag announcing it as one.
fn tidy_harness_text(text: &str) -> String {
if text.trim_start().starts_with("<task-notification>") {
return tidy_task_notification(text);
}
let out = tagged(text, "local-command-stdout").unwrap_or("").trim();
if let Some(name) = tagged(text, "command-name") {
let args = tagged(text, "command-args").unwrap_or("").trim();
let said = format!("{} {args}", name.trim());
return match out.is_empty() {
true => said.trim().to_string(),
false => format!("{}\n\n{out}", said.trim()),
};
}
if text.trim_start().starts_with("<local-command") {
return out.to_string();
}
// Every other block is a wrapper around prose. Dropping the tag lines is
// enough — the text between them was written to be read.
let stripped: Vec<&str> = text
.lines()
.map(str::trim_end)
.filter(|line| !(line.trim_start().starts_with('<') && line.trim_end().ends_with('>')))
.collect();
let stripped = stripped.join("\n");
let stripped = stripped.trim();
match stripped.is_empty() {
// A block written entirely on one line has no line to keep, so the
// tags come off it directly rather than leaving the turn empty.
true => strip_outer_tags(text.trim()).trim().to_string(),
false => stripped.to_string(),
}
}
/// `<tag>body</tag>` on a single line, reduced to `body`.
fn strip_outer_tags(text: &str) -> &str {
let Some(open_end) = text.find('>') else {
return text;
};
if !text.starts_with('<') || !text.ends_with('>') {
return text;
}
let body = &text[open_end + 1..];
match body.rfind("</") {
Some(close) => &body[..close],
None => body,
}
}
/// A `<task-notification>` as news, not markup.
///
/// The notice that a background task or a monitor spoke arrives as a `user`
/// entry full of the fields a dispatcher needs — `task-id`, `tool-use-id`,
/// `output-file`, `usage`, `worktree` — around the ones a reader does:
/// `summary`, `event` when a monitor is reporting, and `result` when a
/// finished agent left a note. Every field sits on its own `<field>value
/// </field>` line, so the generic tidy strips them all, finds the body
/// empty, and strips only the outer tag — which is how the whole field list
/// reached the transcript as the turn's text. Read the fields that carry
/// the news; a notification holding none of them says nothing worth a turn.
fn tidy_task_notification(text: &str) -> String {
let mut lines: Vec<String> = Vec::new();
for field in ["summary", "event", "result"] {
let Some(body) = tagged(text, field).map(str::trim) else {
continue;
};
if body.is_empty() || lines.iter().any(|l| l == body) {
continue;
}
lines.push(unescape_entities(body));
}
// Anything written after the closing tag is the entry's real text — the
// notice is only the part inside it.
if let Some(end) = text.find("</task-notification>") {
let tail = text[end + "</task-notification>".len()..].trim();
if !tail.is_empty() {
lines.push(tail.to_string());
}
}
lines.join("\n")
}
/// The entities the transcript writer escapes inside these fields. `&`
/// goes last, or `&gt;` decodes twice.
fn unescape_entities(text: &str) -> String {
text.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace("&", "&")
}
/// A Devin system event, readable.
///
/// The block arrives dressed for the model, not for a reader: an
/// `additional_metadata` opens with instructions about when to mention it,
/// then a `user_actions` carrying one `[diff_block]` per file the user
/// touched — dozens of lines of pseudo-diff where the event was "you edited
/// these files". The block and its footnote go; the file names and any other
/// action prose stay. Anything else — a subagent's completion report — loses
/// only its envelope, same as every other harness block.
fn tidy_devin_event(text: &str) -> String {
let Some(actions) = tagged(text, "user_actions") else {
return tidy_harness_text(text);
};
let mut files = Vec::new();
let mut prose = Vec::new();
let mut in_diff = false;
for line in actions.lines().map(str::trim) {
match line {
"[diff_block_start]" => in_diff = true,
"[diff_block_end]" => in_diff = false,
_ if in_diff || line.is_empty() => {}
l if l.starts_with("Please note that") => {}
l => match l.strip_prefix("The following changes were made by the USER to: ") {
Some(file) => files.push(file.trim_end_matches('.').to_string()),
None => prose.push(l.to_string()),
},
}
}
if !files.is_empty() {
prose.insert(0, format!("the user edited {}", files.join(", ")));
}
match prose.is_empty() {
true => tidy_harness_text(text),
false => prose.join("\n"),
}
}
fn item_is_meta(item: &Value) -> bool {
item.get("isMeta").and_then(Value::as_bool) == Some(true)
}
#[cfg(test)]
mod tests {
use super::*;
/// The named prefixes were a list that had to be kept up to date, and was
/// not: a `<task-notification>` reached a handoff brief quoted as the
/// user's own words. Every injected block shares a shape.
#[test]
fn a_block_the_harness_injected_is_not_something_a_person_said() {
assert!(is_harness_text(
"<system-reminder>\nbe careful\n</system-reminder>"
));
assert!(is_harness_text(
"<task-notification>\n<task-id>abc</task-id>"
));
assert!(is_harness_text("<user-prompt-submit-hook>\nhook said this"));
assert!(is_harness_text("<command-name>/clear</command-name>"));
assert!(is_harness_text("Caveat: the messages below were generated"));
}
/// Matching on shape must not swallow a person who happens to write markup.
#[test]
fn a_person_who_pastes_markup_is_still_a_person() {
assert!(!is_harness_text("<p>hello</p>"));
assert!(!is_harness_text("why does <div> break here?"));
assert!(!is_harness_text("<img src=\"x\">"));
assert!(!is_harness_text("</closing>"));
assert!(!is_harness_text("fix the parser"));
}
fn sink_claude(lines: &[&str]) -> Conversation {
let mut sink = Sink::default();
for line in lines {
sink.claude(&serde_json::from_str(line).unwrap());
}
sink.finish()
}
fn sink_codex(lines: &[&str]) -> Conversation {
let mut sink = Sink::default();
for line in lines {
sink.codex(&serde_json::from_str(line).unwrap());
}
sink.finish()
}
#[test]
fn a_claude_exchange_becomes_a_user_turn_and_an_assistant_turn() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t1","message":{"role":"user","content":"fix the parser"}}"#,
r#"{"type":"assistant","timestamp":"t2","message":{"role":"assistant","content":[{"type":"text","text":"on it"}]}}"#,
]);
assert!(chat.supported);
assert_eq!(chat.turns.len(), 2);
assert_eq!(chat.turns[0].role, "user");
assert_eq!(chat.turns[0].text, "fix the parser");
assert_eq!(chat.turns[1].role, "assistant");
assert_eq!(chat.turns[1].text, "on it");
}
/// The call and its result are two entries a long way apart in the file, and
/// the whole point of the id index is that they come back as one thing.
#[test]
fn a_tool_result_lands_on_the_call_it_answers() {
let chat = sink_claude(&[
r#"{"type":"assistant","timestamp":"t1","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{"file_path":"/a/b.rs"}}]}}"#,
r#"{"type":"assistant","timestamp":"t2","message":{"content":[{"type":"text","text":"meanwhile"}]}}"#,
r#"{"type":"user","timestamp":"t3","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"fn main() {}"}]}}"#,
]);
let tool = &chat.turns[0].tools[0];
assert_eq!(tool.name, "Read");
assert_eq!(tool.detail, "/a/b.rs");
assert_eq!(tool.result.as_deref(), Some("fn main() {}"));
assert!(!tool.failed);
// The result entry carried no text of its own, so it is not a turn —
// and the two assistant entries are one reply, since nothing came back
// in between.
assert_eq!(chat.turns.len(), 1);
assert_eq!(chat.turns[0].text, "meanwhile");
}
/// Claude writes the text of a reply and each of its tool calls as separate
/// records. Rendered one box per record, a single answer becomes four, three
/// of them empty but for a tool name.
#[test]
fn a_run_of_assistant_entries_is_one_reply() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t0","message":{"content":"go"}}"#,
r#"{"type":"assistant","timestamp":"t1","message":{"content":[{"type":"text","text":"first"}]}}"#,
r#"{"type":"assistant","timestamp":"t2","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{"file_path":"/a"}}]}}"#,
r#"{"type":"assistant","timestamp":"t3","message":{"content":[{"type":"text","text":"second"}]}}"#,
r#"{"type":"user","timestamp":"t4","message":{"content":"again"}}"#,
r#"{"type":"assistant","timestamp":"t5","message":{"content":[{"type":"text","text":"a new reply"}]}}"#,
]);
let roles: Vec<&str> = chat.turns.iter().map(|t| t.role.as_ref()).collect();
assert_eq!(roles, vec!["user", "assistant", "user", "assistant"]);
assert_eq!(chat.turns[1].text, "first\n\nsecond");
assert_eq!(chat.turns[1].tools.len(), 1);
// A user turn between them ends the run, so the next reply is its own.
assert_eq!(chat.turns[3].text, "a new reply");
}
#[test]
fn a_failed_result_is_marked_as_one() {
let chat = sink_claude(&[
r#"{"type":"assistant","timestamp":"t1","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"cargo test"}}]}}"#,
r#"{"type":"user","timestamp":"t2","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","is_error":true,"content":"no such command"}]}}"#,
]);
assert!(chat.turns[0].tools[0].failed);
}
/// A call with no result yet is what a live session looks like, and the page
/// shows it as running — so the absence has to survive to the JSON.
#[test]
fn a_call_still_running_has_no_result() {
let chat = sink_claude(&[
r#"{"type":"assistant","timestamp":"t1","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"sleep 60"}}]}}"#,
]);
assert!(chat.turns[0].tools[0].result.is_none());
}
#[test]
fn an_edits_patch_is_carried_with_its_tool_call() {
let chat = sink_claude(&[
r#"{"type":"assistant","timestamp":"t1","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Edit","input":{"file_path":"/a/b.rs"}}]}}"#,
r#"{"type":"user","timestamp":"t2","toolUseResult":{"structuredPatch":[{"lines":["-old","+new","+also"]}]},"message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}}"#,
]);
let tool = &chat.turns[0].tools[0];
assert_eq!((tool.added, tool.removed), (2, 1));
assert_eq!(tool.diff, vec!["-old", "+new", "+also"]);
}
/// A subagent writes into the same file, and its turns are another
/// conversation. Including them interleaves two agents into one thread.
#[test]
fn sidechain_turns_are_left_out() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t1","message":{"content":"the real ask"}}"#,
r#"{"type":"user","timestamp":"t2","isSidechain":true,"message":{"content":"a subagent's brief"}}"#,
]);
assert_eq!(chat.turns.len(), 1);
assert_eq!(chat.turns[0].text, "the real ask");
}
#[test]
fn a_compaction_summary_is_a_seam_not_a_message() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t1","isCompactSummary":true,"message":{"content":"everything so far"}}"#,
]);
assert_eq!(chat.turns[0].kind, "compaction");
assert_eq!(chat.turns[0].role, "system");
}
#[test]
fn a_slash_command_expansion_is_attributed_to_the_harness() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t1","message":{"content":"<command-name>/clear</command-name>"}}"#,
]);
assert_eq!(chat.turns[0].role, "system");
}
/// What the harness records for one `/loop 5m` is four tags. What it did
/// is one line, and that is what the page has room for.
#[test]
fn a_slash_command_reads_as_the_command_that_was_typed() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t1","message":{"content":"<command-name>/loop</command-name>\n<command-message>loop</command-message>\n<command-args>5m</command-args>\n<local-command-stdout>started</local-command-stdout>"}}"#,
]);
assert_eq!(chat.turns[0].text, "/loop 5m\n\nstarted");
}
/// `/clear` prints nothing, so its turn is the command alone rather than
/// the command and an empty line where the output would have been.
#[test]
fn a_command_that_printed_nothing_is_just_the_command() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t1","message":{"content":"<command-name>/clear</command-name>\n<local-command-stdout></local-command-stdout>"}}"#,
]);
assert_eq!(chat.turns[0].text, "/clear");
}
/// A reminder is prose in a wrapper. The prose survives; the wrapper does
/// not, and neither does a turn that was nothing but wrapper.
#[test]
fn a_reminder_keeps_its_words_and_loses_its_tags() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t1","message":{"content":"<system-reminder>\nthe file changed on disk\n</system-reminder>"}}"#,
r#"{"type":"user","timestamp":"t2","message":{"content":"<local-command-stdout></local-command-stdout>"}}"#,
]);
assert_eq!(chat.turns.len(), 1);
assert_eq!(chat.turns[0].text, "the file changed on disk");
}
/// The same block written on one line has no line the filter can keep, and
/// dropping it would lose the only thing it said.
#[test]
fn a_one_line_reminder_survives_the_same_way() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t1","message":{"content":"<system-reminder>read the file first</system-reminder>"}}"#,
]);
assert_eq!(chat.turns[0].text, "read the file first");
}
/// A task notification is fields for a dispatcher around one line of
/// news. The generic tidy used to strip every field line, find nothing
/// left, and keep the whole list as the turn's text.
#[test]
fn a_task_notification_reads_as_its_summary() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t1","message":{"content":"<task-notification>\n<task-id>bgbdzpbxb</task-id>\n<tool-use-id>toolu_01EK11bfRvKo9QaZMQWsMBGf</tool-use-id>\n<output-file>/tmp/tasks/bgbdzpbxb.output</output-file>\n<status>completed</status>\n<summary>Background command "cargo build 2>&1 | tail -3" completed (exit code 0)</summary>\n</task-notification>"}}"#,
]);
assert_eq!(chat.turns.len(), 1);
assert_eq!(chat.turns[0].role, "system");
assert_eq!(
chat.turns[0].text,
"Background command \"cargo build 2>&1 | tail -3\" completed (exit code 0)"
);
}
/// A monitor's report names its watch in `summary` and carries the news
/// in `event`; both belong on the turn.
#[test]
fn a_monitor_event_keeps_the_event_it_reports() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t1","message":{"content":"<task-notification>\n<task-id>br7crykx8</task-id>\n<summary>Monitor event: "rebuild chain"</summary>\n<event>[Monitor expired after 30m with 11 events delivered.]</event>\n</task-notification>"}}"#,
]);
assert_eq!(
chat.turns[0].text,
"Monitor event: \"rebuild chain\"\n[Monitor expired after 30m with 11 events delivered.]"
);
}
/// A finished agent can leave a `result` note under its summary.
#[test]
fn a_task_notification_keeps_an_agents_parting_note() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t1","message":{"content":"<task-notification>\n<task-id>ad13e524</task-id>\n<status>completed</status>\n<summary>Agent "bench linkage" finished</summary>\n<result>the build query is running in the background</result>\n<usage><subagent_tokens>83901</subagent_tokens></usage>\n</task-notification>"}}"#,
]);
assert_eq!(
chat.turns[0].text,
"Agent \"bench linkage\" finished\nthe build query is running in the background"
);
}
/// A notification carrying none of the fields a reader needs is not a
/// turn at all — the field list itself was the bug, not the content.
#[test]
fn a_notification_with_no_news_is_no_turn() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t1","message":{"content":"<task-notification>\n<task-id>x</task-id>\n<output-file>/tmp/x.output</output-file>\n<status>completed</status>\n</task-notification>"}}"#,
r#"{"type":"user","timestamp":"t2","message":{"content":"a real message"}}"#,
]);
assert_eq!(chat.turns.len(), 1);
assert_eq!(chat.turns[0].text, "a real message");
}
/// Everything after the cap is dropped from the *front*, because a
/// conversation is read from where it got to — and the count of what was
/// dropped is what stops the page implying the session started there.
#[test]
fn only_the_newest_turns_survive_and_the_rest_are_counted() {
let lines: Vec<String> = (0..MAX_TURNS + 10)
.map(|i| {
format!(
r#"{{"type":"user","timestamp":"t","message":{{"content":"message {i}"}}}}"#
)
})
.collect();
let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
let chat = sink_claude(&refs);
assert_eq!(chat.turns.len(), MAX_TURNS);
assert_eq!(chat.earlier, 10);
assert_eq!(chat.turns[0].text, "message 10");
}
/// A result whose call has already fallen off the front must not land on
/// whichever turn now occupies that slot. This is the bug the sequence
/// numbering exists to prevent.
#[test]
fn a_result_for_a_dropped_call_is_discarded() {
let mut lines = vec![
r#"{"type":"assistant","timestamp":"t0","message":{"content":[{"type":"tool_use","id":"toolu_old","name":"Read","input":{"file_path":"/gone.rs"}}]}}"#.to_string(),
];
for i in 0..MAX_TURNS + 5 {
lines.push(format!(
r#"{{"type":"user","timestamp":"t","message":{{"content":"filler {i}"}}}}"#
));
}
lines.push(
r#"{"type":"user","timestamp":"tz","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_old","content":"late"}]}}"#
.to_string(),
);
let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
let chat = sink_claude(&refs);
assert!(
chat.turns
.iter()
.all(|t| t.tools.iter().all(|tool| tool.result.is_none())),
"a result was attached to a turn that did not make the call"
);
}
#[test]
fn text_past_the_cap_is_cut_and_says_so() {
let long = "x".repeat(MAX_TEXT_CHARS + 100);
let chat = sink_claude(&[&format!(
r#"{{"type":"user","timestamp":"t","message":{{"content":"{long}"}}}}"#
)]);
assert!(chat.turns[0].clipped);
assert_eq!(chat.turns[0].text.chars().count(), MAX_TEXT_CHARS);
}
#[test]
fn thinking_is_its_own_turn_ahead_of_the_reply() {
let chat = sink_claude(&[
r#"{"type":"assistant","timestamp":"t1","message":{"content":[{"type":"thinking","thinking":"weighing it up"},{"type":"text","text":"here goes"}]}}"#,
]);
assert_eq!(chat.turns.len(), 2);
assert_eq!(chat.turns[0].kind, "reasoning");
assert_eq!(chat.turns[1].text, "here goes");
}
#[test]
fn a_codex_exchange_reads_the_same_way() {
let chat = sink_codex(&[
r#"{"type":"response_item","timestamp":"t1","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"ship it"}]}}"#,
r#"{"type":"response_item","timestamp":"t2","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"shipping"}]}}"#,
]);
assert_eq!(chat.turns.len(), 2);
assert_eq!(chat.turns[0].role, "user");
assert_eq!(chat.turns[1].text, "shipping");
}
/// Codex writes a call as its own entry with nothing linking it to the
/// message that issued it, so it has to attach to the turn in progress.
#[test]
fn a_codex_call_attaches_to_the_assistant_turn_in_progress() {
let chat = sink_codex(&[
r#"{"type":"response_item","timestamp":"t1","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"looking"}]}}"#,
r#"{"type":"response_item","timestamp":"t2","payload":{"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":\"ls\"}"}}"#,
r#"{"type":"response_item","timestamp":"t3","payload":{"type":"function_call_output","call_id":"c1","output":"a.rs\nb.rs"}}"#,
]);
assert_eq!(chat.turns.len(), 1);
assert_eq!(chat.turns[0].tools.len(), 1);
assert_eq!(chat.turns[0].tools[0].result.as_deref(), Some("a.rs\nb.rs"));
}
#[test]
fn a_repeated_codex_call_id_is_one_call() {
let entry = r#"{"type":"response_item","timestamp":"t","payload":{"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":\"ls\"}"}}"#;
let chat = sink_codex(&[entry, entry]);
assert_eq!(chat.turns[0].tools.len(), 1);
}
#[test]
fn a_codex_apply_patch_carries_its_diff() {
let chat = sink_codex(&[
r#"{"type":"response_item","timestamp":"t","payload":{"type":"custom_tool_call","call_id":"c1","name":"apply_patch","input":"*** Begin Patch\n*** Update File: src/a.rs\n-old\n+new\n*** End Patch"}}"#,
]);
let tool = &chat.turns[0].tools[0];
assert!(tool.added >= 1 && tool.removed >= 1, "{tool:?}");
assert!(!tool.diff.is_empty());
}
/// The other half of the merge: everything between two user turns is *not*
/// one reply, because a tool result means the model was asked again. Without
/// this bound an afternoon's session comes back as two boxes holding sixty
/// tool calls each — which is what the first cut of this did.
/// A reply with extended thinking writes a thinking block into every record
/// of itself, and its parallel tool calls arrive as separate records too.
/// The request id is what says they are all one answer.
#[test]
fn records_sharing_a_request_id_are_one_reply() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t0","message":{"content":"go"}}"#,
r#"{"type":"assistant","timestamp":"t1","requestId":"req_1","message":{"content":[{"type":"thinking","thinking":"weighing"},{"type":"text","text":"two at once"}]}}"#,
r#"{"type":"assistant","timestamp":"t2","requestId":"req_1","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{"file_path":"/a"}}]}}"#,
r#"{"type":"assistant","timestamp":"t3","requestId":"req_1","message":{"content":[{"type":"tool_use","id":"toolu_2","name":"Read","input":{"file_path":"/b"}}]}}"#,
]);
let kinds: Vec<&str> = chat.turns.iter().map(|t| t.kind.as_ref()).collect();
assert_eq!(kinds, vec!["message", "reasoning", "message"]);
assert_eq!(chat.turns[2].text, "two at once");
assert_eq!(chat.turns[2].tools.len(), 2, "{:?}", chat.turns[2]);
}
#[test]
fn a_tool_result_ends_the_reply_it_came_back_to() {
let chat = sink_claude(&[
r#"{"type":"user","timestamp":"t0","message":{"content":"go"}}"#,
r#"{"type":"assistant","timestamp":"t1","message":{"content":[{"type":"text","text":"looking"},{"type":"tool_use","id":"toolu_1","name":"Read","input":{"file_path":"/a"}}]}}"#,
r#"{"type":"user","timestamp":"t2","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"contents"}]}}"#,
r#"{"type":"assistant","timestamp":"t3","message":{"content":[{"type":"text","text":"now I know"}]}}"#,
]);
let roles: Vec<&str> = chat.turns.iter().map(|t| t.role.as_ref()).collect();
assert_eq!(roles, vec!["user", "assistant", "assistant"]);
assert_eq!(chat.turns[1].text, "looking");
assert_eq!(chat.turns[1].tools.len(), 1);
assert_eq!(chat.turns[2].text, "now I know");
assert!(chat.turns[2].tools.is_empty());
}
#[test]
fn a_provider_with_no_reader_says_so_instead_of_looking_empty() {
let mut session = Session::new(Provider::Windsurf, "s1".into());
session.data_file = Some(std::path::PathBuf::from("/nonexistent"));
let chat = build(&session, None);
assert!(!chat.supported);
assert!(chat.note.is_some_and(|n| n.contains("Windsurf")));
}
fn sink_devin(steps: &[&str], statuses: &HashMap<String, String>) -> Conversation {
let mut sink = Sink::default();
for step in steps {
sink.devin(&serde_json::from_str(step).unwrap(), statuses);
}
sink.finish()
}
/// One ATIF step is one model response: the message is the reply text,
/// `reasoning_content` is the thinking that preceded it.
#[test]
fn a_devin_exchange_reads_the_same_way() {
let chat = sink_devin(
&[
r#"{"step_id":1,"source":"user","timestamp":"t1","message":"fix the parser"}"#,
r#"{"step_id":2,"source":"agent","timestamp":"t2","reasoning_content":"weighing it up","message":"on it"}"#,
],
&HashMap::new(),
);
assert!(chat.supported);
let roles: Vec<(&str, &str)> = chat
.turns
.iter()
.map(|t| (t.role.as_ref(), t.kind.as_ref()))
.collect();
assert_eq!(
roles,
vec![
("user", "message"),
("assistant", "reasoning"),
("assistant", "message")
]
);
assert_eq!(chat.turns[0].text, "fix the parser");
assert_eq!(chat.turns[1].text, "weighing it up");
assert_eq!(chat.turns[2].text, "on it");
}
/// Devin records a call's result on the same step that made it, under
/// `observation.results` keyed by `source_call_id` — the call and its
/// outcome still come back as one thing.
#[test]
fn a_devin_result_lands_on_the_call_it_made() {
let chat = sink_devin(
&[
r#"{"step_id":3,"source":"agent","timestamp":"t","message":"reading","tool_calls":[{"tool_call_id":"read_1#abc","function_name":"read","arguments":{"file_path":"/a/b.rs"}}],"observation":{"results":[{"source_call_id":"read_1#abc","content":"fn main() {}"}]}}"#,
],
&HashMap::new(),
);
assert_eq!(chat.turns.len(), 1);
let tool = &chat.turns[0].tools[0];
assert_eq!(tool.name, "read");
assert_eq!(tool.detail, "/a/b.rs");
assert_eq!(tool.result.as_deref(), Some("fn main() {}"));
assert!(!tool.failed);
}
/// Two steps are two replies even when neither made a call — the step id
/// is what stops them being folded into one.
#[test]
fn consecutive_devin_steps_stay_separate_replies() {
let chat = sink_devin(
&[
r#"{"step_id":1,"source":"agent","timestamp":"t1","message":"first"}"#,
r#"{"step_id":2,"source":"agent","timestamp":"t2","message":"second"}"#,
],
&HashMap::new(),
);
assert_eq!(chat.turns.len(), 2);
assert_eq!(chat.turns[0].text, "first");
assert_eq!(chat.turns[1].text, "second");
}
/// The transcript does not say how a call ended; the database's status
/// map does. A call whose last reported status is not `completed` failed.
#[test]
fn a_devin_call_the_database_marked_failed_is_marked() {
let statuses = HashMap::from([("exec_1#abc".to_string(), "failed".to_string())]);
let chat = sink_devin(
&[
r#"{"step_id":1,"source":"agent","timestamp":"t","tool_calls":[{"tool_call_id":"exec_1#abc","function_name":"exec","arguments":{"command":"cargo test"}}],"observation":{"results":[{"source_call_id":"exec_1#abc","content":"error: no such target"}]}}"#,
],
&statuses,
);
let tool = &chat.turns[0].tools[0];
assert!(tool.failed);
assert_eq!(tool.result.as_deref(), Some("error: no such target"));
}
/// A call still running has an observation pending, so no result at all —
/// which is what the page shows as in-flight.
#[test]
fn a_devin_call_with_no_observation_is_still_running() {
let chat = sink_devin(
&[
r#"{"step_id":1,"source":"agent","timestamp":"t","tool_calls":[{"tool_call_id":"exec_1#abc","function_name":"exec","arguments":{"command":"sleep 60"}}]}"#,
],
&HashMap::new(),
);
assert!(chat.turns[0].tools[0].result.is_none());
}
/// ATIF has no `structuredPatch`; the patch an `edit` applied is its own
/// `old_string`/`new_string` arguments.
#[test]
fn a_devin_edit_carries_its_diff() {
let chat = sink_devin(
&[
r#"{"step_id":1,"source":"agent","timestamp":"t","tool_calls":[{"tool_call_id":"edit_1#abc","function_name":"edit","arguments":{"file_path":"/a/b.rs","old_string":"old\nlines","new_string":"new\nalso"}}],"observation":{"results":[{"source_call_id":"edit_1#abc","content":"updated"}]}}"#,
],
&HashMap::new(),
);
let tool = &chat.turns[0].tools[0];
assert_eq!((tool.added, tool.removed), (2, 2));
assert_eq!(tool.diff, vec!["-old", "-lines", "+new", "+also"]);
}
/// The system prompt, the rule files and the context blocks re-injected at
/// every turn are all steps too, but they are the prompt being assembled,
/// not the conversation. A step the harness injected mid-run — a
/// backgrounded subagent finishing, the user editing a file — is one.
#[test]
fn devin_prompt_assembly_is_not_a_turn() {
let chat = sink_devin(
&[
r#"{"step_id":1,"source":"system","timestamp":"t1","message":"You are Devin…","extra":{"telemetry":{"source":"sysprompt"}}}"#,
r#"{"step_id":2,"source":"system","timestamp":"t2","message":"<rules>…</rules>","extra":{"telemetry":{"source":"rules"}}}"#,
r#"{"step_id":3,"source":"system","timestamp":"t3","message":"<available_skills>…</available_skills>","extra":{"telemetry":{"source":"system"}}}"#,
r#"{"step_id":4,"source":"system","timestamp":"t4","message":"You are powered by SWE-2 High.","extra":{"telemetry":{"source":"system"}}}"#,
r#"{"step_id":5,"source":"system","timestamp":"t5","message":"<subagent_completion_notification>done</subagent_completion_notification>","extra":{"telemetry":{"source":"system"}}}"#,
],
&HashMap::new(),
);
assert_eq!(chat.turns.len(), 1);
assert_eq!(chat.turns[0].role, "system");
// The envelope comes off an event the same as any other harness block.
assert_eq!(chat.turns[0].text, "done");
}
/// The user-edited-a-file block is aimed at the model: a preamble about
/// when to mention it and a pseudo-diff per file. The turn is the event.
#[test]
fn a_devin_user_action_reads_as_the_files_touched() {
let chat = sink_devin(
&[
r#"{"step_id":1,"source":"system","timestamp":"t1","message":"<additional_metadata>\nThe user took the following actions after the last message. ONLY talk about this if it is directly relevant to the user's next request.\n\n<user_actions>\nThe following changes were made by the USER to: /home/flo/src/a.rs.\n[diff_block_start]\n@@ -1 +1 @@\n-old\n+new\n[diff_block_end]\nPlease note that the above snippet only shows the MODIFIED lines.\nThe following changes were made by the USER to: /home/flo/src/b.rs.\n[diff_block_start]\n@@ -2 +2 @@\n-x\n+y\n[diff_block_end]\n</user_actions>\n</additional_metadata>","extra":{"telemetry":{"source":"system"}}}"#,
],
&HashMap::new(),
);
assert_eq!(chat.turns.len(), 1);
assert_eq!(
chat.turns[0].text,
"the user edited /home/flo/src/a.rs, /home/flo/src/b.rs"
);
}
/// A transcript that is not the document ATIF says it is reports as
/// unreadable rather than panicking or looking empty.
#[test]
fn a_devin_transcript_that_is_not_json_is_unsupported() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s1.json");
std::fs::write(&path, b"not json").unwrap();
let mut session = Session::new(Provider::Devin, "s1".into());
session.data_file = Some(path);
let chat = build(&session, None);
assert!(!chat.supported);
assert!(chat.note.is_some_and(|n| n.contains("could not read")));
}
/// A turn's `seq` is its place in the whole transcript, not its position
/// in the window — what `?before=` and the page's turn links count in.
#[test]
fn turns_kept_off_the_front_keep_their_sequence_numbers() {
let mut sink = Sink::default();
for i in 0..MAX_TURNS + 3 {
let mut turn = Turn::new("user", "message", "t");
turn.text = format!("turn {i}");
sink.push(turn);
}
let chat = sink.finish();
assert_eq!(chat.turns.len(), MAX_TURNS);
assert_eq!(chat.earlier, 3);
assert_eq!(chat.turns[0].seq, 3);
assert_eq!(chat.turns[0].text, "turn 3");
}
/// `?before=` ends the window at the boundary rather than the newest
/// turn, same size and same `earlier` count as the unwindowed one.
#[test]
fn a_before_window_ends_where_it_was_asked_to() {
let mut sink = Sink {
before: Some(MAX_TURNS + 2),
..Sink::default()
};
for i in 0..MAX_TURNS + 5 {
let mut turn = Turn::new("user", "message", "t");
turn.text = format!("turn {i}");
sink.push(turn);
}
let chat = sink.finish();
// The three turns past the boundary were numbered — `earlier` and the
// next window's seqs depend on it — but never kept.
assert_eq!(chat.turns.len(), MAX_TURNS);
assert_eq!(chat.earlier, 2);
assert_eq!(chat.turns[0].seq, 2);
assert_eq!(chat.turns.last().unwrap().seq, MAX_TURNS + 1);
}
/// A result can be written entries after the window's boundary; the call
/// it answers still has to show as finished.
#[test]
fn a_result_past_the_boundary_still_lands_on_its_call() {
let mut sink = Sink {
before: Some(1),
..Sink::default()
};
for line in [
r#"{"type":"assistant","timestamp":"t1","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{"file_path":"/a"}}]}}"#,
// Turn 1 — past the boundary, numbered but not kept.
r#"{"type":"user","timestamp":"t2","message":{"content":"next question"}}"#,
// The call's result, recorded last of all.
r#"{"type":"user","timestamp":"t3","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"done"}]}}"#,
] {
sink.claude(&serde_json::from_str(line).unwrap());
}
let chat = sink.finish();
assert_eq!(chat.turns.len(), 1);
assert_eq!(chat.turns[0].seq, 0);
assert_eq!(chat.turns[0].tools[0].result.as_deref(), Some("done"));
}
}