met 1.0.0-beta-preview

Metrale Engine: pure Rust LLM inference server (HTTP API, scheduler, TUI, CLI).
// SPDX-License-Identifier: MIT OR Apache-2.0

//! 2026-09-26: Tests for Responses-API lowering (tool shapes, `tool_choice`, input items)
//! and for Responses stream event names and reasoning wire shapes.
//!
//! Owner: server (OpenAI API layer) tests.
//! Invariants: none beyond the types.

use crate::openai::*;

fn lower_with_tools(
    tools: serde_json::Value,
) -> Result<ChatCompletionRequest, LowerResponsesError> {
    let req: ResponsesRequest = serde_json::from_value(serde_json::json!({
        "model": "test-model",
        "input": "ping",
        "tools": tools,
    }))
    .expect("ResponsesRequest deserialize");
    lower_responses_to_chat(req, |_| None)
}

#[test]
fn responses_flat_function_tool_accepted() {
    let chat = lower_with_tools(serde_json::json!([
        {
            "type": "function",
            "name": "get_weather",
            "description": "look up weather",
            "parameters": {"type": "object", "properties": {"loc": {"type": "string"}}, "required": ["loc"]}
        }
    ])).expect("flat-form function tool should parse");
    let tools = chat.tools.expect("tools present");
    assert_eq!(tools.len(), 1);
    assert_eq!(tools[0].tool_type, "function");
    assert_eq!(tools[0].function.name, "get_weather");
}

#[test]
fn responses_nested_function_tool_still_accepted() {
    let chat = lower_with_tools(serde_json::json!([
        {
            "type": "function",
            "function": {
                "name": "get_weather",
                "parameters": {"type": "object"}
            }
        }
    ]))
    .expect("nested-form function tool should parse");
    let tools = chat.tools.expect("tools present");
    assert_eq!(tools.len(), 1);
    assert_eq!(tools[0].function.name, "get_weather");
}

#[test]
fn responses_flat_tool_choice_accepted() {
    let req: ResponsesRequest = serde_json::from_value(serde_json::json!({
        "model": "test",
        "input": "ping",
        "tool_choice": {"type": "function", "name": "get_weather"},
    }))
    .unwrap();
    let chat = lower_responses_to_chat(req, |_| None).expect("flat tool_choice");
    match chat.tool_choice {
        Some(crate::tool_parser::ToolChoice::Specific { function }) => {
            assert_eq!(function.name, "get_weather");
        }
        other => panic!("expected Specific tool_choice, got {other:?}"),
    }
}

#[test]
fn responses_string_tool_choice_accepted() {
    let req: ResponsesRequest = serde_json::from_value(serde_json::json!({
        "model": "test",
        "input": "ping",
        "tool_choice": "required",
    }))
    .unwrap();
    let chat = lower_responses_to_chat(req, |_| None).expect("string tool_choice");
    match chat.tool_choice {
        Some(crate::tool_parser::ToolChoice::Mode(s)) => {
            assert_eq!(s, "required");
        }
        other => panic!("expected Mode tool_choice, got {other:?}"),
    }
}

#[test]
fn responses_input_image_string_url_is_carried() {
    let item = serde_json::json!({
        "type": "message",
        "role": "user",
        "content": [
            {"type": "input_text", "text": "what is this?"},
            {"type": "input_image", "image_url": "data:image/png;base64,AAA"}
        ]
    });
    let m = IncomingMessage::from_responses_input_item(&item).expect("message");
    assert_eq!(m.content.text, "what is this?");
    assert_eq!(
        m.content.images().collect::<Vec<_>>(),
        vec!["data:image/png;base64,AAA"]
    );
}

#[test]
fn responses_input_image_object_url_is_carried() {
    let item = serde_json::json!({
        "type": "message",
        "role": "user",
        "content": [
            {"type": "input_image", "image_url": {"url": "https://example.com/a.png"}}
        ]
    });
    let m = IncomingMessage::from_responses_input_item(&item).expect("message");
    assert_eq!(
        m.content.images().collect::<Vec<_>>(),
        vec!["https://example.com/a.png"]
    );
    assert_eq!(m.content.text, "");
}

#[test]
fn responses_function_call_output_image_is_carried() {
    let item = serde_json::json!({
        "type": "function_call_output",
        "call_id": "call_1",
        "output": [
            {"type": "output_text", "text": "screenshot follows"},
            {"type": "input_image", "image_url": "data:image/png;base64,BBB"}
        ]
    });
    let m = IncomingMessage::from_responses_input_item(&item).expect("tool message");
    assert_eq!(m.role, "tool");
    assert_eq!(m.tool_call_id.as_deref(), Some("call_1"));
    assert_eq!(m.content.text, "screenshot follows");
    assert_eq!(
        m.content.images().collect::<Vec<_>>(),
        vec!["data:image/png;base64,BBB"]
    );
}

#[test]
fn responses_function_call_output_string_unchanged() {
    let item = serde_json::json!({
        "type": "function_call_output",
        "call_id": "call_2",
        "output": "plain result"
    });
    let m = IncomingMessage::from_responses_input_item(&item).expect("tool message");
    assert_eq!(m.content.text, "plain result");
    assert!(!m.content.has_images());
}

#[test]
fn responses_function_call_output_opaque_array_stringified() {
    // 2026-09-26: An output array with no text or image part is carried as its JSON text.
    let opaque = serde_json::json!([{"weather": "sunny", "temp_c": 21}]);
    let item = serde_json::json!({
        "type": "function_call_output",
        "call_id": "call_3",
        "output": opaque.clone()
    });
    let m = IncomingMessage::from_responses_input_item(&item).expect("tool message");
    assert_eq!(m.content.text, opaque.to_string());
    assert!(!m.content.has_images());
}

#[test]
fn responses_in_progress_event_name() {
    let ev = ResponsesStreamEvent::InProgress {
        sequence_number: 1,
        response: ResponsesStreamEnvelope {
            id: "resp_test".into(),
            object: "response",
            created_at: 0,
            model: "m".into(),
            status: "in_progress",
            metadata: None,
        },
    };
    assert_eq!(responses_event_name(&ev), "response.in_progress");
}

#[test]
fn reasoning_item_wire_shape() {
    let item = ResponsesOutputItem::Reasoning {
        id: "rs_1".into(),
        summary: vec![ResponsesSummaryPart::SummaryText {
            text: "thought".into(),
        }],
    };
    let json = serde_json::to_string(&item).unwrap();
    assert!(json.contains("\"type\":\"reasoning\""), "{json}");
    assert!(
        json.contains("\"summary\":[{\"type\":\"summary_text\",\"text\":\"thought\"}]"),
        "{json}"
    );
}

#[test]
fn reasoning_summary_stream_events_wire_shape() {
    let delta = ResponsesStreamEvent::ReasoningSummaryTextDelta {
        sequence_number: 3,
        item_id: "rs_1".into(),
        output_index: 0,
        summary_index: 0,
        delta: "thinking".into(),
    };
    assert_eq!(
        responses_event_name(&delta),
        "response.reasoning_summary_text.delta"
    );
    let json = serde_json::to_string(&delta).unwrap();
    assert!(
        json.contains("\"type\":\"response.reasoning_summary_text.delta\""),
        "{json}"
    );
    assert!(json.contains("\"summary_index\":0"), "{json}");
    assert!(json.contains("\"delta\":\"thinking\""), "{json}");
    assert!(!json.contains("reasoning_content"), "{json}");

    let added = ResponsesStreamEvent::ReasoningSummaryPartAdded {
        sequence_number: 2,
        item_id: "rs_1".into(),
        output_index: 0,
        summary_index: 0,
        part: ResponsesSummaryPart::SummaryText {
            text: String::new(),
        },
    };
    assert_eq!(
        responses_event_name(&added),
        "response.reasoning_summary_part.added"
    );
    let json = serde_json::to_string(&added).unwrap();
    assert!(
        json.contains("\"part\":{\"type\":\"summary_text\",\"text\":\"\"}"),
        "{json}"
    );

    let text_done = ResponsesStreamEvent::ReasoningSummaryTextDone {
        sequence_number: 4,
        item_id: "rs_1".into(),
        output_index: 0,
        summary_index: 0,
        text: "thinking".into(),
    };
    assert_eq!(
        responses_event_name(&text_done),
        "response.reasoning_summary_text.done"
    );

    let part_done = ResponsesStreamEvent::ReasoningSummaryPartDone {
        sequence_number: 5,
        item_id: "rs_1".into(),
        output_index: 0,
        summary_index: 0,
        part: ResponsesSummaryPart::SummaryText {
            text: "thinking".into(),
        },
    };
    assert_eq!(
        responses_event_name(&part_done),
        "response.reasoning_summary_part.done"
    );
}