use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde_json::Value;
use toolpath::v1::Graph;
use toolpath_convo::{
ConversationProjector, ConversationView, DeriveConfig, Role, Turn, derive_path,
extract_conversation,
};
use toolpath_opencode::project::OpencodeProjector;
use toolpath_opencode::to_view;
use toolpath_opencode::types::{Message, MessageData, Part, PartData, Session};
fn fixture_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("test-fixtures")
.join("opencode")
.join("convo.json")
}
fn parse_opencode_export(json: &str) -> Session {
let v: Value = serde_json::from_str(json).expect("opencode wrapper parse");
let info = &v["info"];
let msgs_in = v["messages"].as_array().cloned().unwrap_or_default();
let str_or = |key: &str, fallback: &str| -> String {
info.get(key)
.and_then(Value::as_str)
.unwrap_or(fallback)
.to_string()
};
let i64_at = |path: &[&str]| -> Option<i64> {
let mut cur = info;
for k in path {
cur = cur.get(*k)?;
}
cur.as_i64()
};
let mut messages: Vec<Message> = Vec::with_capacity(msgs_in.len());
for m in msgs_in {
let mi = m.get("info").cloned().unwrap_or(Value::Null);
let mi_obj = mi.as_object().cloned().unwrap_or_default();
let id = mi_obj
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let session_id = mi_obj
.get("sessionID")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let time_created = mi_obj
.get("time")
.and_then(|t| t.get("created"))
.and_then(Value::as_i64)
.unwrap_or(0);
let mut data_obj = mi_obj.clone();
data_obj.remove("id");
data_obj.remove("sessionID");
let data: MessageData =
serde_json::from_value(Value::Object(data_obj)).unwrap_or(MessageData::Other);
let mut parts: Vec<Part> = Vec::new();
if let Some(parts_in) = m.get("parts").and_then(Value::as_array) {
for p in parts_in {
let p_obj = p.as_object().cloned().unwrap_or_default();
let pid = p_obj
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let pmsg = p_obj
.get("messageID")
.and_then(Value::as_str)
.unwrap_or(&id)
.to_string();
let psess = p_obj
.get("sessionID")
.and_then(Value::as_str)
.unwrap_or(&session_id)
.to_string();
let mut data_obj = p_obj.clone();
data_obj.remove("id");
data_obj.remove("messageID");
data_obj.remove("sessionID");
let part_data: PartData =
serde_json::from_value(Value::Object(data_obj)).unwrap_or(PartData::Unknown);
parts.push(Part {
id: pid,
message_id: pmsg,
session_id: psess,
time_created,
time_updated: time_created,
data: part_data,
});
}
}
messages.push(Message {
id,
session_id,
time_created,
time_updated: time_created,
data,
parts,
});
}
Session {
id: str_or("id", ""),
project_id: str_or("projectID", ""),
workspace_id: info
.get("workspaceID")
.and_then(Value::as_str)
.map(str::to_string),
parent_id: info
.get("parentID")
.and_then(Value::as_str)
.map(str::to_string),
slug: str_or("slug", ""),
directory: PathBuf::from(str_or("directory", "/")),
title: str_or("title", ""),
version: str_or("version", "0.0.0"),
share_url: info
.get("shareURL")
.and_then(Value::as_str)
.map(str::to_string),
summary_additions: i64_at(&["summary", "additions"]),
summary_deletions: i64_at(&["summary", "deletions"]),
summary_files: i64_at(&["summary", "files"]),
time_created: i64_at(&["time", "created"]).unwrap_or(0),
time_updated: i64_at(&["time", "updated"])
.or_else(|| i64_at(&["time", "created"]))
.unwrap_or(0),
time_compacting: i64_at(&["time", "compacting"]),
time_archived: i64_at(&["time", "archived"]),
messages,
}
}
fn load_fixture_session() -> Session {
let json = std::fs::read_to_string(fixture_path()).expect("read opencode fixture");
parse_opencode_export(&json)
}
fn load_fixture_view() -> ConversationView {
let session = load_fixture_session();
to_view(&session)
}
fn ir_roundtrip(view: &ConversationView) -> ConversationView {
let path = derive_path(view, &DeriveConfig::default());
let graph = Graph::from_path(path);
let json = graph.to_json().expect("serialize Graph");
let back = Graph::from_json(&json).expect("parse Graph");
let path = back.into_single_path().expect("single path");
extract_conversation(&path)
}
fn is_system_envelope(turn: &Turn) -> bool {
if !matches!(turn.role, Role::User) {
return false;
}
let t = turn.text.trim_start();
t.starts_with('<') && t.contains('>')
}
fn meaningful(view: &ConversationView) -> Vec<&Turn> {
view.turns
.iter()
.filter(|t| !is_system_envelope(t))
.collect()
}
fn norm(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[test]
fn fixture_loads() {
let view = load_fixture_view();
assert!(
!view.turns.is_empty(),
"opencode fixture should produce a non-empty view"
);
let m = meaningful(&view);
assert!(
m.iter().any(|t| matches!(t.role, Role::User)),
"fixture should contain at least one meaningful user turn"
);
assert!(
m.iter().any(|t| matches!(t.role, Role::Assistant)),
"fixture should contain at least one assistant turn"
);
}
#[test]
fn roundtrip_preserves_meaningful_turn_count_and_roles() {
let original = load_fixture_view();
let after = ir_roundtrip(&original);
let o = meaningful(&original);
let a = meaningful(&after);
assert_eq!(
o.len(),
a.len(),
"meaningful turn count diverged: original={} after={}",
o.len(),
a.len()
);
for (i, (x, y)) in o.iter().zip(a.iter()).enumerate() {
assert_eq!(
x.role, y.role,
"role at meaningful turn {i}: {:?} vs {:?}",
x.role, y.role
);
}
}
#[test]
fn roundtrip_preserves_turn_text() {
let original = load_fixture_view();
let after = ir_roundtrip(&original);
for (i, (x, y)) in meaningful(&original)
.iter()
.zip(meaningful(&after).iter())
.enumerate()
{
assert_eq!(
norm(&x.text),
norm(&y.text),
"text at turn {i} diverged\n original: {:?}\n after: {:?}",
x.text,
y.text
);
}
}
#[test]
fn roundtrip_preserves_tool_call_topology() {
let original = load_fixture_view();
let after = ir_roundtrip(&original);
for (i, (x, y)) in meaningful(&original)
.iter()
.zip(meaningful(&after).iter())
.enumerate()
{
if !matches!(x.role, Role::Assistant) {
continue;
}
let xs: BTreeSet<&str> = x.tool_uses.iter().map(|t| t.id.as_str()).collect();
let ys: BTreeSet<&str> = y.tool_uses.iter().map(|t| t.id.as_str()).collect();
assert_eq!(
xs, ys,
"tool_use id set diverged at turn {i}: {xs:?} vs {ys:?}"
);
for tx in &x.tool_uses {
let ty = y
.tool_uses
.iter()
.find(|t| t.id == tx.id)
.unwrap_or_else(|| panic!("missing tool {} after roundtrip", tx.id));
assert_eq!(tx.name, ty.name, "tool {} name diverged", tx.id);
match (&tx.result, &ty.result) {
(Some(rx), Some(ry)) => {
assert_eq!(
rx.content, ry.content,
"tool {} result content diverged",
tx.id
);
assert_eq!(rx.is_error, ry.is_error, "tool {} is_error diverged", tx.id);
}
(None, None) => {}
(l, r) => panic!(
"tool {} result presence diverged: original={} after={}",
tx.id,
l.is_some(),
r.is_some()
),
}
}
}
}
#[test]
fn roundtrip_preserves_delegations() {
let original = load_fixture_view();
let after = ir_roundtrip(&original);
let total_before: usize = original.turns.iter().map(|t| t.delegations.len()).sum();
let total_after: usize = after.turns.iter().map(|t| t.delegations.len()).sum();
assert_eq!(
total_before, total_after,
"total delegation count diverged: {total_before} → {total_after}"
);
for (i, (a, b)) in original.turns.iter().zip(after.turns.iter()).enumerate() {
assert_eq!(
a.delegations.len(),
b.delegations.len(),
"turn {i} delegation count diverged"
);
for da in &a.delegations {
let db = b
.delegations
.iter()
.find(|d| d.agent_id == da.agent_id)
.unwrap_or_else(|| panic!("delegation {} dropped at turn {i}", da.agent_id));
assert_eq!(
norm(&da.prompt),
norm(&db.prompt),
"delegation {} prompt diverged at turn {i}",
da.agent_id
);
assert_eq!(
da.turns.len(),
db.turns.len(),
"delegation {} child-turn count diverged at turn {i}",
da.agent_id
);
}
}
}
#[test]
fn projected_session_is_json_serde_symmetric() {
let view = load_fixture_view();
let after = ir_roundtrip(&view);
let projector = OpencodeProjector::new();
let session = projector
.project(&after)
.expect("project to opencode session");
let json = serde_json::to_string(&session).expect("serialize Session");
let _back: Session = serde_json::from_str(&json).expect("re-parse Session");
}