bcode_server 0.0.1-alpha.0

Local bcode daemon runtime
use super::*;
use bcode_session_models::{
    SessionHistoryCursor, ToolInvocationResult, ToolInvocationResultRecord,
};

fn event(session_id: SessionId, sequence: u64, kind: SessionEventKind) -> SessionEvent {
    SessionEvent {
        schema_version: CURRENT_SESSION_EVENT_SCHEMA_VERSION,
        session_id,
        sequence,
        timestamp_ms: 1,
        provenance: None,
        kind,
    }
}

fn page(session_id: SessionId, events: Vec<SessionEvent>) -> SessionHistoryPage {
    SessionHistoryPage {
        session_id,
        events,
        compatibility_issues: Vec::new(),
        next_cursor: None,
        has_more: false,
    }
}

fn pair(
    session_id: SessionId,
    name: &str,
    arguments: &str,
    output: &str,
) -> (SessionHistoryPage, SessionHistoryPage) {
    let request = event(
        session_id,
        4,
        SessionEventKind::ToolCallRequested {
            tool_call_id: "invocation".into(),
            producer_plugin_id: Some("fixture.plugin".into()),
            tool_name: name.into(),
            arguments_json: arguments.into(),
            working_directory: Some("/private/working-directory".into()),
        },
    );
    let result = event(
        session_id,
        90,
        SessionEventKind::ToolInvocationResultRecorded {
            record: ToolInvocationResultRecord {
                invocation_id: "invocation".into(),
                model_output: output.into(),
                is_error: false,
                content: Vec::new(),
                presentation: None,
                result: Some(ToolInvocationResult::Text {
                    text: "private semantic payload omitted".into(),
                }),
            },
        },
    );
    (
        page(session_id, vec![request]),
        page(session_id, vec![result]),
    )
}

fn query(session_id: SessionId, byte_limit: usize) -> PairQuery<'static> {
    PairQuery {
        session_id,
        invocation_id: "invocation",
        request_sequence: 4,
        result_sequence: 90,
        byte_limit,
    }
}

#[test]
fn two_tool_shapes_preserve_model_evidence_without_private_fields() {
    let id = SessionId::new();
    for (name, arguments, output) in [
        (
            "fixture.command",
            r#"{"command":"validate"}"#,
            r#"{"exit_code":0,"output":"PASS"}"#,
        ),
        ("fixture.lookup", r#"{"key":"record"}"#, "found record"),
    ] {
        let (request, result) = pair(id, name, arguments, output);
        let evidence = read_pair(&query(id, MAX_BYTES), &request, &result).expect("valid pair");
        assert_eq!(evidence.tool_name, name);
        assert_eq!(evidence.arguments_json.text, arguments);
        assert_eq!(evidence.model_output.text, output);
        assert!(!evidence.model_output.truncated);
        assert_eq!(evidence.producer_capture_complete, None);
        let encoded = serde_json::to_value(evidence).expect("projection serializes");
        assert!(encoded.get("presentation").is_none());
        assert!(encoded.get("content").is_none());
        assert!(encoded.get("result").is_none());
        assert!(encoded.get("working_directory").is_none());
    }
}

#[test]
fn positioned_requests_share_the_same_generic_receipt_contract() {
    let id = SessionId::new();
    let (mut request, result) = pair(id, "fixture", "{}", "ok");
    request.events[0].kind = SessionEventKind::PositionedToolCallRequested {
        turn_id: "private turn".into(),
        output_position: bcode_session_models::TurnOutputPosition::new(0),
        tool_call_id: "invocation".into(),
        producer_plugin_id: None,
        tool_name: "fixture".into(),
        arguments_json: "{}".into(),
        working_directory: None,
    };
    let evidence = read_pair(&query(id, MAX_BYTES), &request, &result).expect("positioned request");
    assert_eq!(evidence.model_output.text, "ok");
    assert_eq!(evidence.producer_plugin_id, None);
}

#[test]
fn discovery_counts_all_events_and_retains_forward_continuation() {
    let id = SessionId::new();
    let (mut requested, _) = pair(id, "fixture", "{}", "ok");
    requested.has_more = true;
    requested.next_cursor = Some(SessionHistoryCursor { sequence: 5 });
    let found = discover(id, &requested, 4, 1).expect("bounded page");
    assert_eq!(found.scanned_events, 1);
    assert_eq!(found.next_sequence, Some(5));
    assert_eq!(found.events[0].role, EventRole::Request);
    assert_eq!(found.events[0].invocation_id, "invocation");
    requested.next_cursor = Some(SessionHistoryCursor { sequence: 6 });
    assert_eq!(discover(id, &requested, 4, 1), Err(EvidenceError::Conflict));
}

#[test]
fn missing_foreign_wrong_sequence_and_invocation_fail_closed() {
    let id = SessionId::new();
    let (request, mut result) = pair(id, "fixture", "{}", "ok");
    assert_eq!(
        read_pair(&query(id, MAX_BYTES), &request, &page(id, vec![])),
        Err(EvidenceError::Missing)
    );
    result.session_id = SessionId::new();
    assert_eq!(
        read_pair(&query(id, MAX_BYTES), &request, &result),
        Err(EvidenceError::Conflict)
    );
    result.session_id = id;
    result.events[0].session_id = SessionId::new();
    assert_eq!(
        read_pair(&query(id, MAX_BYTES), &request, &result),
        Err(EvidenceError::Conflict)
    );
    result.events[0].session_id = id;
    result.events[0].sequence = 91;
    assert_eq!(
        read_pair(&query(id, MAX_BYTES), &request, &result),
        Err(EvidenceError::Conflict)
    );
    result.events[0].sequence = 90;
    let SessionEventKind::ToolInvocationResultRecorded { record } = &mut result.events[0].kind
    else {
        panic!("fixture result")
    };
    record.invocation_id = "different".into();
    assert_eq!(
        read_pair(&query(id, MAX_BYTES), &request, &result),
        Err(EvidenceError::Conflict)
    );
}

#[test]
fn future_versions_and_oversized_pages_are_rejected() {
    let id = SessionId::new();
    let (mut request, result) = pair(id, "fixture", "{}", "ok");
    request.events[0].schema_version += 1;
    assert_eq!(
        read_pair(&query(id, MAX_BYTES), &request, &result),
        Err(EvidenceError::Incompatible)
    );
    assert_eq!(
        discover(id, &request, 4, 1),
        Err(EvidenceError::Incompatible)
    );
    request.events[0].schema_version = CURRENT_SESSION_EVENT_SCHEMA_VERSION;
    request.events.push(request.events[0].clone());
    assert_eq!(
        read_pair(&query(id, MAX_BYTES), &request, &result),
        Err(EvidenceError::LimitExceeded)
    );
}

#[test]
fn discovery_ignores_non_tool_events_but_advances_over_them() {
    let id = SessionId::new();
    let created = event(
        id,
        0,
        SessionEventKind::SessionCreated {
            name: Some("private session title".into()),
            working_directory: "/private".into(),
        },
    );
    let mut history = page(id, vec![created]);
    history.has_more = true;
    history.next_cursor = Some(SessionHistoryCursor { sequence: 1 });
    let discovery = discover(id, &history, 0, 1).expect("non-tool page");
    assert!(discovery.events.is_empty());
    assert_eq!(discovery.scanned_events, 1);
    assert_eq!(discovery.next_sequence, Some(1));
}

#[test]
fn wrong_kind_and_reversed_pair_are_not_receipts() {
    let id = SessionId::new();
    let (request, result) = pair(id, "fixture", "{}", "ok");
    let mut wrong_kind = result.clone();
    wrong_kind.events[0].kind = SessionEventKind::ModelTurnStarted {
        turn_id: "private turn".into(),
    };
    assert_eq!(
        read_pair(&query(id, MAX_BYTES), &request, &wrong_kind),
        Err(EvidenceError::Conflict)
    );
    let mut reversed = query(id, MAX_BYTES);
    reversed.result_sequence = reversed.request_sequence;
    assert_eq!(
        read_pair(&reversed, &request, &result),
        Err(EvidenceError::Conflict)
    );
    assert_eq!(
        read_pair(&query(id, 0), &request, &result),
        Err(EvidenceError::LimitExceeded)
    );
}

#[test]
fn truncation_is_utf8_safe_and_explicit_not_full_process_capture() {
    let id = SessionId::new();
    let (request, result) = pair(id, "fixture", "{}", "évidence");
    let evidence = read_pair(&query(id, 3), &request, &result).expect("bounded text");
    assert_eq!(evidence.arguments_json.retained_bytes, 2);
    assert!(!evidence.arguments_json.truncated);
    assert_eq!(evidence.model_output.retained_bytes, 0);
    assert!(evidence.model_output.truncated);
    assert_eq!(evidence.model_output.original_bytes, "évidence".len());
    assert_eq!(evidence.producer_capture_complete, None);
}