feat: stream agent turns through session lifecycle

This commit is contained in:
xiaoxixi 2026-07-17 15:44:08 +08:00
parent 588449d373
commit 3ada5b9421
8 changed files with 769 additions and 78 deletions

View File

@ -1,11 +1,15 @@
use crate::agent::context_compressor::estimate_tokens; use crate::agent::context_compressor::estimate_tokens;
use crate::agent::media_handler::MediaHandlerRegistry; use crate::agent::media_handler::MediaHandlerRegistry;
use crate::agent::system_prompt::build_system_prompt; use crate::agent::system_prompt::build_system_prompt;
use crate::agent::turn_event::{AgentTurnContext, TurnEvent};
use crate::bus::message::ContentBlock; use crate::bus::message::ContentBlock;
use crate::bus::{ChatMessage, MediaRef}; use crate::bus::{ChatMessage, MediaRef};
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::observability::{Observer, ObserverEvent, ToolExecutionOutcome, truncate_args}; use crate::observability::{Observer, ObserverEvent, ToolExecutionOutcome, truncate_args};
use crate::providers::{ChatCompletionRequest, LLMProvider, Message, ToolCall, create_provider}; use crate::providers::{
ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Message, ProviderChunk,
ProviderResponseAccumulator, ToolCall, create_provider,
};
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
@ -13,11 +17,14 @@ use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use futures_util::StreamExt;
/// Maximum characters in a tool result before truncation. /// Maximum characters in a tool result before truncation.
/// Prevents context overflow from large tool outputs. /// Prevents context overflow from large tool outputs.
const MAX_TOOL_RESULT_CHARS: usize = 16_000; const MAX_TOOL_RESULT_CHARS: usize = 16_000;
/// Minimum characters to keep when truncating /// Minimum characters to keep when truncating
const TRUNCATION_SUFFIX_LEN: usize = 200; const TRUNCATION_SUFFIX_LEN: usize = 200;
const TOOL_PREVIEW_CHARS: usize = 1_000;
enum MediaOrigin<'a> { enum MediaOrigin<'a> {
User, User,
@ -164,6 +171,17 @@ fn truncate_tool_result(output: &str) -> String {
} }
} }
fn tool_result_preview(output: &str) -> String {
if output.len() <= TOOL_PREVIEW_CHARS {
output.to_string()
} else {
format!(
"{}…",
&output[..output.floor_char_boundary(TOOL_PREVIEW_CHARS)]
)
}
}
/// Loop detection result. /// Loop detection result.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
enum LoopDetectionResult { enum LoopDetectionResult {
@ -296,6 +314,32 @@ pub struct AgentProcessResult {
pub final_response: ChatMessage, pub final_response: ChatMessage,
pub emitted_messages: Vec<ChatMessage>, pub emitted_messages: Vec<ChatMessage>,
pub total_tokens: Option<u32>, pub total_tokens: Option<u32>,
pub usage: Option<crate::providers::Usage>,
}
fn merge_usage(total: &mut crate::providers::Usage, next: &crate::providers::Usage) {
total.prompt_tokens = total.prompt_tokens.saturating_add(next.prompt_tokens);
total.completion_tokens = total
.completion_tokens
.saturating_add(next.completion_tokens);
total.total_tokens = total.total_tokens.saturating_add(next.total_tokens);
total.cached_tokens = sum_optional_tokens(total.cached_tokens, next.cached_tokens);
total.cache_read_input_tokens =
sum_optional_tokens(total.cache_read_input_tokens, next.cache_read_input_tokens);
total.cache_creation_input_tokens = sum_optional_tokens(
total.cache_creation_input_tokens,
next.cache_creation_input_tokens,
);
}
fn sum_optional_tokens(left: Option<u32>, right: Option<u32>) -> Option<u32> {
match (left, right) {
(None, None) => None,
(left, right) => Some(
left.unwrap_or_default()
.saturating_add(right.unwrap_or_default()),
),
}
} }
impl AgentLoop { impl AgentLoop {
@ -451,6 +495,60 @@ impl AgentLoop {
&self.tools &self.tools
} }
async fn stream_completion(
&self,
request: ChatCompletionRequest,
iteration: u32,
turn: Option<&AgentTurnContext>,
) -> Result<ChatCompletionResponse, AgentError> {
let mut provider_stream = self.provider.stream(request).await.map_err(|error| {
tracing::error!(error = %error, "LLM request failed");
AgentError::LlmError(error.to_string())
})?;
let mut accumulator = ProviderResponseAccumulator::default();
while let Some(chunk) = provider_stream.next().await {
let chunk = chunk.map_err(|error| {
tracing::error!(error = %error, "LLM stream failed");
AgentError::LlmError(error.to_string())
})?;
if let Some(turn) = turn {
let event = match &chunk {
ProviderChunk::Reasoning(delta) => Some(TurnEvent::ReasoningDelta {
iteration,
delta: delta.clone(),
}),
ProviderChunk::Text(delta) => Some(TurnEvent::TextDelta {
iteration,
delta: delta.clone(),
}),
_ => None,
};
if let Some(event) = event {
turn.emitter.emit(event).map_err(|error| {
AgentError::Other(format!("turn event rejected: {error}"))
})?;
}
}
accumulator.push(chunk);
}
Ok(accumulator.finish())
}
fn annotate_message(
message: &mut ChatMessage,
turn: Option<&AgentTurnContext>,
iteration: u32,
final_response: bool,
) {
message.iteration = Some(iteration);
if let Some(turn) = turn {
message.turn_id = Some(turn.turn_id.clone());
if final_response {
message.id = turn.message_id.clone();
}
}
}
fn chat_message_to_llm_message(&self, m: &ChatMessage, include_media: bool) -> Message { fn chat_message_to_llm_message(&self, m: &ChatMessage, include_media: bool) -> Message {
let content = if m.media_refs.is_empty() || !include_media { let content = if m.media_refs.is_empty() || !include_media {
vec![ContentBlock::text(&m.content)] vec![ContentBlock::text(&m.content)]
@ -498,8 +596,24 @@ impl AgentLoop {
/// - The LLM returns no more tool calls (final response) /// - The LLM returns no more tool calls (final response)
/// - Maximum iterations are reached /// - Maximum iterations are reached
pub async fn process( pub async fn process(
&self,
messages: Vec<ChatMessage>,
) -> Result<AgentProcessResult, AgentError> {
self.process_inner(messages, None).await
}
pub async fn process_streaming(
&self,
messages: Vec<ChatMessage>,
turn: AgentTurnContext,
) -> Result<AgentProcessResult, AgentError> {
self.process_inner(messages, Some(turn)).await
}
async fn process_inner(
&self, &self,
mut messages: Vec<ChatMessage>, mut messages: Vec<ChatMessage>,
turn: Option<AgentTurnContext>,
) -> Result<AgentProcessResult, AgentError> { ) -> Result<AgentProcessResult, AgentError> {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
tracing::debug!( tracing::debug!(
@ -522,6 +636,7 @@ impl AgentLoop {
let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default()); let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default());
let mut emitted_messages = Vec::new(); let mut emitted_messages = Vec::new();
let mut accumulated_tokens: u32 = 0; let mut accumulated_tokens: u32 = 0;
let mut accumulated_usage = crate::providers::Usage::default();
for iteration in 0..self.max_iterations { for iteration in 0..self.max_iterations {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@ -564,12 +679,14 @@ impl AgentLoop {
}; };
// Call LLM // Call LLM
let response = (*self.provider).chat(request).await.map_err(|e| { let iteration = u32::try_from(iteration)
tracing::error!(error = %e, "LLM request failed"); .map_err(|_| AgentError::Other("tool iteration exceeds u32".to_string()))?;
AgentError::LlmError(e.to_string()) let response = self
})?; .stream_completion(request, iteration, turn.as_ref())
.await?;
accumulated_tokens += response.usage.total_tokens; accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
merge_usage(&mut accumulated_usage, &response.usage);
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
tracing::debug!( tracing::debug!(
@ -583,14 +700,23 @@ impl AgentLoop {
if response.tool_calls.is_empty() { if response.tool_calls.is_empty() {
let mut assistant_message = ChatMessage::assistant(response.content); let mut assistant_message = ChatMessage::assistant(response.content);
assistant_message.reasoning_content = response.reasoning_content; assistant_message.reasoning_content = response.reasoning_content;
assistant_message.provider_state = response.provider_state;
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, true);
emitted_messages.push(assistant_message.clone()); emitted_messages.push(assistant_message.clone());
return Ok(AgentProcessResult { return Ok(AgentProcessResult {
final_response: assistant_message, final_response: assistant_message,
emitted_messages, emitted_messages,
total_tokens: Some(accumulated_tokens), total_tokens: Some(accumulated_tokens),
usage: Some(accumulated_usage),
}); });
} }
if let Some(turn) = turn.as_ref() {
turn.emitter
.emit(TurnEvent::TextSegmentFinished { iteration })
.map_err(|error| AgentError::Other(format!("turn event rejected: {error}")))?;
}
// Execute tool calls — log and notify immediately // Execute tool calls — log and notify immediately
{ {
let tools_info: Vec<String> = response let tools_info: Vec<String> = response
@ -614,11 +740,15 @@ impl AgentLoop {
response.tool_calls.clone(), response.tool_calls.clone(),
); );
assistant_message.reasoning_content = response.reasoning_content; assistant_message.reasoning_content = response.reasoning_content;
assistant_message.provider_state = response.provider_state;
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false);
messages.push(assistant_message.clone()); messages.push(assistant_message.clone());
emitted_messages.push(assistant_message); emitted_messages.push(assistant_message);
// Execute tools and add results to messages // Execute tools and add results to messages
let tool_results = self.execute_tools(&response.tool_calls).await; let tool_results = self
.execute_tools(&response.tool_calls, iteration, turn.as_ref())
.await?;
for (tool_call, result) in response.tool_calls.iter().zip(tool_results.iter()) { for (tool_call, result) in response.tool_calls.iter().zip(tool_results.iter()) {
// Log function call with name and arguments // Log function call with name and arguments
@ -644,22 +774,24 @@ impl AgentLoop {
"Loop warning: {}", "Loop warning: {}",
msg msg
); );
let tool_message = ChatMessage::tool_with_media( let mut tool_message = ChatMessage::tool_with_media(
tool_call.id.clone(), tool_call.id.clone(),
tool_call.name.clone(), tool_call.name.clone(),
format!("{}\n\n[上一条结果]\n{}", msg, truncated_output), format!("{}\n\n[上一条结果]\n{}", msg, truncated_output),
result.media_refs.clone(), result.media_refs.clone(),
); );
Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
messages.push(tool_message.clone()); messages.push(tool_message.clone());
emitted_messages.push(tool_message); emitted_messages.push(tool_message);
} }
LoopDetectionResult::Ok => { LoopDetectionResult::Ok => {
let tool_message = ChatMessage::tool_with_media( let mut tool_message = ChatMessage::tool_with_media(
tool_call.id.clone(), tool_call.id.clone(),
tool_call.name.clone(), tool_call.name.clone(),
truncated_output, truncated_output,
result.media_refs.clone(), result.media_refs.clone(),
); );
Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
messages.push(tool_message.clone()); messages.push(tool_message.clone());
emitted_messages.push(tool_message); emitted_messages.push(tool_message);
} }
@ -695,25 +827,56 @@ impl AgentLoop {
tools: None, // No tools in final summary call tools: None, // No tools in final summary call
}; };
match (*self.provider).chat(request).await { let summary_iteration = u32::try_from(self.max_iterations)
.map_err(|_| AgentError::Other("tool iteration exceeds u32".to_string()))?;
match self
.stream_completion(request, summary_iteration, turn.as_ref())
.await
{
Ok(response) => { Ok(response) => {
accumulated_tokens += response.usage.total_tokens; accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
merge_usage(&mut accumulated_usage, &response.usage);
let mut assistant_message = ChatMessage::assistant(response.content); let mut assistant_message = ChatMessage::assistant(response.content);
assistant_message.reasoning_content = response.reasoning_content; assistant_message.reasoning_content = response.reasoning_content;
assistant_message.provider_state = response.provider_state;
Self::annotate_message(
&mut assistant_message,
turn.as_ref(),
summary_iteration,
true,
);
emitted_messages.push(assistant_message.clone()); emitted_messages.push(assistant_message.clone());
Ok(AgentProcessResult { Ok(AgentProcessResult {
final_response: assistant_message, final_response: assistant_message,
emitted_messages, emitted_messages,
total_tokens: Some(accumulated_tokens), total_tokens: Some(accumulated_tokens),
usage: Some(accumulated_usage),
}) })
} }
Err(e) => { Err(e) => {
// Fallback if summary call fails // Fallback if summary call fails
tracing::error!(error = %e, "Failed to get summary from LLM"); tracing::error!(error = %e, "Failed to get summary from LLM");
let final_message = ChatMessage::assistant(format!( let fallback = format!(
"I reached the maximum number of tool call iterations ({}) without completing the task. The work done so far has been lost due to an error. Please try breaking the task into smaller steps.", "I reached the maximum number of tool call iterations ({}) without completing the task. The work done so far has been lost due to an error. Please try breaking the task into smaller steps.",
self.max_iterations self.max_iterations
)); );
if let Some(turn) = turn.as_ref() {
turn.emitter
.emit(TurnEvent::TextSegmentFinished {
iteration: summary_iteration,
})
.and_then(|()| {
turn.emitter.emit(TurnEvent::TextDelta {
iteration: summary_iteration,
delta: fallback.clone(),
})
})
.map_err(|error| {
AgentError::Other(format!("turn event rejected: {error}"))
})?;
}
let mut final_message = ChatMessage::assistant(fallback);
Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true);
emitted_messages.push(final_message.clone()); emitted_messages.push(final_message.clone());
Ok(AgentProcessResult { Ok(AgentProcessResult {
final_response: final_message, final_response: final_message,
@ -723,6 +886,7 @@ impl AgentLoop {
} else { } else {
None None
}, },
usage: (accumulated_usage.total_tokens > 0).then_some(accumulated_usage),
}) })
} }
} }
@ -753,42 +917,76 @@ impl AgentLoop {
} }
/// Execute multiple tool calls, choosing parallel or sequential based on conditions. /// Execute multiple tool calls, choosing parallel or sequential based on conditions.
async fn execute_tools(&self, tool_calls: &[ToolCall]) -> Vec<ToolExecutionOutcome> { async fn execute_tools(
&self,
tool_calls: &[ToolCall],
iteration: u32,
turn: Option<&AgentTurnContext>,
) -> Result<Vec<ToolExecutionOutcome>, AgentError> {
if self.should_execute_in_parallel(tool_calls) { if self.should_execute_in_parallel(tool_calls) {
tracing::debug!("Executing {} tools in parallel", tool_calls.len()); tracing::debug!("Executing {} tools in parallel", tool_calls.len());
self.execute_tools_parallel(tool_calls).await self.execute_tools_parallel(tool_calls, iteration, turn)
.await
} else { } else {
tracing::debug!("Executing {} tools sequentially", tool_calls.len()); tracing::debug!("Executing {} tools sequentially", tool_calls.len());
self.execute_tools_sequential(tool_calls).await self.execute_tools_sequential(tool_calls, iteration, turn)
.await
} }
} }
/// Execute tools in parallel using join_all. /// Execute tools in parallel using join_all.
async fn execute_tools_parallel(&self, tool_calls: &[ToolCall]) -> Vec<ToolExecutionOutcome> { async fn execute_tools_parallel(
&self,
tool_calls: &[ToolCall],
iteration: u32,
turn: Option<&AgentTurnContext>,
) -> Result<Vec<ToolExecutionOutcome>, AgentError> {
let futures: Vec<_> = tool_calls let futures: Vec<_> = tool_calls
.iter() .iter()
.map(|tc| self.execute_one_tool(tc)) .map(|tool_call| self.execute_one_tool(tool_call, iteration, turn))
.collect(); .collect();
futures_util::future::join_all(futures).await futures_util::future::join_all(futures)
.await
.into_iter()
.collect()
} }
/// Execute tools sequentially. /// Execute tools sequentially.
async fn execute_tools_sequential(&self, tool_calls: &[ToolCall]) -> Vec<ToolExecutionOutcome> { async fn execute_tools_sequential(
&self,
tool_calls: &[ToolCall],
iteration: u32,
turn: Option<&AgentTurnContext>,
) -> Result<Vec<ToolExecutionOutcome>, AgentError> {
let mut outcomes = Vec::with_capacity(tool_calls.len()); let mut outcomes = Vec::with_capacity(tool_calls.len());
for tool_call in tool_calls { for tool_call in tool_calls {
outcomes.push(self.execute_one_tool(tool_call).await); outcomes.push(self.execute_one_tool(tool_call, iteration, turn).await?);
} }
outcomes Ok(outcomes)
} }
/// Execute a single tool and return the outcome with event tracking. /// Execute a single tool and return the outcome with event tracking.
async fn execute_one_tool(&self, tool_call: &ToolCall) -> ToolExecutionOutcome { async fn execute_one_tool(
&self,
tool_call: &ToolCall,
iteration: u32,
turn: Option<&AgentTurnContext>,
) -> Result<ToolExecutionOutcome, AgentError> {
let start = Instant::now(); let start = Instant::now();
let tool_name = tool_call.name.clone(); let tool_name = tool_call.name.clone();
if let Some(turn) = turn {
turn.emitter
.emit(TurnEvent::ToolStarted {
iteration,
call: tool_call.clone(),
})
.map_err(|error| AgentError::Other(format!("turn event rejected: {error}")))?;
}
// Record ToolCallStart event // Record ToolCallStart event
if let Some(ref observer) = self.observer { if let Some(ref observer) = self.observer {
observer.record_event(&ObserverEvent::ToolCallStart { observer.record_event(&ObserverEvent::ToolCallStart {
@ -800,6 +998,17 @@ impl AgentLoop {
let result = self.execute_tool_internal(tool_call).await; let result = self.execute_tool_internal(tool_call).await;
let duration = start.elapsed(); let duration = start.elapsed();
if let Some(turn) = turn {
turn.emitter
.emit(TurnEvent::ToolFinished {
iteration,
call_id: tool_call.id.clone(),
success: result.success,
preview: Some(tool_result_preview(&truncate_tool_result(&result.output))),
})
.map_err(|error| AgentError::Other(format!("turn event rejected: {error}")))?;
}
// Record ToolCall event // Record ToolCall event
if let Some(ref observer) = self.observer { if let Some(ref observer) = self.observer {
observer.record_event(&ObserverEvent::ToolCall { observer.record_event(&ObserverEvent::ToolCall {
@ -810,7 +1019,7 @@ impl AgentLoop {
} }
// Apply duration // Apply duration
ToolExecutionOutcome { duration, ..result } Ok(ToolExecutionOutcome { duration, ..result })
} }
/// Internal tool execution without event tracking. /// Internal tool execution without event tracking.
@ -851,13 +1060,115 @@ impl AgentLoop {
mod tests { mod tests {
use super::*; use super::*;
use crate::observability::{MultiObserver, Observer}; use crate::observability::{MultiObserver, Observer};
use crate::providers::{ChatCompletionResponse, Usage}; use crate::providers::{
ChatCompletionResponse, FinishReason, ProviderChunk, ProviderStream, Usage,
};
use crate::session::{TurnBlock, TurnController};
use crate::tools::FileReadTool; use crate::tools::FileReadTool;
struct TestObserver { struct TestObserver {
events: std::sync::Mutex<Vec<ObserverEvent>>, events: std::sync::Mutex<Vec<ObserverEvent>>,
} }
struct StreamingTextProvider;
#[async_trait::async_trait]
impl LLMProvider for StreamingTextProvider {
async fn stream(
&self,
_request: ChatCompletionRequest,
) -> Result<ProviderStream, crate::providers::DynProviderError> {
let chunks = vec![
ProviderChunk::Metadata {
id: "response".into(),
model: "streaming-test".into(),
},
ProviderChunk::Reasoning("because ".into()),
ProviderChunk::Reasoning("facts".into()),
ProviderChunk::Text("hello ".into()),
ProviderChunk::Text("world".into()),
ProviderChunk::ProviderState(crate::bus::ProviderReasoningState {
provider: "test".into(),
payload: serde_json::json!({"opaque":"state"}),
}),
ProviderChunk::Usage(Usage {
prompt_tokens: 2,
completion_tokens: 3,
total_tokens: 5,
..Usage::default()
}),
ProviderChunk::Done(FinishReason::Stop),
];
Ok(Box::pin(futures_util::stream::iter(
chunks.into_iter().map(Ok),
)))
}
fn ptype(&self) -> &str {
"test"
}
fn name(&self) -> &str {
"streaming-test"
}
fn model_id(&self) -> &str {
"streaming-test"
}
}
#[tokio::test]
async fn process_streaming_emits_turn_blocks_and_stamps_durable_message() {
let agent = AgentLoop::with_provider(
Arc::new(StreamingTextProvider),
1,
"streaming-test".into(),
PathBuf::from("."),
Vec::new(),
);
let (controller, emitter, _) = TurnController::start("session", "assistant-id");
let initial = controller.snapshot();
let context =
AgentTurnContext::new(initial.id.0.clone(), initial.message_id.clone(), emitter);
let result = agent
.process_streaming(vec![ChatMessage::user("hi")], context)
.await
.unwrap();
assert_eq!(result.final_response.id, "assistant-id");
assert_eq!(result.final_response.content, "hello world");
assert_eq!(
result.final_response.reasoning_content.as_deref(),
Some("because facts")
);
assert_eq!(result.final_response.turn_id, Some(initial.id.0.clone()));
assert_eq!(result.final_response.iteration, Some(0));
assert_eq!(
result
.final_response
.provider_state
.as_ref()
.map(|state| state.provider.as_str()),
Some("test")
);
assert_eq!(
result.usage.as_ref().map(|usage| usage.total_tokens),
Some(5)
);
let snapshot = controller.snapshot();
assert_eq!(snapshot.blocks.len(), 2);
assert!(matches!(
&snapshot.blocks[0],
TurnBlock::Reasoning { text, .. } if text == "because facts"
));
assert!(matches!(
&snapshot.blocks[1],
TurnBlock::Assistant { text, .. } if text == "hello world"
));
}
impl TestObserver { impl TestObserver {
fn new() -> Self { fn new() -> Self {
Self { Self {
@ -973,8 +1284,13 @@ mod tests {
vec!["text".to_string(), "image".to_string()], vec!["text".to_string(), "image".to_string()],
); );
let (controller, emitter, _) = TurnController::start("session", "assistant-id");
let turn = controller.snapshot();
let result = agent let result = agent
.process(vec![ChatMessage::user("inspect the image")]) .process_streaming(
vec![ChatMessage::user("inspect the image")],
AgentTurnContext::new(turn.id.0.clone(), turn.message_id.clone(), emitter),
)
.await .await
.unwrap(); .unwrap();
@ -999,6 +1315,14 @@ mod tests {
.iter() .iter()
.any(|media| media.media_type == "image") .any(|media| media.media_type == "image")
})); }));
assert!(controller.snapshot().blocks.iter().any(|block| matches!(
block,
TurnBlock::Tool {
id,
status: crate::session::ToolStatus::Completed,
..
} if id == "call-image"
)));
} }
#[test] #[test]

View File

@ -15,4 +15,4 @@ pub use system_prompt::{
PromptContext, PromptSection, SystemPromptBuilder, build_sub_agent_system_prompt, PromptContext, PromptSection, SystemPromptBuilder, build_sub_agent_system_prompt,
build_system_prompt, build_system_prompt,
}; };
pub use turn_event::{TurnEmitError, TurnEmitter, TurnEvent}; pub use turn_event::{AgentTurnContext, TurnEmitError, TurnEmitter, TurnEvent};

View File

@ -1,4 +1,4 @@
use std::sync::Arc; use std::sync::{Arc, Mutex};
use thiserror::Error; use thiserror::Error;
@ -49,6 +49,29 @@ type EmitFn = dyn Fn(TurnEvent) -> Result<(), TurnEmitError> + Send + Sync;
#[derive(Clone)] #[derive(Clone)]
pub struct TurnEmitter { pub struct TurnEmitter {
emit: Arc<EmitFn>, emit: Arc<EmitFn>,
enabled: Arc<Mutex<bool>>,
}
/// Session-owned identity and emitter for one AgentLoop execution.
#[derive(Clone)]
pub struct AgentTurnContext {
pub turn_id: String,
pub message_id: String,
pub emitter: TurnEmitter,
}
impl AgentTurnContext {
pub fn new(
turn_id: impl Into<String>,
message_id: impl Into<String>,
emitter: TurnEmitter,
) -> Self {
Self {
turn_id: turn_id.into(),
message_id: message_id.into(),
emitter,
}
}
} }
impl TurnEmitter { impl TurnEmitter {
@ -58,10 +81,27 @@ impl TurnEmitter {
{ {
Self { Self {
emit: Arc::new(emit), emit: Arc::new(emit),
enabled: Arc::new(Mutex::new(true)),
} }
} }
pub fn emit(&self, event: TurnEvent) -> Result<(), TurnEmitError> { pub fn emit(&self, event: TurnEvent) -> Result<(), TurnEmitError> {
let enabled = self
.enabled
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if !*enabled {
return Ok(());
}
(self.emit)(event) (self.emit)(event)
} }
/// Stop forwarding new presentation facts without changing durable or
/// terminal Turn status. Session uses this before invalidating a worker.
pub fn deactivate(&self) {
*self
.enabled
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = false;
}
} }

View File

@ -8,8 +8,8 @@ pub use self::openai::OpenAIProvider;
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
pub use stream::{ pub use stream::{
DynProviderError, FinishReason, ProviderChunk, ProviderStream, ProviderStreamItem, DynProviderError, FinishReason, ProviderChunk, ProviderResponseAccumulator, ProviderStream,
collect_provider_stream, provider_stream_from_response, ProviderStreamItem, collect_provider_stream, provider_stream_from_response,
}; };
pub use traits::{ pub use traits::{
ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Message, Tool, ToolCall, ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Message, Tool, ToolCall,

View File

@ -64,34 +64,35 @@ struct PartialToolCall {
arguments: String, arguments: String,
} }
pub async fn collect_provider_stream( #[derive(Default)]
mut provider_stream: ProviderStream, pub struct ProviderResponseAccumulator {
) -> Result<ChatCompletionResponse, DynProviderError> { id: String,
let mut id = String::new(); model: String,
let mut model = String::new(); content: String,
let mut content = String::new(); reasoning_content: String,
let mut reasoning_content = String::new(); provider_state: Option<ProviderReasoningState>,
let mut provider_state = None; usage: Usage,
let mut usage = Usage::default(); tool_calls: BTreeMap<usize, PartialToolCall>,
let mut tool_calls = BTreeMap::<usize, PartialToolCall>::new(); }
while let Some(chunk) = provider_stream.next().await { impl ProviderResponseAccumulator {
match chunk? { pub fn push(&mut self, chunk: ProviderChunk) {
match chunk {
ProviderChunk::Metadata { ProviderChunk::Metadata {
id: response_id, id: response_id,
model: response_model, model: response_model,
} => { } => {
id = response_id; self.id = response_id;
model = response_model; self.model = response_model;
} }
ProviderChunk::Text(delta) => content.push_str(&delta), ProviderChunk::Text(delta) => self.content.push_str(&delta),
ProviderChunk::Reasoning(delta) => reasoning_content.push_str(&delta), ProviderChunk::Reasoning(delta) => self.reasoning_content.push_str(&delta),
ProviderChunk::ToolCallStart { ProviderChunk::ToolCallStart {
index, index,
id: call_id, id: call_id,
name, name,
} => { } => {
let partial = tool_calls.entry(index).or_default(); let partial = self.tool_calls.entry(index).or_default();
if call_id.is_some() { if call_id.is_some() {
partial.id = call_id; partial.id = call_id;
} }
@ -100,36 +101,51 @@ pub async fn collect_provider_stream(
} }
} }
ProviderChunk::ToolCallArguments { index, delta } => { ProviderChunk::ToolCallArguments { index, delta } => {
tool_calls self.tool_calls
.entry(index) .entry(index)
.or_default() .or_default()
.arguments .arguments
.push_str(&delta); .push_str(&delta);
} }
ProviderChunk::ProviderState(state) => provider_state = Some(state), ProviderChunk::ProviderState(state) => self.provider_state = Some(state),
ProviderChunk::Usage(value) => usage = value, ProviderChunk::Usage(value) => self.usage = value,
ProviderChunk::Done(_) => {} ProviderChunk::Done(_) => {}
} }
} }
let tool_calls = tool_calls pub fn finish(self) -> ChatCompletionResponse {
.into_iter() let tool_calls = self
.map(|(index, partial)| ToolCall { .tool_calls
id: partial.id.unwrap_or_else(|| format!("tool_call_{index}")), .into_iter()
name: partial.name.unwrap_or_default(), .map(|(index, partial)| ToolCall {
arguments: serde_json::from_str(&partial.arguments).unwrap_or(serde_json::Value::Null), id: partial.id.unwrap_or_else(|| format!("tool_call_{index}")),
}) name: partial.name.unwrap_or_default(),
.collect(); arguments: serde_json::from_str(&partial.arguments)
.unwrap_or(serde_json::Value::Null),
})
.collect();
Ok(ChatCompletionResponse { ChatCompletionResponse {
id, id: self.id,
model, model: self.model,
content, content: self.content,
reasoning_content: (!reasoning_content.is_empty()).then_some(reasoning_content), reasoning_content: (!self.reasoning_content.is_empty())
provider_state, .then_some(self.reasoning_content),
tool_calls, provider_state: self.provider_state,
usage, tool_calls,
}) usage: self.usage,
}
}
}
pub async fn collect_provider_stream(
mut provider_stream: ProviderStream,
) -> Result<ChatCompletionResponse, DynProviderError> {
let mut accumulator = ProviderResponseAccumulator::default();
while let Some(chunk) = provider_stream.next().await {
accumulator.push(chunk?);
}
Ok(accumulator.finish())
} }
/// Adapt a complete response to the stream-first provider contract. /// Adapt a complete response to the stream-first provider contract.

View File

@ -1,10 +1,12 @@
use std::sync::Arc; use std::sync::Arc;
use std::{fmt::Display, future::Future};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use super::session::{MessagePersistSnapshot, Session}; use super::session::{MessagePersistSnapshot, Session};
use crate::bus::ChatMessage; use crate::bus::ChatMessage;
use crate::storage::StorageError; use crate::storage::StorageError;
use crate::{providers::Usage, session::TurnController};
async fn persist_added_messages( async fn persist_added_messages(
snapshots: Vec<Option<MessagePersistSnapshot>>, snapshots: Vec<Option<MessagePersistSnapshot>>,
@ -63,3 +65,74 @@ pub(super) async fn append_persisted_messages(
} }
Ok(()) Ok(())
} }
/// Publish `Completed` only after the supplied durable write succeeds.
///
/// Keeping this ordering in one helper makes the user-visible terminal status
/// impossible to publish optimistically before SQLite commits.
pub(super) async fn finalize_turn_after_persistence<F, T, E>(
controller: &TurnController,
usage: Option<Usage>,
persistence: F,
) -> Result<T, E>
where
F: Future<Output = Result<T, E>>,
E: Display,
{
controller.begin_finalizing();
match persistence.await {
Ok(value) => {
controller.complete(usage);
Ok(value)
}
Err(error) => {
controller.fail(format!("failed to persist turn: {error}"));
Err(error)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{TurnController, TurnStatus};
#[tokio::test]
async fn completed_is_published_only_after_persistence_succeeds() {
let (controller, _emitter, receiver) = TurnController::start("session", "message");
let persisted = Arc::new(std::sync::atomic::AtomicBool::new(false));
let persisted_in_future = persisted.clone();
let result: Result<(), String> =
finalize_turn_after_persistence(&controller, None, async move {
assert_eq!(receiver.borrow().status, TurnStatus::Running);
assert_eq!(
receiver.borrow().phase,
crate::session::TurnPhase::Finalizing
);
persisted_in_future.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(())
})
.await;
assert!(result.is_ok());
assert!(persisted.load(std::sync::atomic::Ordering::SeqCst));
assert_eq!(controller.snapshot().status, TurnStatus::Completed);
}
#[tokio::test]
async fn persistence_failure_never_publishes_completed() {
let (controller, _emitter, _receiver) = TurnController::start("session", "message");
let result: Result<(), &str> =
finalize_turn_after_persistence(&controller, None, async { Err("database down") })
.await;
assert_eq!(result, Err("database down"));
let snapshot = controller.snapshot();
assert_eq!(snapshot.status, TurnStatus::Failed);
assert_eq!(
snapshot.error.as_deref(),
Some("failed to persist turn: database down")
);
}
}

View File

@ -3,8 +3,11 @@ use std::sync::Arc;
use tokio::sync::{Mutex, mpsc, oneshot}; use tokio::sync::{Mutex, mpsc, oneshot};
use super::persistence::append_persisted_messages; use super::persistence::{append_persisted_messages, finalize_turn_after_persistence};
use crate::bus::{ChatMessage, MediaItem, MediaRef, MessageSource, OutboundMessage, SourceKind}; use super::turn::{TurnBlock, TurnController, TurnSnapshot};
use crate::bus::{
ChatMessage, CompletionStatus, MediaItem, MediaRef, MessageSource, OutboundMessage, SourceKind,
};
use crate::mcp::get_mcp_status; use crate::mcp::get_mcp_status;
use crate::storage::{Storage, StorageError}; use crate::storage::{Storage, StorageError};
use std::sync::Arc as StdArc; use std::sync::Arc as StdArc;
@ -37,7 +40,7 @@ pub enum HandleResult {
} }
use crate::agent::context_compressor::ContextCompressionConfig; use crate::agent::context_compressor::ContextCompressionConfig;
use crate::agent::system_prompt::{build_runtime_context, build_system_prompt}; use crate::agent::system_prompt::{build_runtime_context, build_system_prompt};
use crate::agent::{AgentError, AgentLoop, ContextCompressor}; use crate::agent::{AgentError, AgentLoop, AgentTurnContext, ContextCompressor, TurnEmitter};
use crate::channels::slash_command::parse_slash_command; use crate::channels::slash_command::parse_slash_command;
use crate::config::BrowserConfig; use crate::config::BrowserConfig;
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
@ -53,6 +56,115 @@ fn is_context_overflow_error(msg: &str) -> bool {
|| lower.contains("prompt is too long") || lower.contains("prompt is too long")
|| lower.contains("input is too long") || lower.contains("input is too long")
} }
fn partial_assistant_message(
snapshot: &TurnSnapshot,
completion_status: CompletionStatus,
) -> Option<ChatMessage> {
let mut assistant_segments = Vec::new();
let mut reasoning_segments = Vec::new();
let mut last_iteration = None;
for block in &snapshot.blocks {
match block {
TurnBlock::Assistant {
iteration, text, ..
} if !text.is_empty() => {
assistant_segments.push(text.as_str());
last_iteration = Some(*iteration);
}
TurnBlock::Reasoning { text, .. } if !text.is_empty() => {
reasoning_segments.push(text.as_str());
}
_ => {}
}
}
if assistant_segments.is_empty() {
return None;
}
let mut message = ChatMessage::assistant(assistant_segments.join("\n\n"));
message.id = snapshot.message_id.clone();
message.turn_id = Some(snapshot.id.0.clone());
message.iteration = last_iteration;
message.completion_status = completion_status;
message.reasoning_content =
(!reasoning_segments.is_empty()).then(|| reasoning_segments.join("\n\n"));
Some(message)
}
async fn fail_turn_with_partial(
controller: &TurnController,
session: &Arc<Mutex<Session>>,
error: String,
) {
let snapshot = controller.snapshot();
let partial = partial_assistant_message(&snapshot, CompletionStatus::Interrupted);
if let Some(partial) = partial {
controller.begin_finalizing();
if let Err(persistence_error) = append_persisted_messages(session, vec![partial]).await {
controller.fail(format!(
"{error}; failed to persist interrupted turn: {persistence_error}"
));
return;
}
}
controller.fail(error);
}
#[cfg(test)]
mod cancelled_partial_tests {
use super::*;
use crate::agent::TurnEvent;
#[test]
fn visible_partial_text_becomes_cancelled_persisted_message() {
let (controller, emitter, _) = TurnController::start("session", "message-id");
emitter
.emit(TurnEvent::ReasoningDelta {
iteration: 0,
delta: "reason".into(),
})
.unwrap();
emitter
.emit(TurnEvent::TextDelta {
iteration: 0,
delta: "first".into(),
})
.unwrap();
emitter
.emit(TurnEvent::TextSegmentFinished { iteration: 0 })
.unwrap();
emitter
.emit(TurnEvent::TextDelta {
iteration: 1,
delta: "second".into(),
})
.unwrap();
let message =
partial_assistant_message(&controller.snapshot(), CompletionStatus::Cancelled).unwrap();
assert_eq!(message.id, "message-id");
assert_eq!(message.content, "first\n\nsecond");
assert_eq!(message.reasoning_content.as_deref(), Some("reason"));
assert_eq!(message.iteration, Some(1));
assert_eq!(message.completion_status, CompletionStatus::Cancelled);
}
#[test]
fn reasoning_only_cancel_does_not_create_assistant_history() {
let (controller, emitter, _) = TurnController::start("session", "message-id");
emitter
.emit(TurnEvent::ReasoningDelta {
iteration: 0,
delta: "private".into(),
})
.unwrap();
assert!(
partial_assistant_message(&controller.snapshot(), CompletionStatus::Cancelled,)
.is_none()
);
}
}
use crate::bus::MessageBus; use crate::bus::MessageBus;
use crate::providers::{LLMProvider, create_provider}; use crate::providers::{LLMProvider, create_provider};
use crate::session::events::DialogInfo; use crate::session::events::DialogInfo;
@ -93,6 +205,7 @@ pub struct Session {
agent_tx: Option<mpsc::Sender<AgentTask>>, agent_tx: Option<mpsc::Sender<AgentTask>>,
/// Cancel signal for the currently executing agent task /// Cancel signal for the currently executing agent task
current_cancel: Option<oneshot::Sender<()>>, current_cancel: Option<oneshot::Sender<()>>,
active_turn_emitter: Option<ActiveTurnEmitter>,
/// Monotonic counter to detect stale workers /// Monotonic counter to detect stale workers
worker_generation: u64, worker_generation: u64,
/// Monotonic counter for in-memory session mutations. /// Monotonic counter for in-memory session mutations.
@ -108,6 +221,11 @@ pub struct Session {
pub(super) persistence_lock: Arc<Mutex<()>>, pub(super) persistence_lock: Arc<Mutex<()>>,
} }
struct ActiveTurnEmitter {
turn_id: String,
emitter: TurnEmitter,
}
/// A task to be processed by the per-session agent worker /// A task to be processed by the per-session agent worker
struct AgentTask { struct AgentTask {
channel: String, channel: String,
@ -178,6 +296,7 @@ impl Session {
memory_manager, memory_manager,
agent_tx: None, agent_tx: None,
current_cancel: None, current_cancel: None,
active_turn_emitter: None,
worker_generation: 0, worker_generation: 0,
state_version: 0, state_version: 0,
persistence_lock: Arc::new(Mutex::new(())), persistence_lock: Arc::new(Mutex::new(())),
@ -366,6 +485,7 @@ impl Session {
memory_manager, memory_manager,
agent_tx: None, agent_tx: None,
current_cancel: None, current_cancel: None,
active_turn_emitter: None,
worker_generation: 0, worker_generation: 0,
state_version: 0, state_version: 0,
persistence_lock: Arc::new(Mutex::new(())), persistence_lock: Arc::new(Mutex::new(())),
@ -1473,6 +1593,9 @@ impl SessionManager {
if guard.current_cancel.take().is_some() { if guard.current_cancel.take().is_some() {
msgs.push("当前任务已发送停止信号。".to_string()); msgs.push("当前任务已发送停止信号。".to_string());
} }
if let Some(active_turn) = guard.active_turn_emitter.take() {
active_turn.emitter.deactivate();
}
if guard.agent_tx.take().is_some() { if guard.agent_tx.take().is_some() {
msgs.push("消息队列已清空。".to_string()); msgs.push("消息队列已清空。".to_string());
} }
@ -2391,12 +2514,40 @@ fn spawn_agent_worker(
Session::append_runtime_context_to_user_message(last_msg, &runtime_context); Session::append_runtime_context_to_user_message(last_msg, &runtime_context);
} }
let (turn_controller, turn_emitter, _turn_receiver) = TurnController::start(
unified_str.clone(),
uuid::Uuid::new_v4().to_string(),
);
let initial_turn = turn_controller.snapshot();
let active_turn_id = initial_turn.id.0.clone();
{
let mut guard = session.lock().await;
if guard.worker_generation != worker_gen || guard.state_version != base_version {
turn_emitter.deactivate();
turn_controller.cancel(Some(
"session changed before model execution".to_string(),
));
guard.current_cancel = None;
continue 'tasks;
}
guard.active_turn_emitter = Some(ActiveTurnEmitter {
turn_id: initial_turn.id.0.clone(),
emitter: turn_emitter.clone(),
});
}
let agent_turn = AgentTurnContext::new(
initial_turn.id.0.clone(),
initial_turn.message_id.clone(),
turn_emitter,
);
// Phase 2 + 3: LLM call with cancellation // Phase 2 + 3: LLM call with cancellation
let session2 = session.clone(); let session2 = session.clone();
let bus2 = bus.clone(); let bus2 = bus.clone();
let chan2 = task_chan.clone(); let chan2 = task_chan.clone();
let cid2 = task_cid.clone(); let cid2 = task_cid.clone();
let unified_str2 = unified_str.clone(); let unified_str2 = unified_str.clone();
let turn_lifecycle = &turn_controller;
let process_future = async move { let process_future = async move {
let response_session_id = unified_str2.clone(); let response_session_id = unified_str2.clone();
let process_result = crate::agent::sub_agent::DELEGATE_CONTEXT.scope( let process_result = crate::agent::sub_agent::DELEGATE_CONTEXT.scope(
@ -2405,7 +2556,7 @@ fn spawn_agent_worker(
channel: chan2.clone(), channel: chan2.clone(),
chat_id: cid2.clone(), chat_id: cid2.clone(),
}, },
agent.process(history_out.clone()), agent.process_streaming(history_out.clone(), agent_turn.clone()),
).await; ).await;
let result = match process_result { let result = match process_result {
Ok(r) => r, Ok(r) => r,
@ -2435,6 +2586,12 @@ fn spawn_agent_worker(
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
tracing::error!(error = %e, "Retry compression failed"); tracing::error!(error = %e, "Retry compression failed");
fail_turn_with_partial(
turn_lifecycle,
&session2,
format!("context overflow handling failed: {e}"),
)
.await;
let err_outbound = OutboundMessage { let err_outbound = OutboundMessage {
channel: chan2, channel: chan2,
chat_id: cid2, chat_id: cid2,
@ -2459,6 +2616,10 @@ fn spawn_agent_worker(
session_id = %guard.id, session_id = %guard.id,
"Session changed while retry-compressing after context overflow" "Session changed while retry-compressing after context overflow"
); );
turn_lifecycle.cancel(Some(
"session changed during context overflow recovery"
.to_string(),
));
return; return;
} }
guard.compressor.set_context_window(new_window); guard.compressor.set_context_window(new_window);
@ -2490,13 +2651,22 @@ fn spawn_agent_worker(
retry retry
}; };
match agent.process(retry_history).await { match agent
.process_streaming(retry_history, agent_turn.clone())
.await
{
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
tracing::error!( tracing::error!(
error = %e, error = %e,
"Agent retry after overflow failed" "Agent retry after overflow failed"
); );
fail_turn_with_partial(
turn_lifecycle,
&session2,
e.to_string(),
)
.await;
let err_outbound = OutboundMessage { let err_outbound = OutboundMessage {
channel: chan2, channel: chan2,
chat_id: cid2, chat_id: cid2,
@ -2513,6 +2683,12 @@ fn spawn_agent_worker(
} }
Err(e) => { Err(e) => {
tracing::error!(error = %e, "Agent processing error"); tracing::error!(error = %e, "Agent processing error");
fail_turn_with_partial(
turn_lifecycle,
&session2,
e.to_string(),
)
.await;
let err_outbound = OutboundMessage { let err_outbound = OutboundMessage {
channel: chan2, channel: chan2,
chat_id: cid2, chat_id: cid2,
@ -2529,16 +2705,36 @@ fn spawn_agent_worker(
let response_content = result.final_response.content; let response_content = result.final_response.content;
let total_tokens = result.total_tokens; let total_tokens = result.total_tokens;
let response = let usage = result.usage;
if let Err(e) = append_persisted_messages(&session2, result.emitted_messages).await { {
tracing::error!(error = %e, "Failed to atomically persist agent turn"); let guard = session2.lock().await;
None if guard.worker_generation != worker_gen
} else { || guard.state_version != base_version
{
turn_lifecycle.cancel(Some(
"session changed before turn commit".to_string(),
));
return;
}
}
let response = match finalize_turn_after_persistence(
turn_lifecycle,
usage,
append_persisted_messages(&session2, result.emitted_messages),
)
.await
{
Ok(()) => {
let mut guard = session2.lock().await; let mut guard = session2.lock().await;
let sent_count = guard.messages.len(); let sent_count = guard.messages.len();
guard.compressor.set_last_api_info(sent_count, total_tokens); guard.compressor.set_last_api_info(sent_count, total_tokens);
Some(response_content) Some(response_content)
}; }
Err(e) => {
tracing::error!(error = %e, "Failed to atomically persist agent turn");
None
}
};
let Some(response) = response else { let Some(response) = response else {
let err_outbound = OutboundMessage { let err_outbound = OutboundMessage {
@ -2575,11 +2771,39 @@ fn spawn_agent_worker(
() = process_future => {} () = process_future => {}
_ = cancel_rx => { _ = cancel_rx => {
// cancelled — current_cancel already taken by /stop // cancelled — current_cancel already taken by /stop
let snapshot = turn_controller.snapshot();
if let Some(partial) = partial_assistant_message(
&snapshot,
CompletionStatus::Cancelled,
) {
turn_controller.begin_finalizing();
match append_persisted_messages(&session, vec![partial]).await {
Ok(()) => {
turn_controller.cancel(Some("stopped by user".to_string()));
}
Err(error) => {
tracing::error!(error = %error, "Failed to persist cancelled partial turn");
turn_controller.fail(format!(
"failed to persist cancelled turn: {error}"
));
}
}
} else {
turn_controller.cancel(Some("stopped by user".to_string()));
}
} }
} }
// Clean up // Clean up
let mut guard = session.lock().await; let mut guard = session.lock().await;
if guard
.active_turn_emitter
.as_ref()
.is_some_and(|active| active.turn_id == active_turn_id)
&& let Some(active) = guard.active_turn_emitter.take()
{
active.emitter.deactivate();
}
if guard.worker_generation == worker_gen { if guard.worker_generation == worker_gen {
guard.current_cancel = None; guard.current_cancel = None;
} }

View File

@ -589,4 +589,18 @@ mod tests {
assert_eq!(snapshot.error.as_deref(), Some("provider disconnected")); assert_eq!(snapshot.error.as_deref(), Some("provider disconnected"));
assert_eq!(snapshot.phase, TurnPhase::Finalizing); assert_eq!(snapshot.phase, TurnPhase::Finalizing);
} }
#[test]
fn deactivated_emitter_drops_events_before_stale_worker_reduction() {
let (controller, emitter, _) = start();
emitter.deactivate();
emitter
.emit(TurnEvent::TextDelta {
iteration: 0,
delta: "late".into(),
})
.unwrap();
assert_eq!(controller.snapshot().revision, 0);
assert!(controller.snapshot().blocks.is_empty());
}
} }