openquiver-runtime 0.1.0

Event-sourced executor: exec tokens, journal, run resumption.
Documentation
//! Walking a graph from its event node to the end.

mod support;

use openquiver_acp::StopReason;
use openquiver_acp::mock::{MockAgents, Script};
use openquiver_runtime::Runtime;
use openquiver_store::{RunStatus, Store};

use support::{HELLO, kinds, plan, printed};

#[tokio::test]
async fn a_run_walks_the_graph_and_prints_what_the_agent_said() {
    let plan = plan(HELLO);
    let store = Store::open_in_memory().await.expect("store");
    let agents = MockAgents::new(Script::new().says("Hi."));

    let mut run = Runtime::start(&store, &plan, &agents, "hello")
        .await
        .expect("start");
    run.run().await.expect("run");

    assert_eq!(agents.transcript().prompts(), ["Say hello."]);
    assert_eq!(printed(&store, run.id()).await, ["Hi."]);
    assert_eq!(
        store.status(run.id()).await.expect("status"),
        RunStatus::Finished
    );
}

#[tokio::test]
async fn the_journal_brackets_every_node_that_ran() {
    let plan = plan(HELLO);
    let store = Store::open_in_memory().await.expect("store");
    let agents = MockAgents::new(Script::new().says("Hi."));

    let mut run = Runtime::start(&store, &plan, &agents, "hello")
        .await
        .expect("start");
    run.run().await.expect("run");

    // Entered before the effect and exited after it is what makes resumption
    // possible at all: a node with one and not the other is the only case a
    // resumed run cannot decide by itself.
    assert_eq!(
        kinds(&store, run.id()).await,
        [
            "run.started",
            "node.entered",
            "node.exited", // start
            "node.entered",
            "node.exited", // agent
            "node.entered",
            "node.exited", // sess
            "node.entered",
            "node.exited", // ask
            "node.entered",
            "print",
            "node.exited", // out
            "run.finished",
        ]
    );
}

/// A refused turn must leave by a different pin than an answered one.
const BRANCHING: &str = r#"
schema 1

graph "branching" {
  node start = event.manual
  node agent = acp.connect_agent { agent = "mock" }
  node sess = acp.new_session
  node ask = acp.prompt { text = "Say hello." }
  node answered = io.print
  node declined = io.print { value = "declined" }

  start.out -> agent.in
  agent.out -> sess.in
  sess.out -> ask.in
  ask.end_turn -> answered.in
  ask.refusal -> declined.in

  agent.agent => sess.agent
  ask.text => answered.value
  sess.session => ask.session
}
"#;

/// The Blueprint Branch node, with no agent anywhere near it.
const BRANCH: &str = r#"
schema 1

graph "branch" {
  node start = event.manual
  node gate = flow.branch { condition = true }
  node yes = io.print { value = "taken" }
  node no = io.print { value = "not taken" }

  start.out -> gate.in
  gate.true -> yes.in
  gate.false -> no.in
}
"#;

#[tokio::test]
async fn a_branch_sends_execution_down_one_path_only() {
    let plan = plan(BRANCH);
    let store = Store::open_in_memory().await.expect("store");
    let agents = MockAgents::new(Script::new());

    let mut run = Runtime::start(&store, &plan, &agents, "branch")
        .await
        .expect("start");
    run.run().await.expect("run");

    assert_eq!(printed(&store, run.id()).await, ["taken"]);
}

/// Two agents, two sessions, and a prompt wired to the first of them.
const TWO_AGENTS: &str = r#"
schema 1

graph "two-agents" {
  node start = event.manual
  node alpha = acp.connect_agent { agent = "alpha" }
  node first = acp.new_session
  node beta = acp.connect_agent { agent = "beta" }
  node second = acp.new_session
  node ask = acp.prompt { text = "whose conversation is this?" }
  node out = io.print

  start.out -> alpha.in
  alpha.out -> first.in
  first.out -> beta.in
  beta.out -> second.in
  second.out -> ask.in
  ask.end_turn -> out.in

  alpha.agent => first.agent
  ask.text => out.value
  beta.agent => second.agent
  first.session => ask.session
}
"#;

#[tokio::test]
async fn a_prompt_goes_to_the_session_it_is_wired_to() {
    let plan = plan(TWO_AGENTS);
    let store = Store::open_in_memory().await.expect("store");
    let agents = MockAgents::new(Script::new().says("mine"));

    let mut run = Runtime::start(&store, &plan, &agents, "two-agents")
        .await
        .expect("start");
    run.run().await.expect("run");

    // Session ids are unique within an agent and mean nothing across agents, so
    // two agents both call their first session the same thing. Held by id alone,
    // `beta`'s session displaces `alpha`'s and this prompt — wired to `alpha` —
    // is answered by a conversation that never heard the earlier one, while the
    // run reports success.
    let asked = agents.transcript().asked();
    assert_eq!(asked.len(), 1);
    assert_eq!(asked[0].agent, "alpha");
}

#[tokio::test]
async fn the_stop_reason_chooses_which_way_execution_leaves_a_prompt() {
    let plan = plan(BRANCHING);
    let store = Store::open_in_memory().await.expect("store");
    let agents = MockAgents::new(Script::new().stops(StopReason::Refusal, "I would rather not."));

    let mut run = Runtime::start(&store, &plan, &agents, "branching")
        .await
        .expect("start");
    run.run().await.expect("run");

    assert_eq!(printed(&store, run.id()).await, ["declined"]);
}