sprawling-runtime 0.0.10

One run of a sprawling resident, from dispatch to freeze: turns, tools, replay and the sandbox that executes code.
Documentation
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors

#![allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing,
    reason = "test code"
)]

use super::super::*;
use super::helpers::*;
use crate::conversation::Opening;
use kernel::ToolOutcome;

#[test]
fn a_full_turn_appends_the_canonical_event_sequence() {
    let mut ledger = TestLedger::new();
    let mut model = OneShotModel {
        calls: vec![probe_call()],
    };
    let mut conversation = Conversation::new();
    conversation.push_task_lines("probe the city", "one probe", Opening::FromJob, "the User");
    let mut lines = lines();
    let turn = opened_on::<1>(&mut lines);
    let turn = advance(
        turn.assemble(
            Interrupt::None,
            &mut ledger,
            RunPrompt::new(&prefix(), &mut PromptRecord::default()),
            &conversation,
            &[],
            &shape(),
        )
        .unwrap(),
    );
    let turn = advance(
        turn.call(
            Interrupt::None,
            &mut ledger,
            &mut model,
            &BuildingPolicy::default(),
            Generating::Unwatched,
        )
        .unwrap(),
    );
    let mut invoked = 0u32;
    let turn = advance(
        turn.execute_concurrent(
            Interrupt::None,
            &mut ledger,
            &mut |_call: &ToolCall, _: TimeMs| {
                invoked += 1;
                Ok(ToolOutcome {
                    result: Payload::empty(),
                    attachments: Vec::new(),
                })
            },
            &mut |_| Interrupt::None,
        )
        .unwrap(),
    );
    let PhaseOutcome::Advanced(report) = turn.record(Interrupt::None, &mut ledger).unwrap() else {
        panic!("the boundary was not interrupted");
    };
    assert_eq!(invoked, 1);
    assert_eq!(report.calls_made(), 1);
    lines.barrier(&mut ledger).unwrap();
    assert_eq!(
        ledger.kinds(),
        [
            "prompt_assembled",
            "model_called",
            "model_returned",
            "tool_called",
            "tool_result"
        ]
    );
    assert_eq!(lines.refs().len(), 5);
    assert_eq!(lines.durable(report.model_returned()), Ok(lines.refs()[2]));
    // Conversation-folding material mirrors the ledger content.
    assert_eq!(report.assistant().len(), 1);
    assert_eq!(report.wave_results().len(), 1);
    match &report.wave_results()[0] {
        ContentBlock::ToolResult {
            tool_use_id,
            is_error,
            ..
        } => {
            assert_eq!(tool_use_id, "call-1");
            assert!(!is_error);
        }
        other => panic!("expected a tool_result block, got {other:?}"),
    }
    // model_called carries the duty name; tool events carry ids.
    let called: serde_json::Value = serde_json::from_slice(&ledger.lines[1]).unwrap();
    assert_eq!(called["data"]["model"], "script");
    let tool_called: serde_json::Value = serde_json::from_slice(&ledger.lines[3]).unwrap();
    assert_eq!(tool_called["data"]["id"], "call-1");
    let tool_result: serde_json::Value = serde_json::from_slice(&ledger.lines[4]).unwrap();
    assert_eq!(tool_result["data"]["tool_use_id"], "call-1");
}

#[test]
fn cancel_at_the_call_boundary_stops_before_any_model_bytes() {
    let mut ledger = TestLedger::new();
    let mut model = OneShotModel { calls: vec![] };
    let turn = opened::<1>();
    let turn = advance(
        turn.assemble(
            Interrupt::None,
            &mut ledger,
            RunPrompt::new(&prefix(), &mut PromptRecord::default()),
            blank_conversation(),
            &[],
            &shape(),
        )
        .unwrap(),
    );
    let outcome = turn
        .call(
            Interrupt::Cancel,
            &mut ledger,
            &mut model,
            &BuildingPolicy::default(),
            Generating::Unwatched,
        )
        .unwrap();
    match outcome {
        PhaseOutcome::Cancelled(cancelled) => {
            assert_eq!(
                cancelled.refs().last().unwrap().kind(),
                kernel::EventKind::CancelReceived
            );
        }
        PhaseOutcome::Advanced(_) => panic!("cancel must end the turn at the boundary"),
    }
    assert_eq!(ledger.kinds(), ["prompt_assembled", "cancel_received"]);
}

#[test]
fn steer_at_a_boundary_records_and_advances() {
    let mut ledger = TestLedger::new();
    let mut model = OneShotModel { calls: vec![] };
    let mut lines = lines();
    let turn = opened_on::<4>(&mut lines);
    let turn = advance(
        turn.assemble(
            Interrupt::Steer {
                speaker: crate::conversation::Speaker::Person,
                text: "prefer the short route".to_owned(),
            },
            &mut ledger,
            RunPrompt::new(&prefix(), &mut PromptRecord::default()),
            blank_conversation(),
            &[],
            &shape(),
        )
        .unwrap(),
    );
    let turn = advance(
        turn.call(
            Interrupt::None,
            &mut ledger,
            &mut model,
            &BuildingPolicy::default(),
            Generating::Unwatched,
        )
        .unwrap(),
    );
    closed(turn, &mut ledger);
    lines.barrier(&mut ledger).unwrap();
    assert_eq!(
        ledger.kinds(),
        [
            "steer_received",
            "prompt_assembled",
            "model_called",
            "model_returned"
        ]
    );
    let steer: serde_json::Value = serde_json::from_slice(&ledger.lines[0]).unwrap();
    assert_eq!(steer["data"]["source"], "user");
    assert_eq!(steer["data"]["text"], "prefer the short route");
}
#[test]
fn a_tool_error_lands_in_tool_result_not_in_the_turn() {
    let mut ledger = TestLedger::new();
    let mut model = OneShotModel {
        calls: vec![ToolCall {
            id: "call-9".to_owned(),
            name: kernel::ToolName::parse("broken").unwrap(),
            args: Payload::empty(),
        }],
    };
    let mut lines = lines();
    let turn = opened_on::<2>(&mut lines);
    let turn = advance(
        turn.assemble(
            Interrupt::None,
            &mut ledger,
            RunPrompt::new(&prefix(), &mut PromptRecord::default()),
            blank_conversation(),
            &[],
            &shape(),
        )
        .unwrap(),
    );
    let turn = advance(
        turn.call(
            Interrupt::None,
            &mut ledger,
            &mut model,
            &BuildingPolicy::default(),
            Generating::Unwatched,
        )
        .unwrap(),
    );
    let turn = advance(
        turn.execute_concurrent(
            Interrupt::None,
            &mut ledger,
            &mut |call: &ToolCall, _: TimeMs| {
                Err(AxError::failure(
                    AxCode::ToolUnavailable,
                    "invoke tool",
                    call.name.to_string(),
                )
                .with_recovery("this bench registers no tools"))
            },
            &mut |_| Interrupt::None,
        )
        .unwrap(),
    );
    let PhaseOutcome::Advanced(report) = turn.record(Interrupt::None, &mut ledger).unwrap() else {
        panic!("the boundary was not interrupted");
    };
    lines.barrier(&mut ledger).unwrap();
    assert_eq!(report.calls_made(), 1);
    let last = ledger.lines.last().unwrap();
    let value: serde_json::Value = serde_json::from_slice(last).unwrap();
    assert_eq!(value["kind"], "tool_result");
    assert_eq!(value["data"]["error"]["code"], "E_TOOL_UNAVAILABLE");
    match &report.wave_results()[0] {
        ContentBlock::ToolResult { is_error, .. } => assert!(is_error),
        other => panic!("expected a tool_result block, got {other:?}"),
    }
}

#[test]
fn the_ledger_chain_stays_verifiable_after_a_turn() {
    let mut ledger = TestLedger::new();
    let mut model = OneShotModel { calls: vec![] };
    let turn = opened::<3>();
    let turn = advance(
        turn.assemble(
            Interrupt::None,
            &mut ledger,
            RunPrompt::new(&prefix(), &mut PromptRecord::default()),
            blank_conversation(),
            &[],
            &shape(),
        )
        .unwrap(),
    );
    let turn = advance(
        turn.call(
            Interrupt::None,
            &mut ledger,
            &mut model,
            &BuildingPolicy::default(),
            Generating::Unwatched,
        )
        .unwrap(),
    );
    let turn = advance(
        turn.execute_concurrent(
            Interrupt::None,
            &mut ledger,
            &mut |_call: &ToolCall, _: TimeMs| panic!("empty wave must not invoke"),
            &mut |_| Interrupt::None,
        )
        .unwrap(),
    );
    let _report = turn.record(Interrupt::None, &mut ledger).unwrap();
    crate::replay::verify_lines(ledger.lines.clone()).unwrap();
}

/// A halt that arrives during a wave stops the wave where it stands.
///
/// One question per turn would make the person who stopped the city
/// wait for every effect the model asked for in that one reply.
#[test]
fn a_wave_halted_between_two_calls_does_not_make_the_second() {
    let mut ledger = TestLedger::new();
    let second = ToolCall {
        id: "call-2".to_owned(),
        name: kernel::ToolName::parse("probe").unwrap(),
        args: Payload::empty(),
    };
    let mut model = OneShotModel {
        calls: vec![probe_call(), second],
    };
    let turn = opened::<9>();
    let turn = advance(
        turn.assemble(
            Interrupt::None,
            &mut ledger,
            RunPrompt::new(&prefix(), &mut PromptRecord::default()),
            blank_conversation(),
            &[],
            &shape(),
        )
        .unwrap(),
    );
    let turn = advance(
        turn.call(
            Interrupt::None,
            &mut ledger,
            &mut model,
            &BuildingPolicy::default(),
            Generating::Unwatched,
        )
        .unwrap(),
    );
    let mut invoked = 0u32;
    let outcome = turn
        .execute_concurrent(
            Interrupt::None,
            &mut ledger,
            &mut |_call: &ToolCall, _: TimeMs| {
                invoked += 1;
                Ok(ToolOutcome {
                    result: Payload::empty(),
                    attachments: Vec::new(),
                })
            },
            &mut |call| match call {
                0 => Interrupt::None,
                _ => Interrupt::Cancel,
            },
        )
        .unwrap();
    let PhaseOutcome::Cancelled(cancelled) = outcome else {
        panic!("a halted wave ends the turn");
    };
    assert_eq!(invoked, 1, "the second call was never made");
    assert!(
        !cancelled.refs().is_empty(),
        "the cancellation is on the ledger"
    );
    let kinds = ledger.kinds();
    assert_eq!(
        kinds.iter().filter(|kind| *kind == "tool_called").count(),
        1,
        "nothing was written down for work nothing did: {kinds:?}"
    );
    assert_eq!(
        kinds.last().map(String::as_str),
        Some("cancel_received"),
        "{kinds:?}"
    );
}

/// A request lives for one call, so assembling it borrows the
/// conversation and the tool table instead of copying either: a long
/// conversation would otherwise be copied once per turn.
#[test]
fn assembling_borrows_the_conversation_and_the_tools() {
    let mut ledger = TestLedger::new();
    let mut conversation = Conversation::new();
    conversation.push_task_lines("probe the city", "one probe", Opening::FromJob, "the User");
    let tools = [kernel::ToolDef {
        name: kernel::ToolName::parse("exec").unwrap(),
        description: "run a command".to_owned(),
        input_schema: Payload::empty(),
    }];
    let turn = opened::<1>();
    let calling = advance(
        turn.assemble(
            Interrupt::None,
            &mut ledger,
            RunPrompt::new(&prefix(), &mut PromptRecord::default()),
            &conversation,
            &tools,
            &shape(),
        )
        .unwrap(),
    );
    assert_eq!(
        (
            calling.state.chat.messages.as_ptr(),
            calling.state.chat.tools.as_ptr()
        ),
        (conversation.messages().as_ptr(), tools.as_ptr())
    );
}