Files
tkmind_go/crates/goose/src/agents/state_machine/tests/pipeline.rs
T

1082 lines
36 KiB
Rust

use std::collections::VecDeque;
use std::sync::Arc;
use anyhow::Result;
use rmcp::model::ElicitationAction;
use tokio::sync::mpsc;
use tokio::sync::Mutex as TokioMutex;
use tokio_util::sync::CancellationToken;
use super::calculator_extension::CalculatorExtension;
use super::dummy_api::{DummyApi, ProviderFeatures};
use crate::action_required_manager::ElicitationOutcome;
use crate::agents::extension::ExtensionConfig;
use crate::agents::extension_manager::{ExtensionManager, ExtensionManagerCapabilities};
use crate::agents::mcp_client::McpClientTrait;
use crate::agents::prompt_manager::PromptManager;
use crate::agents::state_machine::{
BangShellOperation, CompactionOperation, DoctorOperation, Emitter, ExitOnErrorOperation,
InferenceRunner, MaxTurnsOperation, Operation, ProjectOperation, RecipeOperation,
RetryOperation, SkillOperation, SlashCommandOperation, StateMachine, SteerOperation,
SteerQueue, Step, StopHookOperation, ToolApprovalOperation, ToolExecutionOperation,
ToolPairCompactionOperation, UnknownToolOperation,
};
use crate::agents::AgentEvent;
use crate::config::permission::{PermissionLevel, PermissionManager};
use crate::config::GooseMode;
use crate::conversation::message::{ActionRequiredData, Message, MessageContent};
use crate::conversation::Conversation;
use crate::hooks::HookManager;
use crate::permission::permission_inspector::PermissionInspector;
use crate::permission::Permission;
use crate::providers::base::Provider;
use crate::security::security_inspector::SecurityInspector;
use crate::session::extension_data::EnabledExtensionsState;
use crate::session::{Session, SessionManager, SessionType};
use crate::tool_inspection::ToolInspectionManager;
use goose_providers::model::ModelConfig;
struct ResolvedModelProvider {
inner: Arc<dyn Provider>,
resolved_model: &'static str,
}
#[async_trait::async_trait]
impl Provider for ResolvedModelProvider {
fn get_name(&self) -> &str {
self.inner.get_name()
}
async fn stream(
&self,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[rmcp::model::Tool],
) -> Result<crate::providers::base::MessageStream, goose_providers::errors::ProviderError> {
self.inner
.stream(model_config, system, messages, tools)
.await
}
async fn get_context_limit(
&self,
model_config: &ModelConfig,
) -> Result<usize, goose_providers::errors::ProviderError> {
self.inner.get_context_limit(model_config).await
}
async fn fetch_model_info(
&self,
model_name: &str,
) -> Result<goose_providers::base::ModelInfo, goose_providers::errors::ProviderError> {
let mut model_info = self.inner.fetch_model_info(model_name).await?;
model_info.resolved_model = Some(self.resolved_model.to_string());
Ok(model_info)
}
}
pub(super) const MAX_TURNS: u32 = 25;
pub(super) const COMPACTION_THRESHOLD: f64 = 0.8;
pub(super) struct TestPipeline {
pub(super) session_manager: Arc<SessionManager>,
api: Arc<DummyApi>,
provider_features: ProviderFeatures,
provider: Arc<dyn Provider>,
model_config: ModelConfig,
extension_manager: Arc<ExtensionManager>,
goose_mode: TokioMutex<GooseMode>,
prompt_manager: TokioMutex<PromptManager>,
tool_inspection_manager: ToolInspectionManager,
permission_manager: Arc<PermissionManager>,
frontend_instructions: TokioMutex<Option<String>>,
hook_manager: HookManager,
stop_hook_block_cap: u32,
goal: TokioMutex<Option<String>>,
grind: TokioMutex<Option<String>>,
calculator: Arc<CalculatorExtension>,
pub(super) session_id: String,
working_dir: std::path::PathBuf,
steer_queue: SteerQueue,
max_turns: u32,
scheduler: Option<Arc<crate::scheduler::Scheduler>>,
_temp_dir: Arc<tempfile::TempDir>,
}
impl TestPipeline {
pub(super) fn machine(&self, cancel: CancellationToken) -> StateMachine<'_> {
let provider = self.provider.clone();
let tool_call_cutoff = crate::context_mgmt::compute_tool_call_cutoff(
self.model_config.context_limit(),
COMPACTION_THRESHOLD,
);
let operations: Vec<Arc<dyn Operation + '_>> = vec![
Arc::new(SteerOperation::new(
self.steer_queue.clone(),
self.hook_manager.clone(),
)),
Arc::new(MaxTurnsOperation::new(self.max_turns)),
Arc::new(BangShellOperation::new()),
Arc::new(CompactionOperation::new(
provider.clone(),
self.model_config.clone(),
self.model_config.context_limit(),
COMPACTION_THRESHOLD,
)),
Arc::new(ToolPairCompactionOperation::new(
provider.clone(),
self.model_config.clone(),
tool_call_cutoff,
true,
)),
Arc::new(ToolApprovalOperation::new(
&self.goose_mode,
&self.tool_inspection_manager,
)),
Arc::new(DoctorOperation),
Arc::new(ProjectOperation),
Arc::new(SkillOperation),
Arc::new(RecipeOperation),
Arc::new(ToolExecutionOperation::new(
&self.goose_mode,
self.extension_manager.clone(),
self.hook_manager.clone(),
)),
Arc::new(UnknownToolOperation),
Arc::new(RetryOperation::new(
&self.goal,
&self.grind,
std::time::Duration::from_secs(1),
std::time::Duration::from_secs(1),
)),
Arc::new(StopHookOperation::new(
self.hook_manager.clone(),
self.stop_hook_block_cap,
)),
Arc::new(ExitOnErrorOperation),
];
let inference = Arc::new(InferenceRunner::new(
provider,
self.model_config.clone(),
self.extension_manager.clone(),
&self.goose_mode,
&self.prompt_manager,
&self.tool_inspection_manager,
&self.frontend_instructions,
));
let mut command_handlers = operations.clone();
command_handlers.push(inference.clone());
let command_operation: Arc<dyn Operation + '_> =
Arc::new(SlashCommandOperation::new(command_handlers));
let steps = std::iter::once(command_operation)
.chain(operations)
.map(Step::Operation)
.chain(std::iter::once(Step::Inference(inference)))
.collect();
StateMachine::new(steps, cancel).with_hook_manager(self.hook_manager.clone())
}
pub(super) async fn with_goose_mode(self, mode: GooseMode) -> Self {
*self.goose_mode.lock().await = mode;
self.session_manager
.update(&self.session_id)
.goose_mode(mode)
.apply()
.await
.unwrap();
self
}
pub(super) fn context_limit(&self) -> usize {
self.model_config.context_limit()
}
pub(super) async fn with_model(mut self, model: &str) -> Self {
self.model_config = ModelConfig::new(model).with_canonical_limits("openai");
self.session_manager
.update(&self.session_id)
.model_config(self.model_config.clone())
.apply()
.await
.unwrap();
self
}
pub(super) async fn with_model_config(mut self, model_config: ModelConfig) -> Self {
self.model_config = model_config;
self.session_manager
.update(&self.session_id)
.model_config(self.model_config.clone())
.apply()
.await
.unwrap();
self
}
pub(super) async fn with_provider_name(self, provider_name: &str) -> Result<Self> {
self.session_manager
.update(&self.session_id)
.provider_name(provider_name)
.apply()
.await?;
self.reconstruct().await
}
pub(super) fn with_max_turns(mut self, max_turns: u32) -> Self {
self.max_turns = max_turns;
self
}
pub(super) fn working_dir(&self) -> &std::path::Path {
&self.working_dir
}
pub(super) fn with_hook_manager(mut self, hook_manager: HookManager) -> Self {
self.hook_manager = hook_manager;
self
}
pub(super) fn with_stop_hook_block_cap(mut self, cap: u32) -> Self {
self.stop_hook_block_cap = cap;
self
}
pub(super) async fn set_total_tokens(&self, tokens: i32) {
use goose_providers::conversation::token_usage::Usage;
self.session_manager
.update(&self.session_id)
.usage(Usage::new(None, None, Some(tokens)))
.apply()
.await
.unwrap();
}
pub(super) async fn set_recipe(&self, recipe: crate::recipe::Recipe) -> Result<()> {
self.session_manager
.update(&self.session_id)
.recipe(Some(recipe.clone()))
.apply()
.await?;
for extension in recipe.extensions.clone().unwrap_or_default() {
self.extension_manager
.add_extension(
extension,
Some(self.working_dir.clone()),
None,
Some(&self.session_id),
)
.await?;
}
Ok(())
}
pub(super) async fn set_schedule_id(&self, schedule_id: String) -> Result<()> {
self.session_manager
.update(&self.session_id)
.schedule_id(Some(schedule_id))
.apply()
.await
}
pub(super) async fn get_goal(&self) -> Option<String> {
self.goal.lock().await.clone()
}
pub(super) async fn set_grind(&self, grind: Option<String>) {
*self.grind.lock().await = grind;
}
pub(super) async fn session(&self) -> Result<Session> {
self.session_manager
.get_session(&self.session_id, true)
.await
}
pub(super) async fn steer(&self, message: Message) {
self.steer_queue.lock().await.push_back(message);
}
pub(super) async fn has_pending_steers(&self) -> bool {
!self.steer_queue.lock().await.is_empty()
}
pub(super) fn calculator_total(&self) -> i64 {
self.calculator.total()
}
pub(super) async fn wait_for_calculator_result(&self) {
self.calculator.wait_for_result().await;
}
pub(super) fn tool_contexts(&self) -> Vec<crate::agents::tool_execution::ToolCallContext> {
self.calculator.contexts()
}
pub(super) async fn reconstruct(&self) -> Result<Self> {
let session = self.session().await?;
let goal = self.goal.lock().await.clone();
let grind = self.grind.lock().await.clone();
let pipeline = build_test_pipeline(
self.session_manager.clone(),
self.api.clone(),
self.provider_features,
self.scheduler.clone(),
session,
self._temp_dir.clone(),
)
.await?
.with_hook_manager(self.hook_manager.clone())
.with_stop_hook_block_cap(self.stop_hook_block_cap);
*pipeline.goal.lock().await = goal;
*pipeline.grind.lock().await = grind;
Ok(pipeline)
}
pub(super) async fn new_session(&self, working_dir: std::path::PathBuf) -> Result<Self> {
let source_session = self.session().await?;
let session = self
.session_manager
.create_session(
working_dir,
"pipeline-test".to_string(),
SessionType::Hidden,
GooseMode::Auto,
)
.await?;
self.session_manager
.update(&session.id)
.provider_name(
source_session
.provider_name
.expect("source test session has a provider"),
)
.model_config(self.model_config.clone())
.apply()
.await?;
build_test_pipeline(
self.session_manager.clone(),
self.api.clone(),
self.provider_features,
self.scheduler.clone(),
self.session_manager.get_session(&session.id, true).await?,
self._temp_dir.clone(),
)
.await
}
pub(super) fn synchronize_calculator(&self, calls: usize) {
self.calculator.synchronize(calls);
}
pub(super) async fn run<const N: usize>(&self, user_messages: [&str; N]) -> Result<TestRun> {
let mut events = Vec::new();
for text in user_messages {
self.session_manager
.add_message(&self.session_id, &Message::user().with_text(text))
.await?;
events.extend(run_machine(self).await?);
}
Ok(TestRun::new(self.session().await?, events))
}
pub(super) async fn run_message(&self, message: Message) -> Result<TestRun> {
self.session_manager
.add_message(&self.session_id, &message)
.await?;
let events = run_machine(self).await?;
Ok(TestRun::new(self.session().await?, events))
}
pub(super) async fn run_reconstructing_each_step(
mut self,
message: &str,
) -> Result<(Self, TestRun, usize)> {
self.session_manager
.add_message(&self.session_id, &Message::user().with_text(message))
.await?;
let cancel = CancellationToken::new();
let (tx, mut rx) = mpsc::channel(1024);
let emit = Emitter::new(tx, cancel.clone());
let mut events = Vec::new();
let mut applied_steps = 0;
loop {
let session = self.session().await?;
let machine = self.machine(cancel.clone());
let Some(mut result) = machine.step(&session, &emit).await? else {
break;
};
machine
.apply(self.session_manager.as_ref(), &session, &mut result, &emit)
.await?;
applied_steps += 1;
while let Ok(event) = rx.try_recv() {
events.push(event);
}
let yield_to_client = result.yield_to_client;
drop(machine);
self = self.reconstruct().await?;
if yield_to_client {
break;
}
}
drop(emit);
while let Some(event) = rx.recv().await {
events.push(event);
}
let result = TestRun::new(self.session().await?, events);
Ok((self, result, applied_steps))
}
pub(super) async fn seed<const N: usize>(&self, messages: [Message; N]) -> Result<()> {
for message in messages {
self.session_manager
.add_message(&self.session_id, &message)
.await?;
}
Ok(())
}
pub(super) async fn resume(&self) -> Result<TestRun> {
let events = run_machine(self).await?;
Ok(TestRun::new(self.session().await?, events))
}
pub(super) async fn confirm(&self, id: &str, permission: Permission) -> Result<()> {
self.session_manager
.add_message(
&self.session_id,
&Message::user()
.with_content(MessageContent::action_required_tool_confirmation_response(
id, permission,
))
.with_visibility(false, false),
)
.await
}
pub(super) fn set_permission(&self, tool: &str, level: PermissionLevel) {
self.permission_manager.update_user_permission(tool, level);
}
pub(super) async fn remove_extension(&self, name: &str) -> Result<()> {
self.extension_manager
.remove_extension(name)
.await
.map_err(anyhow::Error::from)
}
pub(super) async fn add_extension(&self, name: &str) -> Result<()> {
self.extension_manager
.add_extension(
ExtensionConfig::Platform {
name: name.to_string(),
description: name.to_string(),
display_name: None,
bundled: None,
available_tools: vec![],
},
Some(self.working_dir.clone()),
None,
Some(&self.session_id),
)
.await
.map_err(anyhow::Error::from)
}
pub(super) async fn resume_cancelled(&self) -> Result<TestRun> {
let cancel = CancellationToken::new();
cancel.cancel();
let machine = self.machine(cancel.clone());
let (tx, mut rx) = mpsc::channel(1024);
let emit = Emitter::new(tx, cancel);
let session = machine
.run(self.session_manager.as_ref(), &self.session_id, &emit)
.await?;
drop(emit);
let mut events = Vec::new();
while let Some(event) = rx.recv().await {
events.push(event);
}
Ok(TestRun::new(session, events))
}
pub(super) async fn run_with_cancel(
&self,
message: &str,
cancel: CancellationToken,
) -> Result<TestRun> {
self.session_manager
.add_message(&self.session_id, &Message::user().with_text(message))
.await?;
let machine = self.machine(cancel.clone());
let (tx, mut rx) = mpsc::channel(1024);
let emit = Emitter::new(tx, cancel);
let session = machine
.run(self.session_manager.as_ref(), &self.session_id, &emit)
.await?;
drop(emit);
let mut events = Vec::new();
while let Some(event) = rx.recv().await {
events.push(event);
}
Ok(TestRun::new(session, events))
}
pub(super) async fn run_with_elicitation(
&self,
message: &str,
action: ElicitationAction,
user_data: serde_json::Value,
) -> Result<TestRun> {
self.session_manager
.add_message(&self.session_id, &Message::user().with_text(message))
.await?;
let cancel = CancellationToken::new();
let machine = self.machine(cancel.clone());
let (tx, mut rx) = mpsc::channel(1024);
let emit = Emitter::new(tx, cancel);
let mut events = Vec::new();
let mut answered = false;
loop {
let session = self.session().await?;
let step = machine.step(&session, &emit);
tokio::pin!(step);
let mut result = loop {
tokio::select! {
result = &mut step => break result?,
Some(event) = rx.recv() => {
if let AgentEvent::Message(message) = &event {
let elicitation_id = message.content.iter().find_map(|content| {
match content {
MessageContent::ActionRequired(action) => match &action.data {
ActionRequiredData::Elicitation { id, .. } => Some(id.clone()),
_ => None,
},
_ => None,
}
});
if let Some(id) = elicitation_id {
let outcome = match &action {
ElicitationAction::Accept => {
ElicitationOutcome::Accept(user_data.clone())
}
ElicitationAction::Decline => ElicitationOutcome::Decline,
ElicitationAction::Cancel => ElicitationOutcome::Cancel,
_ => ElicitationOutcome::Cancel,
};
let response = Message::user()
.with_generated_id()
.with_content(
MessageContent::action_required_elicitation_response(
id.clone(),
user_data.clone(),
action.clone(),
),
)
.agent_only();
crate::elicitation::complete_elicitation_with_message(
&self.session_manager,
&self.session_id,
&id,
outcome,
&response,
)
.await?;
answered = true;
}
}
events.push(event);
}
}
};
let Some(ref mut result) = result else {
break;
};
machine
.apply(self.session_manager.as_ref(), &session, result, &emit)
.await?;
while let Ok(event) = rx.try_recv() {
events.push(event);
}
if result.yield_to_client {
break;
}
}
assert!(answered, "tool did not request elicitation");
drop(emit);
while let Some(event) = rx.recv().await {
events.push(event);
}
Ok(TestRun::new(self.session().await?, events))
}
pub(super) async fn set_system_prompt_override(&self, prompt: impl Into<String>) {
self.prompt_manager
.lock()
.await
.set_system_prompt_override(prompt.into());
}
pub(super) async fn clear_system_prompt_override(&self) {
self.prompt_manager
.lock()
.await
.clear_system_prompt_override();
}
}
pub(super) async fn test_pipeline() -> Result<(TestPipeline, Arc<DummyApi>)> {
test_pipeline_with(ProviderFeatures::default()).await
}
pub(super) async fn test_pipeline_with_scheduler() -> Result<(
TestPipeline,
Arc<DummyApi>,
Arc<crate::scheduler::Scheduler>,
)> {
let (pipeline, api, scheduler) =
test_pipeline_with_components(ProviderFeatures::default(), true).await?;
Ok((pipeline, api, scheduler.expect("scheduler was requested")))
}
pub(super) async fn test_pipeline_with(
features: ProviderFeatures,
) -> Result<(TestPipeline, Arc<DummyApi>)> {
let (pipeline, api, _) = test_pipeline_with_components(features, false).await?;
Ok((pipeline, api))
}
async fn test_pipeline_with_components(
features: ProviderFeatures,
with_scheduler: bool,
) -> Result<(
TestPipeline,
Arc<DummyApi>,
Option<Arc<crate::scheduler::Scheduler>>,
)> {
let api = Arc::new(DummyApi::start(features).await);
let temp_dir = Arc::new(tempfile::tempdir()?);
let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf()));
let scheduler = if with_scheduler {
Some(
crate::scheduler::Scheduler::new(
temp_dir.path().join("schedule.json"),
session_manager.clone(),
)
.await?,
)
} else {
None
};
let session = session_manager
.create_session(
temp_dir.path().to_path_buf(),
"pipeline-test".to_string(),
if with_scheduler {
SessionType::Scheduled
} else {
SessionType::Hidden
},
GooseMode::Auto,
)
.await?;
let model_config = ModelConfig::new(goose_providers::openai::OPEN_AI_DEFAULT_MODEL)
.with_canonical_limits("openai");
session_manager
.update(&session.id)
.provider_name("openai")
.model_config(model_config)
.apply()
.await?;
let session = session_manager.get_session(&session.id, true).await?;
let pipeline = build_test_pipeline(
session_manager,
api.clone(),
features,
scheduler.clone(),
session,
temp_dir,
)
.await?;
Ok((pipeline, api, scheduler))
}
async fn build_test_pipeline(
session_manager: Arc<SessionManager>,
api: Arc<DummyApi>,
provider_features: ProviderFeatures,
scheduler: Option<Arc<crate::scheduler::Scheduler>>,
session: Session,
temp_dir: Arc<tempfile::TempDir>,
) -> Result<TestPipeline> {
let provider_name = session
.provider_name
.as_deref()
.expect("test session has a provider");
let api_client = goose_providers::api_client::ApiClient::new_with_tls(
api.uri(),
goose_providers::api_client::AuthMethod::NoAuth,
None,
)?;
let provider: Arc<dyn Provider> = Arc::new(
goose_providers::openai::OpenAiProviderBuilder::new(api_client)
.name(provider_name)
.preserve_thinking_context(provider_features.preserves_thinking)
.build(),
);
let provider: Arc<dyn Provider> = match provider_features.resolved_model {
Some(resolved_model) => Arc::new(ResolvedModelProvider {
inner: provider,
resolved_model,
}),
None => provider,
};
let shared_provider = Arc::new(TokioMutex::new(Some(provider.clone())));
let extension_manager = Arc::new(ExtensionManager::new(
shared_provider.clone(),
session_manager.clone(),
scheduler
.clone()
.map(|scheduler| scheduler as Arc<dyn crate::scheduler_trait::SchedulerTrait>),
"pipeline-test".to_string(),
ExtensionManagerCapabilities {
mcpui: false,
host_info: None,
},
false,
));
let permission_manager = Arc::new(PermissionManager::new(temp_dir.path().join("permissions")));
let mut tool_inspection_manager = ToolInspectionManager::new();
tool_inspection_manager.add_inspector(Box::new(SecurityInspector::enabled()));
tool_inspection_manager.add_inspector(Box::new(PermissionInspector::new(
permission_manager.clone(),
shared_provider,
session_manager.clone(),
)));
let model_config = session
.model_config
.clone()
.expect("test session has a model config");
let calculator = Arc::new(CalculatorExtension::new(session_manager.action_required()));
let pipeline = TestPipeline {
session_manager,
api,
provider_features,
provider: provider.clone(),
model_config,
extension_manager,
goose_mode: TokioMutex::new(session.goose_mode),
prompt_manager: TokioMutex::new(PromptManager::new()),
tool_inspection_manager,
permission_manager,
frontend_instructions: TokioMutex::new(None),
hook_manager: HookManager::default(),
stop_hook_block_cap: 3,
goal: TokioMutex::new(None),
grind: TokioMutex::new(None),
calculator: calculator.clone(),
session_id: session.id.clone(),
working_dir: session.working_dir.clone(),
steer_queue: Arc::new(tokio::sync::Mutex::new(VecDeque::new())),
max_turns: MAX_TURNS,
scheduler,
_temp_dir: temp_dir,
};
let extension_manager = pipeline.extension_manager.clone();
let session_id = pipeline.session_id.clone();
let mut extensions = EnabledExtensionsState::from_extension_data(&session.extension_data)
.map(|state| state.extensions)
.unwrap_or_else(default_extensions);
for recipe_extension in session
.recipe
.as_ref()
.and_then(|recipe| recipe.extensions.as_ref())
.into_iter()
.flatten()
{
if !extensions
.iter()
.any(|extension| extension.name() == recipe_extension.name())
{
extensions.push(recipe_extension.clone());
}
}
if !extensions
.iter()
.any(|extension| extension.name() == "calculator")
{
extensions.push(platform_extension("calculator", "Stateful test calculator"));
}
for extension in extensions {
if extension.name() == "calculator" {
extension_manager
.add_client(
"calculator".to_string(),
extension,
calculator.clone(),
calculator.get_info().cloned(),
None,
)
.await;
} else {
extension_manager
.add_extension(
extension,
Some(session.working_dir.clone()),
None,
Some(&session_id),
)
.await?;
}
}
Ok(pipeline)
}
fn default_extensions() -> Vec<ExtensionConfig> {
[
("calculator", "Stateful test calculator"),
("extensionmanager", "Extension Manager"),
("todo", "Todo"),
(
crate::agents::platform_extensions::scheduler::EXTENSION_NAME,
"Scheduler",
),
]
.into_iter()
.map(|(name, description)| platform_extension(name, description))
.collect()
}
fn platform_extension(name: &str, description: &str) -> ExtensionConfig {
ExtensionConfig::Platform {
name: name.to_string(),
description: description.to_string(),
display_name: None,
bundled: None,
available_tools: vec![],
}
}
pub(super) struct TestRun {
pub session: Session,
pub events: Vec<AgentEvent>,
}
#[derive(Debug, Clone, Copy)]
pub(super) enum MessageKind {
Agent,
Confirmation,
Error,
Thinking,
ToolCall,
ToolResponse,
User,
}
impl TestRun {
fn new(session: Session, events: Vec<AgentEvent>) -> Self {
let conversation = session
.conversation
.as_ref()
.expect("session has a conversation");
for message in conversation.messages() {
assert!(
message.id.is_some(),
"persisted message has no id: {message:#?}"
);
}
for event in &events {
if let AgentEvent::Message(message) = event {
assert!(
message.id.is_some(),
"emitted message has no id: {message:#?}"
);
}
}
Self { session, events }
}
pub(super) fn conversation(&self) -> &Conversation {
self.session
.conversation
.as_ref()
.expect("session has a conversation")
}
pub(super) fn history_replacements(&self) -> usize {
self.events
.iter()
.filter(|event| matches!(event, AgentEvent::HistoryReplaced(_)))
.count()
}
pub(super) fn assert_emitted(&self, contains: &str) {
let emitted = self
.events
.iter()
.filter_map(|event| match event {
AgentEvent::Message(message) => Some(
message
.content
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(" "),
),
_ => None,
})
.collect::<Vec<_>>();
assert!(
emitted.iter().any(|message| message.contains(contains)),
"no emitted message contains {contains:?}:\n{}",
emitted.join("\n")
);
}
pub(super) fn assert_emitted_message_matches_persisted(&self, contains: &str) {
let emitted = self
.events
.iter()
.find_map(|event| match event {
AgentEvent::Message(message) if message.as_concat_text().contains(contains) => {
Some(message)
}
_ => None,
})
.unwrap_or_else(|| panic!("no emitted message contains {contains:?}"));
let persisted = self
.conversation()
.messages()
.iter()
.find(|message| message.as_concat_text().contains(contains))
.unwrap_or_else(|| panic!("no persisted message contains {contains:?}"));
assert_eq!(emitted.id, persisted.id);
}
pub(super) fn assert_message(&self, index: isize, kind: MessageKind, contains: &str) {
let messages = self
.conversation()
.messages()
.iter()
.filter(|message| !message.is_turn_context())
.flat_map(|message| {
message
.content
.iter()
.map(move |content| (message, content))
})
.collect::<Vec<_>>();
let resolved = if index < 0 {
messages.len() as isize + index
} else {
index
};
assert!(
resolved >= 0 && resolved < messages.len() as isize,
"message index {index} is out of bounds for {} content blocks",
messages.len()
);
let (message, content) = messages[resolved as usize];
let content = match (kind, content) {
(MessageKind::Agent, MessageContent::Text(text))
if message.role == rmcp::model::Role::Assistant =>
{
text.text.clone()
}
(MessageKind::User, MessageContent::Text(text))
if message.role == rmcp::model::Role::User =>
{
text.text.clone()
}
(MessageKind::Thinking, MessageContent::Thinking(thinking)) => {
thinking.thinking.clone()
}
(MessageKind::ToolCall, MessageContent::ToolRequest(request)) => {
match &request.tool_call {
Ok(call) => {
let arguments = call
.arguments
.clone()
.map(serde_json::Value::Object)
.unwrap_or_default();
format!("{}({arguments})", call.name)
}
Err(error) => error.message.to_string(),
}
}
(MessageKind::ToolResponse, MessageContent::ToolResponse(response)) => {
match response.tool_result.as_ref() {
Ok(result) => result
.content
.iter()
.filter_map(|content| content.as_text().map(|text| text.text.clone()))
.collect(),
Err(error) => error.message.to_string(),
}
}
(MessageKind::Confirmation, MessageContent::ActionRequired(action)) => {
match &action.data {
ActionRequiredData::ToolConfirmation {
id,
tool_name,
prompt,
..
} => format!("{id} {tool_name} {}", prompt.as_deref().unwrap_or_default()),
_ => panic!("message {index} is not a tool confirmation: {content:?}"),
}
}
(MessageKind::Error, MessageContent::Error(error)) => error.message.clone(),
_ => panic!("message {index} is not {kind:?}: {content:?}"),
};
assert!(
content.contains(contains),
"message {index} does not contain {contains:?}: {content:?}"
);
}
}
pub(super) async fn run_machine(pipeline: &TestPipeline) -> Result<Vec<AgentEvent>> {
let cancel = CancellationToken::new();
let machine = pipeline.machine(cancel.clone());
let (tx, mut rx) = mpsc::channel(1024);
let emit = Emitter::new(tx, cancel);
let mut events = Vec::new();
loop {
let session = pipeline.session().await?;
let Some(mut result) = machine.step(&session, &emit).await? else {
break;
};
machine
.apply(
pipeline.session_manager.as_ref(),
&session,
&mut result,
&emit,
)
.await?;
while let Ok(event) = rx.try_recv() {
events.push(event);
}
if result.yield_to_client {
break;
}
}
drop(emit);
while let Some(event) = rx.recv().await {
events.push(event);
}
Ok(events)
}