Files
IdeA/crates/app-tauri/tests/dto_chat.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

251 lines
8.7 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, ReattachChatDto, ReplyChunk, TerminalSessionDto};
use application::{LaunchAgentOutput, StructuredSessionDescriptor};
use domain::project::ProjectPath;
use domain::SessionId;
use domain::{AgentId, NodeId, PtySize, SessionKind, SessionStatus, TerminalSession};
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_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::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");
}
// ---------------------------------------------------------------------------
// 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");
}