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 `msg_entry`: duplicate-error masking, the
//! vacuous-system check, and `build_msg_entries`.
//!
//! Owner: server (chat API) tests.
//! Invariants: none beyond the types.

#[cfg(test)]
mod error_dedup_tests {
    use super::super::duplicate_error_masks;

    #[test]
    fn exact_duplicate_errors_mask_older_keep_newest() {
        let err = "Error: BadResource: FileSystem.readFile (/home/nologik)";
        let results = vec![
            (2, err.to_string()),
            (5, format!("  {err}\n")),
            (9, err.to_string()),
        ];
        let masks = duplicate_error_masks(&results);
        assert_eq!(
            masks,
            vec![
                (2, "[same error as below, attempt 1 of 3]".to_string()),
                (5, "[same error as below, attempt 2 of 3]".to_string()),
            ],
            "older duplicates masked, newest (idx 9) untouched"
        );
    }

    #[test]
    fn non_error_duplicates_untouched() {
        let ok = "src\nCargo.toml\nREADME.md";
        let results = vec![
            (1, ok.to_string()),
            (3, ok.to_string()),
            (7, ok.to_string()),
        ];
        assert!(
            duplicate_error_masks(&results).is_empty(),
            "identical SUCCESS observations are legitimate — never masked"
        );
    }

    #[test]
    fn distinct_errors_untouched() {
        let results = vec![
            (1, "Error: ENOENT no such file /a/b".to_string()),
            (
                4,
                "Error: Permission denied writing /etc/passwd".to_string(),
            ),
        ];
        assert!(duplicate_error_masks(&results).is_empty());
    }

    #[test]
    fn near_duplicate_errors_group_via_jaccard() {
        // 2026-09-26: Two long errors that differ only in their last token
        // are not equal, so they group through the Jaccard threshold.
        let body: Vec<String> = (0..90).map(|i| format!("frame{i}")).collect();
        let base = format!("Error: {}", body.join(" "));
        let mut variant_body = body.clone();
        *variant_body.last_mut().unwrap() = "different".to_string();
        let variant = format!("Error: {}", variant_body.join(" "));
        let results = vec![(0, base), (2, variant)];
        let masks = duplicate_error_masks(&results);
        assert_eq!(
            masks,
            vec![(0, "[same error as below, attempt 1 of 2]".to_string())]
        );
    }

    #[test]
    fn single_error_never_masked() {
        let results = vec![(0, "Error: something failed".to_string())];
        assert!(duplicate_error_masks(&results).is_empty());
    }
}

#[cfg(test)]
mod vacuous_system_tests {
    use super::super::is_vacuous_system_content;

    #[test]
    fn empty_or_whitespace_is_vacuous() {
        assert!(is_vacuous_system_content(""));
        assert!(is_vacuous_system_content("   \n\t  "));
    }

    #[test]
    fn open_webui_empty_context_residue_is_vacuous() {
        assert!(is_vacuous_system_content("User Context:\n\n"));
        assert!(is_vacuous_system_content("Context:"));
        assert!(is_vacuous_system_content("  System:  "));
    }

    #[test]
    fn substantive_prompt_is_not_vacuous() {
        assert!(!is_vacuous_system_content(
            "User Context:\nThe user is a senior Rust engineer."
        ));
        assert!(!is_vacuous_system_content("You are a helpful assistant."));
        assert!(!is_vacuous_system_content("Always answer in French."));
        assert!(!is_vacuous_system_content("Role: expert chess coach"));
        // 2026-09-26: Longer than the 32-byte label limit.
        assert!(!is_vacuous_system_content(
            "Summarize the following transcript and then ask the user this:"
        ));
    }
}

#[cfg(test)]
mod build_tests {
    use super::super::build_msg_entries;
    use crate::ir::message::{ContentPart, ImageData, ImageSource, Message, Role};
    use axum::http::StatusCode;

    fn assert_bad_request(msgs: &[Message], tools_active: bool) {
        match build_msg_entries(
            None,
            None,
            &crate::api::chat::remote_image::RemoteImagePolicy::default(),
            &crate::api::chat::msg_entry::VideoDecode {
                ffmpeg: &metrale_model_layers::video_decode_ffmpeg::FfmpegPolicy {
                    enabled: false,
                    ..Default::default()
                },
                fps: 2.0,
            },
            msgs,
            tools_active,
            &crate::api::chat::levers::ChatLevers::OFF,
            false,
        ) {
            Ok(_) => panic!("expected 400, got Ok"),
            Err(resp) => assert_eq!(resp.status(), StatusCode::BAD_REQUEST),
        }
    }

    fn text(role: Role, t: &str) -> Message {
        Message {
            role,
            content: vec![ContentPart::Text(t.into())],
            tool_calls: Vec::new(),
            tool_call_id: None,
            name: None,
            reasoning: None,
            tool_error: false,
        }
    }

    fn image(role: Role) -> Message {
        Message {
            role,
            content: vec![
                ContentPart::Image(ImageSource {
                    data: ImageData::Base64("data:image/png;base64,AAA".into()),
                }),
                ContentPart::Text("result".into()),
            ],
            tool_calls: Vec::new(),
            tool_call_id: Some("c1".into()),
            name: None,
            reasoning: None,
            tool_error: false,
        }
    }

    #[test]
    fn text_only_builds_without_vision_config() {
        let msgs = vec![text(Role::User, "hello")];
        let out = build_msg_entries(
            None,
            None,
            &crate::api::chat::remote_image::RemoteImagePolicy::default(),
            &crate::api::chat::msg_entry::VideoDecode {
                ffmpeg: &metrale_model_layers::video_decode_ffmpeg::FfmpegPolicy {
                    enabled: false,
                    ..Default::default()
                },
                fps: 2.0,
            },
            &msgs,
            false,
            &crate::api::chat::levers::ChatLevers::OFF,
            false,
        )
        .expect("text-only ok");
        assert_eq!(out.messages.len(), 1);
        assert!(out.messages[0].media.is_empty());
    }

    #[test]
    fn user_image_without_vision_config_is_rejected() {
        assert_bad_request(&[text(Role::User, "hi"), image(Role::User)], false);
    }

    #[test]
    fn tool_result_image_is_collected_and_rejected_without_vision() {
        // 2026-09-26: The 400 fires only if the tool-result branch collected
        // the image.
        assert_bad_request(&[text(Role::User, "look"), image(Role::Tool)], true);
    }

    #[test]
    fn developer_role_normalized_to_system_at_build() {
        let msgs = vec![text(Role::Other("developer".into()), "be terse")];
        let out = build_msg_entries(
            None,
            None,
            &crate::api::chat::remote_image::RemoteImagePolicy::default(),
            &crate::api::chat::msg_entry::VideoDecode {
                ffmpeg: &metrale_model_layers::video_decode_ffmpeg::FfmpegPolicy {
                    enabled: false,
                    ..Default::default()
                },
                fps: 2.0,
            },
            &msgs,
            false,
            &crate::api::chat::levers::ChatLevers::OFF,
            false,
        )
        .expect("ok");
        assert_eq!(out.messages[0].role, "system");

        let msgs = vec![text(Role::Other("critic".into()), "hm")];
        let out = build_msg_entries(
            None,
            None,
            &crate::api::chat::remote_image::RemoteImagePolicy::default(),
            &crate::api::chat::msg_entry::VideoDecode {
                ffmpeg: &metrale_model_layers::video_decode_ffmpeg::FfmpegPolicy {
                    enabled: false,
                    ..Default::default()
                },
                fps: 2.0,
            },
            &msgs,
            false,
            &crate::api::chat::levers::ChatLevers::OFF,
            false,
        )
        .expect("ok");
        assert_eq!(out.messages[0].role, "critic");
    }

    #[test]
    fn deepseek_v4_preserves_developer_and_tool_result_identity() {
        let mut tool = text(Role::Tool, "result");
        tool.tool_call_id = Some("call_7".into());
        let messages = vec![
            text(Role::Other("developer".into()), "native developer"),
            tool,
        ];
        let out = build_msg_entries(
            None,
            None,
            &crate::api::chat::remote_image::RemoteImagePolicy::default(),
            &crate::api::chat::msg_entry::VideoDecode {
                ffmpeg: &metrale_model_layers::video_decode_ffmpeg::FfmpegPolicy {
                    enabled: false,
                    ..Default::default()
                },
                fps: 2.0,
            },
            &messages,
            true,
            &crate::api::chat::levers::ChatLevers::OFF,
            true,
        )
        .expect("DeepSeek-V4 message lowering");
        assert_eq!(out.messages[0].role, "developer");
        assert_eq!(out.messages[1].role, "tool");
        assert_eq!(out.messages[1].tool_call_id.as_deref(), Some("call_7"));
    }

    #[test]
    fn remote_url_image_rejected_with_clear_reason() {
        // 2026-09-26: With remote fetching off, the URL is refused when it is
        // collected, before the missing vision config is checked.
        let url_msg = Message {
            role: Role::User,
            content: vec![ContentPart::Image(ImageSource {
                data: ImageData::Url("https://example.com/cat.png".into()),
            })],
            tool_calls: Vec::new(),
            tool_call_id: None,
            name: None,
            reasoning: None,
            tool_error: false,
        };
        match build_msg_entries(
            None,
            None,
            &crate::api::chat::remote_image::RemoteImagePolicy::default(),
            &crate::api::chat::msg_entry::VideoDecode {
                ffmpeg: &metrale_model_layers::video_decode_ffmpeg::FfmpegPolicy {
                    enabled: false,
                    ..Default::default()
                },
                fps: 2.0,
            },
            &[url_msg],
            false,
            &crate::api::chat::levers::ChatLevers::OFF,
            false,
        ) {
            Ok(_) => panic!("expected 400, got Ok"),
            Err(resp) => assert_eq!(resp.status(), StatusCode::BAD_REQUEST),
        }
    }

    #[test]
    fn model_can_disable_duplicate_cwd_hint_injection() {
        use crate::api::chat::levers::ChatLevers;

        let msgs = vec![text(
            Role::System,
            "client prompt\nworking directory: /tmp/project",
        )];

        let enabled = build_msg_entries(
            None,
            None,
            &crate::api::chat::remote_image::RemoteImagePolicy::default(),
            &crate::api::chat::msg_entry::VideoDecode {
                ffmpeg: &metrale_model_layers::video_decode_ffmpeg::FfmpegPolicy {
                    enabled: false,
                    ..Default::default()
                },
                fps: 2.0,
            },
            &msgs,
            true,
            &ChatLevers::OFF,
            false,
        )
        .expect("enabled");
        assert_eq!(enabled.cwd_hint.as_deref(), Some("/tmp/project"));
        assert!(enabled.messages[0].content.contains("<environment>"));

        let disabled_levers = ChatLevers {
            disable_cwd_hint_injection: true,
            ..ChatLevers::OFF
        };
        let disabled = build_msg_entries(
            None,
            None,
            &crate::api::chat::remote_image::RemoteImagePolicy::default(),
            &crate::api::chat::msg_entry::VideoDecode {
                ffmpeg: &metrale_model_layers::video_decode_ffmpeg::FfmpegPolicy {
                    enabled: false,
                    ..Default::default()
                },
                fps: 2.0,
            },
            &msgs,
            true,
            &disabled_levers,
            false,
        )
        .expect("disabled");
        assert_eq!(disabled.cwd_hint.as_deref(), Some("/tmp/project"));
        assert_eq!(
            disabled.messages[0].content,
            "client prompt\nworking directory: /tmp/project"
        );
    }
}