use serde::{Deserialize, Serialize};
use super::lifecycle::{StopReason, ToolCall};
use crate::compact::types::CompactReason;
use crate::error::LoopError;
use crate::message::{Message, MessagePart, Role, ToolContent};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum MachineStep {
CallLLM {
turn: usize,
},
CallTools {
calls: Vec<PendingToolCall>,
},
Compact {
reason: CompactReason,
},
Done(MachineOutcome),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PendingToolCall {
pub call: ToolCall,
pub preresolved_result: Option<Message>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ModelResponse {
pub message: Message,
pub input_tokens: u64,
pub output_tokens: u64,
pub stop_reason: StopReason,
pub available_tools: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum MachineOutcome {
Completed {
final_text: String,
},
MaxTurnsExceeded,
Cancelled,
Failed {
error: LoopError,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MachineState {
Start,
AwaitingModel {
turn: usize,
},
AwaitingTools {
turn: usize,
},
AwaitingCompaction {
reason: CompactReason,
},
Terminal(MachineOutcome),
}
#[derive(Debug, Clone, Copy)]
pub struct MachinePolicy {
pub max_turns: usize,
pub context_window: u64,
pub compact_threshold: u8,
pub auto_compact: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoopMachine {
history: Vec<Message>,
pending: Vec<Message>,
state: MachineState,
turns_taken: usize,
context_tokens: u64,
last_compaction_tokens: Option<u64>,
cancelled: bool,
pending_tools: Vec<PendingToolCall>,
}
impl LoopMachine {
#[must_use]
pub fn from_history(history: Vec<Message>) -> Self {
Self {
history,
pending: Vec::new(),
state: MachineState::Start,
turns_taken: 0,
context_tokens: 0,
last_compaction_tokens: None,
cancelled: false,
pending_tools: Vec::new(),
}
}
pub fn accept_input(&mut self, input: &str) {
self.pending.clear();
self.pending.push(Message::user(input));
self.state = MachineState::Start;
self.turns_taken = 0;
self.context_tokens = 0;
self.cancelled = false;
self.pending_tools.clear();
}
pub fn next_step(&mut self, policy: MachinePolicy) -> MachineStep {
if let MachineState::Terminal(outcome) = &self.state {
let outcome = outcome.clone();
return MachineStep::Done(outcome);
}
if self.cancelled {
let outcome = MachineOutcome::Cancelled;
self.state = MachineState::Terminal(outcome.clone());
return MachineStep::Done(outcome);
}
let turn = match &self.state {
MachineState::AwaitingModel { turn } => *turn,
_ => self.turns_taken.saturating_add(1),
};
match self.state.clone() {
MachineState::Start | MachineState::AwaitingModel { .. } => {
self.request_model(turn, policy)
}
MachineState::AwaitingTools { .. } => {
let calls = std::mem::take(&mut self.pending_tools);
MachineStep::CallTools { calls }
}
MachineState::AwaitingCompaction { reason } => MachineStep::Compact { reason },
MachineState::Terminal(outcome) => MachineStep::Done(outcome),
}
}
fn request_model(&mut self, turn: usize, policy: MachinePolicy) -> MachineStep {
if self.turns_taken >= policy.max_turns {
let outcome = MachineOutcome::MaxTurnsExceeded;
self.state = MachineState::Terminal(outcome.clone());
return MachineStep::Done(outcome);
}
if self.is_emergency(policy) {
let reason = CompactReason::Emergency;
self.last_compaction_tokens = Some(self.context_tokens);
self.state = MachineState::AwaitingCompaction { reason };
return MachineStep::Compact { reason };
}
if policy.auto_compact && self.should_compact(policy) {
let reason = CompactReason::ThresholdExceeded;
self.last_compaction_tokens = Some(self.context_tokens);
self.state = MachineState::AwaitingCompaction { reason };
return MachineStep::Compact { reason };
}
self.state = MachineState::AwaitingModel { turn };
MachineStep::CallLLM { turn }
}
fn should_compact(&self, policy: MachinePolicy) -> bool {
if policy.context_window == 0 || policy.compact_threshold == 0 {
return false;
}
let limit = policy
.context_window
.saturating_mul(u64::from(policy.compact_threshold))
/ 100;
self.context_tokens > limit
}
fn is_emergency(&self, policy: MachinePolicy) -> bool {
if policy.context_window == 0 {
return false;
}
self.context_tokens >= policy.context_window.saturating_mul(95) / 100
}
pub fn model_response(&mut self, response: ModelResponse, context_tokens: u64) {
if self.is_terminal() {
return;
}
let message = response.message;
let tool_calls: Vec<ToolCall> = message
.tool_call_parts()
.into_iter()
.map(|(id, tool, input)| ToolCall {
id: id.to_string(),
tool: tool.to_string(),
input: input.clone(),
})
.collect();
self.pending.push(message);
self.context_tokens = context_tokens;
self.turns_taken = self.turns_taken.saturating_add(1);
if tool_calls.is_empty() {
let final_text = self
.pending
.last()
.map(Message::text_content)
.unwrap_or_default();
let outcome = MachineOutcome::Completed { final_text };
self.state = MachineState::Terminal(outcome);
return;
}
let turn_number = self.turns_taken;
self.pending_tools = tool_calls
.into_iter()
.map(|call| Self::classify(call, &response.available_tools))
.collect();
self.state = MachineState::AwaitingTools { turn: turn_number };
}
pub fn tool_results(&mut self, messages: Vec<Message>) {
if self.is_terminal() {
return;
}
self.pending.extend(messages);
self.pending_tools.clear();
self.state = MachineState::Start;
}
pub fn inject(&mut self, message: Message) {
if self.is_terminal() {
return;
}
self.pending.push(message);
}
pub fn compaction_result(&mut self, compacted: Vec<Message>, tokens_after: u64) {
if self.is_terminal() {
return;
}
if let Some(before) = self.last_compaction_tokens
&& tokens_after >= before
{
self.state = MachineState::Terminal(MachineOutcome::Failed {
error: LoopError::ContextExceeded {
used: tokens_after,
limit: before,
},
});
self.last_compaction_tokens = None;
return;
}
self.last_compaction_tokens = None;
self.history = compacted;
self.pending.clear();
self.context_tokens = tokens_after;
self.state = MachineState::Start;
}
pub fn cancel(&mut self) {
self.cancelled = true;
}
pub fn fail(&mut self, error: LoopError) {
if self.is_terminal() {
return;
}
self.state = MachineState::Terminal(MachineOutcome::Failed { error });
}
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled
}
#[must_use]
pub fn history(&self) -> &[Message] {
&self.history
}
#[must_use]
pub fn full_history(&self) -> Vec<Message> {
let mut merged = self.history.clone();
merged.extend_from_slice(&self.pending);
merged
}
pub fn commit_pending(&mut self) {
self.history.append(&mut self.pending);
}
pub fn discard_pending(&mut self) {
self.pending.clear();
}
#[must_use]
pub fn state(&self) -> MachineState {
self.state.clone()
}
#[must_use]
pub fn turns_taken(&self) -> usize {
self.turns_taken
}
#[must_use]
pub fn is_terminal(&self) -> bool {
matches!(self.state, MachineState::Terminal(_))
}
fn classify(call: ToolCall, available: &[String]) -> PendingToolCall {
let known = available.iter().any(|name| name == &call.tool);
if known {
return PendingToolCall {
call,
preresolved_result: None,
};
}
let result = Self::unknown_tool_result(&call);
PendingToolCall {
call,
preresolved_result: Some(result),
}
}
fn unknown_tool_result(call: &ToolCall) -> Message {
let message = format!("tool '{}' is not available", call.tool);
Message::new(
Role::User,
vec![MessagePart::tool_result(
call.id.clone(),
call.tool.clone(),
ToolContent::Text(message),
true,
)],
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn small_machine() -> LoopMachine {
LoopMachine::from_history(vec![Message::user("hello")])
}
fn test_policy(max_turns: usize) -> MachinePolicy {
MachinePolicy {
max_turns,
context_window: 200_000,
compact_threshold: 80,
auto_compact: true,
}
}
fn text_response(text: &str, input_tokens: u64, output_tokens: u64) -> ModelResponse {
ModelResponse {
message: Message::assistant(text),
input_tokens,
output_tokens,
stop_reason: StopReason::EndTurn,
available_tools: Vec::new(),
}
}
fn tool_response(tool: &str, available: &[&str], input_tokens: u64) -> ModelResponse {
let part = MessagePart::tool_call("call_1", tool, Value::Object(serde_json::Map::new()));
ModelResponse {
message: Message::new(Role::Assistant, vec![part]),
input_tokens,
output_tokens: 10,
stop_reason: StopReason::ToolCall,
available_tools: available.iter().map(|s| (*s).to_string()).collect(),
}
}
fn long_text(n: usize) -> String {
"x".repeat(n)
}
fn count_tokens(machine: &LoopMachine) -> u64 {
use crate::compact::TokenCounter;
crate::compact::HeuristicTokenCounter.count(&machine.full_history())
}
fn same_step(a: &MachineStep, b: &MachineStep) -> bool {
serde_json::to_string(a).unwrap_or_default() == serde_json::to_string(b).unwrap_or_default()
}
#[test]
fn calling_llm_from_new_emits_call_llm_step() {
let mut machine = LoopMachine::from_history(vec![Message::user("hello")]);
let step = machine.next_step(test_policy(5));
let MachineStep::CallLLM { turn } = step else {
panic!("expected CallLLM, got {step:?}");
};
assert_eq!(turn, 1);
assert_eq!(machine.state(), MachineState::AwaitingModel { turn: 1 });
}
#[test]
fn machine_api_has_no_async_no_tokio_no_apiclient() {
let mut machine = LoopMachine::from_history(vec![Message::user("hello")]);
assert!(matches!(
machine.next_step(test_policy(5)),
MachineStep::CallLLM { .. }
));
machine.model_response(text_response("hi", 5, 3), 0);
assert!(machine.is_terminal());
assert_eq!(machine.turns_taken(), 1);
assert_eq!(machine.full_history().len(), 2);
assert!(matches!(
machine.state(),
MachineState::Terminal(MachineOutcome::Completed { .. })
));
machine.cancel();
assert!(machine.is_cancelled());
}
#[test]
fn resume_after_model_response_round_trips() {
let mut machine = small_machine();
let _ = machine.next_step(test_policy(5));
machine.model_response(tool_response("echo", &["echo"], 10), 0);
let snapshot = serde_json::to_string(&machine).expect("serialize");
let mut restored: LoopMachine = serde_json::from_str(&snapshot).expect("deserialize");
let a = machine.next_step(test_policy(5));
let b = restored.next_step(test_policy(5));
assert!(same_step(&a, &b), "steps diverged after round-trip");
}
#[test]
fn resume_after_tool_results_round_trips() {
let mut machine = small_machine();
let _ = machine.next_step(test_policy(5));
machine.model_response(tool_response("echo", &["echo"], 10), 0);
let step = machine.next_step(test_policy(5));
let MachineStep::CallTools { calls } = &step else {
panic!("expected CallTools, got {step:?}");
};
let results: Vec<Message> = calls
.iter()
.map(|c| {
Message::new(
Role::User,
vec![MessagePart::tool_result(
c.call.id.clone(),
c.call.tool.clone(),
ToolContent::Text("ok".to_string()),
false,
)],
)
})
.collect();
machine.tool_results(results);
let snapshot = serde_json::to_string(&machine).expect("serialize");
let mut restored: LoopMachine = serde_json::from_str(&snapshot).expect("deserialize");
let a = machine.next_step(test_policy(5));
let b = restored.next_step(test_policy(5));
assert!(same_step(&a, &b), "steps diverged after round-trip");
}
#[test]
fn resume_after_compaction_result_round_trips() {
let policy = MachinePolicy {
max_turns: 5,
context_window: 100,
compact_threshold: 50,
auto_compact: true,
};
let mut machine = LoopMachine::from_history(vec![Message::user(long_text(250))]);
let _ = machine.next_step(policy); machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine)); let _ = machine.next_step(policy); machine.tool_results(vec![Message::user(long_text(250))]); assert!(matches!(
machine.next_step(policy),
MachineStep::Compact { .. }
));
machine.compaction_result(vec![Message::user("compacted")], 0);
let snapshot = serde_json::to_string(&machine).expect("serialize");
let mut restored: LoopMachine = serde_json::from_str(&snapshot).expect("deserialize");
let a = machine.next_step(policy);
let b = restored.next_step(policy);
assert!(same_step(&a, &b), "steps diverged after round-trip");
}
#[test]
fn max_turns_enforced_by_machine() {
let mut machine = small_machine();
assert!(matches!(
machine.next_step(test_policy(2)),
MachineStep::CallLLM { turn: 1 }
));
machine.model_response(tool_response("echo", &["echo"], 1), 0);
let _ = machine.next_step(test_policy(2));
machine.tool_results(vec![Message::user("r")]);
assert!(matches!(
machine.next_step(test_policy(2)),
MachineStep::CallLLM { turn: 2 }
));
machine.model_response(tool_response("echo", &["echo"], 1), 0);
let _ = machine.next_step(test_policy(2));
machine.tool_results(vec![Message::user("r")]);
match machine.next_step(test_policy(2)) {
MachineStep::Done(MachineOutcome::MaxTurnsExceeded) => {}
other => panic!("expected MaxTurnsExceeded, got {other:?}"),
}
assert!(machine.is_terminal());
}
#[test]
fn cancel_returns_done_cancelled_at_next_step() {
let mut machine = small_machine();
let _ = machine.next_step(test_policy(5));
machine.cancel();
match machine.next_step(test_policy(5)) {
MachineStep::Done(MachineOutcome::Cancelled) => {}
other => panic!("expected Done(Cancelled), got {other:?}"),
}
assert!(matches!(
machine.next_step(test_policy(5)),
MachineStep::Done(MachineOutcome::Cancelled)
));
}
#[test]
fn fail_returns_done_failed_at_next_step() {
let mut machine = small_machine();
let _ = machine.next_step(test_policy(5));
let err = LoopError::Api("stream failed".to_string());
machine.fail(err.clone());
match machine.next_step(test_policy(5)) {
MachineStep::Done(MachineOutcome::Failed { error }) => {
assert_eq!(error, err);
}
other => panic!("expected Done(Failed), got {other:?}"),
}
assert!(machine.is_terminal());
}
#[test]
fn failed_outcome_survives_round_trip() {
let mut machine = small_machine();
let _ = machine.next_step(test_policy(5));
let err = LoopError::Api("stream failed".to_string());
machine.fail(err.clone());
let snapshot = serde_json::to_string(&machine).expect("serialize");
let mut restored: LoopMachine = serde_json::from_str(&snapshot).expect("deserialize");
match restored.next_step(test_policy(5)) {
MachineStep::Done(MachineOutcome::Failed { error }) => {
assert_eq!(error, err, "failure record survives round-trip");
}
other => panic!("expected Done(Failed) after resume, got {other:?}"),
}
}
#[test]
fn unknown_tool_call_gets_preresolved_result() {
let mut machine = small_machine();
let _ = machine.next_step(test_policy(5));
machine.model_response(tool_response("ghost", &["echo", "ls"], 3), 0);
let step = machine.next_step(test_policy(5));
let MachineStep::CallTools { calls } = step else {
panic!("expected CallTools, got {step:?}");
};
let call = calls.first().expect("one call");
let result = call
.preresolved_result
.as_ref()
.expect("unknown tool has a preresolved result");
assert_eq!(result.role, Role::User);
assert!(result.parts.iter().any(|p| match p {
MessagePart::ToolResult { is_error, .. } => *is_error == Some(true),
_ => false,
}));
}
#[test]
fn known_tool_call_emits_plain_pending_call() {
let mut machine = small_machine();
let _ = machine.next_step(test_policy(5));
machine.model_response(tool_response("echo", &["echo", "ls"], 3), 0);
let step = machine.next_step(test_policy(5));
let MachineStep::CallTools { calls } = step else {
panic!("expected CallTools, got {step:?}");
};
let call = calls.first().expect("one call");
assert!(
call.preresolved_result.is_none(),
"known tool has no preresolved result"
);
}
#[test]
fn compaction_triggered_when_tokens_exceed_threshold() {
let policy = MachinePolicy {
max_turns: 5,
context_window: 100,
compact_threshold: 50,
auto_compact: true,
};
let mut machine = LoopMachine::from_history(vec![Message::user(long_text(250))]);
assert!(matches!(
machine.next_step(policy),
MachineStep::CallLLM { .. }
));
machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine));
assert!(matches!(
machine.next_step(policy),
MachineStep::CallTools { .. }
));
machine.tool_results(vec![Message::user(long_text(250))]);
match machine.next_step(policy) {
MachineStep::Compact { reason } => {
assert_eq!(reason, CompactReason::ThresholdExceeded);
}
other => panic!("expected Compact, got {other:?}"),
}
}
#[test]
fn emergency_compaction_fires_at_95_percent() {
let policy = MachinePolicy {
max_turns: 5,
context_window: 100,
compact_threshold: 50,
auto_compact: false,
};
let mut machine = LoopMachine::from_history(vec![Message::user(long_text(400))]);
assert!(matches!(
machine.next_step(policy),
MachineStep::CallLLM { .. }
));
machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine));
assert!(matches!(
machine.next_step(policy),
MachineStep::CallTools { .. }
));
machine.tool_results(vec![Message::user(long_text(400))]);
match machine.next_step(policy) {
MachineStep::Compact { reason } => {
assert_eq!(reason, CompactReason::Emergency);
}
other => panic!("expected emergency Compact, got {other:?}"),
}
}
#[test]
fn compaction_result_replaces_history() {
let policy = MachinePolicy {
max_turns: 5,
context_window: 100,
compact_threshold: 50,
auto_compact: true,
};
let mut machine = LoopMachine::from_history(vec![Message::user(long_text(250))]);
let _ = machine.next_step(policy); machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine)); let _ = machine.next_step(policy); machine.tool_results(vec![Message::user(long_text(250))]); assert!(matches!(
machine.next_step(policy),
MachineStep::Compact { .. }
));
let compacted = vec![Message::user("compacted-only")];
machine.compaction_result(compacted.clone(), 0);
let got = serde_json::to_string(&machine.full_history()).expect("serialize history");
let want = serde_json::to_string(&compacted).expect("serialize expected");
assert_eq!(got, want, "history must be replaced by the compacted slice");
assert!(matches!(
machine.next_step(test_policy(5)),
MachineStep::CallLLM { .. }
));
}
#[test]
fn compaction_includes_pending_messages() {
let policy = MachinePolicy {
max_turns: 5,
context_window: 100,
compact_threshold: 50,
auto_compact: true,
};
let mut machine = LoopMachine::from_history(vec![Message::user("from previous run")]);
machine.accept_input("current run input");
machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine));
let _ = machine.next_step(policy);
machine.tool_results(vec![Message::user("tool-out")]);
let full = machine.full_history();
assert!(
full.len() >= 4,
"full_history must include both committed history and pending messages; \
got {} messages",
full.len()
);
}
#[test]
fn compaction_result_clears_pending() {
let policy = MachinePolicy {
max_turns: 5,
context_window: 100,
compact_threshold: 50,
auto_compact: true,
};
let mut machine = LoopMachine::from_history(vec![Message::user(long_text(250))]);
let _ = machine.next_step(policy);
machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine));
let _ = machine.next_step(policy);
machine.tool_results(vec![Message::user(long_text(250))]);
assert!(matches!(
machine.next_step(policy),
MachineStep::Compact { .. }
));
machine.compaction_result(vec![Message::user("compacted")], 0);
assert_eq!(
machine.history().len(),
1,
"history must contain only the compacted message"
);
assert_eq!(
machine.full_history().len(),
1,
"pending must be cleared after compaction"
);
}
#[test]
fn compaction_no_progress_terminates_not_loops() {
let policy = MachinePolicy {
max_turns: 5,
context_window: 100,
compact_threshold: 50,
auto_compact: true,
};
let mut machine = LoopMachine::from_history(vec![Message::user(long_text(250))]);
let _ = machine.next_step(policy);
machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine));
let _ = machine.next_step(policy);
machine.tool_results(vec![Message::user(long_text(250))]);
assert!(matches!(
machine.next_step(policy),
MachineStep::Compact { .. }
));
machine.compaction_result(vec![Message::user("compacted")], 90);
match machine.next_step(policy) {
MachineStep::Done(MachineOutcome::Failed {
error: LoopError::ContextExceeded { .. },
}) => {}
other => {
panic!("no-progress compaction must terminate with ContextExceeded, got {other:?}")
}
}
}
#[test]
fn compaction_progress_continues_normally() {
let policy = MachinePolicy {
max_turns: 5,
context_window: 100,
compact_threshold: 50,
auto_compact: true,
};
let mut machine = LoopMachine::from_history(vec![Message::user(long_text(250))]);
let _ = machine.next_step(policy);
machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine));
let _ = machine.next_step(policy);
machine.tool_results(vec![Message::user(long_text(250))]);
assert!(matches!(
machine.next_step(policy),
MachineStep::Compact { .. }
));
machine.compaction_result(vec![Message::user("compacted")], 30);
assert!(
matches!(machine.next_step(policy), MachineStep::CallLLM { .. }),
"compaction that reduced tokens must continue to CallLLM"
);
}
#[test]
fn history_accumulates_user_assistant_tool_round() {
let mut machine = small_machine();
let _ = machine.next_step(test_policy(5));
machine.model_response(tool_response("echo", &["echo"], 1), 0);
let step = machine.next_step(test_policy(5));
let MachineStep::CallTools { calls } = step else {
panic!("expected CallTools, got {step:?}");
};
let result = Message::new(
Role::User,
calls
.iter()
.map(|c| {
MessagePart::tool_result(
c.call.id.clone(),
c.call.tool.clone(),
ToolContent::Text("ok".to_string()),
false,
)
})
.collect(),
);
machine.tool_results(vec![result]);
let roles: Vec<Role> = machine.full_history().iter().map(|m| m.role).collect();
assert_eq!(
roles,
vec![Role::User, Role::Assistant, Role::User],
"history must be [user, assistant, tool_result(user)]"
);
}
#[test]
fn compact_reason_is_serde() {
for reason in [
CompactReason::ThresholdExceeded,
CompactReason::Emergency,
CompactReason::Manual,
] {
let text = serde_json::to_string(&reason).expect("serialize");
let back: CompactReason = serde_json::from_str(&text).expect("deserialize");
assert_eq!(back, reason);
}
}
fn make_call() -> ToolCall {
ToolCall {
id: "test".to_string(),
tool: "Read".to_string(),
input: serde_json::json!({"path": "/tmp"}),
}
}
#[test]
fn input_fix_accepts_json_object() {
use crate::reflection::{Correction, CorrectionResult, CorrectionType};
let mut call = ToolCall {
id: "test".to_string(),
tool: "Read".to_string(),
input: serde_json::json!({"path": "/tmp"}),
};
let correction = Correction {
correction_type: CorrectionType::InputFix,
description: "fix path".into(),
modified_input: Some(serde_json::json!({"path": "/tmp/fixed"})),
alternative_tool: None,
guidance: None,
};
let result = call.apply_correction(&correction);
assert!(matches!(result, CorrectionResult::Applied));
assert_eq!(call.input, serde_json::json!({"path": "/tmp/fixed"}));
}
#[test]
fn input_fix_fails_when_modified_input_missing() {
use crate::reflection::{Correction, CorrectionResult, CorrectionType};
let mut call = make_call();
let correction = Correction {
correction_type: CorrectionType::InputFix,
description: "fix path".into(),
modified_input: None,
alternative_tool: None,
guidance: None,
};
let result = call.apply_correction(&correction);
assert!(matches!(result, CorrectionResult::Failed(_)));
}
#[test]
fn input_fix_fails_when_modified_input_not_object() {
use crate::reflection::{Correction, CorrectionResult, CorrectionType};
let mut call = make_call();
let correction = Correction {
correction_type: CorrectionType::InputFix,
description: "fix path".into(),
modified_input: Some(serde_json::json!("not an object")),
alternative_tool: None,
guidance: None,
};
let result = call.apply_correction(&correction);
assert!(matches!(result, CorrectionResult::Failed(_)));
}
#[test]
fn tool_change_swaps_tool_name() {
use crate::reflection::{Correction, CorrectionResult, CorrectionType};
let mut call = make_call();
let correction = Correction {
correction_type: CorrectionType::ToolChange,
description: "use alt tool".into(),
modified_input: None,
alternative_tool: Some("Write".into()),
guidance: None,
};
let result = call.apply_correction(&correction);
assert!(matches!(result, CorrectionResult::Applied));
assert_eq!(call.tool, "Write");
}
#[test]
fn tool_change_fails_without_alternative() {
use crate::reflection::{Correction, CorrectionResult, CorrectionType};
let mut call = make_call();
let correction = Correction {
correction_type: CorrectionType::ToolChange,
description: "use alt tool".into(),
modified_input: None,
alternative_tool: None,
guidance: None,
};
let result = call.apply_correction(&correction);
assert!(matches!(result, CorrectionResult::Failed(_)));
}
#[test]
fn prerequisite_fix_approach_change_escalate_all_skip() {
use crate::reflection::{Correction, CorrectionResult, CorrectionType};
let mut call = make_call();
for ct in [
CorrectionType::PrerequisiteFix,
CorrectionType::ApproachChange,
CorrectionType::Escalate,
] {
let correction = Correction {
correction_type: ct,
description: "n/a".into(),
modified_input: None,
alternative_tool: None,
guidance: None,
};
let result = call.apply_correction(&correction);
assert!(
matches!(result, CorrectionResult::Skipped),
"{ct:?} should skip"
);
}
}
#[test]
fn configs_carry_no_model_field() {
use crate::config::SessionConfig;
let session = SessionConfig::default();
let _: &Option<String> = &session.system_prompt;
let _: u64 = session.context_window;
let run = crate::engine::RunConfig::default();
let _: usize = run.max_turns;
let _ = run.parallel_tool_dispatch;
}
}