Files
IdeA/crates/app-tauri/tests/dto_chat.rs

404 lines
14 KiB
Rust

//! 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, ImportChatAttachmentsRequestDto,
ImportChatAttachmentsResponseDto, ReattachChatDto, ReplyChunk, ReplyProgressDto,
ReplyProgressKindDto, ReplyProgressSourceDto, ReplyProgressStageDto, 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 {
text: "run tests".into(),
})
.unwrap();
assert_eq!(v, json!({ "kind": "userPrompt", "text": "run tests" }));
}
#[test]
fn reply_chunk_text_delta_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::TextDelta {
text: "hello".into(),
})
.unwrap();
assert_eq!(v, json!({ "kind": "textDelta", "text": "hello" }));
}
#[test]
fn reply_chunk_tool_activity_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::ToolActivity {
label: "reads file".into(),
})
.unwrap();
assert_eq!(v, json!({ "kind": "toolActivity", "label": "reads file" }));
}
#[test]
fn reply_chunk_progress_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::Progress {
progress: ReplyProgressDto {
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",
"kind": "mcp",
"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 {
content: "done".into(),
})
.unwrap();
assert_eq!(v, json!({ "kind": "final", "content": "done" }));
}
#[test]
fn reply_chunk_error_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::Error {
message: "Réponse vide du modèle.".into(),
})
.unwrap();
assert_eq!(
v,
json!({ "kind": "error", "message": "Réponse vide du modèle." })
);
}
#[test]
fn reply_chunk_round_trips_through_json_for_every_variant() {
for chunk in [
ReplyChunk::UserPrompt { text: "u".into() },
ReplyChunk::TextDelta { text: "x".into() },
ReplyChunk::ToolActivity {
label: "runs".into(),
},
ReplyChunk::Progress {
progress: ReplyProgressDto {
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 {
content: "y".into(),
},
ReplyChunk::Error {
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 { text: "hi".into() });
}
#[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<ReplyChunk, _> =
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_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 {
session_id: "sess-1".into(),
scrollback: vec![
ReplyChunk::UserPrompt { text: "Hi".into() },
ReplyChunk::TextDelta { text: "Hi".into() },
ReplyChunk::Final {
content: "Hi".into(),
},
],
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(
v,
json!({
"sessionId": "sess-1",
"scrollback": [
{ "kind": "userPrompt", "text": "Hi" },
{ "kind": "textDelta", "text": "Hi" },
{ "kind": "final", "content": "Hi" },
],
})
);
assert!(v.get("session_id").is_none(), "no snake_case leak");
}
#[test]
fn reattach_chat_dto_empty_scrollback_is_empty_array() {
let dto = ReattachChatDto {
session_id: "s".into(),
scrollback: vec![],
};
let v = serde_json::to_value(&dto).unwrap();
assert_eq!(v["scrollback"], json!([]));
}
// ---------------------------------------------------------------------------
// 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<TerminalSession> (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");
}