Files
IdeA/crates/domain/tests/serde_roundtrip.rs
Blomios dcba76b871 feat(chat): livre la CLI custom de chat agent (#147) et corrige Cancel
Implémente la vue chat structurée par cellule agent (toggle TUI/CLI custom,
préférence persistée `preferred_view`, reattach live, composer + pièces
jointes) avec le socle backend AgentSession/ChatBridge (UserPrompt,
cancel_current_turn, routage interrupt_agent, commande cancel_agent_chat).

Corrige le bug bloquant relevé par QA : le bouton Cancel de
CustomAgentChatView interrompait tout le tour via closeAgentChat au lieu
de n'annuler que le tour courant via cancelAgentChat, ce qui tuait la
session contrairement au contrat produit validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 11:59:39 +02:00

505 lines
16 KiB
Rust

//! JSON round-trip (serde) of persisted domain types, plus camelCase / tagging
//! checks (ARCHITECTURE §7.3, §9).
mod helpers;
use domain::{
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection,
CustomPluginLayoutCell, Direction, LayoutNode, LayoutTree, LeafCell, ManifestEntry,
MarkdownDoc, PluginId, PluginLayoutType, Project, ProjectPath, RemoteRef, SessionStrategy,
Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion, WeightedChild,
};
use helpers::{node, session};
use uuid::Uuid;
fn pid(n: u128) -> domain::ProjectId {
domain::ProjectId::from_uuid(Uuid::from_u128(n))
}
fn profid(n: u128) -> domain::ProfileId {
domain::ProfileId::from_uuid(Uuid::from_u128(n))
}
fn tid(n: u128) -> domain::TemplateId {
domain::TemplateId::from_uuid(Uuid::from_u128(n))
}
fn aid(n: u128) -> domain::AgentId {
domain::AgentId::from_uuid(Uuid::from_u128(n))
}
fn roundtrip<T>(value: &T) -> T
where
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
{
let json = serde_json::to_string(value).expect("serialize");
serde_json::from_str(&json).expect("deserialize")
}
// ---------------------------------------------------------------------------
// Project
// ---------------------------------------------------------------------------
#[test]
fn project_roundtrip() {
let p = Project::new(
pid(1),
"demo",
ProjectPath::new("/srv/demo").unwrap(),
RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "/srv").unwrap(),
123,
)
.unwrap();
assert_eq!(roundtrip(&p), p);
}
#[test]
fn project_uses_camel_case_and_tagged_remote() {
let p = Project::new(
pid(1),
"demo",
ProjectPath::new("/srv/demo").unwrap(),
RemoteRef::local(),
123,
)
.unwrap();
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("\"createdAt\":123"), "json was {json}");
// RemoteRef tagged with `kind`, camelCased "local".
assert!(json.contains("\"kind\":\"local\""), "json was {json}");
}
// ---------------------------------------------------------------------------
// RemoteRef variants
// ---------------------------------------------------------------------------
#[test]
fn remote_ssh_roundtrip_and_tags() {
let r = RemoteRef::ssh(
"host",
2222,
"me",
SshAuth::Key { path: "/k".into() },
"/srv",
)
.unwrap();
assert_eq!(roundtrip(&r), r);
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("\"kind\":\"ssh\""), "json was {json}");
// SshAuth tagged with `type`.
assert!(json.contains("\"type\":\"key\""), "json was {json}");
// Enum-variant fields must be camelCased on the wire (ARCHITECTURE §9).
assert!(json.contains("\"remoteRoot\""), "json was {json}");
assert!(!json.contains("\"remote_root\""), "json was {json}");
}
#[test]
fn remote_wsl_roundtrip() {
let r = RemoteRef::wsl("Ubuntu").unwrap();
assert_eq!(roundtrip(&r), r);
}
// ---------------------------------------------------------------------------
// AgentProfile + ContextInjection (tagged with `strategy`)
// ---------------------------------------------------------------------------
#[test]
fn profile_roundtrip_all_injection_variants() {
for ci in [
ContextInjection::convention_file("CLAUDE.md").unwrap(),
ContextInjection::flag("-f {path}").unwrap(),
ContextInjection::stdin(),
ContextInjection::env("CTX").unwrap(),
] {
let p = AgentProfile::new(
profid(1),
"Name",
"claude",
vec!["a".into(), "b".into()],
ci,
Some("claude --version".into()),
"{projectRoot}",
None,
)
.unwrap();
assert_eq!(roundtrip(&p), p);
}
}
#[test]
fn context_injection_strategy_tag_is_camel_case() {
let json =
serde_json::to_string(&ContextInjection::convention_file("CLAUDE.md").unwrap()).unwrap();
assert!(
json.contains("\"strategy\":\"conventionFile\""),
"json was {json}"
);
let json = serde_json::to_string(&ContextInjection::stdin()).unwrap();
assert!(json.contains("\"strategy\":\"stdin\""), "json was {json}");
}
#[test]
fn profile_cwd_template_is_camel_case() {
let p = AgentProfile::new(
profid(1),
"n",
"c",
vec![],
ContextInjection::stdin(),
None,
"{projectRoot}",
None,
)
.unwrap();
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("\"cwdTemplate\""), "json was {json}");
}
#[test]
fn profile_with_session_roundtrips_and_uses_camel_case() {
let session = SessionStrategy::new(Some("--session-id".into()), "--resume").unwrap();
let p = AgentProfile::new(
profid(1),
"Claude Code",
"claude",
vec![],
ContextInjection::stdin(),
None,
"{agentRunDir}",
Some(session),
)
.unwrap();
let json = serde_json::to_string(&p).unwrap();
assert!(
json.contains("\"session\":{\"assignFlag\":\"--session-id\",\"resumeFlag\":\"--resume\"}"),
"json was {json}"
);
assert_eq!(roundtrip(&p), p);
}
#[test]
fn profile_without_session_omits_the_field() {
let p = AgentProfile::new(
profid(1),
"n",
"c",
vec![],
ContextInjection::stdin(),
None,
"{projectRoot}",
None,
)
.unwrap();
let json = serde_json::to_string(&p).unwrap();
assert!(!json.contains("session"), "json was {json}");
assert_eq!(roundtrip(&p), p);
}
#[test]
fn legacy_profile_without_session_deserialises_to_none() {
// A `profiles.json` produced before the `session` field existed.
let json = r#"{
"id": "00000000-0000-0000-0000-000000000001",
"name": "Claude Code",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "stdin" },
"detect": null,
"cwdTemplate": "{agentRunDir}"
}"#;
let p: AgentProfile = serde_json::from_str(json).expect("legacy profile must deserialise");
assert_eq!(p.session, None);
}
#[test]
fn session_assign_flag_omitted_when_none() {
let session = SessionStrategy::new(None, "--continue").unwrap();
let json = serde_json::to_string(&session).unwrap();
assert!(!json.contains("assignFlag"), "json was {json}");
assert!(
json.contains("\"resumeFlag\":\"--continue\""),
"json was {json}"
);
assert_eq!(roundtrip(&session), session);
}
// ---------------------------------------------------------------------------
// AgentTemplate
// ---------------------------------------------------------------------------
#[test]
fn template_roundtrip() {
let t = AgentTemplate::new(tid(1), "T", MarkdownDoc::new("# hi"), profid(2))
.unwrap()
.with_updated_content(MarkdownDoc::new("# bye"));
assert_eq!(roundtrip(&t), t);
let json = serde_json::to_string(&t).unwrap();
assert!(json.contains("\"contentMd\""), "json was {json}");
assert!(json.contains("\"defaultProfileId\""), "json was {json}");
}
// ---------------------------------------------------------------------------
// Agent + manifest
// ---------------------------------------------------------------------------
#[test]
fn agent_roundtrip_from_template() {
let a = Agent::new(
aid(1),
"Backend",
"agents/backend.md",
profid(2),
AgentOrigin::FromTemplate {
template_id: tid(3),
synced_template_version: TemplateVersion(4),
},
true,
)
.unwrap();
assert_eq!(roundtrip(&a), a);
let json = serde_json::to_string(&a).unwrap();
assert!(json.contains("\"contextPath\""), "json was {json}");
// AgentOrigin tagged with `type`, camelCased.
assert!(
json.contains("\"type\":\"fromTemplate\""),
"json was {json}"
);
// Inner fields must be camelCased per ARCHITECTURE §9.1:
// { "type":"fromTemplate", "templateId":"...", "syncedTemplateVersion":N }.
assert!(json.contains("\"templateId\""), "json was {json}");
assert!(
json.contains("\"syncedTemplateVersion\":4"),
"json was {json}"
);
assert!(!json.contains("\"template_id\""), "json was {json}");
assert!(
!json.contains("\"synced_template_version\""),
"json was {json}"
);
assert!(!json.contains("\"synced_version\""), "json was {json}");
}
// ---------------------------------------------------------------------------
// SessionKind (tagged enum: `type`, camelCased variant fields)
// ---------------------------------------------------------------------------
#[test]
fn session_kind_agent_roundtrip_and_camel_case() {
use domain::SessionKind;
let k = SessionKind::Agent { agent_id: aid(7) };
assert_eq!(roundtrip(&k), k);
let json = serde_json::to_string(&k).unwrap();
assert!(json.contains("\"type\":\"agent\""), "json was {json}");
assert!(json.contains("\"agentId\""), "json was {json}");
assert!(!json.contains("\"agent_id\""), "json was {json}");
// Plain variant carries no fields.
let plain = serde_json::to_string(&SessionKind::Plain).unwrap();
assert!(plain.contains("\"type\":\"plain\""), "json was {plain}");
}
#[test]
fn manifest_roundtrip_and_camel_case() {
let e1 = ManifestEntry::new(
aid(1),
"Alpha",
"agents/a.md",
profid(9),
Some(tid(2)),
true,
Some(TemplateVersion(5)),
)
.unwrap();
let e2 =
ManifestEntry::new(aid(3), "Beta", "agents/b.md", profid(9), None, false, None).unwrap();
let m = AgentManifest::new(1, vec![e1, e2]).unwrap();
assert_eq!(roundtrip(&m), m);
let json = serde_json::to_string(&m).unwrap();
// entries are serialized under "agents".
assert!(json.contains("\"agents\":["), "json was {json}");
assert!(json.contains("\"mdPath\""), "json was {json}");
assert!(
json.contains("\"syncedTemplateVersion\":5"),
"json was {json}"
);
// Non-synchronized entry omits optional template fields (skip_serializing_if).
assert!(!json.contains("\"templateId\":null"), "json was {json}");
}
// ---------------------------------------------------------------------------
// Skill (L12) — round-trip, camelCase scope tag, manifest skills back-compat
// ---------------------------------------------------------------------------
fn sid(n: u128) -> SkillId {
SkillId::from_uuid(Uuid::from_u128(n))
}
#[test]
fn skill_roundtrip_and_camel_case_scope() {
let s = Skill::new(
sid(1),
"code-review",
MarkdownDoc::new("body"),
SkillScope::Global,
)
.unwrap();
assert_eq!(roundtrip(&s), s);
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"scope\":\"global\""), "json was {json}");
assert!(json.contains("\"contentMd\""), "json was {json}");
let p = Skill::new(
sid(2),
"simplify",
MarkdownDoc::new("b"),
SkillScope::Project,
)
.unwrap();
let pj = serde_json::to_string(&p).unwrap();
assert!(pj.contains("\"scope\":\"project\""), "json was {pj}");
}
#[test]
fn manifest_entry_skills_roundtrip_and_camel_case() {
let entry = ManifestEntry::from_agent(
&Agent::new(
aid(1),
"dev",
"agents/dev.md",
profid(9),
AgentOrigin::Scratch,
false,
)
.unwrap()
.with_skills(vec![SkillRef::new(sid(5), SkillScope::Project)]),
);
assert_eq!(roundtrip(&entry), entry);
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"skillId\""), "json was {json}");
assert!(json.contains("\"scope\":\"project\""), "json was {json}");
}
#[test]
fn manifest_entry_without_skills_omits_field_and_deserialises() {
// An entry with no skills must not emit "skills" (skip_serializing_if),
// and a pre-L12 manifest JSON (no skills key) must deserialise to empty.
let entry =
ManifestEntry::new(aid(1), "dev", "agents/dev.md", profid(9), None, false, None).unwrap();
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("\"skills\""), "json was {json}");
let legacy = r#"{"agentId":"00000000-0000-0000-0000-000000000001","name":"dev","mdPath":"agents/dev.md","profileId":"00000000-0000-0000-0000-000000000009","synchronized":false}"#;
let parsed: ManifestEntry = serde_json::from_str(legacy).unwrap();
assert!(parsed.skills.is_empty());
}
// ---------------------------------------------------------------------------
// LayoutTree (tagged enum: type/node)
// ---------------------------------------------------------------------------
#[test]
fn layout_roundtrip() {
let tree = LayoutTree::new(LayoutNode::Split(SplitContainer {
id: node(9),
direction: Direction::Column,
children: vec![
WeightedChild {
node: LayoutNode::Leaf(LeafCell {
id: node(1),
session: Some(session(100)),
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}),
weight: 1.5,
},
WeightedChild {
node: LayoutNode::Leaf(LeafCell {
id: node(2),
session: None,
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}),
weight: 2.5,
},
],
}));
assert_eq!(roundtrip(&tree), tree);
let json = serde_json::to_string(&tree).unwrap();
// enum adjacently tagged: type + node ; direction camelCase.
assert!(json.contains("\"type\":\"split\""), "json was {json}");
assert!(json.contains("\"type\":\"leaf\""), "json was {json}");
assert!(json.contains("\"direction\":\"column\""), "json was {json}");
// empty session leaf omits the field.
assert!(!json.contains("\"session\":null"), "json was {json}");
}
#[test]
fn leaf_with_agent_roundtrip_and_omits_null() {
use domain::ids::AgentId;
let agent_uuid = Uuid::from_u128(0xABC);
let tree = LayoutTree::new(LayoutNode::Leaf(LeafCell {
id: node(1),
session: None,
agent: Some(AgentId::from_uuid(agent_uuid)),
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}));
let rt = roundtrip(&tree);
match rt.root {
LayoutNode::Leaf(l) => assert_eq!(l.agent, Some(AgentId::from_uuid(agent_uuid))),
_ => panic!("expected leaf"),
}
let json = serde_json::to_string(&tree).unwrap();
// agent present when set
assert!(
json.contains("\"agent\""),
"agent field should be present when set; json was {json}"
);
// null variant omitted
let tree_no_agent = LayoutTree::new(LayoutNode::Leaf(LeafCell {
id: node(2),
session: None,
agent: None,
conversation_id: None,
engine_session_id: None,
agent_was_running: false,
preferred_view: domain::PreferredView::Tui,
}));
let json2 = serde_json::to_string(&tree_no_agent).unwrap();
assert!(
!json2.contains("\"agent\""),
"agent field should be omitted when None; json was {json2}"
);
}
#[test]
fn custom_plugin_layout_roundtrips_with_opaque_state() {
let tree = LayoutTree::new(LayoutNode::CustomPluginLayout(CustomPluginLayoutCell {
id: node(43),
plugin_id: PluginId::new("dev.acme.gitgraph").unwrap(),
layout_type: PluginLayoutType::new("dev.acme.gitgraph.layout").unwrap(),
state: serde_json::json!({"branch":"main","zoom":2}),
}));
assert_eq!(roundtrip(&tree), tree);
let json = serde_json::to_string(&tree).unwrap();
assert!(
json.contains("\"type\":\"customPluginLayout\""),
"json was {json}"
);
assert!(
json.contains("\"pluginId\":\"dev.acme.gitgraph\""),
"json was {json}"
);
assert!(
json.contains("\"layoutType\":\"dev.acme.gitgraph.layout\""),
"json was {json}"
);
assert!(json.contains("\"state\""), "json was {json}");
}