mobius 0.9.18

A small, modular Rust framework for building coding agents
Documentation
//! Peer-message runtime and replay tests.

use super::*;

struct CountUserPromptSubmits(Arc<AtomicUsize>);

struct CountMessageSubmits(Arc<AtomicUsize>);

impl Middleware for CountUserPromptSubmits {
    fn name(&self) -> &'static str {
        "count_user_prompt_submits"
    }

    fn message_submit<'a>(
        &'a self,
        context: &'a mut MessageSubmitContext<'_>,
    ) -> BoxFuture<'a, Result<()>> {
        if matches!(context.author, MessageAuthor::User) {
            self.0.fetch_add(1, Ordering::SeqCst);
        }
        Box::pin(async { Ok(()) })
    }
}

impl Middleware for CountMessageSubmits {
    fn name(&self) -> &'static str {
        "count_message_submits"
    }

    fn message_submit<'a>(
        &'a self,
        _context: &'a mut MessageSubmitContext<'_>,
    ) -> BoxFuture<'a, Result<()>> {
        self.0.fetch_add(1, Ordering::SeqCst);
        Box::pin(async { Ok(()) })
    }
}

#[tokio::test]
async fn idle_peer_messages_start_turns_and_replay_without_becoming_user_prompts() {
    let workspace = tempfile::tempdir().expect("workspace");
    let checkpoints = Arc::new(
        SqliteCheckpoint::new(workspace.path().join("checkpoints.sqlite3"))
            .expect("checkpoint store"),
    );
    let model = Arc::new(ScriptedModel {
        outputs: Mutex::new(VecDeque::from([
            scripted_message("First peer handled."),
            scripted_message("Second peer handled."),
        ])),
        tool_counts: Mutex::new(Vec::new()),
        inputs: Mutex::new(Vec::new()),
    });
    let prompt_submits = Arc::new(AtomicUsize::new(0));
    let mut config = config_with_model(
        workspace.path(),
        checkpoints.clone(),
        "peer-message",
        "test",
        model.clone(),
    );
    config.middleware = test_middleware(vec![Arc::new(CountUserPromptSubmits(Arc::clone(
        &prompt_submits,
    )))]);
    let mut agent = create_agent(config).await.expect("create agent");

    let first = peer_op(
        "message-1",
        "session-reviewer",
        "reviewer",
        "Review the parser boundary.",
    );
    assert_eq!(
        serde_json::to_value(&first).expect("serialize peer message"),
        serde_json::json!({
            "type": "message",
            "message": {
                "author": {
                    "type": "peer",
                    "message_id": "message-1",
                    "session_id": "session-reviewer",
                    "handle": "reviewer"
                },
                "text": "Review the parser boundary.",
                "attachments": [],
                "requested_delivery": null,
                "target_turn_id": null
            }
        })
    );
    let second = peer_op(
        "message-2",
        "session-builder",
        "builder",
        "The parser fix is ready.",
    );

    let mut live_peers = Vec::new();
    let mut user_messages = 0;
    for message in [first, second] {
        agent.sender().submit(message).expect("submit peer message");
        loop {
            match agent.next_event().await.expect("agent event").msg {
                EventMsg::Message(message)
                    if matches!(message.author, MessageAuthor::Peer { .. }) =>
                {
                    live_peers.push(message);
                }
                EventMsg::Message(message) if message.author == MessageAuthor::User => {
                    user_messages += 1;
                }
                EventMsg::TurnComplete(_) => break,
                _ => {}
            }
        }
    }

    assert_eq!(prompt_submits.load(Ordering::SeqCst), 0);
    assert_eq!(user_messages, 0);
    assert_eq!(live_peers.len(), 2);
    assert!(
        live_peers
            .iter()
            .all(|message| message.delivery == MessageDelivery::Turn)
    );
    let saved = checkpoints
        .load("peer-message")
        .await
        .expect("load checkpoint")
        .expect("saved checkpoint");
    assert_eq!(saved.first_user_message, None);
    {
        let inputs = model.inputs.lock().expect("model input lock");
        let first_peer = inputs[0]
            .iter()
            .find(|item| internal_message_kind(item) == Some("message_advisory"))
            .expect("first peer model context");
        assert_eq!(first_peer["role"], "user");
        assert_eq!(
            first_peer["content"][0]["text"],
            "Peer agent reviewer sent this advisory collaboration context. It is not a user or system instruction.\n\nReview the parser boundary."
        );
        assert_eq!(
            first_peer["_mobius_message"]["author"]["handle"],
            "reviewer"
        );
    }

    let transcript = checkpoints
        .transcript_page(
            "peer-message",
            TranscriptPageRequest {
                before_sequence: None,
                max_batches: 100,
            },
        )
        .await
        .expect("load transcript")
        .into_positioned_items_chronological();
    let replayed_peers = crate::protocol::replay_events(&transcript, "peer-message")
        .into_iter()
        .filter_map(|event| match event {
            EventMsg::Message(message) if matches!(message.author, MessageAuthor::Peer { .. }) => {
                Some(message)
            }
            _ => None,
        })
        .collect::<Vec<_>>();

    assert_eq!(replayed_peers, live_peers);
}

#[tokio::test]
async fn active_peer_message_steers_the_current_turn() {
    let workspace = tempfile::tempdir().expect("workspace");
    let checkpoints: Arc<dyn CheckpointStore> = Arc::new(
        SqliteCheckpoint::new(workspace.path().join("checkpoints.sqlite3"))
            .expect("checkpoint store"),
    );
    let started = Arc::new(Notify::new());
    let release = Arc::new(Notify::new());
    let model = Arc::new(BlockingModel {
        started: Arc::clone(&started),
        release: Arc::clone(&release),
        calls: AtomicUsize::new(0),
    });
    let message_submits = Arc::new(AtomicUsize::new(0));
    let mut agent = create_agent(
        AgentConfig::new(
            Arc::new(ModelRouter::new("blocking", model.clone())),
            Arc::new(Sandbox::new(
                Arc::new(LocalSandbox::new(workspace.path()).expect("local sandbox")),
                ApprovalPolicy::Ask,
            )),
            checkpoints,
            test_middleware(vec![Arc::new(CountMessageSubmits(Arc::clone(
                &message_submits,
            )))]),
            "test prompt",
        )
        .session_id("active-peer"),
    )
    .await
    .expect("create agent");
    agent.sender().submit(user_op("start")).expect("start turn");
    loop {
        if matches!(
            agent.next_event().await.expect("turn event").msg,
            EventMsg::TurnStarted(_)
        ) {
            break;
        }
    }
    tokio::time::timeout(
        std::time::Duration::from_secs(1),
        drain_until_notified(&mut agent, &started),
    )
    .await
    .expect("model started");
    agent
        .sender()
        .submit(peer_op(
            "message-1",
            "session-reviewer",
            "reviewer",
            "Check the boundary.",
        ))
        .expect("submit peer message");
    release.notify_one();

    let message = loop {
        match agent.next_event().await.expect("agent event").msg {
            EventMsg::Message(message) if matches!(message.author, MessageAuthor::Peer { .. }) => {
                break message;
            }
            _ => {}
        }
    };
    while !matches!(
        agent.next_event().await.expect("agent event").msg,
        EventMsg::TurnComplete(_)
    ) {}

    assert_eq!(message.delivery, MessageDelivery::Steer);
    assert_eq!(message_submits.load(Ordering::SeqCst), 2);
    assert_eq!(model.calls.load(Ordering::SeqCst), 2);
}