use super::config::*;
use super::helpers::apply_input_filters;
use super::streaming::stream_assistant_response;
use super::tools::execute_tool_calls;
use crate::context::{
build_context_from_session, compact_session_loops, BlockCompactionStrategy, CompactionStrategy,
DefaultBlockCompaction, DefaultCompaction, ExecutionTracker,
};
use crate::types::*;
use chrono::Utc;
use tokio::sync::mpsc;
use tracing::warn;
pub(super) async fn run_loop(
context: &mut AgentContext, new_messages: &mut Vec<AgentMessage>, config: &AgentLoopConfig, tx: &mpsc::UnboundedSender<AgentEvent>, cancel: &tokio_util::sync::CancellationToken, first_turn_prompts: Option<&[AgentMessage]>, ) -> Usage {
let loop_id = context.loop_id.clone().unwrap_or_default();
if config.revert_pending.is_some() {
context.seed_next_node_id_from_messages();
}
let mut first_turn = true;
let mut turn: usize = 0; #[allow(unused_assignments)]
let mut current_turn_id: Option<TurnId> = None; let mut loop_usage = Usage::default(); let mut tracker = config
.execution_limits
.as_ref()
.map(|limits| ExecutionTracker::new(limits.clone()));
let raw = config
.get_steering_messages
.as_ref()
.map(|f| f())
.unwrap_or_default();
let mut pending = match apply_input_filters(raw, &config.input_filters, tx, &loop_id).await {
Ok(filtered) => filtered,
Err(_) => return loop_usage,
};
loop {
if cancel.is_cancelled() {
return loop_usage;
}
let mut steering_after_tools: Option<Vec<AgentMessage>> = None;
loop {
if cancel.is_cancelled() {
return loop_usage;
}
let turn_trigger = if first_turn {
if matches!(
context.continuation_kind,
Some(ContinuationKind::Branch { .. })
) {
TurnTrigger::Branch
} else if context.continuation_kind.is_some() {
TurnTrigger::Continuation
} else {
config.first_turn_trigger.clone()
}
} else {
TurnTrigger::Continuation
};
if let Some(ref tracker) = tracker {
if let Some(reason) = tracker.check_limits() {
warn!("Execution limit reached: {}", reason);
let limit_msg = AgentMessage::Llm(LlmMessage::new(Message::User {
content: vec![Content::Text {
text: format!("[Agent stopped: {}]", reason),
}],
timestamp: now_ms(),
}));
tx.send(AgentEvent::MessageStart {
loop_id: loop_id.clone(),
message: limit_msg.clone(),
})
.ok();
tx.send(AgentEvent::MessageEnd {
loop_id: loop_id.clone(),
message: limit_msg.clone(),
})
.ok();
context.messages.push(limit_msg.clone());
new_messages.push(limit_msg);
return loop_usage;
}
}
if let Some(ref before_turn) = config.before_turn {
if !before_turn(&context.messages, turn).await {
return loop_usage;
}
}
tx.send(AgentEvent::TurnStart {
loop_id: loop_id.clone(),
turn_index: turn as u32,
timestamp: chrono::Utc::now(),
triggered_by: turn_trigger,
})
.ok();
current_turn_id = Some(TurnId {
loop_id: loop_id.clone(),
turn_index: turn as u32,
});
let was_first_turn = first_turn;
if first_turn {
first_turn = false;
}
let this_turn_index = turn as u32;
turn += 1;
if was_first_turn {
if let Some(prompts) = first_turn_prompts {
for prompt in prompts {
tx.send(AgentEvent::MessageStart {
loop_id: loop_id.clone(),
message: prompt.clone(),
})
.ok();
tx.send(AgentEvent::MessageEnd {
loop_id: loop_id.clone(),
message: prompt.clone(),
})
.ok();
}
}
}
if !pending.is_empty() {
for msg in pending.drain(..) {
let msg = msg.with_turn_id(current_turn_id.clone());
let msg = stamp_node_identity(context, config, msg);
tx.send(AgentEvent::MessageStart {
loop_id: loop_id.clone(),
message: msg.clone(),
})
.ok();
tx.send(AgentEvent::MessageEnd {
loop_id: loop_id.clone(),
message: msg.clone(),
})
.ok();
context.messages.push(msg.clone());
new_messages.push(msg.clone());
context.user_context.push(msg);
}
}
if let Some(ref ctx_config) = config.context_config {
let max_tokens = ctx_config.max_context_tokens;
let comp = &ctx_config.compaction;
let estimated = ctx_config.counter().estimate_messages(&context.messages);
let system_frac = ctx_config.system_prompt_tokens as f64 / max_tokens as f64;
let current_frac = estimated as f64 / max_tokens as f64;
let headroom = comp.compact_at_pct - system_frac - current_frac;
if headroom < comp.compact_budget_threshold_pct {
let msgs_before = context.messages.len();
let compaction_allowed = match config.before_compaction_start.as_ref() {
Some(hook) => hook(estimated, msgs_before).await,
None => true,
};
if compaction_allowed {
tx.send(AgentEvent::CompactionStarted {
loop_id: loop_id.clone(),
estimated_tokens: estimated,
message_count: msgs_before,
timestamp: Utc::now(),
})
.ok();
if let Some(ref mut session) = context.session {
let lid = context.loop_id.as_deref().unwrap_or("");
if session.get_loop(lid).is_none() {
session.loops.push(crate::session::LoopRecord {
loop_id: lid.to_string(),
session_id: context.session_id.clone().unwrap_or_default(),
agent_id: context.agent_id.clone().unwrap_or_default(),
parent_loop_id: context.parent_loop_id.clone(),
continuation_kind: context
.continuation_kind
.clone()
.unwrap_or_default(),
started_at: Utc::now(),
ended_at: None,
status: crate::session::LoopStatus::Running,
rejection: None,
config: None,
messages: context.messages.clone(),
turns: Vec::new(),
usage: Usage::default(),
metadata: None,
events: Vec::new(),
children_loop_ids: Vec::new(),
child_loop_refs: Vec::new(),
parallel_group: None,
compaction_block: None,
});
} else if let Some(record) = session.get_loop_mut(lid) {
record.messages = context.messages.clone();
}
let block_strategy: &dyn BlockCompactionStrategy = comp
.block_strategy
.as_deref()
.unwrap_or(&DefaultBlockCompaction);
compact_session_loops(
session,
lid,
block_strategy,
comp,
max_tokens,
ctx_config.token_counter.as_ref(),
)
.await;
context.messages = build_context_from_session(
session,
lid,
comp,
max_tokens,
ctx_config.token_counter.as_ref(),
);
let chain = session.loop_chain_to(lid);
let loops_compacted = chain
.iter()
.filter(|l| {
session
.get_loop(l)
.map(|r| r.compaction_block.is_some())
.unwrap_or(false)
})
.count();
let messages_after = context.messages.len();
let tokens_after =
ctx_config.counter().estimate_messages(&context.messages);
tx.send(AgentEvent::CompactionEnded {
loop_id: loop_id.clone(),
messages_before: msgs_before,
messages_after,
estimated_tokens_before: estimated,
estimated_tokens_after: tokens_after,
loops_compacted,
timestamp: Utc::now(),
})
.ok();
if let Some(ref hook) = config.after_compaction_end {
hook(msgs_before, messages_after, estimated, tokens_after).await;
}
} else {
let strategy: &dyn CompactionStrategy = comp
.in_memory_strategy
.as_deref()
.unwrap_or(&DefaultCompaction);
context.messages =
strategy.compact(std::mem::take(&mut context.messages), ctx_config);
let messages_after = context.messages.len();
let tokens_after =
ctx_config.counter().estimate_messages(&context.messages);
tx.send(AgentEvent::CompactionEnded {
loop_id: loop_id.clone(),
messages_before: msgs_before,
messages_after,
estimated_tokens_before: estimated,
estimated_tokens_after: tokens_after,
loops_compacted: 0,
timestamp: Utc::now(),
})
.ok();
if let Some(ref hook) = config.after_compaction_end {
hook(msgs_before, messages_after, estimated, tokens_after).await;
}
}
} }
}
let message =
stream_assistant_response(context, config, tx, cancel, &loop_id, this_turn_index)
.await;
let agent_msg: AgentMessage =
AgentMessage::from(message.clone()).with_turn_id(current_turn_id.clone());
let agent_msg = stamp_node_identity(context, config, agent_msg);
context.messages.push(agent_msg.clone());
new_messages.push(agent_msg.clone());
context
.inrun_context
.push(crate::types::InRunEntry::Live(agent_msg.clone()));
if let Message::Assistant {
ref stop_reason,
ref error_message,
ref usage,
..
} = message
{
if *stop_reason == StopReason::Error || *stop_reason == StopReason::Aborted {
if *stop_reason == StopReason::Error {
if let Some(ref on_error) = config.on_error {
let err_str = error_message.as_deref().unwrap_or("Unknown error");
on_error(err_str).await;
}
}
loop_usage.input += usage.input;
loop_usage.output += usage.output;
loop_usage.reasoning += usage.reasoning;
loop_usage.cache_read += usage.cache_read;
loop_usage.cache_write += usage.cache_write;
loop_usage.total_tokens += usage.total_tokens;
if let Some(ref mut t) = tracker {
t.record_cost(usage.estimated_cost(&config.model_config.cost));
}
tx.send(AgentEvent::TurnEnd {
loop_id: loop_id.clone(),
message: agent_msg,
usage: usage.clone(),
timestamp: chrono::Utc::now(),
tool_results: vec![],
})
.ok();
if let Some(ref after_turn) = config.after_turn {
after_turn(&context.messages, usage).await;
}
return loop_usage;
}
}
let tool_calls: Vec<_> = match &message {
Message::Assistant { content, .. } => content
.iter()
.filter_map(|c| match c {
Content::ToolCall {
id,
name,
arguments,
} => Some((id.clone(), name.clone(), arguments.clone())),
_ => None,
})
.collect(),
_ => vec![],
};
let has_tool_calls = !tool_calls.is_empty();
let mut tool_results: Vec<Message> = Vec::new();
if has_tool_calls {
let execution = execute_tool_calls(
&context.tools,
&tool_calls,
tx,
cancel,
config.get_steering_messages.as_ref(),
&config.tool_execution,
config,
&loop_id,
)
.await;
tool_results = execution.tool_results;
steering_after_tools = execution.steering_messages;
for result in &tool_results {
let am: AgentMessage =
AgentMessage::from(result.clone()).with_turn_id(current_turn_id.clone());
let am = stamp_node_identity(context, config, am);
context.messages.push(am.clone());
new_messages.push(am.clone());
context
.inrun_context
.push(crate::types::InRunEntry::Live(am));
}
if let Some(ref prun_pending) = config.prun_pending {
let requests: Vec<crate::tools::prun::PrunRequest> =
prun_pending.lock().unwrap().drain(..).collect();
for request in requests {
apply_prun(context, &request, tx, &loop_id);
}
}
if let Some(ref revert_pending) = config.revert_pending {
let requests: Vec<crate::tools::revert::RevertRequest> =
revert_pending.lock().unwrap().drain(..).collect();
for request in requests {
apply_revert(context, &request, turn, tx, &loop_id);
}
}
}
let turn_usage = match &message {
Message::Assistant { usage, .. } => usage.clone(),
_ => Usage::default(),
};
if let Some(ref mut tracker) = tracker {
let turn_tokens = (turn_usage.input + turn_usage.output) as usize;
tracker.record_turn(turn_tokens);
}
loop_usage.input += turn_usage.input;
loop_usage.output += turn_usage.output;
loop_usage.reasoning += turn_usage.reasoning;
loop_usage.cache_read += turn_usage.cache_read;
loop_usage.cache_write += turn_usage.cache_write;
loop_usage.total_tokens += turn_usage.total_tokens;
if let Some(ref mut t) = tracker {
t.record_cost(turn_usage.estimated_cost(&config.model_config.cost));
}
tx.send(AgentEvent::TurnEnd {
loop_id: loop_id.clone(),
message: agent_msg,
usage: turn_usage.clone(),
timestamp: chrono::Utc::now(),
tool_results,
})
.ok();
if let Some(ref after_turn) = config.after_turn {
after_turn(&context.messages, &turn_usage).await;
}
if let Some(steering) = steering_after_tools.take() {
if !steering.is_empty() {
match apply_input_filters(steering, &config.input_filters, tx, &loop_id).await {
Ok(filtered) => {
pending = filtered;
continue;
}
Err(_) => return loop_usage,
}
}
}
let raw = config
.get_steering_messages
.as_ref()
.map(|f| f())
.unwrap_or_default();
pending = match apply_input_filters(raw, &config.input_filters, tx, &loop_id).await {
Ok(filtered) => filtered,
Err(_) => return loop_usage,
};
if !has_tool_calls && pending.is_empty() {
break;
}
}
let follow_ups = config
.get_follow_up_messages
.as_ref()
.map(|f| f())
.unwrap_or_default();
if !follow_ups.is_empty() {
match apply_input_filters(follow_ups, &config.input_filters, tx, &loop_id).await {
Ok(filtered) => {
pending = filtered;
continue;
}
Err(_) => return loop_usage,
}
}
break;
}
loop_usage
}
fn stamp_node_identity(
context: &mut AgentContext,
config: &AgentLoopConfig,
msg: AgentMessage,
) -> AgentMessage {
if config.revert_pending.is_none() {
return msg;
}
match msg {
AgentMessage::Llm(_) => {}
AgentMessage::Extension(_) => return msg,
}
let new_id = context.alloc_node_id();
let parent = context.active_node_id.or_else(|| {
context.messages.iter().rev().find_map(|m| match m {
AgentMessage::Llm(lm) => lm.node_id,
_ => None,
})
});
let stamped = msg.with_node_identity(new_id, parent);
context.active_node_id = Some(new_id);
stamped
}
fn apply_prun(
context: &mut AgentContext,
request: &crate::tools::prun::PrunRequest,
tx: &mpsc::UnboundedSender<AgentEvent>,
loop_id: &str,
) {
use crate::context::token::message_tokens;
use crate::types::InRunEntry;
let mut tokens_remaining = request.tokens_to_remove;
let mut total_tokens_removed: usize = 0;
let mut messages_removed: usize = 0;
let mut pruned_timestamps: Vec<u64> = Vec::new();
for entry in context.inrun_context.iter_mut().rev() {
if tokens_remaining == 0 {
break;
}
if let InRunEntry::Live(msg) = entry {
let msg_tokens = message_tokens(msg);
let tokens_to_remove = msg_tokens.min(tokens_remaining);
tokens_remaining = tokens_remaining.saturating_sub(tokens_to_remove);
total_tokens_removed += tokens_to_remove;
messages_removed += 1;
let ts = msg.timestamp();
pruned_timestamps.push(ts);
if let Some(ref memo) = request.memo {
*entry = InRunEntry::PrunedMemo {
memo: memo.clone(),
tokens_removed: msg_tokens,
timestamp: ts,
};
} else {
*entry = InRunEntry::PrunedSilent {
tokens_removed: msg_tokens,
timestamp: ts,
};
}
}
}
if total_tokens_removed > 0 {
tx.send(AgentEvent::PrunApplied {
loop_id: loop_id.to_string(),
tokens_removed: total_tokens_removed,
messages_removed,
memo: request.memo.clone(),
pruned_timestamps,
timestamp: chrono::Utc::now(),
})
.ok();
}
}
fn apply_revert(
context: &mut AgentContext,
request: &crate::tools::revert::RevertRequest,
current_turn: usize,
tx: &mpsc::UnboundedSender<AgentEvent>,
loop_id: &str,
) {
use crate::types::{InRunEntry, NodeTag};
let emit_rejected = |reason: &str, tx: &mpsc::UnboundedSender<AgentEvent>| {
tx.send(AgentEvent::RevertApplied {
loop_id: loop_id.to_string(),
category: request.category,
target: None,
abandoned_node_ids: Vec::new(),
summary: request.summary.clone(),
applied: false,
reason: Some(reason.to_string()),
timestamp: chrono::Utc::now(),
})
.ok();
};
let target_idx_opt = context.messages.iter().position(|m| match m {
AgentMessage::Llm(lm) => lm.node_id == Some(request.target),
_ => false,
});
let Some(target_idx) = target_idx_opt else {
emit_rejected(
&format!(
"revert target {} not found in message history",
request.target
),
tx,
);
return;
};
for after in &context.messages[target_idx + 1..] {
if let AgentMessage::Llm(lm) = after {
if matches!(lm.message, Message::User { .. }) {
emit_rejected(
"revert refused: abandoned span contains a user message; \
auto-rebase is not implemented in 0.8.0",
tx,
);
return;
}
}
}
let abandoned_node_ids: Vec<crate::types::NodeId> = context.messages[target_idx + 1..]
.iter()
.filter_map(|m| match m {
AgentMessage::Llm(lm) => lm.node_id,
_ => None,
})
.collect();
context.active_node_id = Some(request.target);
let surviving_ids: std::collections::HashSet<crate::types::NodeId> = context.messages
[..=target_idx]
.iter()
.filter_map(|m| match m {
AgentMessage::Llm(lm) => lm.node_id,
_ => None,
})
.collect();
context.inrun_context.retain(|entry| match entry {
InRunEntry::Live(AgentMessage::Llm(lm)) => match lm.node_id {
Some(id) => surviving_ids.contains(&id),
None => true,
},
_ => true,
});
let tag = NodeTag::new(
request.category.tag_kind(),
request.summary.clone().unwrap_or_default(),
current_turn as u32,
abandoned_node_ids.clone(),
);
if let AgentMessage::Llm(lm) = &mut context.messages[target_idx] {
lm.add_tag(tag);
}
tx.send(AgentEvent::RevertApplied {
loop_id: loop_id.to_string(),
category: request.category,
target: Some(request.target),
abandoned_node_ids,
summary: request.summary.clone(),
applied: true,
reason: None,
timestamp: chrono::Utc::now(),
})
.ok();
}
#[cfg(test)]
mod apply_revert_tests {
use super::*;
use crate::tools::revert::RevertRequest;
use crate::types::{
AgentMessage, Content, LlmMessage, Message, NodeId, RevertCategory, StopReason, TagKind,
Usage,
};
fn user_msg_node(text: &str, ts: u64, node: NodeId, parent: Option<NodeId>) -> AgentMessage {
AgentMessage::Llm(
LlmMessage::new(Message::User {
content: vec![Content::Text {
text: text.to_string(),
}],
timestamp: ts,
})
.with_node_identity(node, parent),
)
}
fn assistant_msg_node(
text: &str,
ts: u64,
node: NodeId,
parent: Option<NodeId>,
) -> AgentMessage {
AgentMessage::Llm(
LlmMessage::new(Message::Assistant {
content: vec![Content::Text {
text: text.to_string(),
}],
stop_reason: StopReason::Stop,
model: "test".into(),
provider: "test".into(),
usage: Usage::default(),
timestamp: ts,
error_message: None,
})
.with_node_identity(node, parent),
)
}
fn drain_events(rx: &mut mpsc::UnboundedReceiver<AgentEvent>) -> Vec<AgentEvent> {
let mut out = Vec::new();
while let Ok(e) = rx.try_recv() {
out.push(e);
}
out
}
#[test]
fn apply_revert_success_moves_pointer_attaches_tag_emits_event() {
let mut ctx = AgentContext {
messages: vec![
user_msg_node("write a sort", 1, NodeId(10), None),
assistant_msg_node("I'll write bubble sort", 2, NodeId(11), Some(NodeId(10))),
assistant_msg_node("timed out", 3, NodeId(12), Some(NodeId(11))),
],
next_node_id: 13,
..Default::default()
};
let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
let req = RevertRequest {
category: RevertCategory::Failure,
target: NodeId(10),
summary: Some("bubble sort timed out — try a faster algorithm".into()),
};
apply_revert(&mut ctx, &req, 7, &tx, "loop-1");
assert_eq!(ctx.active_node_id, Some(NodeId(10)));
let target_msg = match &ctx.messages[0] {
AgentMessage::Llm(lm) => lm,
_ => unreachable!(),
};
assert_eq!(target_msg.tags.len(), 1);
assert_eq!(target_msg.tags[0].kind, TagKind::Lesson);
assert_eq!(
target_msg.tags[0].text,
"bubble sort timed out — try a faster algorithm"
);
assert_eq!(target_msg.tags[0].created_at_turn, 7);
assert_eq!(
target_msg.tags[0].abandoned_node_ids,
vec![NodeId(11), NodeId(12)]
);
assert_eq!(ctx.messages.len(), 3);
let events = drain_events(&mut rx);
let revert_event = events
.iter()
.find_map(|e| match e {
AgentEvent::RevertApplied {
applied,
target,
abandoned_node_ids,
category,
summary,
reason,
..
} if *applied => Some((target, abandoned_node_ids, category, summary, reason)),
_ => None,
})
.expect("a successful RevertApplied event must be emitted");
assert_eq!(*revert_event.0, Some(NodeId(10)));
assert_eq!(*revert_event.1, vec![NodeId(11), NodeId(12)]);
assert_eq!(*revert_event.2, RevertCategory::Failure);
assert_eq!(
revert_event.3.as_deref(),
Some("bubble sort timed out — try a faster algorithm")
);
assert!(revert_event.4.is_none());
}
#[test]
fn apply_revert_unknown_target_is_rejected() {
let mut ctx = AgentContext {
messages: vec![
user_msg_node("hello", 1, NodeId(10), None),
assistant_msg_node("hi", 2, NodeId(11), Some(NodeId(10))),
],
next_node_id: 12,
..Default::default()
};
let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
let req = RevertRequest {
category: RevertCategory::Tangent,
target: NodeId(99),
summary: None,
};
apply_revert(&mut ctx, &req, 0, &tx, "loop-1");
assert!(ctx.active_node_id.is_none());
for m in &ctx.messages {
if let AgentMessage::Llm(lm) = m {
assert!(lm.tags.is_empty());
}
}
let events = drain_events(&mut rx);
let rejected = events
.iter()
.find_map(|e| match e {
AgentEvent::RevertApplied {
applied: false,
reason,
target,
abandoned_node_ids,
..
} => Some((reason, target, abandoned_node_ids)),
_ => None,
})
.expect("rejection event must be emitted");
assert!(rejected.0.as_deref().unwrap_or("").contains("not found"));
assert_eq!(*rejected.1, None);
assert!(rejected.2.is_empty());
}
#[test]
fn apply_revert_refuses_when_span_contains_user_message() {
let mut ctx = AgentContext {
messages: vec![
user_msg_node("write a sort", 1, NodeId(10), None),
assistant_msg_node("trying bubble", 2, NodeId(11), Some(NodeId(10))),
user_msg_node("actually use quicksort", 3, NodeId(12), Some(NodeId(11))),
assistant_msg_node("ok", 4, NodeId(13), Some(NodeId(12))),
],
next_node_id: 14,
..Default::default()
};
let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
let req = RevertRequest {
category: RevertCategory::Failure,
target: NodeId(10),
summary: Some("nope".into()),
};
apply_revert(&mut ctx, &req, 0, &tx, "loop-1");
assert!(ctx.active_node_id.is_none());
let target_tags = match &ctx.messages[0] {
AgentMessage::Llm(lm) => &lm.tags,
_ => unreachable!(),
};
assert!(target_tags.is_empty());
let events = drain_events(&mut rx);
let reason = events
.iter()
.find_map(|e| match e {
AgentEvent::RevertApplied {
applied: false,
reason,
..
} => reason.clone(),
_ => None,
})
.expect("user-message rejection event must be emitted");
assert!(reason.contains("user message"));
}
#[test]
fn apply_revert_drops_inrun_context_entries_off_trunk() {
let a10 = assistant_msg_node("on-trunk", 1, NodeId(10), None);
let a11 = assistant_msg_node("off-trunk", 2, NodeId(11), Some(NodeId(10)));
let mut ctx = AgentContext {
messages: vec![
user_msg_node("seed", 0, NodeId(9), None),
a10.clone(),
a11.clone(),
],
inrun_context: vec![InRunEntry::Live(a10.clone()), InRunEntry::Live(a11.clone())],
next_node_id: 12,
..Default::default()
};
let (tx, _rx) = mpsc::unbounded_channel::<AgentEvent>();
let req = RevertRequest {
category: RevertCategory::Completion,
target: NodeId(10),
summary: None,
};
apply_revert(&mut ctx, &req, 0, &tx, "loop-1");
assert_eq!(ctx.active_node_id, Some(NodeId(10)));
let live_ids: Vec<Option<NodeId>> = ctx
.inrun_context
.iter()
.filter_map(|e| match e {
InRunEntry::Live(AgentMessage::Llm(lm)) => Some(lm.node_id),
_ => None,
})
.collect();
assert_eq!(live_ids, vec![Some(NodeId(10))]);
}
#[test]
fn apply_revert_summary_none_attaches_empty_text_tag() {
let mut ctx = AgentContext {
messages: vec![
user_msg_node("seed", 1, NodeId(10), None),
assistant_msg_node("trail", 2, NodeId(11), Some(NodeId(10))),
],
next_node_id: 12,
..Default::default()
};
let (tx, _rx) = mpsc::unbounded_channel::<AgentEvent>();
let req = RevertRequest {
category: RevertCategory::StepSummary,
target: NodeId(10),
summary: None,
};
apply_revert(&mut ctx, &req, 0, &tx, "loop-1");
let tag = match &ctx.messages[0] {
AgentMessage::Llm(lm) => &lm.tags[0],
_ => unreachable!(),
};
assert_eq!(tag.kind, TagKind::Checkpoint);
assert_eq!(tag.text, "");
}
}