fix: reconcile typed history with run exits
This commit is contained in:
@@ -12,45 +12,12 @@ use agen::Item;
|
||||
use agen::interceptor::{
|
||||
Interceptor, PreRequestAction, PreToolAction, ToolCallInfo, TurnEndAction,
|
||||
};
|
||||
use agen::llm_client::ClientError;
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use agen::{Engine, EngineError, EngineResult, History};
|
||||
use agen::{Engine, EngineError, EngineRunExit, History, StopReason};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
|
||||
#[test]
|
||||
fn engine_source_has_no_worker_owned_interruption_marker() {
|
||||
let source = include_str!("../src/engine.rs");
|
||||
assert!(!source.contains("last_run_interrupted"));
|
||||
assert!(!source.contains("set_last_run_interrupted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_exit_classifies_known_and_unexpected_stops_without_message_parsing() {
|
||||
assert!(matches!(
|
||||
EngineRunExit::from(Err(EngineError::Cancelled)),
|
||||
EngineRunExit::Interrupted(StopReason::Cancelled)
|
||||
));
|
||||
assert!(matches!(
|
||||
EngineRunExit::from(Err(EngineError::Client(ClientError::ContextWindowExceeded))),
|
||||
EngineRunExit::Interrupted(StopReason::ContextWindowExceeded)
|
||||
));
|
||||
|
||||
let message_only = EngineError::Client(ClientError::Api {
|
||||
status: Some(400),
|
||||
code: None,
|
||||
message: "context_length_exceeded".to_string(),
|
||||
retry_after: None,
|
||||
});
|
||||
assert!(matches!(
|
||||
EngineRunExit::from(Err(message_only)),
|
||||
EngineRunExit::Interrupted(StopReason::Unexpected(EngineError::Client(
|
||||
ClientError::Api { .. }
|
||||
)))
|
||||
));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Mutable State Tests
|
||||
// =============================================================================
|
||||
@@ -235,13 +202,11 @@ async fn history_append_failure_stops_before_tool_execution() {
|
||||
});
|
||||
|
||||
let mut engine = engine.lock(&history);
|
||||
let error = engine.run(&mut history, "use the tool").await.unwrap_err();
|
||||
let exit = engine.run(&mut history, "use the tool").await;
|
||||
|
||||
assert!(matches!(
|
||||
exit,
|
||||
EngineRunExit::Interrupted(StopReason::Unexpected(EngineError::HistoryAppend(ref message)))
|
||||
if message == "simulated ENOSPC"
|
||||
));
|
||||
assert!(
|
||||
matches!(exit, EngineRunExit::Interrupted(StopReason::Unexpected(EngineError::HistoryAppend(ref message))) if message == "simulated ENOSPC")
|
||||
);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history.entries()[0].item.as_text(), Some("use the tool"));
|
||||
@@ -319,7 +284,7 @@ async fn test_mutable_run_updates_history() -> Result<(), EngineError> {
|
||||
let mut history: History = History::new();
|
||||
|
||||
// Execute (Mutable::run consumes self, returns EngineRunOutput)
|
||||
let _out = engine.run(&mut history, "Hi there").await?;
|
||||
let _out = engine.run(&mut history, "Hi there").await;
|
||||
|
||||
// History is updated
|
||||
let entries = history.entries();
|
||||
@@ -368,12 +333,12 @@ async fn test_locked_multi_turn_history_accumulation() {
|
||||
|
||||
// Turn 1
|
||||
let result1 = locked_engine.run(&mut history, "Hello!").await;
|
||||
assert!(result1.is_ok());
|
||||
assert!(matches!(result1, EngineRunExit::Finished));
|
||||
assert_eq!(history.len(), 2); // user + assistant
|
||||
|
||||
// Turn 2
|
||||
let result2 = locked_engine.run(&mut history, "Can you help me?").await;
|
||||
assert!(result2.is_ok());
|
||||
assert!(matches!(result2, EngineRunExit::Finished));
|
||||
assert_eq!(history.len(), 4); // 2 * (user + assistant)
|
||||
|
||||
// Verify history contents
|
||||
@@ -438,10 +403,7 @@ async fn test_locked_prefix_len_tracking() {
|
||||
assert_eq!(locked_engine.locked_prefix_len(), 2); // 2 items at lock time
|
||||
|
||||
// Execute turn
|
||||
locked_engine
|
||||
.run(&mut history, "New message")
|
||||
.await
|
||||
.unwrap();
|
||||
locked_engine.run(&mut history, "New message").await;
|
||||
|
||||
// History grows but locked_prefix_len remains unchanged
|
||||
assert_eq!(history.len(), 4); // 2 + 2
|
||||
@@ -477,13 +439,16 @@ async fn test_turn_count_increment() -> Result<(), EngineError> {
|
||||
assert_eq!(engine.llm_call_count(), 0);
|
||||
|
||||
// First run consumes Mutable, returns EngineRunOutput
|
||||
let mut engine = engine.run(&mut history, "First").await?.engine;
|
||||
let mut engine = engine.run(&mut history, "First").await.engine;
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
// Retry not yet implemented → AgentTurn:LlmCall is 1:1.
|
||||
assert_eq!(engine.llm_call_count(), 1);
|
||||
|
||||
// Subsequent runs on Locked take &mut self
|
||||
engine.run(&mut history, "Second").await?;
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "Second").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 2);
|
||||
assert_eq!(engine.llm_call_count(), 2);
|
||||
|
||||
@@ -573,7 +538,10 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
|
||||
engine.register_tool(tool_a.definition());
|
||||
|
||||
let mut locked = engine.lock(&history);
|
||||
locked.run(&mut history, "first").await.expect("first run");
|
||||
assert!(matches!(
|
||||
locked.run(&mut history, "first").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(tool_a.call_count(), 1, "tool_a should be called once");
|
||||
|
||||
let mut unlocked = locked.unlock();
|
||||
@@ -581,10 +549,10 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
|
||||
unlocked.register_tool(tool_b.definition());
|
||||
|
||||
let mut relocked = unlocked.lock(&history);
|
||||
relocked
|
||||
.run(&mut history, "second")
|
||||
.await
|
||||
.expect("second run");
|
||||
assert!(matches!(
|
||||
relocked.run(&mut history, "second").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
|
||||
assert_eq!(tool_a.call_count(), 1, "tool_a should not be called again");
|
||||
assert_eq!(tool_b.call_count(), 1, "tool_b should be called once");
|
||||
@@ -686,54 +654,55 @@ impl Interceptor for ContinueTurnOnce {
|
||||
|
||||
#[tokio::test]
|
||||
async fn max_turns_is_scoped_to_each_fresh_run() {
|
||||
let mut history: History = History::new();
|
||||
let responses = vec![completed_text_events(), completed_text_events()];
|
||||
let mut engine = Engine::new(MockLlmClient::with_responses(responses));
|
||||
let mut history: History = History::new();
|
||||
engine.set_max_turns(Some(1));
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "first").await.unwrap(),
|
||||
EngineResult::Finished
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "first").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "second").await.unwrap(),
|
||||
EngineResult::Finished
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "second").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 2);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn yielded_resume_keeps_the_same_unspent_turn_budget() {
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
let mut history: History = History::new();
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_interceptor(YieldOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "start").await.unwrap(),
|
||||
EngineResult::Yielded
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "start").await,
|
||||
EngineRunExit::Yielded
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 0);
|
||||
assert_eq!(engine.active_run_turn_count(), Some(0));
|
||||
|
||||
assert_eq!(
|
||||
engine.resume(&mut history).await.unwrap(),
|
||||
EngineResult::Finished
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
|
||||
let mut history: History = History::new();
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_1", "count_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
@@ -744,7 +713,6 @@ async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
|
||||
];
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(MockLlmClient::new(events));
|
||||
let mut history: History = History::new();
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.register_tool(tool.definition());
|
||||
engine.set_interceptor(PauseToolOnce {
|
||||
@@ -752,18 +720,18 @@ async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "call it").await.unwrap(),
|
||||
EngineResult::Paused
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "call it").await,
|
||||
EngineRunExit::Paused
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), Some(1));
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
|
||||
assert_eq!(
|
||||
engine.resume(&mut history).await.unwrap(),
|
||||
EngineResult::LimitReached
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
assert_eq!(tool.call_count(), 1, "the consumed turn's tool still runs");
|
||||
@@ -771,6 +739,7 @@ async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
|
||||
let mut history: History = History::new();
|
||||
let tool_events = vec![
|
||||
Event::tool_use_start(0, "call_1", "count_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
@@ -782,7 +751,6 @@ async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
|
||||
let client = MockLlmClient::with_responses(vec![tool_events, completed_text_events()]);
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.register_tool(tool.definition());
|
||||
engine.set_interceptor(PauseToolOnce {
|
||||
@@ -790,16 +758,16 @@ async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "pause").await.unwrap(),
|
||||
EngineResult::Paused
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "pause").await,
|
||||
EngineRunExit::Paused
|
||||
));
|
||||
assert_eq!(engine.active_run_turn_count(), Some(1));
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "replace").await.unwrap(),
|
||||
EngineResult::Finished
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "replace").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 2);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
assert_eq!(tool.call_count(), 1, "pending-tool semantics are unchanged");
|
||||
@@ -807,18 +775,18 @@ async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn interceptor_continuation_consumes_the_logical_run_budget() {
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
let mut history: History = History::new();
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_interceptor(ContinueTurnOnce {
|
||||
calls: AtomicUsize::new(0),
|
||||
});
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert_eq!(
|
||||
engine.run(&mut history, "start").await.unwrap(),
|
||||
EngineResult::LimitReached
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "start").await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.llm_call_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
@@ -826,17 +794,17 @@ async fn interceptor_continuation_consumes_the_logical_run_budget() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_active_run_budget_is_enforced_before_another_llm_call() {
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
let mut history: History = History::new();
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(1));
|
||||
engine.set_turn_count(7);
|
||||
engine.set_active_run_turn_count(Some(1));
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert_eq!(
|
||||
engine.resume(&mut history).await.unwrap(),
|
||||
EngineResult::LimitReached
|
||||
);
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 7);
|
||||
assert_eq!(engine.llm_call_count(), 0);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
|
||||
Reference in New Issue
Block a user