diff --git a/crates/tinyinference-llm/src/providers/openai/mod_tests.rs b/crates/tinyinference-llm/src/providers/openai/mod_tests.rs index 76fab26..8f90e7a 100644 --- a/crates/tinyinference-llm/src/providers/openai/mod_tests.rs +++ b/crates/tinyinference-llm/src/providers/openai/mod_tests.rs @@ -2003,6 +2003,28 @@ fn merge_system_preserves_user_image_blocks() { } } +#[test] +fn folded_system_messages_keep_their_json_blocks_as_sent_unfolded() { + // A system message's JSON block is serialized on the pass-through path; + // hoisting or merging it into the user turn must not drop it, and its + // blocks join exactly as the pass-through path joins them. + use crate::message::SystemMessage; + let structured = Message::System(SystemMessage { + content: vec![ + ContentBlock::Text("Context: ".to_string()), + ContentBlock::Json(serde_json::json!({"tenant": "acme"})), + ], + ..Default::default() + }); + let sent_as_is = r#"Context: {"tenant":"acme"}"#; + + let hoisted = single_system_message(&[Message::user("hi"), structured.clone()]); + assert_eq!(hoisted[0].text(), sent_as_is); + + let merged = merge_system_into_user(&[structured, Message::user("hi")]); + assert_eq!(merged[0].text(), format!("{sent_as_is}\n\nhi")); +} + #[test] fn merge_system_is_noop_without_system_messages() { let input = vec![Message::user("just user")]; diff --git a/crates/tinyinference-llm/src/providers/openai/transport.rs b/crates/tinyinference-llm/src/providers/openai/transport.rs index e7fa8bf..b17b4eb 100644 --- a/crates/tinyinference-llm/src/providers/openai/transport.rs +++ b/crates/tinyinference-llm/src/providers/openai/transport.rs @@ -186,17 +186,20 @@ pub(super) fn auth_headers(auth: &AuthStyle, api_key: &str) -> Vec<(String, Stri } } -/// Concatenates the text of a message's content blocks (non-text blocks — images, -/// json, thinking — are ignored). Used to gather system-prompt text for merging. +/// A system message's content as the pass-through path sends it +/// (`translate_text_content`): text verbatim, JSON serialized, reasoning +/// dropped, blocks joined with nothing between them. Folding with anything +/// else would make a hoisted or merged prompt differ from the same prompt sent +/// as-is, and would silently drop JSON blocks. fn content_text(content: &[ContentBlock]) -> String { content .iter() .filter_map(|block| match block { - ContentBlock::Text(text) => Some(text.as_str()), + ContentBlock::Text(text) => Some(text.clone()), + ContentBlock::Json(value) => Some(value.to_string()), _ => None, }) - .collect::>() - .join("\n") + .collect() } /// Folds all `system` messages into the first `user` message and drops the