lash-core 0.1.0-alpha.85

Sans-IO turn machine and runtime kernel for the lash agent runtime.
Documentation
use super::*;

#[test]
fn stream_accumulator_merges_adjacent_display_reasoning_chunks() {
    let mut accumulator = LlmStreamAccumulator::default();
    accumulator.push_reasoning("I'll".to_string(), None, Vec::new(), None);
    accumulator.push_reasoning(" check".to_string(), None, Vec::new(), None);
    accumulator.push_reasoning(" the time.".to_string(), None, Vec::new(), None);

    assert_eq!(accumulator.parts.len(), 1);
    assert!(matches!(
        &accumulator.parts[0],
        LlmOutputPart::Reasoning { text, .. } if text == "I'll check the time."
    ));
}

#[test]
fn stream_accumulator_enriches_reasoning_delta_with_later_roundtrip_payload() {
    let mut accumulator = LlmStreamAccumulator::default();
    accumulator.push_reasoning("I'll check the time.".to_string(), None, Vec::new(), None);
    accumulator.push_reasoning(
        "I'll check the time.".to_string(),
        Some("rs_1".to_string()),
        vec!["I'll check the time.".to_string()],
        Some("encrypted".to_string()),
    );

    assert_eq!(accumulator.parts.len(), 1);
    assert!(matches!(
        &accumulator.parts[0],
        LlmOutputPart::Reasoning {
            text,
            replay: Some(replay),
            ..
        } if text == "I'll check the time."
            && replay.item_id.as_deref() == Some("rs_1")
            && replay.encrypted_content.as_deref() == Some("encrypted")
    ));
}

#[test]
fn stream_accumulator_preserves_full_reasoning_replay_metadata() {
    let mut accumulator = LlmStreamAccumulator::default();
    accumulator.push_reasoning_with_replay(
        "[Reasoning redacted]".to_string(),
        Some(lash_sansio::llm::types::ProviderReasoningReplay {
            item_id: Some("rs_1".to_string()),
            encrypted_content: None,
            signature: Some("signature".to_string()),
            redacted: true,
            summary: vec!["hidden".to_string()],
        }),
    );

    assert!(matches!(
        &accumulator.parts[0],
        LlmOutputPart::Reasoning {
            replay: Some(replay),
            ..
        } if replay.item_id.as_deref() == Some("rs_1")
            && replay.signature.as_deref() == Some("signature")
            && replay.redacted
            && replay.summary == vec!["hidden".to_string()]
    ));
}

#[test]
fn stream_accumulator_preserves_reasoning_when_final_response_has_tool_call() {
    let mut accumulator = LlmStreamAccumulator::default();
    accumulator.push_reasoning("I'll check the time.".to_string(), None, Vec::new(), None);
    accumulator.push_tool_call(
        "call_1".to_string(),
        "exec_command".to_string(),
        "{\"cmd\":\"date\"}".to_string(),
        Some(lash_sansio::llm::types::ProviderReplayMeta {
            item_id: Some("item_1".to_string()),
            opaque: Some("sig".to_string()),
        }),
    );

    let mut response = LlmResponse {
        full_text: String::new(),
        parts: vec![LlmOutputPart::ToolCall {
            call_id: "call_1".to_string(),
            tool_name: "exec_command".to_string(),
            input_json: "{\"cmd\":\"date\"}".to_string(),
            replay: Some(lash_sansio::llm::types::ProviderReplayMeta {
                item_id: Some("item_1".to_string()),
                opaque: Some("sig".to_string()),
            }),
        }],
        ..Default::default()
    };

    accumulator.apply_to_response(&mut response);

    assert_eq!(response.parts.len(), 2);
    assert!(matches!(
        &response.parts[0],
        LlmOutputPart::Reasoning { text, .. } if text == "I'll check the time."
    ));
    assert!(matches!(
        &response.parts[1],
        LlmOutputPart::ToolCall { tool_name, .. } if tool_name == "exec_command"
    ));
}

#[test]
fn stream_accumulator_does_not_duplicate_complete_final_response() {
    let mut accumulator = LlmStreamAccumulator::default();
    accumulator.push_reasoning("I'll answer.".to_string(), None, Vec::new(), None);
    accumulator.push_text("Done.");

    let mut response = LlmResponse {
        full_text: "Done.".to_string(),
        parts: vec![
            LlmOutputPart::Reasoning {
                text: "I'll answer.".to_string(),
                replay: None,
            },
            LlmOutputPart::Text {
                text: "Done.".to_string(),
                response_meta: None,
            },
        ],
        ..Default::default()
    };

    accumulator.apply_to_response(&mut response);

    assert_eq!(response.parts.len(), 2);
    assert!(matches!(
        &response.parts[0],
        LlmOutputPart::Reasoning { text, .. } if text == "I'll answer."
    ));
    assert!(matches!(
        &response.parts[1],
        LlmOutputPart::Text { text, .. } if text == "Done."
    ));
}

#[test]
fn stream_accumulator_full_text_prefers_final_answer_over_commentary() {
    let mut accumulator = LlmStreamAccumulator::default();
    accumulator.push_text_part(
        "Working notes.".to_string(),
        Some(lash_sansio::llm::types::ResponseTextMeta {
            id: Some("msg_commentary".to_string()),
            status: Some("completed".to_string()),
            phase: Some("commentary".to_string()),
            ..Default::default()
        }),
    );
    accumulator.push_text_part(
        "Final answer.".to_string(),
        Some(lash_sansio::llm::types::ResponseTextMeta {
            id: Some("msg_final".to_string()),
            status: Some("completed".to_string()),
            phase: Some("final_answer".to_string()),
            ..Default::default()
        }),
    );

    let mut response = LlmResponse::default();
    accumulator.apply_to_response(&mut response);

    assert_eq!(response.full_text, "Final answer.");
    assert_eq!(
        response
            .parts
            .iter()
            .filter(|part| matches!(part, LlmOutputPart::Text { .. }))
            .count(),
        2
    );
}