//! L1 tests for the D4 structured-chat DTO contract (ARCHITECTURE §17.7): //! - `ReplyChunk` tagged camelCase round-trip (`kind` + payload), //! - `ReattachChatDto` camelCase wire shape (typed scrollback), //! - the **derived** `cellKind` on `TerminalSessionDto` (`chat` ⇔ //! `structured: Some(..)`, `pty` otherwise), //! - non-regression: every `TerminalSessionDto` construction path now serialises a //! `cellKind` and PTY paths keep `"pty"`. use app_tauri_lib::dto::{ CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto, ChatPromptAttachmentDto, ConversationTranscriptUnavailableReasonDto, ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, ReadChatDraftResponseDto, ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto, ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto, }; use application::{LaunchAgentOutput, StructuredSessionDescriptor}; use domain::project::ProjectPath; use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession}; use domain::{ChatAttachmentSourceKind, SessionId}; use serde_json::json; use uuid::Uuid; // --------------------------------------------------------------------------- // ReplyChunk — tagged camelCase, exact wire shape + round-trip (zone 5/6) // --------------------------------------------------------------------------- #[test] fn reply_chunk_user_prompt_serialises_exact_camel_case() { let v = serde_json::to_value(ReplyChunk::UserPrompt { at_ms: None, text: "run tests".into(), attachments: Vec::new(), attachment_refs: Vec::new(), }) .unwrap(); assert_eq!( v, json!({ "kind": "userPrompt", "text": "run tests", "atMs": null }) ); } #[test] fn reply_chunk_user_prompt_serialises_attachment_labels() { let v = serde_json::to_value(ReplyChunk::UserPrompt { at_ms: None, text: "describe this".into(), attachments: vec!["clipboard.png".into()], attachment_refs: vec![ChatPromptAttachmentDto { reference: "[image 1]".into(), filename: "clipboard.png".into(), mime: "image/png".into(), size_bytes: 9, readable_path: "/project/.ideai/attachments/agent-chat/s/attach-1-clipboard.png".into(), }], }) .unwrap(); assert_eq!( v, json!({ "kind": "userPrompt", "atMs": null, "text": "describe this", "attachments": ["clipboard.png"], "attachmentRefs": [{ "reference": "[image 1]", "filename": "clipboard.png", "mime": "image/png", "sizeBytes": 9, "readablePath": "/project/.ideai/attachments/agent-chat/s/attach-1-clipboard.png" }] }) ); } #[test] fn reply_chunk_text_delta_serialises_exact_camel_case() { let v = serde_json::to_value(ReplyChunk::TextDelta { at_ms: None, text: "hello".into(), }) .unwrap(); assert_eq!( v, json!({ "kind": "textDelta", "text": "hello", "atMs": null }) ); } #[test] fn legacy_reply_chunk_without_timestamp_deserialises_as_none() { let chunk: ReplyChunk = serde_json::from_value(json!({ "kind": "final", "content": "legacy" })) .unwrap(); assert_eq!( chunk, ReplyChunk::Final { at_ms: None, content: "legacy".into() } ); } #[test] fn reply_chunk_tool_activity_serialises_exact_camel_case() { let v = serde_json::to_value(ReplyChunk::ToolActivity { at_ms: None, label: "reads file".into(), }) .unwrap(); assert_eq!( v, json!({ "kind": "toolActivity", "label": "reads file", "atMs": null }) ); } #[test] fn reply_chunk_progress_serialises_exact_camel_case() { let v = serde_json::to_value(ReplyChunk::Progress { progress: ReplyProgressDto { occurred_at_ms: None, progress_id: Some("codex:item:tool0".into()), source: ReplyProgressSourceDto::IdeaLocal, kind: ReplyProgressKindDto::Mcp, stage: ReplyProgressStageDto::Started, label: "idea_ask_agent".into(), text: Some("vers QA".into()), provider: None, native_event: None, tool_name: Some("idea_ask_agent".into()), }, }) .unwrap(); assert_eq!( v, json!({ "kind": "progress", "progress": { "source": "ideaLocal", "occurredAtMs": null, "kind": "mcp", "progressId": "codex:item:tool0", "stage": "started", "label": "idea_ask_agent", "text": "vers QA", "toolName": "idea_ask_agent" } }) ); } #[test] fn reply_chunk_final_serialises_exact_camel_case() { let v = serde_json::to_value(ReplyChunk::Final { at_ms: None, content: "done".into(), }) .unwrap(); assert_eq!( v, json!({ "kind": "final", "content": "done", "atMs": null }) ); } #[test] fn reply_chunk_error_serialises_exact_camel_case() { let v = serde_json::to_value(ReplyChunk::Error { at_ms: None, message: "Réponse vide du modèle.".into(), }) .unwrap(); assert_eq!( v, json!({ "kind": "error", "message": "Réponse vide du modèle.", "atMs": null }) ); } #[test] fn reply_chunk_round_trips_through_json_for_every_variant() { for chunk in [ ReplyChunk::UserPrompt { at_ms: None, text: "u".into(), attachments: vec!["clipboard.png".into()], attachment_refs: vec![ChatPromptAttachmentDto { reference: "[image 1]".into(), filename: "clipboard.png".into(), mime: "image/png".into(), size_bytes: 9, readable_path: "/project/.ideai/attachments/agent-chat/s/attach-1-clipboard.png" .into(), }], }, ReplyChunk::TextDelta { at_ms: None, text: "x".into(), }, ReplyChunk::ToolActivity { at_ms: None, label: "runs".into(), }, ReplyChunk::Progress { progress: ReplyProgressDto { occurred_at_ms: None, progress_id: None, source: ReplyProgressSourceDto::ProviderNative, kind: ReplyProgressKindDto::Turn, stage: ReplyProgressStageDto::Started, label: "tour démarré".into(), text: None, provider: Some("codex".into()), native_event: Some("turn.started".into()), tool_name: None, }, }, ReplyChunk::Final { at_ms: None, content: "y".into(), }, ReplyChunk::Error { at_ms: None, message: "empty".into(), }, ] { let v = serde_json::to_value(&chunk).unwrap(); let back: ReplyChunk = serde_json::from_value(v).unwrap(); assert_eq!(back, chunk, "round-trip preserves the variant + payload"); } } #[test] fn reply_chunk_deserialises_from_camel_case_wire_payload() { // The shape the frontend (or a mock gateway) emits. let back: ReplyChunk = serde_json::from_value(json!({ "kind": "userPrompt", "text": "hi" })).unwrap(); assert_eq!( back, ReplyChunk::UserPrompt { at_ms: None, text: "hi".into(), attachments: Vec::new(), attachment_refs: Vec::new() } ); } #[test] fn reply_chunk_rejects_snake_case_tag() { // Guard: a snake_case `text_delta` is NOT a valid wire tag (contract is camelCase). let r: Result = serde_json::from_value(json!({ "kind": "text_delta", "text": "x" })); assert!(r.is_err(), "snake_case kind must not deserialise"); } // --------------------------------------------------------------------------- // Chat attachments — structured input + durable metadata, camelCase // --------------------------------------------------------------------------- #[test] fn chat_draft_scope_request_deserialises_camel_case() { let dto: ChatDraftScopeRequestDto = serde_json::from_value(json!({ "projectId": Uuid::from_u128(1).to_string(), "layoutId": Uuid::from_u128(2).to_string(), "nodeId": Uuid::from_u128(3).to_string() })) .unwrap(); assert_eq!(dto.project_id, Uuid::from_u128(1).to_string()); assert_eq!(dto.layout_id, Uuid::from_u128(2).to_string()); assert_eq!(dto.node_id, Uuid::from_u128(3).to_string()); } #[test] fn save_chat_draft_request_deserialises_text_only_camel_case() { let dto: SaveChatDraftRequestDto = serde_json::from_value(json!({ "projectId": Uuid::from_u128(1).to_string(), "layoutId": Uuid::from_u128(2).to_string(), "nodeId": Uuid::from_u128(3).to_string(), "text": "draft text" })) .unwrap(); assert_eq!(dto.text, "draft text"); } #[test] fn read_chat_draft_response_serialises_camel_case() { let dto = ReadChatDraftResponseDto { text: Some("draft text".to_owned()), updated_at_ms: Some(42), }; let v = serde_json::to_value(dto).unwrap(); assert_eq!(v["text"], "draft text"); assert_eq!(v["updatedAtMs"], 42); assert!(v.get("updated_at_ms").is_none(), "no snake_case leak"); assert!( v.get("attachments").is_none(), "drafts never persist attachments" ); } #[test] fn chat_attachment_input_dto_deserialises_camel_case() { let dto: ChatAttachmentInputDto = serde_json::from_value(json!({ "path": "/tmp/picked.png", "mime": "image/png", "sourceKind": "clipboard" })) .unwrap(); assert_eq!(dto.path.as_deref(), Some("/tmp/picked.png")); assert_eq!(dto.filename, None); assert_eq!(dto.content_base64, None); assert_eq!(dto.mime.as_deref(), Some("image/png")); assert_eq!(dto.source_kind, Some(ChatAttachmentSourceKind::Clipboard)); } #[test] fn chat_attachment_input_dto_deserialises_clipboard_bytes_camel_case() { let dto: ChatAttachmentInputDto = serde_json::from_value(json!({ "filename": "clipboard.png", "contentBase64": "cG5nIGJ5dGVz", "mime": "image/png", "sourceKind": "clipboard" })) .unwrap(); assert_eq!(dto.path, None); assert_eq!(dto.filename.as_deref(), Some("clipboard.png")); assert_eq!(dto.content_base64.as_deref(), Some("cG5nIGJ5dGVz")); assert_eq!(dto.mime.as_deref(), Some("image/png")); assert_eq!(dto.source_kind, Some(ChatAttachmentSourceKind::Clipboard)); } #[test] fn chat_attachment_dto_serialises_camel_case() { let dto = ChatAttachmentDto { id: "attach-1".to_owned(), filename: "picked.png".to_owned(), mime: "image/png".to_owned(), size_bytes: 9, source_kind: ChatAttachmentSourceKind::LocalFile, storage_path: "agent-chat/session/attach-1-picked.png".to_owned(), readable_path: "/project/.ideai/attachments/agent-chat/session/attach-1-picked.png" .to_owned(), created_at: 42, }; let v = serde_json::to_value(dto).unwrap(); assert_eq!(v["sizeBytes"], 9); assert_eq!(v["sourceKind"], "localFile"); assert_eq!(v["storagePath"], "agent-chat/session/attach-1-picked.png"); assert!(v.get("readable_path").is_none(), "no snake_case leak"); } #[test] fn import_chat_attachments_request_response_use_camel_case() { let request: ImportChatAttachmentsRequestDto = serde_json::from_value(json!({ "sessionId": "sess-1", "attachmentPaths": ["/tmp/legacy.txt"], "attachments": [{ "path": "/tmp/picked.png", "mime": "image/png", "sourceKind": "dragDrop" }, { "filename": "clipboard.png", "contentBase64": "cG5nIGJ5dGVz", "mime": "image/png", "sourceKind": "clipboard" }] })) .unwrap(); assert_eq!(request.session_id, "sess-1"); assert_eq!(request.attachment_paths, vec!["/tmp/legacy.txt"]); assert_eq!( request.attachments[0].source_kind, Some(ChatAttachmentSourceKind::DragDrop) ); assert_eq!( request.attachments[1].content_base64.as_deref(), Some("cG5nIGJ5dGVz") ); let response = ImportChatAttachmentsResponseDto { attachments: vec![ChatAttachmentDto { id: "attach-1".to_owned(), filename: "picked.png".to_owned(), mime: "image/png".to_owned(), size_bytes: 9, source_kind: ChatAttachmentSourceKind::LocalFile, storage_path: "agent-chat/session/attach-1-picked.png".to_owned(), readable_path: "/project/.ideai/attachments/agent-chat/session/attach-1-picked.png" .to_owned(), created_at: 42, }], }; let v = serde_json::to_value(response).unwrap(); assert_eq!(v["attachments"][0]["sizeBytes"], 9); assert!(v.get("attachment_paths").is_none(), "no snake_case leak"); } // --------------------------------------------------------------------------- // ReattachChatDto — typed scrollback, camelCase (zone 5) // --------------------------------------------------------------------------- #[test] fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() { let dto = ReattachChatDto::from_scrollback( "sess-1".into(), Some("conv-1".into()), vec![ ReplyChunk::UserPrompt { at_ms: None, text: "Hi".into(), attachments: Vec::new(), attachment_refs: Vec::new(), }, ReplyChunk::TextDelta { at_ms: None, text: "Hi".into(), }, ReplyChunk::Final { at_ms: None, content: "Hi".into(), }, ], true, ); let v = serde_json::to_value(&dto).unwrap(); assert_eq!( v, json!({ "sessionId": "sess-1", "conversationId": "conv-1", "scrollback": [ { "kind": "userPrompt", "text": "Hi", "atMs": null }, { "kind": "textDelta", "text": "Hi", "atMs": null }, { "kind": "final", "content": "Hi", "atMs": null }, ], }) ); assert!(v.get("session_id").is_none(), "no snake_case leak"); } #[test] fn reattach_chat_dto_empty_scrollback_is_empty_array() { let dto = ReattachChatDto::from_scrollback("s".into(), None, vec![], true); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["scrollback"], json!([])); assert!(v.get("conversationId").is_none()); assert_eq!(v["transcriptUnavailableReason"], "missingTranscript"); assert_eq!( dto.transcript_unavailable_reason, Some(ConversationTranscriptUnavailableReasonDto::MissingTranscript) ); } #[test] fn reattach_chat_dto_distinguishes_fresh_empty_conversation() { let dto = ReattachChatDto::from_scrollback("s".into(), None, vec![], false); let v = serde_json::to_value(dto).unwrap(); assert_eq!(v["transcriptUnavailableReason"], "emptyConversation"); } // --------------------------------------------------------------------------- // CellKind enum wire shape // --------------------------------------------------------------------------- #[test] fn cell_kind_serialises_lowercase_pty_and_chat() { assert_eq!(serde_json::to_value(CellKind::Pty).unwrap(), json!("pty")); assert_eq!(serde_json::to_value(CellKind::Chat).unwrap(), json!("chat")); } // --------------------------------------------------------------------------- // cellKind derivation on TerminalSessionDto (zone 5) // --------------------------------------------------------------------------- fn agent_session(session_id: u128) -> (SessionId, TerminalSession) { let sid = SessionId::from_uuid(Uuid::from_u128(session_id)); 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(sid, node_id, cwd, SessionKind::Agent { agent_id }, size); session.status = SessionStatus::Running; (sid, session) } #[test] fn launch_output_with_structured_descriptor_derives_chat_cell_kind() { let (sid, session) = agent_session(7); let descriptor = StructuredSessionDescriptor { session_id: sid, agent_id: AgentId::from_uuid(Uuid::from_u128(9)), node_id: NodeId::from_uuid(Uuid::from_u128(8)), conversation_id: None, }; let out = LaunchAgentOutput { session, assigned_conversation_id: None, engine_session_id: None, structured: Some(descriptor), profile: None, }; let dto = TerminalSessionDto::from(out); assert_eq!(dto.cell_kind, CellKind::Chat); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["cellKind"], "chat", "structured ⇒ chat on the wire"); } #[test] fn launch_output_without_structured_descriptor_derives_pty_cell_kind() { let (_sid, session) = agent_session(7); let out = LaunchAgentOutput { session, assigned_conversation_id: None, engine_session_id: None, structured: None, profile: None, }; let dto = TerminalSessionDto::from(out); assert_eq!(dto.cell_kind, CellKind::Pty, "no descriptor ⇒ pty"); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["cellKind"], "pty"); } // --------------------------------------------------------------------------- // Non-regression: cellKind is always present & "pty" on the historical paths // --------------------------------------------------------------------------- #[test] fn terminal_session_dto_from_domain_session_is_always_pty() { // From (e.g. open_terminal / change_agent_profile relaunch). let (_sid, session) = agent_session(11); let dto = TerminalSessionDto::from(session); assert_eq!(dto.cell_kind, CellKind::Pty); let v = serde_json::to_value(&dto).unwrap(); assert_eq!( v["cellKind"], "pty", "the new field is always present on the PTY path (non-breaking shape)" ); } #[test] fn pty_launch_output_serialises_cellkind_pty_without_breaking_existing_keys() { // Guard the exact historical key set + the new derived field for a PTY launch. let (sid, session) = agent_session(12); let out = LaunchAgentOutput { session, assigned_conversation_id: None, engine_session_id: None, structured: None, profile: None, }; let v = serde_json::to_value(TerminalSessionDto::from(out)).unwrap(); assert_eq!(v["sessionId"], sid.to_string()); assert_eq!(v["cwd"], "/tmp/project"); assert_eq!(v["rows"], 24); assert_eq!(v["cols"], 80); assert_eq!(v["cellKind"], "pty"); // assignedConversationId omitted when None (skip_serializing_if) — unchanged. assert!(v.get("assignedConversationId").is_none()); assert!(v.get("session_id").is_none(), "no snake_case leak"); }