use anyhow::Result;
use serde_json::{Value, json};
use uuid::Uuid;
const SUBAGENT_NS: Uuid = Uuid::from_u128(0x6d66_5f73_7562_6167_656e_745f_7631_0000);
const CALL_SUBAGENT: &str = "call_subagent";
const OLD_TITLE_CAP: usize = 100;
pub(super) fn chat_to_v4(mut v: Value) -> Result<Value> {
if let Some(messages) = v.get_mut("messages").and_then(Value::as_array_mut) {
restore_run_titles(messages);
}
if let Some(deleted) = v.get_mut("deleted").and_then(Value::as_array_mut) {
for exchange in deleted {
if let Some(messages) = exchange.get_mut("messages").and_then(Value::as_array_mut) {
restore_run_titles(messages);
}
}
}
v["v"] = json!(4);
Ok(v)
}
fn restore_run_titles(messages: &mut [Value]) {
for message in messages.iter_mut() {
let Some(records) = message.get_mut("tool_calls").and_then(Value::as_array_mut) else {
continue;
};
for record in records.iter_mut() {
if let Some(run) = record.get_mut("subagent") {
restore_run_title(run);
}
}
}
}
fn restore_run_title(run: &mut Value) {
let title = run
.get("title")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let manual = run.get("renamed_manually").and_then(Value::as_bool) == Some(true);
let dialogue = run
.get("participants")
.and_then(Value::as_array)
.is_some_and(|p| !p.is_empty());
if title.chars().count() != OLD_TITLE_CAP || manual || dialogue {
return;
}
let Some(whole) = derive_title(run) else {
return;
};
if whole.len() > title.len() && whole.starts_with(&title) {
run["title"] = json!(whole);
}
}
fn derive_title(run: &Value) -> Option<String> {
let named = run
.get("name")
.and_then(Value::as_str)
.and_then(crate::shared::title::sanitize_title);
named.or_else(|| {
run.get("messages")?
.as_array()?
.iter()
.find(|m| m.get("role").and_then(Value::as_str) == Some("user"))?
.get("text")?
.as_str()?
.lines()
.find(|l| !l.trim().is_empty())
.and_then(crate::shared::title::sanitize_title)
})
}
pub(super) fn chat_to_v3(mut v: Value) -> Result<Value> {
v["v"] = json!(3);
Ok(v)
}
pub(super) fn chat_to_v2(mut v: Value) -> Result<Value> {
let chat_id = v
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if let Some(messages) = v.get_mut("messages").and_then(Value::as_array_mut) {
synthesize_runs(&chat_id, messages);
}
if let Some(deleted) = v.get_mut("deleted").and_then(Value::as_array_mut) {
for exchange in deleted {
if let Some(messages) = exchange.get_mut("messages").and_then(Value::as_array_mut) {
synthesize_runs(&chat_id, messages);
}
}
}
v["v"] = json!(2);
Ok(v)
}
fn synthesize_runs(chat_id: &str, messages: &mut [Value]) {
let tool_times: Vec<(String, String)> = messages
.iter()
.filter(|m| m.get("role").and_then(Value::as_str) == Some("tool"))
.filter_map(|m| {
Some((
m.get("tool_call_id")?.as_str()?.to_string(),
m.get("timestamp")?.as_str()?.to_string(),
))
})
.collect();
for message in messages.iter_mut() {
let Some(created_at) = message
.get("timestamp")
.and_then(Value::as_str)
.map(str::to_string)
else {
continue;
};
let Some(records) = message.get_mut("tool_calls").and_then(Value::as_array_mut) else {
continue;
};
for record in records.iter_mut() {
if record.get("name").and_then(Value::as_str) != Some(CALL_SUBAGENT)
|| record.get("subagent").is_some()
{
continue;
}
let call_id = record
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let finished_at = tool_times
.iter()
.find(|(id, _)| *id == call_id)
.map(|(_, t)| t.clone())
.unwrap_or_else(|| created_at.clone());
let run = synthesize_run(chat_id, &call_id, record, &created_at, &finished_at);
record["subagent"] = run;
}
}
}
fn synthesize_run(
chat_id: &str,
call_id: &str,
record: &Value,
created_at: &str,
finished_at: &str,
) -> Value {
let args = record.get("arguments").cloned().unwrap_or(Value::Null);
let arg = |key: &str| {
args.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let system_message = arg("system_message").unwrap_or_default();
let message = arg("message").unwrap_or_default();
let name = arg("name");
let result = record
.get("result")
.and_then(Value::as_str)
.map(str::to_string);
let id = Uuid::new_v5(&SUBAGENT_NS, format!("{chat_id}/{call_id}").as_bytes());
let msg_id = |tag: &str| Uuid::new_v5(&SUBAGENT_NS, format!("{id}/{tag}").as_bytes());
let title = name
.as_deref()
.and_then(crate::shared::title::sanitize_title)
.or_else(|| {
message
.lines()
.find(|l| !l.trim().is_empty())
.and_then(crate::shared::title::sanitize_title)
})
.unwrap_or_else(|| CALL_SUBAGENT.to_string());
let mut messages = vec![json!({
"id": msg_id("user"),
"role": "user",
"text": message,
"timestamp": created_at,
})];
let outcome = match result {
Some(reply) => {
messages.push(json!({
"id": msg_id("assistant"),
"role": "assistant",
"text": reply,
"timestamp": finished_at,
}));
Some("completed")
}
None => None,
};
let mut run = json!({
"id": id,
"kind": "subagent",
"title": title,
"created_at": created_at,
"finished_at": finished_at,
"system_message": system_message,
"messages": messages,
});
if let Some(name) = name {
run["name"] = json!(name);
}
if let Some(outcome) = outcome {
run["outcome"] = json!(outcome);
}
run
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entities::chat::Chat;
const V1_CHAT: &str = include_str!("fixtures/chat_v1_call_subagent.json");
const V3_CHAT: &str = include_str!("fixtures/chat_v3_cut_run_title.json");
const WHOLE: &str = concat!(
"Read the changelog of every dependency we bumped this quarter and list ",
"the ones whose breaking changes we have not yet handled anywhere in the tree"
);
fn v4() -> Value {
chat_to_v4(serde_json::from_str(V3_CHAT).unwrap()).unwrap()
}
fn run_of(out: &Value, call: usize) -> Value {
out["messages"][1]["tool_calls"][call]["subagent"].clone()
}
#[test]
fn the_v3_fixture_is_what_the_cap_stored() {
let v: Value = serde_json::from_str(V3_CHAT).unwrap();
assert_eq!(v["v"], json!(3));
let title = v["messages"][1]["tool_calls"][0]["subagent"]["title"]
.as_str()
.unwrap();
assert_eq!(title.chars().count(), OLD_TITLE_CAP);
assert!(WHOLE.starts_with(title), "the cap cut this line: {title}");
let chat: Chat = serde_json::from_value(v).unwrap();
assert_eq!(chat.v, 3);
}
#[test]
fn a_cut_run_title_gets_its_tail_back_and_stamps_v4() {
let out = v4();
assert_eq!(out["v"], json!(4));
assert_eq!(run_of(&out, 0)["title"], json!(WHOLE));
assert_eq!(
out["deleted"][0]["messages"][0]["tool_calls"][0]["subagent"]["title"],
json!(WHOLE)
);
}
#[test]
fn the_step_leaves_every_other_title_alone() {
let out = v4();
let manual = run_of(&out, 1);
assert_eq!(manual["title"].as_str().unwrap().chars().count(), 100);
assert_ne!(manual["title"], json!(WHOLE));
assert_eq!(run_of(&out, 2)["title"], json!("Reviewer"));
assert_eq!(out["title"], json!("Dependency sweep"));
}
#[test]
fn a_title_that_is_not_a_prefix_is_never_rewritten() {
let mut out: Value = serde_json::from_str(V3_CHAT).unwrap();
let title: String = "z".repeat(OLD_TITLE_CAP);
out["messages"][1]["tool_calls"][0]["subagent"]["title"] = json!(title);
let out = chat_to_v4(out).unwrap();
assert_eq!(run_of(&out, 0)["title"], json!(title));
}
#[test]
fn a_dialogue_run_is_left_alone() {
let mut out: Value = serde_json::from_str(V3_CHAT).unwrap();
let run = &mut out["messages"][1]["tool_calls"][0]["subagent"];
run["kind"] = json!("dialogue");
run["participants"] = json!([{"name": "A"}, {"name": "B"}]);
let out = chat_to_v4(out).unwrap();
assert_eq!(run_of(&out, 0)["title"].as_str().unwrap(), &WHOLE[..100]);
}
#[test]
fn the_step_is_idempotent() {
let once = v4();
let twice = chat_to_v4(once.clone()).unwrap();
assert_eq!(once, twice);
}
fn migrated() -> Value {
chat_to_v2(serde_json::from_str(V1_CHAT).unwrap()).unwrap()
}
#[test]
fn the_fixture_is_a_v1_file_that_parses_today() {
let v: Value = serde_json::from_str(V1_CHAT).unwrap();
assert!(v.get("v").is_none());
let chat: Chat = serde_json::from_value(v).unwrap();
assert_eq!(chat.v, 1);
assert!(chat.messages[1].tool_calls[0].subagent.is_none());
}
#[test]
fn synthesizes_a_run_from_an_old_record_and_stamps_v2() {
let out = migrated();
assert_eq!(out["v"], json!(2));
let run = &out["messages"][1]["tool_calls"][0]["subagent"];
assert_eq!(run["kind"], json!("subagent"));
assert_eq!(run["system_message"], json!("Ты — критик."));
assert_eq!(run["title"], json!("Оцени идею X."));
assert_eq!(run["outcome"], json!("completed"));
assert_eq!(run["created_at"], out["messages"][1]["timestamp"]);
assert_eq!(run["finished_at"], out["messages"][2]["timestamp"]);
let msgs = run["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0]["role"], json!("user"));
assert_eq!(msgs[0]["text"], json!("Оцени идею X.\nПодробно."));
assert_eq!(msgs[1]["role"], json!("assistant"));
assert_eq!(msgs[1]["text"], json!("Идея X слаба: …"));
assert!(
out["messages"][1]["tool_calls"][1]
.get("subagent")
.is_none()
);
assert_eq!(out["title"], json!("Старый чат"));
assert_eq!(out["messages"][4]["text"], json!("Итого: X слаба."));
}
#[test]
fn the_deleted_archive_is_migrated_too() {
let out = migrated();
let run = &out["deleted"][0]["messages"][1]["tool_calls"][0]["subagent"];
assert_eq!(run["title"], json!("Похвали Y"));
assert_eq!(run["messages"].as_array().unwrap().len(), 1);
assert!(run.get("outcome").is_none());
assert_eq!(run["finished_at"], run["created_at"]);
}
#[test]
fn the_migrated_file_control_parses_and_the_run_is_what_the_app_reads() {
let chat: Chat = serde_json::from_value(migrated()).unwrap();
assert_eq!(chat.v, 2);
let run = chat.messages[1].tool_calls[0].subagent.as_deref().unwrap();
assert_eq!(run.final_reply(), Some("Идея X слаба: …"));
assert_eq!(
run.outcome,
Some(crate::entities::subagent::RunOutcome::Completed)
);
assert_eq!(run.name, None);
assert_eq!(run.tokens, 0);
assert!(!run.renamed_manually);
let archived = chat.deleted[0].messages[1].tool_calls[0]
.subagent
.as_deref()
.unwrap();
assert_eq!(archived.outcome, None);
}
#[test]
fn ids_are_deterministic_and_the_step_is_idempotent() {
let a = migrated();
let b = migrated();
assert_eq!(a, b, "the same old file yields the same run ids");
let twice = chat_to_v2(a.clone()).unwrap();
assert_eq!(twice, a, "a record that has a run is skipped");
let live = &a["messages"][1]["tool_calls"][0]["subagent"]["id"];
let archived = &a["deleted"][0]["messages"][1]["tool_calls"][0]["subagent"]["id"];
assert_ne!(live, archived);
}
#[test]
fn a_record_with_a_run_or_without_arguments_is_handled() {
let v = json!({
"id": "00000000-0000-0000-0000-000000000009",
"messages": [{
"id": "00000000-0000-0000-0000-000000000010",
"role": "assistant", "text": "", "timestamp": "2026-01-01T00:00:00Z",
"tool_calls": [
{"id": "a", "name": "call_subagent", "arguments": {}, "result": "r",
"subagent": {"marker": true}},
{"id": "b", "name": "call_subagent", "arguments": {}}
]
}]
});
let out = chat_to_v2(v).unwrap();
assert_eq!(
out["messages"][0]["tool_calls"][0]["subagent"],
json!({"marker": true})
);
let run = &out["messages"][0]["tool_calls"][1]["subagent"];
assert_eq!(run["title"], json!("call_subagent"));
assert_eq!(run["system_message"], json!(""));
assert_eq!(run["messages"][0]["text"], json!(""));
}
}