kcode-k1-chat-chatend 0.6.0

Append-only K1 chat transcript transitions
Documentation
use super::*;
use serde_json::{Value, json};

fn provider(tool: &str) -> ProviderCall {
    ProviderCall {
        tool: tool.into(),
        tool_version: "1.0.0".into(),
        arguments: json!({"value": tool}),
    }
}

fn success(call: &DispatchedToolCall) -> ToolResult {
    ToolResult::new(
        call.call.call_id(),
        call.call_box_id,
        call.call.tool().into(),
        call.call.tool_version().into(),
        ToolResultStatus::Ok,
        json!({"accepted": true}),
        ResultView::OneLine("success".into()),
    )
    .unwrap()
}

#[test]
fn allocates_provider_order_and_correlates_one_return() {
    let mut chat = Chatend::new();
    chat.start_round().unwrap();
    let calls = chat
        .append_stage(
            "thinking".into(),
            vec![provider("First"), provider("Second")],
        )
        .unwrap();
    assert_eq!(calls[0].call.call_id().to_string(), "c1");
    assert_eq!(calls[1].call.call_id().to_string(), "c2");
    assert_eq!(calls[0].call_box_id, BoxId::new(2));
    assert_eq!(calls[1].call_box_id, BoxId::new(3));

    assert_eq!(chat.accept_async_return(success(&calls[0])).unwrap(), None);
    assert_eq!(
        chat.accept_async_return(success(&calls[0])),
        Err(TransitionError::DuplicateReturn)
    );
    let flushed = chat.flush_active_arrivals().unwrap();
    assert_eq!(flushed[0].id(), BoxId::new(4));
    assert_eq!(
        flushed[0]
            .tool_result_metadata()
            .unwrap()
            .unwrap()
            .call_id(),
        calls[0].call.call_id()
    );
}

#[test]
fn rejected_stages_are_transactional_and_call_overflow_is_clean() {
    let mut chat = Chatend::new();
    assert_eq!(
        chat.append_stage(String::new(), vec![provider("Tool")]),
        Err(TransitionError::InvalidPhase)
    );
    chat.start_round().unwrap();
    let invalid = ProviderCall {
        tool: "Tool".into(),
        tool_version: "1.0.0".into(),
        arguments: json!({"tool": "forbidden"}),
    };
    assert_eq!(
        chat.append_stage("must not append".into(), vec![invalid]),
        Err(TransitionError::InvalidToolEnvelope)
    );
    assert!(chat.boxes().is_empty());
    assert_eq!(
        chat.append_stage(String::new(), vec![provider("Tool")])
            .unwrap()[0]
            .call
            .call_id()
            .to_string(),
        "c1"
    );
    chat.next_call = None;
    let before = chat.boxes().to_vec();
    assert_eq!(
        chat.append_stage(String::new(), vec![provider("Later")]),
        Err(TransitionError::CallIdOverflow)
    );
    assert_eq!(chat.boxes(), before);
}

#[test]
fn active_arrivals_flush_fifo_and_done_closes_the_round() {
    let mut chat = Chatend::new();
    chat.start_round().unwrap();
    assert_eq!(chat.accept_user("first".into()).unwrap(), None);
    assert_eq!(
        chat.accept_box("Future".into(), "second".into(), "x".into(), "y".into())
            .unwrap(),
        None
    );
    let appended = chat.done("final".into()).unwrap();
    assert_eq!(
        appended
            .iter()
            .map(|value| (value.box_type(), value.contents()))
            .collect::<Vec<_>>(),
        vec![
            (AGENT_MESSAGE_TYPE, "final"),
            (USER_MESSAGE_TYPE, "first"),
            ("Future", "second"),
        ]
    );
    assert!(!chat.round_active());
    assert_eq!(chat.done(String::new()), Err(TransitionError::InvalidPhase));
}

#[test]
fn recovery_validates_current_history_and_resumes_counter() {
    let mut chat = Chatend::new();
    chat.accept_box("Future".into(), "opaque".into(), "x".into(), "y".into())
        .unwrap();
    chat.start_round().unwrap();
    let calls = chat
        .append_stage(String::new(), vec![provider("One"), provider("Two")])
        .unwrap();
    chat.accept_async_return(success(&calls[1])).unwrap();
    chat.done(String::new()).unwrap();

    let mut recovered = Chatend::recover(chat.boxes().to_vec()).unwrap();
    recovered.start_round().unwrap();
    let next = recovered
        .append_stage(String::new(), vec![provider("Three")])
        .unwrap();
    assert_eq!(next[0].call.call_id().to_string(), "c3");
    assert_eq!(recovered.boxes()[0].box_type(), "Future");
}

#[test]
fn recovery_rejects_noncontiguous_ids_calls_and_malformed_envelopes() {
    let gap = vec![ChatBox::new(
        BoxId::new(2),
        "Future".into(),
        "opaque".into(),
        String::new(),
        String::new(),
    )];
    assert!(matches!(
        Chatend::recover(gap),
        Err(RecoveryError::NonContiguousBoxId)
    ));

    let c2 = ToolCall::new(
        ToolCallId::new(2).unwrap(),
        "Tool".into(),
        "1.0.0".into(),
        json!({}),
    )
    .unwrap();
    assert!(matches!(
        Chatend::recover(vec![ChatBox::tool_call(BoxId::new(1), c2).unwrap()]),
        Err(RecoveryError::InvalidCallSequence)
    ));

    let malformed = ChatBox::new(
        BoxId::new(1),
        TOOL_CALL_TYPE.into(),
        "opaque".into(),
        "k1.tool-call/not-semver".into(),
        "opaque".into(),
    );
    assert!(matches!(
        Chatend::recover(vec![malformed]),
        Err(RecoveryError::MalformedToolConvention)
    ));
}

#[test]
fn result_identity_must_match_without_mutation() {
    let mut chat = Chatend::new();
    chat.start_round().unwrap();
    let call = chat
        .append_stage(String::new(), vec![provider("Tool")])
        .unwrap()
        .remove(0);
    let wrong = ToolResult::new(
        call.call.call_id(),
        call.call_box_id,
        "Different".into(),
        "1.0.0".into(),
        ToolResultStatus::Error,
        Value::Null,
        ResultView::Error("failed".into()),
    )
    .unwrap();
    let before = chat.boxes().to_vec();
    assert_eq!(
        chat.accept_async_return(wrong),
        Err(TransitionError::MismatchedTool)
    );
    assert_eq!(chat.boxes(), before);
}