pub(crate) use harn_llm::wire::openai_normalize::*;
fn should_debug_message_shapes() -> bool {
std::env::var("HARN_DEBUG_MESSAGE_SHAPES")
.map(|value| value != "0" && !value.eq_ignore_ascii_case("false"))
.unwrap_or(false)
}
pub(crate) fn debug_log_message_shapes(label: &str, messages: &[serde_json::Value]) {
if !should_debug_message_shapes() {
return;
}
let summary = messages
.iter()
.enumerate()
.map(|(idx, message)| {
let role = message
.get("role")
.and_then(|value| value.as_str())
.unwrap_or("?");
let content_kind = match message.get("content") {
Some(serde_json::Value::String(_)) => "string",
Some(serde_json::Value::Null) => "null",
Some(serde_json::Value::Array(_)) => "array",
Some(serde_json::Value::Object(_)) => "object",
Some(_) => "other",
None => "missing",
};
let has_tool_call_id = message.get("tool_call_id").is_some();
let tool_calls = message
.get("tool_calls")
.and_then(|value| value.as_array())
.map(|calls| calls.len())
.unwrap_or(0);
let has_reasoning = message
.get("reasoning")
.map(|value| !value.is_null())
.unwrap_or(false);
format!(
"#{idx}:{role}:content={content_kind}:tool_call_id={has_tool_call_id}:tool_calls={tool_calls}:reasoning={has_reasoning}"
)
})
.collect::<Vec<_>>()
.join(" | ");
crate::events::log_info("llm.message_shape", &format!("{label}: {summary}"));
}
#[cfg(test)]
mod tests {
use super::{
extract_openai_delta_field_str, extract_openai_message_field_as_text,
normalize_openai_message_text, normalize_openai_style_messages,
};
#[test]
fn normalize_openai_message_text_uses_reasoning_when_content_missing() {
let message = serde_json::json!({
"reasoning": "hello from reasoning"
});
let (visible, thinking) = normalize_openai_message_text(&message, Some("stop"), true);
assert_eq!(visible, "hello from reasoning");
assert_eq!(thinking, "hello from reasoning");
}
#[test]
fn normalize_openai_message_text_merges_reasoning_and_inline_think_blocks() {
let message = serde_json::json!({
"content": "<think>inline reasoning</think>visible answer",
"reasoning": "separate reasoning"
});
let (visible, thinking) = normalize_openai_message_text(&message, Some("stop"), true);
assert_eq!(visible, "visible answer");
assert_eq!(thinking, "separate reasoning\ninline reasoning");
}
#[test]
fn normalize_openai_message_text_does_not_promote_reasoning_when_truncated() {
let message = serde_json::json!({
"content": "",
"reasoning": "Let me think step by step about the problem. First I need to"
});
let (visible, thinking) = normalize_openai_message_text(&message, Some("length"), true);
assert_eq!(visible, "", "truncated reasoning leaked into visible text");
assert_eq!(
thinking,
"Let me think step by step about the problem. First I need to"
);
}
#[test]
fn normalize_openai_message_text_promotes_reasoning_on_clean_stop() {
let message = serde_json::json!({
"content": "",
"reasoning": "the answer is 42"
});
let (visible, thinking) = normalize_openai_message_text(&message, Some("stop"), true);
assert_eq!(visible, "the answer is 42");
assert_eq!(thinking, "the answer is 42");
}
#[test]
fn normalize_openai_message_text_does_not_promote_reasoning_when_tool_call_present() {
let message = serde_json::json!({
"content": "",
"reasoning_content": "We need to write unit tests for the parser. First inspect parser.rs.",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "look", "arguments": "{\"path\":\"parser.rs\"}"}
}]
});
let (visible, thinking) = normalize_openai_message_text(&message, Some("tool_calls"), true);
assert_eq!(
visible, "",
"reasoning leaked into visible text on a tool-call turn"
);
assert_eq!(
thinking,
"We need to write unit tests for the parser. First inspect parser.rs."
);
}
#[test]
fn normalize_openai_message_text_does_not_promote_reasoning_when_tools_are_offered() {
let message = serde_json::json!({
"content": "",
"reasoning_content": "<tool_call>\nrun({ command: \"echo should-not-run\" })\n</tool_call>"
});
let (visible, thinking) = normalize_openai_message_text(&message, Some("stop"), false);
assert_eq!(
visible, "",
"tools-offered reasoning leaked into visible text"
);
assert_eq!(
thinking,
"<tool_call>\nrun({ command: \"echo should-not-run\" })\n</tool_call>"
);
}
#[test]
fn normalize_openai_message_text_keeps_structured_reasoning_private() {
let message = serde_json::json!({
"content": [
{
"type": "reasoning",
"text": "<tool_call>\nrun({ command: \"echo should-not-run\" })\n</tool_call>"
},
{ "type": "output_text", "text": "Done." }
]
});
let (visible, thinking) = normalize_openai_message_text(&message, Some("stop"), false);
assert_eq!(visible, "Done.");
assert_eq!(
thinking,
"<tool_call>\nrun({ command: \"echo should-not-run\" })\n</tool_call>"
);
}
#[test]
fn normalize_openai_style_messages_does_not_stringify_structured_reasoning() {
let messages = vec![serde_json::json!({
"role": "assistant",
"content": [
{ "type": "reasoning", "text": "private plan" },
{ "type": "output_text", "text": "public answer" }
]
})];
let normalized = normalize_openai_style_messages(messages, true);
assert_eq!(
normalized[0]
.get("content")
.and_then(|value| value.as_str()),
Some("public answer")
);
}
#[test]
fn normalize_openai_message_text_uses_minimax_reasoning_details() {
let message = serde_json::json!({
"content": "visible answer",
"reasoning_details": "minimax private trace"
});
let (visible, thinking) = normalize_openai_message_text(&message, Some("stop"), true);
assert_eq!(visible, "visible answer");
assert_eq!(thinking, "minimax private trace");
}
#[test]
fn normalize_openai_message_text_can_keep_reasoning_private_without_content() {
let message = serde_json::json!({
"content": "",
"reasoning": "private trace ending with OK"
});
let (visible, thinking) = normalize_openai_message_text(&message, Some("stop"), false);
assert_eq!(visible, "");
assert_eq!(thinking, "private trace ending with OK");
}
#[test]
fn extract_openai_delta_field_str_returns_raw_chunk_with_inter_token_whitespace() {
for chunk in [r#""Here""#, r#""'s""#, r#"" a""#, r#"" thinking""#] {
let delta: serde_json::Value =
serde_json::from_str(&format!(r#"{{"reasoning":{chunk}}}"#)).unwrap();
let raw = extract_openai_delta_field_str(&delta, &["reasoning", "reasoning_content"]);
assert_eq!(raw, chunk.trim_matches('"'));
}
}
#[test]
fn extract_openai_delta_field_str_prefers_first_present_field() {
let delta = serde_json::json!({
"reasoning_content": "from-content",
"reasoning": "from-bare",
});
let raw = extract_openai_delta_field_str(&delta, &["reasoning", "reasoning_content"]);
assert_eq!(raw, "from-bare");
}
#[test]
fn extract_openai_delta_field_str_skips_empty_fields() {
let delta = serde_json::json!({
"reasoning": "",
"reasoning_content": " token-with-leading-space",
});
let raw = extract_openai_delta_field_str(&delta, &["reasoning", "reasoning_content"]);
assert_eq!(raw, " token-with-leading-space");
}
#[test]
fn extract_openai_delta_field_str_returns_empty_for_missing_fields() {
let delta = serde_json::json!({"content": "anything"});
let raw = extract_openai_delta_field_str(&delta, &["reasoning", "reasoning_content"]);
assert!(raw.is_empty());
}
#[test]
fn extract_openai_message_field_still_paragraph_joins_for_non_streaming_blocks() {
let message = serde_json::json!({
"reasoning": " block one ",
"reasoning_content": "block two",
});
let combined =
extract_openai_message_field_as_text(&message, &["reasoning", "reasoning_content"]);
assert_eq!(combined, "block one\nblock two");
}
}