diff --git a/crates/lingua/src/import_parse.rs b/crates/lingua/src/import_parse.rs new file mode 100644 index 00000000..2662be35 --- /dev/null +++ b/crates/lingua/src/import_parse.rs @@ -0,0 +1,63 @@ +use crate::serde_json::{self, Value}; +use crate::universal::convert::TryFromLLM; +use crate::universal::Message; +use serde::de::DeserializeOwned; + +pub(crate) type MessageParser = fn(&Value) -> Option>; + +pub(crate) fn try_parsers_in_order( + data: &Value, + parsers: &[MessageParser], +) -> Option> { + for parser in parsers { + if let Some(messages) = parser(data) { + if !messages.is_empty() { + return Some(messages); + } + } + } + None +} + +pub(crate) fn non_empty_messages(messages: Vec) -> Option> { + if messages.is_empty() { + None + } else { + Some(messages) + } +} + +pub(crate) fn try_parse(data: &Value) -> Option +where + T: DeserializeOwned, +{ + serde_json::from_value::(data.clone()).ok() +} + +pub(crate) fn try_convert_non_empty(value: T) -> Option> +where + Vec: TryFromLLM, +{ + let messages = as TryFromLLM>::try_from(value).ok()?; + non_empty_messages(messages) +} + +pub(crate) fn try_parse_and_convert(data: &Value) -> Option> +where + T: DeserializeOwned, + Vec: TryFromLLM, +{ + let value = try_parse::(data)?; + try_convert_non_empty(value) +} + +pub(crate) fn try_parse_vec_or_single(data: &Value) -> Option> +where + T: DeserializeOwned, +{ + match data { + Value::Array(_) => try_parse::>(data), + Value::Object(_) => try_parse::(data).map(|item| vec![item]), + _ => None, + } +} diff --git a/crates/lingua/src/lib.rs b/crates/lingua/src/lib.rs index 1eb7e8be..cf75cedb 100644 --- a/crates/lingua/src/lib.rs +++ b/crates/lingua/src/lib.rs @@ -9,6 +9,7 @@ pub use bytes::Bytes; pub mod capabilities; pub mod error; mod extraction; +mod import_parse; pub mod processing; pub mod providers; pub mod universal; diff --git a/crates/lingua/src/processing/import.rs b/crates/lingua/src/processing/import.rs index f4bb0509..2ee56221 100644 --- a/crates/lingua/src/processing/import.rs +++ b/crates/lingua/src/processing/import.rs @@ -1,6 +1,15 @@ +use crate::import_parse::{try_parsers_in_order, MessageParser}; +#[cfg(feature = "anthropic")] +use crate::providers::anthropic::convert::try_parse_anthropic_for_import; +#[cfg(feature = "anthropic")] use crate::providers::anthropic::generated as anthropic; +#[cfg(feature = "bedrock")] +use crate::providers::bedrock::convert::try_parse_bedrock_for_import; +#[cfg(feature = "google")] +use crate::providers::google::convert::try_parse_google_for_import; +#[cfg(feature = "openai")] use crate::providers::openai::convert::{ - try_parse_responses_items_for_import, ChatCompletionRequestMessageExt, + try_parse_openai_for_import, ChatCompletionRequestMessageExt, }; use crate::serde_json; use crate::serde_json::Value; @@ -25,7 +34,7 @@ pub struct Span { /// Try to convert a value to lingua messages by attempting multiple format conversions fn try_converting_to_messages(data: &Value) -> Vec { - if let Some(messages) = try_parse_responses_items_for_import(data) { + if let Some(messages) = try_parse_provider_messages_for_import(data) { return messages; } @@ -85,24 +94,29 @@ fn try_converting_to_messages(data: &Value) -> Vec { // Try Chat Completions format (most common) // Use extended type to capture reasoning field from vLLM/OpenRouter convention - if let Ok(provider_messages) = - serde_json::from_value::>(data_to_parse.clone()) + #[cfg(feature = "openai")] { - if let Ok(messages) = - as TryFromLLM>>::try_from( - provider_messages, - ) + if let Ok(provider_messages) = + serde_json::from_value::>(data_to_parse.clone()) { - if !messages.is_empty() { - return messages; + if let Ok(messages) = as TryFromLLM< + Vec, + >>::try_from(provider_messages) + { + if !messages.is_empty() { + return messages; + } } } } // Try Anthropic format (including role-based system/developer messages). - if let Some(anthropic_messages) = try_anthropic_or_system_messages(data_to_parse) { - if !anthropic_messages.is_empty() { - return anthropic_messages; + #[cfg(feature = "anthropic")] + { + if let Some(anthropic_messages) = try_anthropic_or_system_messages(data_to_parse) { + if !anthropic_messages.is_empty() { + return anthropic_messages; + } } } @@ -124,6 +138,22 @@ fn try_converting_to_messages(data: &Value) -> Vec { Vec::new() } +fn try_parse_provider_messages_for_import(data: &Value) -> Option> { + let provider_parsers: Vec = vec![ + #[cfg(feature = "openai")] + try_parse_openai_for_import, + #[cfg(feature = "anthropic")] + try_parse_anthropic_for_import, + #[cfg(feature = "google")] + try_parse_google_for_import, + #[cfg(feature = "bedrock")] + try_parse_bedrock_for_import, + ]; + + try_parsers_in_order(data, &provider_parsers) +} + +#[cfg(feature = "anthropic")] #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] enum AnthropicOrSystemMessage { @@ -131,12 +161,14 @@ enum AnthropicOrSystemMessage { SystemOrDeveloper(SystemOrDeveloperMessage), } +#[cfg(feature = "anthropic")] #[derive(Debug, Clone, Serialize, Deserialize)] struct SystemOrDeveloperMessage { role: SystemOrDeveloperRole, content: Value, } +#[cfg(feature = "anthropic")] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] enum SystemOrDeveloperRole { @@ -144,6 +176,7 @@ enum SystemOrDeveloperRole { Developer, } +#[cfg(feature = "anthropic")] fn try_parse_anthropic_or_system_message(item: AnthropicOrSystemMessage) -> Option { match item { AnthropicOrSystemMessage::Anthropic(provider_message) => { @@ -156,6 +189,7 @@ fn try_parse_anthropic_or_system_message(item: AnthropicOrSystemMessage) -> Opti } } +#[cfg(feature = "anthropic")] fn try_anthropic_or_system_messages(data: &Value) -> Option> { let items: Vec = serde_json::from_value(data.clone()).ok()?; if items.is_empty() { diff --git a/crates/lingua/src/providers/anthropic/convert.rs b/crates/lingua/src/providers/anthropic/convert.rs index d1fb44bb..fc94477a 100644 --- a/crates/lingua/src/providers/anthropic/convert.rs +++ b/crates/lingua/src/providers/anthropic/convert.rs @@ -2,6 +2,10 @@ use std::convert::TryFrom; use crate::capabilities::ProviderFormat; use crate::error::ConvertError; +use crate::import_parse::{ + try_convert_non_empty, try_parse, try_parse_and_convert, try_parse_vec_or_single, + try_parsers_in_order, MessageParser, +}; use crate::providers::anthropic::generated; use crate::providers::anthropic::generated::{ CustomTool, JsonOutputFormat, JsonOutputFormatType, Tool, ToolChoice, ToolChoiceType, @@ -867,6 +871,64 @@ impl TryFromLLM for generated::InputMessage { } } +pub(crate) fn try_parse_content_blocks_for_import( + data: &serde_json::Value, +) -> Option> { + let blocks = try_parse_vec_or_single::(data)?; + try_convert_non_empty(blocks) +} + +fn try_messages_from_anthropic_request( + request: generated::CreateMessageParams, +) -> Option> { + let mut messages = Vec::new(); + + if let Some(system) = request.system { + messages.push(Message::System { + content: system_to_user_content(system), + }); + } + + let mut request_messages = + as TryFromLLM>>::try_from(request.messages) + .ok()?; + messages.append(&mut request_messages); + + if messages.is_empty() { + None + } else { + Some(messages) + } +} + +fn try_messages_from_anthropic_response(response: generated::Message) -> Option> { + try_convert_non_empty(response.content) +} + +fn try_parse_input_messages_for_import(data: &serde_json::Value) -> Option> { + try_parse_and_convert::>(data) +} + +fn try_parse_anthropic_request_for_import(data: &serde_json::Value) -> Option> { + let request = try_parse::(data)?; + try_messages_from_anthropic_request(request) +} + +fn try_parse_anthropic_response_for_import(data: &serde_json::Value) -> Option> { + let response = try_parse::(data)?; + try_messages_from_anthropic_response(response) +} + +pub(crate) fn try_parse_anthropic_for_import(data: &serde_json::Value) -> Option> { + const PARSERS: &[MessageParser] = &[ + try_parse_content_blocks_for_import, + try_parse_input_messages_for_import, + try_parse_anthropic_request_for_import, + try_parse_anthropic_response_for_import, + ]; + try_parsers_in_order(data, PARSERS) +} + // Convert from Anthropic response ContentBlock to Universal Message impl TryFromLLM> for Vec { type Error = ConvertError; diff --git a/crates/lingua/src/providers/bedrock/convert.rs b/crates/lingua/src/providers/bedrock/convert.rs index 24284ce5..33c5b029 100644 --- a/crates/lingua/src/providers/bedrock/convert.rs +++ b/crates/lingua/src/providers/bedrock/convert.rs @@ -6,12 +6,17 @@ AWS Bedrock's Converse API format and Lingua's universal message format. */ use crate::error::ConvertError; +use crate::import_parse::{ + try_convert_non_empty, try_parse, try_parse_vec_or_single, try_parsers_in_order, MessageParser, +}; use crate::providers::bedrock::request::{ BedrockContentBlock, BedrockConversationRole, BedrockImageBlock, BedrockImageFormat, BedrockImageSource, BedrockMessage, BedrockToolResultBlock, BedrockToolResultContent, BedrockToolUseBlock, ConverseRequest, }; -use crate::providers::bedrock::response::{BedrockOutputContentBlock, BedrockOutputMessage}; +use crate::providers::bedrock::response::{ + BedrockOutputContentBlock, BedrockOutputMessage, ConverseResponse, +}; use crate::serde_json::{self, Value}; use crate::universal::convert::TryFromLLM; use crate::universal::message::{ @@ -282,6 +287,65 @@ pub fn universal_to_bedrock(messages: &[Message]) -> Result }) } +fn try_messages_from_bedrock_messages(messages: Vec) -> Option> { + try_convert_non_empty(messages) +} + +fn try_message_from_bedrock_output_message(message: BedrockOutputMessage) -> Option> { + if message.role != "assistant" { + return None; + } + + let message = >::try_from(message).ok()?; + Some(vec![message]) +} + +fn try_messages_from_bedrock_output_messages( + output_messages: Vec, +) -> Option> { + if output_messages + .iter() + .any(|message| message.role != "assistant") + { + return None; + } + + try_convert_non_empty(output_messages) +} + +fn try_parse_bedrock_message_for_import(data: &Value) -> Option> { + let messages = try_parse_vec_or_single::(data)?; + try_messages_from_bedrock_messages(messages) +} + +fn try_parse_bedrock_request_for_import(data: &Value) -> Option> { + let request = try_parse::(data)?; + try_messages_from_bedrock_messages(request.messages) +} + +fn try_parse_bedrock_output_message_for_import(data: &Value) -> Option> { + let output_messages = try_parse_vec_or_single::(data)?; + if output_messages.len() == 1 { + return try_message_from_bedrock_output_message(output_messages.into_iter().next()?); + } + try_messages_from_bedrock_output_messages(output_messages) +} + +fn try_parse_bedrock_response_for_import(data: &Value) -> Option> { + let response = try_parse::(data)?; + try_message_from_bedrock_output_message(response.output.message) +} + +pub(crate) fn try_parse_bedrock_for_import(data: &Value) -> Option> { + const PARSERS: &[MessageParser] = &[ + try_parse_bedrock_message_for_import, + try_parse_bedrock_request_for_import, + try_parse_bedrock_output_message_for_import, + try_parse_bedrock_response_for_import, + ]; + try_parsers_in_order(data, PARSERS) +} + // ============================================================================ // BedrockOutputMessage (Response) -> Universal Message // ============================================================================ diff --git a/crates/lingua/src/providers/google/convert.rs b/crates/lingua/src/providers/google/convert.rs index bc1fc0fa..13c292e1 100644 --- a/crates/lingua/src/providers/google/convert.rs +++ b/crates/lingua/src/providers/google/convert.rs @@ -7,11 +7,15 @@ Google's GenerateContent API format and Lingua's universal message format. use crate::capabilities::ProviderFormat; use crate::error::ConvertError; +use crate::import_parse::{ + try_convert_non_empty, try_parse, try_parse_vec_or_single, try_parsers_in_order, MessageParser, +}; use crate::providers::google::generated::{ - Blob as GoogleBlob, Content as GoogleContent, FileData as GoogleFileData, - FinishReason as GoogleFinishReason, FunctionCall as GoogleFunctionCall, FunctionCallingConfig, - FunctionCallingConfigMode, FunctionDeclaration, FunctionResponse as GoogleFunctionResponse, - GenerateContentRequest, GenerationConfig, Part as GooglePart, Tool as GoogleTool, ToolConfig, + Blob as GoogleBlob, Candidate as GoogleCandidate, Content as GoogleContent, + FileData as GoogleFileData, FinishReason as GoogleFinishReason, + FunctionCall as GoogleFunctionCall, FunctionCallingConfig, FunctionCallingConfigMode, + FunctionDeclaration, FunctionResponse as GoogleFunctionResponse, GenerateContentRequest, + GenerateContentResponse, GenerationConfig, Part as GooglePart, Tool as GoogleTool, ToolConfig, UsageMetadata, }; use crate::serde_json::{self, Map, Value}; @@ -466,6 +470,55 @@ pub fn universal_to_google(messages: &[Message]) -> Result }) } +fn try_messages_from_google_contents(contents: Vec) -> Option> { + try_convert_non_empty(contents) +} + +fn try_messages_from_google_candidates(candidates: Vec) -> Option> { + let contents: Vec = candidates + .into_iter() + .filter_map(|candidate| candidate.content) + .collect(); + + if contents.is_empty() { + None + } else { + try_messages_from_google_contents(contents) + } +} + +fn try_parse_google_content_for_import(data: &Value) -> Option> { + let contents = try_parse_vec_or_single::(data)?; + try_messages_from_google_contents(contents) +} + +fn try_parse_google_request_for_import(data: &Value) -> Option> { + let request = try_parse::(data)?; + let contents = request.contents?; + try_messages_from_google_contents(contents) +} + +fn try_parse_google_response_for_import(data: &Value) -> Option> { + let response = try_parse::(data)?; + let candidates = response.candidates?; + try_messages_from_google_candidates(candidates) +} + +fn try_parse_google_candidate_for_import(data: &Value) -> Option> { + let candidates = try_parse_vec_or_single::(data)?; + try_messages_from_google_candidates(candidates) +} + +pub(crate) fn try_parse_google_for_import(data: &Value) -> Option> { + const PARSERS: &[MessageParser] = &[ + try_parse_google_content_for_import, + try_parse_google_request_for_import, + try_parse_google_response_for_import, + try_parse_google_candidate_for_import, + ]; + try_parsers_in_order(data, PARSERS) +} + impl From<&FunctionDeclaration> for UniversalTool { fn from(decl: &FunctionDeclaration) -> Self { let parameters = decl.parameters_json_schema.clone().or_else(|| { diff --git a/crates/lingua/src/providers/openai/convert.rs b/crates/lingua/src/providers/openai/convert.rs index 0de435ca..da92cfb0 100644 --- a/crates/lingua/src/providers/openai/convert.rs +++ b/crates/lingua/src/providers/openai/convert.rs @@ -1,4 +1,7 @@ use crate::error::ConvertError; +use crate::import_parse::{ + non_empty_messages, try_convert_non_empty, try_parse, try_parse_vec_or_single, +}; use crate::providers::openai::generated as openai; use crate::serde_json; use crate::universal::convert::TryFromLLM; @@ -260,35 +263,15 @@ fn merge_adjacent_reasoning_assistant_messages(messages: Vec) -> Vec Option> { - let wrapped; - let candidate = if candidate.is_object() { - wrapped = serde_json::Value::Array(vec![candidate.clone()]); - &wrapped - } else { - candidate - }; - - if let Ok(provider_messages) = - serde_json::from_value::>(candidate.clone()) - { - if let Ok(messages) = - as TryFromLLM>>::try_from(provider_messages) - { - if !messages.is_empty() { - return Some(merge_adjacent_reasoning_assistant_messages(messages)); - } + if let Some(provider_messages) = try_parse_vec_or_single::(candidate) { + if let Some(messages) = try_convert_non_empty(provider_messages) { + return non_empty_messages(merge_adjacent_reasoning_assistant_messages(messages)); } } - if let Ok(provider_messages) = - serde_json::from_value::>(candidate.clone()) - { - if let Ok(messages) = - as TryFromLLM>>::try_from(provider_messages) - { - if !messages.is_empty() { - return Some(merge_adjacent_reasoning_assistant_messages(messages)); - } + if let Some(provider_messages) = try_parse_vec_or_single::(candidate) { + if let Some(messages) = try_convert_non_empty(provider_messages) { + return non_empty_messages(merge_adjacent_reasoning_assistant_messages(messages)); } } @@ -306,6 +289,39 @@ pub(crate) fn try_parse_responses_items_for_import( try_from_responses_items_candidate(&normalized) } +fn try_messages_from_openai_instructions(input: openai::Instructions) -> Option> { + match input { + openai::Instructions::InputItemArray(items) => { + let messages = try_convert_non_empty(items)?; + non_empty_messages(merge_adjacent_reasoning_assistant_messages(messages)) + } + openai::Instructions::String(text) => Some(vec![Message::User { + content: UserContent::String(text), + }]), + } +} + +pub(crate) fn try_parse_openai_for_import(data: &serde_json::Value) -> Option> { + if let Some(messages) = try_parse_responses_items_for_import(data) { + return Some(messages); + } + + if let Some(request) = try_parse::(data) { + if let Some(input) = request.input { + if let Some(messages) = try_messages_from_openai_instructions(input) { + return Some(messages); + } + } + } + + if let Some(response) = try_parse::(data) { + let messages = try_convert_non_empty(response.output)?; + return non_empty_messages(merge_adjacent_reasoning_assistant_messages(messages)); + } + + None +} + /// Convert OpenAI InputItem collection to universal Message collection /// This handles OpenAI-specific logic for combining or transforming multiple items impl TryFromLLM> for Vec { diff --git a/payloads/import-cases/adk-basic-input-output.assertions.json b/payloads/import-cases/adk-basic-input-output.assertions.json index 9ad3ab64..ec5c1bc5 100644 --- a/payloads/import-cases/adk-basic-input-output.assertions.json +++ b/payloads/import-cases/adk-basic-input-output.assertions.json @@ -1,6 +1,9 @@ { - "_migrationNote": "Diverges from app/ui/trace/converters/adk-converter.test.ts (basic ADK input/output): the old converter normalized ADK/Gemini-shaped `contents/parts` + `content.parts` into user+assistant messages, but lingua import_messages_from_spans currently imports 0 messages for this ADK span shape.", - "expectedMessageCount": 0, - "expectedRolesInOrder": [], + "_migrationNote": "Now aligned with app/ui/trace/converters/adk-converter.test.ts (basic ADK input/output): lingua imports ADK/Gemini-shaped `contents/parts` + `content.parts` as user+assistant messages.", + "expectedMessageCount": 2, + "expectedRolesInOrder": [ + "user", + "assistant" + ], "mustContainText": [] } diff --git a/payloads/import-cases/anthropic-thinking-output-with-system-input.assertions.json b/payloads/import-cases/anthropic-thinking-output-with-system-input.assertions.json new file mode 100644 index 00000000..cd80daa3 --- /dev/null +++ b/payloads/import-cases/anthropic-thinking-output-with-system-input.assertions.json @@ -0,0 +1,12 @@ +{ + "expectedMessageCount": 3, + "expectedRolesInOrder": [ + "system", + "user", + "assistant" + ], + "mustContainText": [ + "anon_15", + "anon_16" + ] +} diff --git a/payloads/import-cases/anthropic-thinking-output-with-system-input.json b/payloads/import-cases/anthropic-thinking-output-with-system-input.json new file mode 100644 index 00000000..ae8453d3 --- /dev/null +++ b/payloads/import-cases/anthropic-thinking-output-with-system-input.json @@ -0,0 +1,96 @@ +[ + { + "_pagination_key": "p07611302904676089857", + "_xact_id": "1000196731518474014", + "classifications": null, + "created": "2026-02-26T22:20:12.210Z", + "facets": null, + "id": "dfb401fe2b5514b4", + "metrics": { + "completion_tokens": 8666, + "end": 1772144552.146522, + "prompt_tokens": 171566, + "start": 1772144412.210261, + "tokens": 180232, + "estimated_cost": 0.6446879999999999 + }, + "model": "claude-sonnet-4-5-20250929", + "origin": null, + "root_span_id": "e96c91ed31ece85733866e1705eaba8d", + "scores": null, + "span_attributes": { + "name": "GenerateSelectFieldOptionDescriptions", + "type": "llm" + }, + "span_id": "dfb401fe2b5514b4", + "tags": [], + "_async_scoring_state": null, + "audit_data": [ + { + "_xact_id": "1000196731518474014", + "audit_data": { + "action": "upsert" + }, + "metadata": {}, + "source": "api" + } + ], + "input": [ + { + "content": "anon_1", + "role": "system" + }, + { + "content": "anon_2", + "role": "user" + } + ], + "is_root": true, + "log_id": "g", + "metadata": { + "braintrust.metrics.completion_tokens": 8666, + "braintrust.metrics.end": 1772144552.146522, + "braintrust.metrics.prompt_tokens": 171566, + "braintrust.metrics.start": 1772144412.210261, + "braintrust.metrics.tokens": 180232, + "custom_field_definition_id": "anon_3", + "detailed_tokens": { + "completion_tokens": 8666, + "prompt_tokens": 171566 + }, + "ecsService": "anon_4", + "gen_ai.operation.name": "anon_5", + "gen_ai.provider.name": "anon_6", + "gen_ai.request.model": "anon_7", + "gen_ai.response.model": "anon_7", + "gen_ai.usage.input_tokens": 171566, + "gen_ai.usage.output_tokens": 8666, + "input": { + "custom_field_name": "anon_8", + "custom_field_value_options": "anon_9", + "sampled_issue_data": "anon_10" + }, + "model": "claude-sonnet-4-5-20250929", + "org_id": "anon_11", + "org_name": "anon_12", + "promptName": "anon_13", + "pylon.org.id": "anon_11", + "pylon.org.name": "anon_12", + "pylon.prompt.name": "anon_13", + "pylon.span.emit_always": true + }, + "org_id": "f1b9d63a-0673-4a90-a9aa-fcda2f5313b0", + "output": [ + { + "signature": "anon_14", + "thinking": "anon_15", + "type": "thinking" + }, + { + "text": "anon_16", + "type": "text" + } + ], + "project_id": "b2fe0186-16e9-429d-8ec5-123da617ef13" + } +] diff --git a/payloads/import-cases/gemini-basic-generate-content.assertions.json b/payloads/import-cases/gemini-basic-generate-content.assertions.json index e6521c90..8dd33f67 100644 --- a/payloads/import-cases/gemini-basic-generate-content.assertions.json +++ b/payloads/import-cases/gemini-basic-generate-content.assertions.json @@ -1,6 +1,8 @@ { - "_migrationNote": "Diverges from app/ui/trace/converters/gemini-converter.test.ts (Gemini input without output): the old converter normalized Gemini `contents/parts` into a user chat message, but lingua import_messages_from_spans currently imports 0 messages for raw Gemini generateContent request shape.", - "expectedMessageCount": 0, - "expectedRolesInOrder": [], + "_migrationNote": "Now aligned with app/ui/trace/converters/gemini-converter.test.ts (Gemini input without output): lingua imports raw Gemini `contents/parts` request shape as a user message.", + "expectedMessageCount": 1, + "expectedRolesInOrder": [ + "user" + ], "mustContainText": [] } diff --git a/payloads/import-cases/mastra-agent-run-parts-input.assertions.json b/payloads/import-cases/mastra-agent-run-parts-input.assertions.json index 92f83c9d..a762bc7f 100644 --- a/payloads/import-cases/mastra-agent-run-parts-input.assertions.json +++ b/payloads/import-cases/mastra-agent-run-parts-input.assertions.json @@ -1,6 +1,8 @@ { - "_migrationNote": "Diverges from app/ui/trace/converters/mastra-response-converter.test.ts (agent_run parts-based input): the old converter normalized `parts` input plus `{text}` output into user+assistant messages, but lingua import_messages_from_spans currently imports 0 messages for this Mastra-specific shape.", - "expectedMessageCount": 0, - "expectedRolesInOrder": [], + "_migrationNote": "Partially aligned with app/ui/trace/converters/mastra-response-converter.test.ts (agent_run parts-based input): lingua now imports the `parts` user input, but still does not synthesize an assistant message from `{text}` output.", + "expectedMessageCount": 1, + "expectedRolesInOrder": [ + "user" + ], "mustContainText": [] }