fabro/lib/foundation/fabro-api/tests/completion_message_round_trip.rs
Bryan Helmkamp 619cb44e3c
Import lithos-llm types directly instead of through fabro-types
fabro-types no longer re-exports the lithos catalog and request types
(ProviderId, ModelId, ModelHandle, Message, ContentPart, TokenCounts,
Cost, Speed, ReasoningEffort, ReasoningOutput, and the rest). Every
crate that uses them depends on lithos-llm and names them there, and
the fabro-api progenitor replacements point at the lithos paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 10:03:08 -06:00

108 lines
3.4 KiB
Rust

//! Proves the `CompletionMessage` / `CompletionMessageRole` /
//! `CompletionContentPart` OpenAPI schemas are served by the lithos
//! `Message`, `Role`, and `ContentPart` types re-exported from `fabro_types`
//! via build.rs `with_replacement`, and that the lithos serde output matches
//! the wire shape the spec describes.
use std::any::{TypeId, type_name};
use fabro_api::types::{ContentPart as ApiContentPart, Message as ApiMessage, Role as ApiRole};
use lithos_llm::types::{ContentPart, Message, Role, ToolCall, ToolResult};
use serde_json::json;
#[test]
fn completion_message_reuses_domain_types() {
assert_same_type::<ApiMessage, Message>();
assert_same_type::<ApiRole, Role>();
assert_same_type::<ApiContentPart, ContentPart>();
}
#[test]
fn role_json_matches_openapi_enum() {
for (role, wire) in [
(Role::System, "system"),
(Role::Developer, "developer"),
(Role::User, "user"),
(Role::Assistant, "assistant"),
(Role::Tool, "tool"),
] {
assert_eq!(serde_json::to_value(role).unwrap(), json!(wire));
assert_eq!(
serde_json::from_value::<Role>(json!(wire)).unwrap(),
role,
"round trip for {wire}"
);
}
}
#[test]
fn message_json_matches_openapi_shape() {
// Optional fields are omitted, not serialized as null.
assert_eq!(
serde_json::to_value(Message::text(Role::User, "hello")).unwrap(),
json!({
"role": "user",
"content": [{"type": "text", "text": "hello"}]
})
);
let message = Message::new(Role::Tool, vec![ContentPart::ToolResult(ToolResult {
tool_call_id: "call_1".to_string(),
name: None,
content: vec![ContentPart::Text {
text: "ok".to_string(),
}],
is_error: false,
})])
.with_name("checker")
.with_tool_call_id("call_1");
assert_eq!(
serde_json::to_value(message).unwrap(),
json!({
"role": "tool",
"content": [{
"type": "tool_result",
"tool_call_id": "call_1",
"content": [{"type": "text", "text": "ok"}],
"is_error": false
}],
"name": "checker",
"tool_call_id": "call_1"
})
);
}
#[test]
fn tool_call_part_json_matches_lithos_shape() {
let part = ContentPart::ToolCall(ToolCall::function(
"call_1",
"write_workflow_file",
json!({"file_name": "workflow.fabro"}),
));
let json = serde_json::to_value(&part).unwrap();
assert_eq!(json["type"], "tool_call");
assert_eq!(json["id"], "call_1");
assert_eq!(json["name"], "write_workflow_file");
let round_trip: ContentPart = serde_json::from_value(json).unwrap();
assert_eq!(round_trip, part);
}
#[test]
fn content_part_preserves_unknown_types() {
// The spec leaves `type` open-ended; unknown types must round-trip so a
// newer writer's parts survive an older reader.
let wire = json!({"type": "mystery", "x": 1});
let part: ContentPart = serde_json::from_value(wire.clone()).unwrap();
assert!(matches!(part, ContentPart::Unknown(_)));
assert_eq!(serde_json::to_value(part).unwrap(), wire);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}