//! L6 tests for the agent DTO (de)serialisation contract: camelCase on the //! wire, embedded [`Agent`] shape preserved, `parse_agent_id` error behaviour, //! and `From` for [`TerminalSessionDto`]. use app_tauri_lib::dto::{ parse_agent_id, AgentDto, AgentListDto, ConversationDetailsDto, CreateAgentRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, TerminalSessionDto, UpdateAgentContextRequestDto, }; use application::AppError; use application::{ AgentTicketState, AgentWorkState, CreateAgentOutput, InspectConversationOutput, LaunchAgentOutput, ListAgentsOutput, LiveSessionKind, LiveWorkSession, ProjectWorkState, TicketWorkSource, TicketWorkStatus, }; use domain::ids::{AgentId, NodeId, ProfileId, SessionId}; use domain::ports::ConversationDetails; use domain::terminal::{PtySize, SessionKind, SessionStatus, TerminalSession}; use domain::{Agent, AgentOrigin, ProjectPath}; use serde_json::json; use uuid::Uuid; /// Helper: build a minimal validated [`Agent`]. fn make_agent(agent_uuid: u128, profile_uuid: u128) -> Agent { Agent::new( AgentId::from_uuid(Uuid::from_u128(agent_uuid)), "My Agent", "agents/my-agent.md", ProfileId::from_uuid(Uuid::from_u128(profile_uuid)), AgentOrigin::Scratch, false, ) .expect("valid agent") } // --------------------------------------------------------------------------- // AgentDto / AgentListDto serialisation // --------------------------------------------------------------------------- #[test] fn agent_dto_serialises_camelcase() { let agent = make_agent(1, 2); let dto = AgentDto(agent.clone()); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["id"], agent.id.to_string()); assert_eq!(v["name"], "My Agent"); assert_eq!(v["contextPath"], "agents/my-agent.md", "camelCase key"); assert_eq!( v["profileId"], ProfileId::from_uuid(Uuid::from_u128(2)).to_string() ); assert_eq!(v["synchronized"], false); // origin: tagged `{ "type": "scratch" }` assert_eq!(v["origin"]["type"], "scratch"); // no snake_case leak assert!(v.get("context_path").is_none()); assert!(v.get("profile_id").is_none()); } #[test] fn agent_list_dto_is_transparent_array() { let out = ListAgentsOutput { agents: vec![make_agent(1, 2), make_agent(3, 4)], }; let dto = AgentListDto::from(out); let v = serde_json::to_value(&dto).unwrap(); let arr = v.as_array().expect("transparent array"); assert_eq!(arr.len(), 2); assert_eq!(arr[0]["name"], "My Agent"); } #[test] fn create_agent_output_maps_to_agent_dto() { let agent = make_agent(5, 6); let out = CreateAgentOutput { agent: agent.clone(), }; let dto = AgentDto::from(out); assert_eq!(dto.0.id, agent.id); } // --------------------------------------------------------------------------- // Request DTO deserialisation // --------------------------------------------------------------------------- #[test] fn create_agent_request_deserialises_camelcase() { let project_id = Uuid::from_u128(10).to_string(); let profile_id = Uuid::from_u128(20).to_string(); let raw = json!({ "projectId": project_id, "name": "Backend Dev", "profileId": profile_id, "initialContent": "# Hello" }); let dto: CreateAgentRequestDto = serde_json::from_value(raw).unwrap(); assert_eq!(dto.project_id, project_id); assert_eq!(dto.name, "Backend Dev"); assert_eq!(dto.profile_id, profile_id); assert_eq!(dto.initial_content.as_deref(), Some("# Hello")); } #[test] fn create_agent_request_initial_content_defaults_to_none() { let raw = json!({ "projectId": Uuid::nil().to_string(), "name": "Agent", "profileId": Uuid::nil().to_string() }); let dto: CreateAgentRequestDto = serde_json::from_value(raw).unwrap(); assert!(dto.initial_content.is_none()); } #[test] fn update_agent_context_request_deserialises_camelcase() { let raw = json!({ "projectId": Uuid::nil().to_string(), "agentId": Uuid::nil().to_string(), "content": "# Updated" }); let dto: UpdateAgentContextRequestDto = serde_json::from_value(raw).unwrap(); assert_eq!(dto.content, "# Updated"); } #[test] fn launch_agent_request_deserialises_camelcase() { let project_id = Uuid::from_u128(1).to_string(); let agent_id = Uuid::from_u128(2).to_string(); let raw = json!({ "projectId": project_id, "agentId": agent_id, "rows": 24, "cols": 80 }); let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap(); assert_eq!(dto.rows, 24); assert_eq!(dto.cols, 80); assert_eq!(dto.project_id, project_id); assert_eq!(dto.agent_id, agent_id); // Omitting the resume id defaults to None (a fresh cell). assert_eq!(dto.conversation_id, None); // Omitting the node id defaults to None (a fresh node is minted backend-side). assert_eq!(dto.node_id, None); } #[test] fn launch_agent_request_carries_node_id() { let node_id = Uuid::from_u128(33).to_string(); let raw = json!({ "projectId": Uuid::from_u128(1).to_string(), "agentId": Uuid::from_u128(2).to_string(), "rows": 24, "cols": 80, "nodeId": node_id }); let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap(); // The hosting cell reaches the backend so the singleton guard can enforce // "one live session per agent". assert_eq!(dto.node_id.as_deref(), Some(node_id.as_str())); // No snake_case leak on the wire. } // --------------------------------------------------------------------------- // AGENT_ALREADY_RUNNING error code + LiveAgentListDto (T2/T3) // --------------------------------------------------------------------------- #[test] fn agent_already_running_error_code_is_stable() { let err = AppError::AgentAlreadyRunning { agent_id: AgentId::from_uuid(Uuid::from_u128(2)), node_id: NodeId::from_uuid(Uuid::from_u128(3)), }; let dto = app_tauri_lib::dto::ErrorDto::from(err); // Option A: a stable code + a text message (the node is NOT enriched into the // ErrorDto — the frontend branches on the code alone). assert_eq!(dto.code, "AGENT_ALREADY_RUNNING"); assert!( dto.message.contains("already running"), "message: {}", dto.message ); } #[test] fn live_agent_list_dto_serialises_camelcase_array() { let agent_a = AgentId::from_uuid(Uuid::from_u128(11)); let node_a = NodeId::from_uuid(Uuid::from_u128(21)); let session_a = domain::SessionId::from_uuid(Uuid::from_u128(31)); let dto = LiveAgentListDto::from_pairs(vec![(agent_a, node_a, session_a)]); let v = serde_json::to_value(&dto).unwrap(); let arr = v.as_array().expect("transparent array"); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["agentId"], agent_a.to_string()); assert_eq!(arr[0]["nodeId"], node_a.to_string()); assert_eq!(arr[0]["sessionId"], session_a.to_string()); // No snake_case leak. assert!(arr[0].get("agent_id").is_none()); assert!(arr[0].get("node_id").is_none()); } #[test] fn project_work_state_dto_serialises_live_and_busy_camelcase() { let agent = AgentId::from_uuid(Uuid::from_u128(11)); let profile = ProfileId::from_uuid(Uuid::from_u128(12)); let node = NodeId::from_uuid(Uuid::from_u128(13)); let session = SessionId::from_uuid(Uuid::from_u128(14)); let ticket = domain::TicketId::from_uuid(Uuid::from_u128(15)); let dto = app_tauri_lib::dto::ProjectWorkStateDto::from(ProjectWorkState { agents: vec![AgentWorkState { agent_id: agent, name: "Worker".to_owned(), profile_id: profile, live: Some(LiveWorkSession { node_id: node, session_id: session, kind: LiveSessionKind::Structured, }), busy: domain::AgentBusyState::Busy { ticket, since_ms: 1_234, }, tickets: vec![], }], }); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["agents"][0]["agentId"], agent.to_string()); assert_eq!(v["agents"][0]["profileId"], profile.to_string()); assert_eq!(v["agents"][0]["live"]["nodeId"], node.to_string()); assert_eq!(v["agents"][0]["live"]["sessionId"], session.to_string()); assert_eq!(v["agents"][0]["live"]["kind"], "structured"); assert_eq!(v["agents"][0]["busy"]["state"], "busy"); assert_eq!(v["agents"][0]["busy"]["ticket"], ticket.to_string()); assert_eq!(v["agents"][0]["busy"]["sinceMs"], 1_234); assert_eq!(v["agents"][0]["tickets"], json!([])); assert!(v["agents"][0].get("agent_id").is_none()); assert!(v["agents"][0]["live"].get("session_id").is_none()); assert!(v["agents"][0]["busy"].get("since_ms").is_none()); } #[test] fn project_work_state_dto_serialises_tickets_camelcase() { let agent = AgentId::from_uuid(Uuid::from_u128(11)); let profile = ProfileId::from_uuid(Uuid::from_u128(12)); let requester = AgentId::from_uuid(Uuid::from_u128(20)); let head_ticket = domain::TicketId::from_uuid(Uuid::from_u128(30)); let next_ticket = domain::TicketId::from_uuid(Uuid::from_u128(31)); let conversation = domain::ConversationId::from_uuid(Uuid::from_u128(40)); let dto = app_tauri_lib::dto::ProjectWorkStateDto::from(ProjectWorkState { agents: vec![AgentWorkState { agent_id: agent, name: "Worker".to_owned(), profile_id: profile, live: None, busy: domain::AgentBusyState::Idle, tickets: vec![ AgentTicketState { ticket_id: head_ticket, conversation_id: conversation, position: 0, status: TicketWorkStatus::InProgress, source: TicketWorkSource::Agent { agent_id: requester, }, requester_label: "Main".to_owned(), task_preview: "Analyser le module".to_owned(), task_len: 842, }, AgentTicketState { ticket_id: next_ticket, conversation_id: conversation, position: 1, status: TicketWorkStatus::Queued, source: TicketWorkSource::Human, requester_label: "User".to_owned(), task_preview: "Vérifier".to_owned(), task_len: 8, }, ], }], }); let v = serde_json::to_value(&dto).unwrap(); let head = &v["agents"][0]["tickets"][0]; assert_eq!(head["ticketId"], head_ticket.to_string()); assert_eq!(head["conversationId"], conversation.to_string()); assert_eq!(head["position"], 0); assert_eq!(head["status"], "inProgress"); assert_eq!(head["source"]["kind"], "agent"); assert_eq!(head["source"]["agentId"], requester.to_string()); assert_eq!(head["requesterLabel"], "Main"); assert_eq!(head["taskPreview"], "Analyser le module"); assert_eq!(head["taskLen"], 842); // snake_case must not leak. assert!(head.get("ticket_id").is_none()); assert!(head.get("task_preview").is_none()); assert!(head["source"].get("agent_id").is_none()); let next = &v["agents"][0]["tickets"][1]; assert_eq!(next["status"], "queued"); assert_eq!(next["source"]["kind"], "human"); assert!(next["source"].get("agentId").is_none()); assert_eq!(next["requesterLabel"], "User"); } #[test] fn launch_agent_request_carries_conversation_id_for_resume() { let raw = json!({ "projectId": Uuid::from_u128(1).to_string(), "agentId": Uuid::from_u128(2).to_string(), "rows": 24, "cols": 80, "conversationId": "resume-me" }); let dto: LaunchAgentRequestDto = serde_json::from_value(raw).unwrap(); // The leaf's persisted id reaches the backend so the launch resumes (T4b). assert_eq!(dto.conversation_id.as_deref(), Some("resume-me")); } // --------------------------------------------------------------------------- // InspectConversation DTOs (T7) // --------------------------------------------------------------------------- #[test] fn inspect_conversation_request_deserialises_camelcase() { let raw = json!({ "projectId": Uuid::from_u128(1).to_string(), "agentId": Uuid::from_u128(2).to_string(), "conversationId": "conv-7" }); let dto: InspectConversationRequestDto = serde_json::from_value(raw).unwrap(); assert_eq!(dto.project_id, Uuid::from_u128(1).to_string()); assert_eq!(dto.agent_id, Uuid::from_u128(2).to_string()); assert_eq!(dto.conversation_id, "conv-7"); } #[test] fn conversation_details_dto_serialises_camelcase_when_present() { let out = InspectConversationOutput { details: ConversationDetails { last_topic: Some("refactor parser".to_owned()), token_count: Some(1234), }, }; let dto = ConversationDetailsDto::from(out); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["lastTopic"], "refactor parser"); assert_eq!(v["tokenCount"], 1234); // No snake_case leak. assert!(v.get("last_topic").is_none()); assert!(v.get("token_count").is_none()); } #[test] fn conversation_details_dto_omits_fields_when_none() { let out = InspectConversationOutput { details: ConversationDetails { last_topic: None, token_count: None, }, }; let dto = ConversationDetailsDto::from(out); let v = serde_json::to_value(&dto).unwrap(); // Both optional fields are omitted from the wire (absent, not null). assert!(v.get("lastTopic").is_none(), "absent topic ⇒ field omitted"); assert!( v.get("tokenCount").is_none(), "absent tokens ⇒ field omitted" ); assert_eq!(v, json!({}), "fully degraded ⇒ empty object"); } // --------------------------------------------------------------------------- // parse_agent_id // --------------------------------------------------------------------------- #[test] fn parse_agent_id_accepts_uuid() { let id = Uuid::from_u128(99); assert_eq!( parse_agent_id(&id.to_string()).unwrap(), AgentId::from_uuid(id) ); } #[test] fn parse_agent_id_rejects_garbage() { let err = parse_agent_id("not-a-uuid").expect_err("garbage rejected"); assert_eq!(err.code, "INVALID"); } // --------------------------------------------------------------------------- // From for TerminalSessionDto // --------------------------------------------------------------------------- #[test] fn launch_agent_output_maps_to_terminal_session_dto() { let session_id = SessionId::from_uuid(Uuid::from_u128(7)); let node_id = NodeId::from_uuid(Uuid::from_u128(8)); let agent_id = AgentId::from_uuid(Uuid::from_u128(9)); let cwd = ProjectPath::new("/tmp/project".to_owned()).expect("valid path"); let size = PtySize::new(24, 80).unwrap(); let mut session = TerminalSession::starting( session_id, node_id, cwd, SessionKind::Agent { agent_id }, size, ); session.status = SessionStatus::Running; let out = LaunchAgentOutput { session, assigned_conversation_id: None, engine_session_id: None, structured: None, profile: None, }; let dto = TerminalSessionDto::from(out); assert_eq!(dto.session_id, session_id.to_string()); assert_eq!(dto.cwd, "/tmp/project"); assert_eq!(dto.rows, 24); assert_eq!(dto.cols, 80); // Nothing assigned ⇒ no conversation id surfaced. assert_eq!(dto.assigned_conversation_id, None); // Also verify camelCase serialisation. let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["sessionId"], session_id.to_string()); assert_eq!(v["cwd"], "/tmp/project"); assert!(v.get("session_id").is_none(), "no snake_case leak"); // Absent assignment must be omitted from the wire payload entirely. assert!( v.get("assignedConversationId").is_none(), "no assignment ⇒ field omitted" ); } #[test] fn launch_agent_output_propagates_assigned_conversation_id() { let session_id = SessionId::from_uuid(Uuid::from_u128(70)); let node_id = NodeId::from_uuid(Uuid::from_u128(80)); let agent_id = AgentId::from_uuid(Uuid::from_u128(90)); let cwd = ProjectPath::new("/tmp/project".to_owned()).expect("valid path"); let size = PtySize::new(24, 80).unwrap(); let mut session = TerminalSession::starting( session_id, node_id, cwd, SessionKind::Agent { agent_id }, size, ); session.status = SessionStatus::Running; let out = LaunchAgentOutput { session, assigned_conversation_id: Some("conv-xyz".to_owned()), engine_session_id: None, structured: None, profile: None, }; let dto = TerminalSessionDto::from(out); // The id minted by the launch is carried through to the DTO … assert_eq!(dto.assigned_conversation_id.as_deref(), Some("conv-xyz")); // … and serialised in camelCase for the front to persist on the leaf. let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["assignedConversationId"], "conv-xyz"); assert!( v.get("assigned_conversation_id").is_none(), "no snake_case leak" ); }