rho-coding-agent 1.0.2

A lightweight agent harness inspired by Pi
Documentation
use super::*;
use crate::model::{AssistantMessage, ProviderContextBlock};
use serde_json::json;

const SESSION_V1: &str = include_str!("session/fixtures/session-v1.jsonl");
const SESSION_V2: &str = include_str!("session/fixtures/session-v2.jsonl");

#[test]
fn every_supported_application_session_fixture_restores_as_a_snapshot() {
    let cases = [
        (
            1,
            "11111111-1111-4111-8111-111111111111",
            SESSION_V1,
            4,
            1,
            1,
            "rho:migrated-v1",
        ),
        (
            2,
            "22222222-2222-4222-8222-222222222222",
            SESSION_V2,
            4,
            1,
            3,
            "rho:fixture-session",
        ),
    ];

    for (version, id, fixture, revision, compactions, removed, cache_key) in cases {
        let (root, cwd, session) = session_from_fixture(id, fixture);
        let first: serde_json::Value =
            serde_json::from_str(fixture.lines().next().unwrap()).unwrap();
        assert_eq!(first["version"], version);

        let snapshot = session
            .snapshot_for_resume(
                ModelIdentity::new("target", "fixture-api", "fixture-model"),
                "rho:migrated-v1".into(),
            )
            .unwrap();
        let histories = read_histories(session.path()).unwrap();

        assert_eq!(
            snapshot.schema_version(),
            rho_sdk::SESSION_SNAPSHOT_SCHEMA_VERSION
        );
        assert_eq!(snapshot.session_id().as_str(), id);
        assert_eq!(snapshot.revision(), Revision::from_u64(revision));
        assert_eq!(snapshot.compaction().completed_compactions(), compactions);
        assert_eq!(snapshot.compaction().removed_messages(), removed);
        assert_eq!(snapshot.prompt_cache_key(), Some(cache_key));
        assert_eq!(snapshot.history(), histories.model);
        assert_eq!(histories.model.len(), 2);
        assert_eq!(histories.display.len(), if version == 1 { 3 } else { 2 });

        drop((root, cwd));
    }
}

#[test]
fn v1_session_migrates_on_atomic_snapshot_save_without_losing_display_history() {
    let (_root, _cwd, session) =
        session_from_fixture("11111111-1111-4111-8111-111111111111", SESSION_V1);
    let snapshot = session
        .snapshot_for_resume(
            ModelIdentity::new("target", "api", "model"),
            "rho:migrated-v1".into(),
        )
        .unwrap();

    session.save_snapshot(&snapshot, &[]).unwrap();

    let entries = read_entries(session.path()).unwrap();
    let saved = entries.last().unwrap();
    assert!(
        matches!(saved, SessionEntry::Snapshot { snapshot: stored, display_messages, .. }
        if stored.schema_version() == rho_sdk::SESSION_SNAPSHOT_SCHEMA_VERSION
            && stored.compaction() == snapshot.compaction()
            && display_messages.is_empty())
    );
}

#[test]
fn rejects_sessions_from_unsupported_or_malformed_versions() {
    for (name, contents, expected) in [
        (
            "future-version",
            format!(
                "{{\"type\":\"session\",\"version\":{},\"id\":\"future-version\",\"timestamp\":\"1\",\"cwd\":\"/tmp\"}}\n",
                SESSION_VERSION + 1
            ),
            "unsupported session version",
        ),
        (
            "pre-version",
            "{\"type\":\"session\",\"version\":0,\"id\":\"pre-version\",\"timestamp\":\"1\",\"cwd\":\"/tmp\"}\n".into(),
            "unsupported session version",
        ),
        (
            "malformed",
            "{\"type\":\"session\",\"version\":2,\"id\":\"malformed\",\"timestamp\":\"1\",\"cwd\":\"/tmp\"}\n{\"type\":\"snapshot\",\"timestamp\":\"2\",\"snapshot\":{}}\n".into(),
            "missing field",
        ),
    ] {
        let directory = tempfile::tempdir().unwrap();
        let path = directory.path().join(format!("1_{name}.jsonl"));
        fs::write(&path, contents).unwrap();

        let error = summarize_session_file(&path, directory.path()).unwrap_err();
        assert!(error.to_string().contains(expected), "{error:#}");
    }
}

#[test]
fn failed_snapshot_save_retains_the_previous_complete_revision() {
    let root = tempfile::tempdir().unwrap();
    let cwd = tempfile::tempdir().unwrap();
    let session = Session::create_in_root(root.path(), cwd.path()).unwrap();
    let identity = ModelIdentity::new("provider", "api", "model");
    let first = SessionSnapshot::new(
        SessionId::from_string(session.id().to_owned()).unwrap(),
        Revision::from_u64(1),
        vec![Message::user_text("durable")],
        identity.clone(),
        CompactionState::default(),
    )
    .with_prompt_cache_key("rho:durable");
    session
        .save_snapshot(&first, &[Message::user_text("display durable")])
        .unwrap();
    let before = fs::read(session.path()).unwrap();
    let wrong_id = SessionSnapshot::new(
        SessionId::from_string("different-session").unwrap(),
        Revision::from_u64(2),
        vec![Message::user_text("must not commit")],
        identity.clone(),
        CompactionState::default(),
    );

    assert!(session.save_snapshot(&wrong_id, &[]).is_err());
    assert_eq!(fs::read(session.path()).unwrap(), before);

    OpenOptions::new()
        .append(true)
        .open(session.path())
        .unwrap()
        .write_all(b"{\"type\":\"snapshot\"")
        .unwrap();
    let restored = session
        .snapshot_for_resume(identity.clone(), "rho:fallback".into())
        .unwrap();
    assert_eq!(restored.revision(), first.revision());
    assert_eq!(restored.history(), first.history());

    session.save_snapshot(&first, &[]).unwrap();
    let restored_after_repair = session
        .snapshot_for_resume(identity, "rho:fallback".into())
        .unwrap();
    assert_eq!(restored_after_repair, first);
}

#[test]
fn resumed_snapshot_reports_and_filters_incompatible_provider_context() {
    let root = tempfile::tempdir().unwrap();
    let cwd = tempfile::tempdir().unwrap();
    let session = Session::create_in_root(root.path(), cwd.path()).unwrap();
    let source = ModelIdentity::new("source", "source-api", "source-model");
    let target = ModelIdentity::new("target", "target-api", "target-model");
    let snapshot = SessionSnapshot::new(
        SessionId::from_string(session.id().to_owned()).unwrap(),
        Revision::from_u64(1),
        vec![Message::assistant(AssistantMessage {
            content: vec![ContentBlock::Text("portable answer".into())],
            provider_context: vec![ProviderContextBlock {
                identity: source.clone(),
                kind: "source_native_context".into(),
                position: None,
                data: json!({"opaque": true}),
            }],
            ..AssistantMessage::default()
        })],
        source,
        CompactionState::default(),
    );
    session
        .save_snapshot(&snapshot, snapshot.history())
        .unwrap();

    let resumed = session
        .snapshot_for_resume(target.clone(), "rho:resume".into())
        .unwrap();
    let report = resumed.provider_context_omissions(&target);
    let Message::EnrichedAssistant(message) = resumed.history()[0].clone() else {
        panic!("expected enriched assistant");
    };
    let prepared = rho_sdk::model::handoff::prepare_assistant(*message, &target);

    assert_eq!(report.omitted_provider_context, 1);
    assert_eq!(report.omitted_kinds, ["source_native_context"]);
    assert!(prepared.replay_context.is_empty());
}

fn session_from_fixture(
    id: &str,
    fixture: &str,
) -> (tempfile::TempDir, tempfile::TempDir, Session) {
    let root = tempfile::tempdir().unwrap();
    let cwd = tempfile::tempdir().unwrap();
    let dir = session_dir_in_root(root.path(), cwd.path());
    fs::create_dir_all(&dir).unwrap();
    let path = dir.join(format!("100_{id}.jsonl"));
    fs::write(&path, fixture).unwrap();
    let session = Session::from_parts(root.path(), cwd.path(), id.into(), path);
    (root, cwd, session)
}