use crate::{
OneOrMany,
agent::completion::{PreparedCompletionRequest, build_prepared_completion_request},
agent::hook::{
AgentHook, HookContext, HookStack, InvalidToolCallHookAction, StepEvent, StepEventKind,
},
agent::prompt_request::{assistant_text_from_choice, is_empty_assistant_turn},
agent::run::{
AgentRun, AgentRunStep, PendingToolCall,
streamed::{StreamedResolution, StreamedTurnAssembler, StreamedTurnEvent},
},
agent::runner::{
AgentRunner, CompletionCallOutcome, InvalidDecision, ToolExecution, acquire_agent_span,
append_run_messages, build_chat_span, flow_into_invalid, new_execute_tool_span,
observe_flow, resolve_completion_call, run_single_tool,
},
completion::GetTokenUsage,
message::{AssistantContent, UserContent},
streaming::{StreamedAssistantContent, StreamedUserContent, ToolCallDeltaContent},
tool::ToolCallExtensions,
wasm_compat::{WasmBoxedFuture, WasmCompatSend},
};
use futures::{Stream, StreamExt, stream};
use serde::{Deserialize, Serialize};
use std::{collections::VecDeque, pin::Pin, sync::Arc};
use tracing_futures::Instrument;
use super::{CompletionCall, PromptResponse, forward_prompt_setters};
use crate::{
agent::Agent,
completion::{CompletionError, CompletionModel, PromptError},
message::{Message, Text},
tool::ToolSetError,
};
#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))]
pub type StreamingResult<R> =
Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem<R>, StreamingError>> + Send>>;
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
pub type StreamingResult<R> =
Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem<R>, StreamingError>>>>;
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "camelCase")]
#[non_exhaustive]
pub enum MultiTurnStreamItem<R> {
StreamAssistantItem(StreamedAssistantContent<R>),
ToolExecutionStart {
tool_call: crate::message::ToolCall,
internal_call_id: String,
},
StreamUserItem(StreamedUserContent),
CompletionCall(CompletionCall),
FinalResponse(PromptResponse),
}
fn final_response_from_content(
content: OneOrMany<AssistantContent>,
aggregated_usage: crate::completion::Usage,
completion_calls: Vec<CompletionCall>,
history: Option<Vec<Message>>,
) -> PromptResponse {
let mut response = PromptResponse::new(assistant_text_from_choice(&content), aggregated_usage)
.with_content(content)
.with_completion_calls(completion_calls);
response.messages = history;
response
}
impl<R> MultiTurnStreamItem<R> {
pub(crate) fn stream_item(item: StreamedAssistantContent<R>) -> Self {
Self::StreamAssistantItem(item)
}
pub fn final_response(
content: OneOrMany<AssistantContent>,
aggregated_usage: crate::completion::Usage,
) -> Self {
Self::FinalResponse(final_response_from_content(
content,
aggregated_usage,
Vec::new(),
None,
))
}
pub fn final_response_with_history(
content: OneOrMany<AssistantContent>,
aggregated_usage: crate::completion::Usage,
history: Option<Vec<Message>>,
) -> Self {
Self::FinalResponse(final_response_from_content(
content,
aggregated_usage,
Vec::new(),
history,
))
}
pub(crate) fn final_response_with_completion_calls(
content: OneOrMany<AssistantContent>,
aggregated_usage: crate::completion::Usage,
completion_calls: Vec<CompletionCall>,
history: Option<Vec<Message>>,
) -> Self {
Self::FinalResponse(final_response_from_content(
content,
aggregated_usage,
completion_calls,
history,
))
}
}
async fn drain_stream_usage<R>(
stream: &mut crate::streaming::StreamingCompletionResponse<R>,
) -> Result<crate::completion::Usage, StreamingError>
where
R: Clone + Unpin + GetTokenUsage,
{
while let Some(content) = stream.next().await {
match content {
Ok(StreamedAssistantContent::Final(final_resp)) => {
return Ok(final_resp.token_usage());
}
Ok(_) => {}
Err(err) => return Err(err.into()),
}
}
Ok(crate::completion::Usage::new())
}
pub(crate) fn record_usage_on_span(span: &tracing::Span, usage: crate::completion::Usage) {
span.record("gen_ai.usage.input_tokens", usage.input_tokens);
span.record("gen_ai.usage.output_tokens", usage.output_tokens);
span.record(
"gen_ai.usage.cache_read.input_tokens",
usage.cached_input_tokens,
);
span.record(
"gen_ai.usage.cache_creation.input_tokens",
usage.cache_creation_input_tokens,
);
span.record(
"gen_ai.usage.tool_use_prompt_tokens",
usage.tool_use_prompt_tokens,
);
span.record("gen_ai.usage.reasoning_tokens", usage.reasoning_tokens);
}
fn finalize_streamed_choice(
last_final_choice: &OneOrMany<AssistantContent>,
output: &str,
) -> Option<OneOrMany<AssistantContent>> {
let finalized_via_output_tool = last_final_choice
.iter()
.any(|item| matches!(item, AssistantContent::ToolCall(_)));
if !finalized_via_output_tool {
return None;
}
let mut items: Vec<AssistantContent> = last_final_choice
.iter()
.filter(|item| {
!matches!(
item,
AssistantContent::ToolCall(_) | AssistantContent::Text(_)
)
})
.cloned()
.collect();
items.push(AssistantContent::text(output.to_string()));
Some(
OneOrMany::from_iter_optional(items)
.unwrap_or_else(|| OneOrMany::one(AssistantContent::text(output.to_string()))),
)
}
#[derive(Debug, thiserror::Error)]
pub enum StreamingError {
#[error("CompletionError: {0}")]
Completion(#[from] CompletionError),
#[error("PromptError: {0}")]
Prompt(#[from] Box<PromptError>),
#[error("ToolSetError: {0}")]
Tool(#[from] ToolSetError),
}
impl From<crate::memory::MemoryError> for StreamingError {
fn from(err: crate::memory::MemoryError) -> Self {
Self::Completion(CompletionError::RequestError(Box::new(err)))
}
}
pub struct StreamingPromptRequest<M>
where
M: CompletionModel,
{
runner: AgentRunner<M>,
}
impl<M> StreamingPromptRequest<M>
where
M: CompletionModel + 'static,
<M as CompletionModel>::StreamingResponse: WasmCompatSend + GetTokenUsage,
{
pub fn new(agent: Arc<Agent<M>>, prompt: impl Into<Message>) -> StreamingPromptRequest<M> {
let mut runner = AgentRunner::from_agent(agent.as_ref(), prompt);
runner.hooks = HookStack::new();
StreamingPromptRequest { runner }
}
pub fn from_agent(agent: &Agent<M>, prompt: impl Into<Message>) -> StreamingPromptRequest<M> {
StreamingPromptRequest {
runner: AgentRunner::from_agent(agent, prompt),
}
}
pub fn max_turns(mut self, turns: usize) -> Self {
self.runner = self.runner.max_turns(turns);
self
}
pub fn tool_concurrency(mut self, concurrency: usize) -> Self {
self.runner = self.runner.tool_concurrency(concurrency);
self
}
pub fn add_hook<H>(mut self, hook: H) -> Self
where
H: AgentHook<M> + 'static,
{
self.runner = self.runner.add_hook(hook);
self
}
forward_prompt_setters!(runner);
async fn send(self) -> StreamingResult<M::StreamingResponse> {
self.runner.stream().await
}
}
#[cfg(not(all(feature = "wasm", target_arch = "wasm32")))]
pub(crate) type DriveStream<'a, R> =
Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem<R>, StreamingError>> + Send + 'a>>;
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
pub(crate) type DriveStream<'a, R> =
Pin<Box<dyn Stream<Item = Result<MultiTurnStreamItem<R>, StreamingError>> + 'a>>;
#[allow(clippy::large_enum_variant)]
pub(crate) enum DriveItem<R> {
Item(MultiTurnStreamItem<R>),
Done(Box<PromptResponse>),
}
pub(crate) trait TurnSource<M>: WasmCompatSend
where
M: CompletionModel,
{
type Raw: WasmCompatSend;
fn open_chat_span(
&self,
runner: &AgentRunner<M>,
effective_preamble: Option<&str>,
) -> tracing::Span;
#[allow(clippy::too_many_arguments)]
fn run_model_turn<'a>(
&'a mut self,
runner: &'a AgentRunner<M>,
hook_ctx: &'a HookContext,
run: &'a mut AgentRun,
prepared: PreparedCompletionRequest<M>,
chat_span: tracing::Span,
agent_span: &'a tracing::Span,
prompt: Message,
) -> DriveStream<'a, Self::Raw>;
fn run_tool_calls<'a>(
&'a self,
runner: &'a AgentRunner<M>,
hook_ctx: &'a HookContext,
run: &'a mut AgentRun,
calls: Vec<PendingToolCall>,
) -> DriveStream<'a, Self::Raw>;
fn record_run_level_telemetry(
&self,
agent_span: &tracing::Span,
response: &PromptResponse,
created_agent_span: bool,
);
fn final_item(&self, response: &PromptResponse) -> Option<MultiTurnStreamItem<Self::Raw>>;
}
pub(crate) fn streaming_error_into_prompt(err: StreamingError) -> PromptError {
match err {
StreamingError::Completion(err) => PromptError::CompletionError(err),
StreamingError::Prompt(err) => *err,
StreamingError::Tool(err) => PromptError::ToolError(err),
}
}
pub(crate) fn drive_agent<M, S>(
runner: AgentRunner<M>,
mut source: S,
mut run: AgentRun,
agent_span: tracing::Span,
created_agent_span: bool,
memory_handle: Option<(Arc<dyn crate::memory::ConversationMemory>, String)>,
is_streaming: bool,
) -> impl Stream<Item = Result<DriveItem<S::Raw>, StreamingError>>
where
M: CompletionModel,
S: TurnSource<M>,
{
async_stream::stream! {
let hook_ctx = HookContext::new(is_streaming, runner.agent_name.clone());
'outer: loop {
let step = match run.next_step() {
Ok(step) => step,
Err(err) => {
yield Err(Box::new(err).into());
break 'outer;
}
};
match step {
AgentRunStep::CallModel { prompt, history, turn } => {
if runner.max_turns > 1 {
tracing::info!("Current conversation Turns: {}/{}", turn, runner.max_turns);
}
hook_ctx.set_turn(turn);
let request_patch =
match resolve_completion_call(&runner.hooks, &hook_ctx, &prompt, &history, turn).await {
CompletionCallOutcome::Terminate(reason) => {
yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
break 'outer;
}
CompletionCallOutcome::Proceed(request_patch) => request_patch,
};
let effective_preamble = request_patch
.as_ref()
.and_then(|o| o.preamble.as_deref())
.or(runner.preamble.as_deref());
let chat_span = source.open_chat_span(&runner, effective_preamble);
let committed_output_tool = run.output_tool_name().map(str::to_owned);
let prepared = match build_prepared_completion_request(
&runner.model,
prompt.clone(),
&history,
runner.preamble.as_deref(),
&runner.static_context,
runner.temperature,
runner.max_tokens,
runner.additional_params.as_ref(),
runner.tool_choice.as_ref(),
&runner.tool_server_handle,
&runner.dynamic_context,
runner.output_schema.as_ref(),
&runner.output_mode,
committed_output_tool.as_deref(),
request_patch.as_ref(),
)
.await
{
Ok(prepared) => prepared,
Err(err) => {
yield Err(err.into());
break 'outer;
}
};
run.set_output_tool_name(prepared.output_tool_name.clone());
let mut turn_stream = source.run_model_turn(
&runner,
&hook_ctx,
&mut run,
prepared,
chat_span,
&agent_span,
prompt,
);
let mut errored = false;
while let Some(item) = turn_stream.next().await {
match item {
Ok(item) => yield Ok(DriveItem::Item(item)),
Err(err) => {
errored = true;
yield Err(err);
break;
}
}
}
drop(turn_stream);
if errored {
break 'outer;
}
}
AgentRunStep::CallTools { calls } => {
let mut tool_stream = source.run_tool_calls(&runner, &hook_ctx, &mut run, calls);
let mut errored = false;
while let Some(item) = tool_stream.next().await {
match item {
Ok(item) => yield Ok(DriveItem::Item(item)),
Err(err) => {
errored = true;
yield Err(err);
break;
}
}
}
drop(tool_stream);
if errored {
break 'outer;
}
}
AgentRunStep::Done(response) => {
tracing::info!(
turn = run.turn(),
max_turns = runner.max_turns,
"Agent run finished"
);
source.record_run_level_telemetry(&agent_span, &response, created_agent_span);
append_run_messages(
memory_handle.as_ref(),
response.messages.as_deref().unwrap_or_default(),
)
.await;
if let Some(final_item) = source.final_item(&response) {
yield Ok(DriveItem::Item(final_item));
}
yield Ok(DriveItem::Done(Box::new(response)));
break 'outer;
}
}
}
}
}
pub(crate) fn drive_tool_calls<'a, M, R, F>(
runner: &'a AgentRunner<M>,
hook_ctx: &'a HookContext,
run: &'a mut AgentRun,
calls: Vec<PendingToolCall>,
chain_tool_span: F,
forward_items: bool,
) -> DriveStream<'a, R>
where
M: CompletionModel,
R: WasmCompatSend + 'a,
F: Fn(tracing::Span) -> tracing::Span + WasmCompatSend + 'a,
{
struct PreparedToolCall {
tool_call: crate::message::ToolCall,
preresolved_result: Option<UserContent>,
internal_call_id: String,
span: tracing::Span,
}
enum ToolSurface {
Executed(Box<crate::message::ToolCall>),
Skipped,
Preresolved,
}
struct CollectedToolResult {
content: UserContent,
internal_call_id: String,
surface: ToolSurface,
}
Box::pin(async_stream::stream! {
let full_history_for_errors = run.full_history();
let call_count = calls.len();
let mut prepared: Vec<PreparedToolCall> = Vec::with_capacity(call_count);
for pending in calls {
let internal_call_id = pending.internal_call_id.unwrap_or_else(crate::id::generate);
let (span, preresolved_result) = match pending.preresolved_result {
Some(result) => (tracing::Span::none(), Some(result)),
None => {
if forward_items {
yield Ok(MultiTurnStreamItem::stream_item(
StreamedAssistantContent::ToolCall {
tool_call: pending.tool_call.clone(),
internal_call_id: internal_call_id.clone(),
},
));
}
(chain_tool_span(new_execute_tool_span()), None)
}
};
prepared.push(PreparedToolCall {
tool_call: pending.tool_call,
preresolved_result,
internal_call_id,
span,
});
}
let mut collected: Vec<Option<CollectedToolResult>> =
(0..call_count).map(|_| None).collect();
let mut first_error: Option<(usize, PromptError)> = None;
if runner.concurrency <= 1 {
for (index, call) in prepared.into_iter().enumerate() {
let PreparedToolCall { tool_call, preresolved_result, internal_call_id, span } = call;
if let Some(result) = preresolved_result {
if let Some(slot) = collected.get_mut(index) {
*slot = Some(CollectedToolResult {
content: result,
internal_call_id,
surface: ToolSurface::Preresolved,
});
}
continue;
}
let outcome = run_single_tool(
&runner.hooks,
hook_ctx,
&runner.tool_server_handle,
&runner.tool_extensions,
&tool_call,
&internal_call_id,
&full_history_for_errors,
)
.instrument(span)
.await;
match outcome {
Ok(outcome) => {
let surface = match outcome.execution {
ToolExecution::Executed(effective) => ToolSurface::Executed(effective),
ToolExecution::Skipped => ToolSurface::Skipped,
};
if let Some(slot) = collected.get_mut(index) {
*slot = Some(CollectedToolResult {
content: outcome.content,
internal_call_id,
surface,
});
}
}
Err(err) => {
first_error = Some((index, err));
break;
}
}
}
} else {
let terminating = Arc::new(std::sync::atomic::AtomicBool::new(false));
let unordered = stream::iter(prepared.into_iter().enumerate())
.map(|(index, call)| {
let PreparedToolCall { tool_call, preresolved_result, internal_call_id, span } = call;
let hooks = &runner.hooks;
let tool_server_handle = &runner.tool_server_handle;
let tool_extensions = &runner.tool_extensions;
let full_history_for_errors = &full_history_for_errors;
let terminating = terminating.clone();
async move {
if let Some(result) = preresolved_result {
return (
index,
Some(Ok(CollectedToolResult {
content: result,
internal_call_id,
surface: ToolSurface::Preresolved,
})),
);
}
if terminating.load(std::sync::atomic::Ordering::SeqCst) {
return (index, None);
}
let outcome = run_single_tool(
hooks,
hook_ctx,
tool_server_handle,
tool_extensions,
&tool_call,
&internal_call_id,
full_history_for_errors,
)
.await;
let mapped = outcome.map(|o| {
let surface = match o.execution {
ToolExecution::Executed(effective) => {
ToolSurface::Executed(effective)
}
ToolExecution::Skipped => ToolSurface::Skipped,
};
CollectedToolResult {
content: o.content,
internal_call_id,
surface,
}
});
(index, Some(mapped))
}
.instrument(span)
})
.buffer_unordered(runner.concurrency);
futures::pin_mut!(unordered);
while let Some((index, outcome)) = unordered.next().await {
let result = match outcome {
Some(result) => result,
None => continue,
};
match result {
Ok(collected_result) => {
if let Some(slot) = collected.get_mut(index) {
*slot = Some(collected_result);
}
}
Err(err) => {
terminating.store(true, std::sync::atomic::Ordering::SeqCst);
if first_error.as_ref().is_none_or(|(i, _)| index < *i) {
first_error = Some((index, err));
}
}
}
}
}
if let Some((_, err)) = first_error {
yield Err(StreamingError::Prompt(Box::new(err)));
return;
}
let mut committed: Vec<UserContent> = Vec::with_capacity(call_count);
for slot in collected {
let CollectedToolResult { content, internal_call_id, surface } = match slot {
Some(collected_result) => collected_result,
None => {
yield Err(StreamingError::Prompt(Box::new(PromptError::CompletionError(
CompletionError::ResponseError(
"tool execution finished without producing every result".to_string(),
),
))));
return;
}
};
if forward_items {
let surface_result = match surface {
ToolSurface::Executed(tool_call) => {
yield Ok(MultiTurnStreamItem::ToolExecutionStart {
tool_call: *tool_call,
internal_call_id: internal_call_id.clone(),
});
true
}
ToolSurface::Skipped => true,
ToolSurface::Preresolved => false,
};
if surface_result
&& let UserContent::ToolResult(tool_result) = &content
{
yield Ok(MultiTurnStreamItem::StreamUserItem(
StreamedUserContent::ToolResult {
tool_result: tool_result.clone(),
internal_call_id,
},
));
}
}
committed.push(content);
}
if let Err(err) = run.tool_results(committed) {
yield Err(Box::new(err).into());
return;
}
})
}
pub(crate) struct StreamingTurnSource {
last_final_choice: OneOrMany<AssistantContent>,
last_message_id: Option<String>,
agent_name: String,
created_agent_span: bool,
observes_text_delta: bool,
observes_tool_call_delta: bool,
has_hooks: bool,
}
impl StreamingTurnSource {
pub(crate) fn new<M: CompletionModel>(
hooks: &HookStack<M>,
agent_name: String,
created_agent_span: bool,
) -> Self {
Self {
last_final_choice: OneOrMany::one(AssistantContent::text("")),
last_message_id: None,
agent_name,
created_agent_span,
observes_text_delta: hooks.observes(StepEventKind::TextDelta),
observes_tool_call_delta: hooks.observes(StepEventKind::ToolCallDelta),
has_hooks: !hooks.is_empty(),
}
}
}
impl<M> TurnSource<M> for StreamingTurnSource
where
M: CompletionModel,
<M as CompletionModel>::StreamingResponse: WasmCompatSend + GetTokenUsage,
{
type Raw = M::StreamingResponse;
fn open_chat_span(
&self,
runner: &AgentRunner<M>,
effective_preamble: Option<&str>,
) -> tracing::Span {
build_chat_span!(runner, effective_preamble, "chat_streaming")
}
fn run_model_turn<'a>(
&'a mut self,
runner: &'a AgentRunner<M>,
hook_ctx: &'a HookContext,
run: &'a mut AgentRun,
prepared: PreparedCompletionRequest<M>,
chat_span: tracing::Span,
agent_span: &'a tracing::Span,
current_prompt: Message,
) -> DriveStream<'a, M::StreamingResponse> {
Box::pin(async_stream::stream! {
let mut stream = match prepared
.builder
.stream()
.instrument(chat_span.clone())
.await
{
Ok(stream) => stream,
Err(err) => {
yield Err(err.into());
return;
}
};
let mut last_usage = crate::completion::Usage::new();
let mut assembler = StreamedTurnAssembler::new(
prepared.executable_tool_names.clone(),
prepared.allowed_tool_names.clone(),
);
let mut completion_call_emitted = false;
let mut turn_abandoned = false;
let mut turn_recovered = false;
macro_rules! emit_completion_call {
($usage:expr) => {{
let usage = $usage;
last_usage = usage;
if !completion_call_emitted {
if usage.has_values() {
record_usage_on_span(&chat_span, usage);
}
match run.record_streamed_completion_call(usage) {
Ok(call) => {
completion_call_emitted = true;
Ok(Some(MultiTurnStreamItem::CompletionCall(call)))
}
Err(err) => Err(Box::new(err).into()),
}
} else {
Ok(None)
}
}};
}
'turn: while let Some(item) = stream.next().await {
let item = match item {
Ok(item) => item,
Err(err) => {
yield Err(err.into());
return;
}
};
let mut events: VecDeque<StreamedTurnEvent> = match assembler.ingest(&item) {
Ok(events) => events.into(),
Err(err) => {
yield Err(err.into());
return;
}
};
let mut item_slot = Some(item);
while let Some(event) = events.pop_front() {
match event {
StreamedTurnEvent::EmitIngested => {
if self.observes_text_delta
&& let Some(StreamedAssistantContent::Text(text)) =
item_slot.as_ref()
&& let Some(reason) = observe_flow(
runner
.hooks
.on_event(hook_ctx, StepEvent::TextDelta {
delta: &text.text,
aggregated: assembler.aggregated_text(),
})
.await,
)
{
yield Err(StreamingError::Prompt(Box::new(
run.cancel_error(reason),
)));
return;
}
if let Some(item) = item_slot.take() {
yield Ok(MultiTurnStreamItem::stream_item(item));
}
}
StreamedTurnEvent::EmitToolCallDelta {
id,
internal_call_id,
content,
} => {
if self.observes_tool_call_delta {
let (delta_name, delta_text) = match &content {
ToolCallDeltaContent::Name(name) => (Some(name.as_str()), ""),
ToolCallDeltaContent::Delta(delta) => (None, delta.as_str()),
};
if let Some(reason) = observe_flow(
runner
.hooks
.on_event(hook_ctx, StepEvent::ToolCallDelta {
tool_call_id: &id,
internal_call_id: &internal_call_id,
tool_name: delta_name,
delta: delta_text,
})
.await,
) {
yield Err(StreamingError::Prompt(Box::new(
run.cancel_error(reason),
)));
return;
}
}
yield Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta {
id,
internal_call_id,
content,
},
));
}
StreamedTurnEvent::Completed { usage, emit_final } => {
match emit_completion_call!(usage) {
Ok(Some(item)) => yield Ok(item),
Ok(None) => {}
Err(err) => {
yield Err(err);
return;
}
}
if emit_final
&& let Some(StreamedAssistantContent::Final(final_resp)) =
item_slot.as_ref()
{
if !turn_recovered
&& let Some(reason) = observe_flow(
runner
.hooks
.on_event(hook_ctx, StepEvent::StreamResponseFinish {
prompt: ¤t_prompt,
response: final_resp,
})
.await,
)
{
yield Err(StreamingError::Prompt(Box::new(
run.cancel_error(reason),
)));
return;
}
if let Some(item) = item_slot.take() {
yield Ok(MultiTurnStreamItem::stream_item(item));
}
}
}
StreamedTurnEvent::InvalidToolCall(invalid) => {
let partial = assembler.partial_turn(stream.message_id.clone());
let action = if self.has_hooks {
let context =
run.streamed_invalid_tool_call_context(&partial, &invalid);
match flow_into_invalid(
runner
.hooks
.on_event(hook_ctx, StepEvent::InvalidToolCall(&context))
.await,
) {
InvalidDecision::Action(action) => action,
InvalidDecision::Terminate(reason) => {
yield Err(StreamingError::Prompt(Box::new(
run.cancel_error(reason),
)));
return;
}
}
} else {
InvalidToolCallHookAction::fail()
};
let resolution =
match run.resolve_streamed_invalid_tool_call(&partial, &invalid, action) {
Ok(resolution) => resolution,
Err(err) => {
yield Err(Box::new(err).into());
return;
}
};
match resolution {
StreamedResolution::Repaired { .. } => {
turn_recovered = true;
events.extend(assembler.resolve_pending_invalid(&resolution));
}
StreamedResolution::TurnAbandoned {
ref skipped_tool_result,
} => {
let skipped_tool_result = skipped_tool_result.clone();
assembler.resolve_pending_invalid(&resolution);
if let Some(err) = assembler.pending_delta_error() {
yield Err(err.into());
return;
}
let drained_usage = match drain_stream_usage(&mut stream).await {
Ok(usage) => usage,
Err(err) => {
yield Err(err);
return;
}
};
match emit_completion_call!(drained_usage) {
Ok(Some(item)) => yield Ok(item),
Ok(None) => {}
Err(err) => {
yield Err(err);
return;
}
}
if let Some(tool_result) = skipped_tool_result {
yield Ok(MultiTurnStreamItem::StreamUserItem(
StreamedUserContent::ToolResult {
tool_result,
internal_call_id: invalid.internal_call_id.clone(),
},
));
}
turn_abandoned = true;
break 'turn;
}
}
}
}
}
}
if turn_abandoned {
return;
}
if let Some(err) = assembler.pending_delta_error() {
yield Err(err.into());
return;
}
if !completion_call_emitted {
match run.record_streamed_completion_call(crate::completion::Usage::new()) {
Ok(call) => yield Ok(MultiTurnStreamItem::CompletionCall(call)),
Err(err) => {
yield Err(Box::new(err).into());
return;
}
}
}
let final_turn_content = stream.choice.clone();
if self.created_agent_span {
agent_span.record(
"gen_ai.completion",
assistant_text_from_choice(&final_turn_content),
);
}
self.last_message_id = stream.message_id.clone();
let streamed_turn = assembler.finish(stream.message_id.clone(), &final_turn_content);
let canonical_choice = streamed_turn.choice.clone();
if let Err(err) = run.streamed_turn(streamed_turn) {
yield Err(Box::new(err).into());
return;
}
if !turn_recovered
&& let Some(reason) = observe_flow(
runner
.hooks
.on_event(hook_ctx, StepEvent::ModelTurnFinished {
turn: hook_ctx.turn(),
content: &canonical_choice,
usage: last_usage,
})
.await,
)
{
yield Err(StreamingError::Prompt(Box::new(run.cancel_error(reason))));
return;
}
self.last_final_choice = final_turn_content;
})
}
fn run_tool_calls<'a>(
&'a self,
runner: &'a AgentRunner<M>,
hook_ctx: &'a HookContext,
run: &'a mut AgentRun,
calls: Vec<PendingToolCall>,
) -> DriveStream<'a, M::StreamingResponse> {
drive_tool_calls(runner, hook_ctx, run, calls, |span| span, true)
}
fn record_run_level_telemetry(
&self,
agent_span: &tracing::Span,
response: &PromptResponse,
created_agent_span: bool,
) {
if created_agent_span {
record_usage_on_span(agent_span, response.usage);
}
}
fn final_item(
&self,
response: &PromptResponse,
) -> Option<MultiTurnStreamItem<M::StreamingResponse>> {
let final_choice = finalize_streamed_choice(&self.last_final_choice, &response.output)
.unwrap_or_else(|| {
if is_empty_assistant_turn(&self.last_final_choice) {
tracing::warn!(
agent_name = self.agent_name.as_str(),
message_id = ?self.last_message_id,
"Streaming turn completed without assistant text; final response will be empty"
);
}
self.last_final_choice.clone()
});
let final_messages: Option<Vec<Message>> =
Some(response.messages.clone().unwrap_or_default());
Some(MultiTurnStreamItem::final_response_with_completion_calls(
final_choice,
response.usage,
response.completion_calls.clone(),
final_messages,
))
}
}
impl<M> AgentRunner<M>
where
M: CompletionModel + 'static,
<M as CompletionModel>::StreamingResponse: WasmCompatSend + GetTokenUsage,
{
pub async fn stream(self) -> StreamingResult<M::StreamingResponse> {
let (agent_span, created_agent_span) =
acquire_agent_span(self.agent_name_or_default(), self.preamble.as_deref());
if let Some(text) = self.prompt.rag_text() {
agent_span.record("gen_ai.prompt", text);
}
let (history_override, memory_handle) = match &self.chat_history {
Some(_) => (None, None),
None => match (&self.memory, &self.conversation_id) {
(Some(memory), Some(id)) => match memory.load(id).await {
Ok(loaded) => (Some(loaded), Some((memory.clone(), id.clone()))),
Err(err) => {
let stream = async_stream::stream! {
yield Err(StreamingError::from(err));
};
return Box::pin(stream.instrument(agent_span));
}
},
_ => (None, None),
},
};
let run = self.build_run(history_override);
let source = StreamingTurnSource::new(
&self.hooks,
self.agent_name_or_default().to_string(),
created_agent_span,
);
let driver = drive_agent(
self,
source,
run,
agent_span.clone(),
created_agent_span,
memory_handle,
true,
)
.filter_map(|item| {
std::future::ready(match item {
Ok(DriveItem::Item(item)) => Some(Ok(item)),
Ok(DriveItem::Done(_)) => None,
Err(err) => Some(Err(err)),
})
});
Box::pin(driver.instrument(agent_span))
}
}
impl<M> IntoFuture for StreamingPromptRequest<M>
where
M: CompletionModel + 'static,
<M as CompletionModel>::StreamingResponse: WasmCompatSend,
{
type Output = StreamingResult<M::StreamingResponse>; type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(async move { self.send().await })
}
}
pub async fn stream_to_stdout<R>(
stream: &mut StreamingResult<R>,
) -> Result<PromptResponse, std::io::Error> {
let mut final_res = PromptResponse::empty();
print!("Response: ");
while let Some(content) = stream.next().await {
match content {
Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
Text { text, .. },
))) => {
print!("{text}");
std::io::Write::flush(&mut std::io::stdout())?;
}
Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Reasoning(
reasoning,
))) => {
let reasoning = reasoning.display_text();
print!("{reasoning}");
std::io::Write::flush(&mut std::io::stdout())?;
}
Ok(MultiTurnStreamItem::FinalResponse(res)) => {
final_res = res;
}
Err(err) => {
eprintln!("Error: {err}");
}
_ => {}
}
}
Ok(final_res)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::AgentBuilder;
use crate::agent::hook::{AgentHook, Flow, HookContext, InvalidToolCallContext, StepEvent};
use crate::agent::prompt_request::{TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER, tool_result_output};
use crate::agent::run::streamed::merge_reasoning_blocks;
use crate::client::ProviderClient;
use crate::client::completion::CompletionClient;
use crate::completion::{CompletionRequest, PromptError, ToolDefinition, Usage};
use crate::message::{
AssistantContent, DocumentSourceKind, ImageMediaType, Message, ReasoningContent,
ToolChoice, ToolResultContent, UserContent,
};
use crate::providers::anthropic;
use crate::streaming::{StreamingPrompt, ToolCallDeltaContent};
use crate::test_utils::{
AppendFailingMemory, FailingMemory, MockAddTool, MockBarrierTool, MockCompletionModel,
MockExtensionsProbeTool, MockResponse, MockStreamEvent, MockSubtractTool, MockToolError,
SessionId,
};
use crate::tool::{Tool, ToolCallExtensions};
use futures::{StreamExt, TryStreamExt};
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::field::{Field, Visit};
use tracing::{Id, Subscriber};
use tracing_subscriber::layer::{Context, SubscriberExt};
use tracing_subscriber::{Layer, Registry, registry::LookupSpan};
#[test]
fn finalize_streamed_choice_surfaces_output_over_tool_call_and_prose() {
use crate::message::{ToolCall, ToolFunction};
let output_call = AssistantContent::ToolCall(ToolCall::new(
"c1".to_string(),
ToolFunction::new(
"final_result".to_string(),
serde_json::json!({"city": "Tokyo"}),
),
));
let with_prose = OneOrMany::many(vec![
AssistantContent::text("Sure, here is the weather:"),
output_call.clone(),
])
.expect("two items");
let final_choice = finalize_streamed_choice(&with_prose, r#"{"city":"Tokyo"}"#)
.expect("a turn with the output-tool call is finalized via it");
assert_eq!(
assistant_text_from_choice(&final_choice),
r#"{"city":"Tokyo"}"#
);
assert!(
!final_choice
.iter()
.any(|item| matches!(item, AssistantContent::ToolCall(_))),
"no unanswered tool_use should remain in the final content"
);
let only_call = OneOrMany::one(output_call);
let final_choice = finalize_streamed_choice(&only_call, r#"{"city":"Tokyo"}"#)
.expect("finalized via output tool");
assert_eq!(
assistant_text_from_choice(&final_choice),
r#"{"city":"Tokyo"}"#
);
let text_only = OneOrMany::one(AssistantContent::text(r#"{"city":"Tokyo"}"#));
assert!(finalize_streamed_choice(&text_only, r#"{"city":"Tokyo"}"#).is_none());
}
#[test]
fn merge_reasoning_blocks_preserves_order_and_signatures() {
let mut accumulated = Vec::new();
let first = crate::message::Reasoning {
id: Some("rs_1".to_string()),
content: vec![ReasoningContent::Text {
text: "step-1".to_string(),
signature: Some("sig-1".to_string()),
}],
};
let second = crate::message::Reasoning {
id: Some("rs_1".to_string()),
content: vec![
ReasoningContent::Text {
text: "step-2".to_string(),
signature: Some("sig-2".to_string()),
},
ReasoningContent::Summary("summary".to_string()),
],
};
merge_reasoning_blocks(&mut accumulated, &first);
merge_reasoning_blocks(&mut accumulated, &second);
assert_eq!(accumulated.len(), 1);
let merged = accumulated.first().expect("expected accumulated reasoning");
assert_eq!(merged.id.as_deref(), Some("rs_1"));
assert_eq!(merged.content.len(), 3);
assert!(matches!(
merged.content.first(),
Some(ReasoningContent::Text { text, signature: Some(sig) })
if text == "step-1" && sig == "sig-1"
));
assert!(matches!(
merged.content.get(1),
Some(ReasoningContent::Text { text, signature: Some(sig) })
if text == "step-2" && sig == "sig-2"
));
}
#[test]
fn merge_reasoning_blocks_keeps_distinct_ids_as_separate_items() {
let mut accumulated = vec![crate::message::Reasoning {
id: Some("rs_a".to_string()),
content: vec![ReasoningContent::Text {
text: "step-1".to_string(),
signature: None,
}],
}];
let incoming = crate::message::Reasoning {
id: Some("rs_b".to_string()),
content: vec![ReasoningContent::Text {
text: "step-2".to_string(),
signature: None,
}],
};
merge_reasoning_blocks(&mut accumulated, &incoming);
assert_eq!(accumulated.len(), 2);
assert_eq!(
accumulated.first().and_then(|r| r.id.as_deref()),
Some("rs_a")
);
assert_eq!(
accumulated.get(1).and_then(|r| r.id.as_deref()),
Some("rs_b")
);
}
#[test]
fn merge_reasoning_blocks_keeps_none_ids_separate_items() {
let mut accumulated = vec![crate::message::Reasoning {
id: None,
content: vec![ReasoningContent::Text {
text: "first".to_string(),
signature: None,
}],
}];
let incoming = crate::message::Reasoning {
id: None,
content: vec![ReasoningContent::Text {
text: "second".to_string(),
signature: None,
}],
};
merge_reasoning_blocks(&mut accumulated, &incoming);
assert_eq!(accumulated.len(), 2);
assert!(matches!(
accumulated.first(),
Some(crate::message::Reasoning {
id: None,
content
}) if matches!(
content.first(),
Some(ReasoningContent::Text { text, .. }) if text == "first"
)
));
assert!(matches!(
accumulated.get(1),
Some(crate::message::Reasoning {
id: None,
content
}) if matches!(
content.first(),
Some(ReasoningContent::Text { text, .. }) if text == "second"
)
));
}
#[test]
fn tool_result_output_preserves_multimodal_tool_output() {
let user_content = tool_result_output(
"tool_call_1".to_string(),
Some("call_1".to_string()),
serde_json::json!({
"response": {
"instruction": "Use the image part to answer."
},
"parts": [
{
"type": "image",
"data": "base64data==",
"mimeType": "image/png"
}
]
})
.to_string(),
);
let tool_result = match user_content {
UserContent::ToolResult(tool_result) => tool_result,
other => panic!("expected tool result content, got {other:?}"),
};
assert_eq!(tool_result.id, "tool_call_1");
assert_eq!(tool_result.call_id.as_deref(), Some("call_1"));
assert_eq!(tool_result.content.len(), 2);
let mut items = tool_result.content.iter();
match items.next() {
Some(ToolResultContent::Text(text)) => {
assert!(text.text.contains("Use the image part to answer."));
}
other => panic!("expected structured text payload first, got {other:?}"),
}
match items.next() {
Some(ToolResultContent::Image(image)) => {
assert_eq!(image.media_type, Some(ImageMediaType::PNG));
assert!(matches!(
image.data,
DocumentSourceKind::Base64(ref data) if data == "base64data=="
));
}
other => panic!("expected image payload second, got {other:?}"),
}
}
fn validate_follow_up_tool_history(request: &CompletionRequest) -> Result<(), String> {
let history = request.chat_history.iter().cloned().collect::<Vec<_>>();
if history.len() != 3 {
return Err(format!(
"follow-up request should contain [original user prompt, assistant tool call, user tool result]: {history:?}"
));
}
if !matches!(
history.first(),
Some(Message::User { content })
if matches!(
content.first(),
UserContent::Text(text) if text.text == "do tool work"
)
) {
return Err(format!(
"follow-up request should begin with the original user prompt: {history:?}"
));
}
if !matches!(
history.get(1),
Some(Message::Assistant { content, .. })
if matches!(
content.first(),
AssistantContent::ToolCall(tool_call)
if tool_call.id == "tool_call_1"
&& tool_call.call_id.as_deref() == Some("call_1")
)
) {
return Err(format!(
"follow-up request is missing the assistant tool call in position 2: {history:?}"
));
}
if !matches!(
history.get(2),
Some(Message::User { content })
if matches!(
content.first(),
UserContent::ToolResult(tool_result)
if tool_result.id == "tool_call_1"
&& tool_result.call_id.as_deref() == Some("call_1")
)
) {
return Err(format!(
"follow-up request should end with the user tool result: {history:?}"
));
}
Ok(())
}
fn history_contains_tool_call(history: &[Message], tool_name: &str) -> bool {
history.iter().any(|message| {
matches!(
message,
Message::Assistant { content, .. }
if content.iter().any(|item| matches!(
item,
AssistantContent::ToolCall(tool_call)
if tool_call.function.name == tool_name
))
)
})
}
fn history_contains_text(history: &[Message], expected: &str) -> bool {
history.iter().any(|message| {
matches!(
message,
Message::Assistant { content, .. }
if content.iter().any(|item| matches!(
item,
AssistantContent::Text(text) if text.text == expected
))
)
})
}
fn assistant_reasoning_precedes_tool_call(
history: &[Message],
expected_reasoning: &str,
tool_name: &str,
) -> bool {
history.iter().any(|message| {
let Message::Assistant { content, .. } = message else {
return false;
};
let reasoning_index = content.iter().position(|item| {
matches!(
item,
AssistantContent::Reasoning(reasoning)
if reasoning.content.iter().any(|content| matches!(
content,
ReasoningContent::Text { text, .. }
if text == expected_reasoning
))
)
});
let tool_index = content.iter().position(|item| {
matches!(
item,
AssistantContent::ToolCall(tool_call)
if tool_call.function.name == tool_name
)
});
matches!((reasoning_index, tool_index), (Some(reasoning), Some(tool)) if reasoning < tool)
})
}
fn assistant_reasoning_precedes_text_and_tool_call(
history: &[Message],
expected_reasoning: &str,
expected_text: &str,
tool_name: &str,
) -> bool {
history.iter().any(|message| {
let Message::Assistant { content, .. } = message else {
return false;
};
let reasoning_index = content.iter().position(|item| {
matches!(
item,
AssistantContent::Reasoning(reasoning)
if reasoning.content.iter().any(|content| matches!(
content,
ReasoningContent::Text { text, .. }
if text == expected_reasoning
))
)
});
let text_index = content.iter().position(|item| {
matches!(
item,
AssistantContent::Text(text) if text.text == expected_text
)
});
let tool_index = content.iter().position(|item| {
matches!(
item,
AssistantContent::ToolCall(tool_call)
if tool_call.function.name == tool_name
)
});
matches!(
(reasoning_index, text_index, tool_index),
(Some(reasoning), Some(text), Some(tool))
if reasoning < text && text < tool
)
})
}
#[derive(Clone)]
struct PanicOnUnknownToolHook;
impl AgentHook<MockCompletionModel> for PanicOnUnknownToolHook {
async fn on_event(
&self,
_ctx: &HookContext,
event: StepEvent<'_, MockCompletionModel>,
) -> Flow {
match event {
StepEvent::ToolCallDelta { .. } => {
panic!("unknown tool call delta should fail before delta hooks run")
}
StepEvent::ToolCall { .. } => {
panic!("unknown tool call should fail before tool hooks run")
}
StepEvent::StreamResponseFinish { .. } => {
panic!("unknown tool call should fail before stream finish hooks run")
}
_ => Flow::cont(),
}
}
}
#[derive(Clone)]
struct CountingAddTool {
calls: Arc<AtomicU32>,
}
#[derive(Clone)]
struct CountingSubtractTool {
calls: Arc<AtomicU32>,
}
#[derive(Deserialize)]
struct CountingOperationArgs {
x: i32,
y: i32,
}
fn arithmetic_tool_definition(name: &str, description: &str) -> ToolDefinition {
ToolDefinition {
name: name.to_string(),
description: description.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"x": {
"type": "number",
"description": "The first operand"
},
"y": {
"type": "number",
"description": "The second operand"
}
},
"required": ["x", "y"],
}),
}
}
impl Tool for CountingAddTool {
const NAME: &'static str = "add";
type Error = MockToolError;
type Args = CountingOperationArgs;
type Output = i32;
fn description(&self) -> String {
"Add x and y together".to_string()
}
fn parameters(&self) -> serde_json::Value {
arithmetic_tool_definition(Self::NAME, "Add x and y together").parameters
}
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(args.x + args.y)
}
}
impl Tool for CountingSubtractTool {
const NAME: &'static str = "subtract";
type Error = MockToolError;
type Args = CountingOperationArgs;
type Output = i32;
fn description(&self) -> String {
"Subtract y from x".to_string()
}
fn parameters(&self) -> serde_json::Value {
arithmetic_tool_definition(Self::NAME, "Subtract y from x").parameters
}
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(args.x - args.y)
}
}
fn streaming_tool_then_text_model() -> MockCompletionModel {
MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"add",
serde_json::json!({"x": 1, "y": 2}),
)
.with_call_id("call_1"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("done"),
MockStreamEvent::final_response_with_total_tokens(6),
],
])
}
fn usage(input_tokens: u64, output_tokens: u64) -> Usage {
Usage {
input_tokens,
output_tokens,
total_tokens: input_tokens + output_tokens,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
}
}
#[derive(Clone, Debug, Default)]
struct CapturedSpan {
id: u64,
name: String,
parent_id: Option<u64>,
fields: HashMap<String, u64>,
}
#[derive(Clone, Default)]
struct CapturedSpans(Arc<Mutex<Vec<CapturedSpan>>>);
impl CapturedSpans {
fn clear(&self) {
if let Ok(mut spans) = self.0.lock() {
spans.clear();
}
}
fn insert(&self, id: &Id, name: &str, parent_id: Option<u64>) {
let id = id.into_u64();
if let Ok(mut spans) = self.0.lock() {
spans.push(CapturedSpan {
id,
name: name.to_string(),
parent_id,
fields: HashMap::new(),
});
}
}
fn record(&self, id: &Id, fields: Vec<(String, u64)>) {
if let Ok(mut spans) = self.0.lock()
&& let Some(span) = spans.iter_mut().rev().find(|span| span.id == id.into_u64())
{
span.fields.extend(fields);
}
}
fn snapshot(&self) -> Vec<CapturedSpan> {
self.0.lock().map(|spans| spans.clone()).unwrap_or_default()
}
}
struct SpanCaptureLayer {
spans: CapturedSpans,
}
impl<S> Layer<S> for SpanCaptureLayer
where
S: Subscriber,
S: for<'lookup> LookupSpan<'lookup>,
{
fn on_new_span(&self, attrs: &tracing::span::Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
let parent_id = attrs
.parent()
.map(Id::into_u64)
.or_else(|| ctx.current_span().id().map(Id::into_u64));
self.spans.insert(id, attrs.metadata().name(), parent_id);
}
fn on_record(&self, span: &Id, values: &tracing::span::Record<'_>, _ctx: Context<'_, S>) {
let mut fields = Vec::new();
values.record(&mut SpanFieldCaptureVisitor {
fields: &mut fields,
});
self.spans.record(span, fields);
}
}
struct SpanFieldCaptureVisitor<'a> {
fields: &'a mut Vec<(String, u64)>,
}
impl Visit for SpanFieldCaptureVisitor<'_> {
fn record_u64(&mut self, field: &Field, value: u64) {
self.fields.push((field.name().to_string(), value));
}
fn record_str(&mut self, field: &Field, _value: &str) {
self.fields.push((field.name().to_string(), 0));
}
fn record_debug(&mut self, field: &Field, _value: &dyn std::fmt::Debug) {
self.fields.push((field.name().to_string(), 0));
}
}
async fn assert_stream_usage_recorded_on_chat_spans(
agent: crate::agent::Agent<MockCompletionModel>,
prompt: &str,
max_turns: usize,
expected_usages: &[Usage],
) {
let _isolation = crate::test_utils::scoped_tracing_subscriber_guard().await;
let spans = CapturedSpans::default();
let subscriber = Registry::default().with(SpanCaptureLayer {
spans: spans.clone(),
});
let _default = tracing::subscriber::set_default(subscriber);
let warmup_model = MockCompletionModel::from_stream_turns([[
MockStreamEvent::text("warmup"),
MockStreamEvent::final_response(Usage::default()),
]]);
let warmup_agent = crate::agent::AgentBuilder::new(warmup_model).build();
let mut warmup_stream = warmup_agent.stream_prompt("warmup").max_turns(1).await;
while let Some(item) = warmup_stream
.try_next()
.await
.expect("warmup stream should not error")
{
if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
break;
}
}
tracing::callsite::rebuild_interest_cache();
spans.clear();
let empty_history: &[Message] = &[];
let outer_span = tracing::info_span!("outer", gen_ai.completion = tracing::field::Empty);
async {
let mut stream = agent
.stream_prompt(prompt)
.history(empty_history)
.max_turns(max_turns)
.await;
while let Some(item) = stream.try_next().await.expect("stream should not error") {
if matches!(item, MultiTurnStreamItem::FinalResponse(_)) {
break;
}
}
}
.instrument(outer_span)
.await;
let span_snapshot = spans.snapshot();
let outer_span_id = span_snapshot
.iter()
.find(|span| span.name == "outer")
.map(|span| span.id)
.expect("outer span should be captured");
let chat_spans = span_snapshot
.iter()
.filter(|span| span.name == "chat_streaming")
.collect::<Vec<_>>();
assert_eq!(chat_spans.len(), expected_usages.len());
assert!(
span_snapshot.iter().all(|span| span.name != "invoke_agent"),
"outer span path should not create invoke_agent"
);
for (chat_span, expected_usage) in chat_spans.into_iter().zip(expected_usages) {
assert_eq!(chat_span.parent_id, Some(outer_span_id));
assert_eq!(
chat_span.fields.get("gen_ai.usage.input_tokens"),
Some(&expected_usage.input_tokens)
);
assert_eq!(
chat_span.fields.get("gen_ai.usage.output_tokens"),
Some(&expected_usage.output_tokens)
);
assert_eq!(
chat_span.fields.get("gen_ai.usage.cache_read.input_tokens"),
Some(&expected_usage.cached_input_tokens)
);
assert_eq!(
chat_span
.fields
.get("gen_ai.usage.cache_creation.input_tokens"),
Some(&expected_usage.cache_creation_input_tokens)
);
assert_eq!(
chat_span.fields.get("gen_ai.usage.tool_use_prompt_tokens"),
Some(&expected_usage.tool_use_prompt_tokens)
);
assert_eq!(
chat_span.fields.get("gen_ai.usage.reasoning_tokens"),
Some(&expected_usage.reasoning_tokens)
);
}
let outer_span = span_snapshot
.iter()
.find(|span| span.id == outer_span_id)
.expect("outer span should be present");
assert!(
outer_span
.fields
.keys()
.all(|field| !field.starts_with("gen_ai.usage.")),
"usage should not be recorded onto the caller's outer span"
);
assert!(
!outer_span.fields.contains_key("gen_ai.completion"),
"gen_ai.completion should not be recorded onto the caller's outer span \
(parity with the blocking driver)"
);
}
#[test]
fn completion_calls_stream_item_serializes_and_deserializes_expected_shape() {
let item: MultiTurnStreamItem<MockResponse> =
MultiTurnStreamItem::CompletionCall(CompletionCall::new(2, usage(3, 4)));
let value = serde_json::to_value(&item).expect("serialize completion call event");
assert_eq!(
value,
serde_json::json!({
"type": "completionCall",
"call_index": 2,
"usage": {
"input_tokens": 3,
"output_tokens": 4,
"total_tokens": 7,
"cached_input_tokens": 0,
"cache_creation_input_tokens": 0,
"tool_use_prompt_tokens": 0,
"reasoning_tokens": 0,
}
})
);
let item: MultiTurnStreamItem<MockResponse> =
serde_json::from_value(value).expect("deserialize completion call event");
match item {
MultiTurnStreamItem::CompletionCall(call_usage) => {
assert_eq!(call_usage, CompletionCall::new(2, usage(3, 4)));
}
other => panic!("expected completion call event, got {other:?}"),
}
let item: MultiTurnStreamItem<MockResponse> =
MultiTurnStreamItem::CompletionCall(CompletionCall::new(3, Usage::new()));
let value = serde_json::to_value(&item).expect("serialize missing usage event");
assert_eq!(
value,
serde_json::json!({
"type": "completionCall",
"call_index": 3,
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"cached_input_tokens": 0,
"cache_creation_input_tokens": 0,
"tool_use_prompt_tokens": 0,
"reasoning_tokens": 0,
}
})
);
let legacy: MultiTurnStreamItem<MockResponse> = serde_json::from_value(serde_json::json!({
"type": "completionCall",
"call_index": 3,
"usage": null
}))
.expect("legacy null-usage event should deserialize");
match legacy {
MultiTurnStreamItem::CompletionCall(call) => {
assert_eq!(call, CompletionCall::new(3, Usage::new()));
}
other => panic!("expected completion call event, got {other:?}"),
}
}
#[test]
fn final_response_serializes_completion_calls_with_missing_usage() {
let item: MultiTurnStreamItem<MockResponse> =
MultiTurnStreamItem::final_response_with_completion_calls(
OneOrMany::one(AssistantContent::text("done")),
usage(3, 4),
vec![
CompletionCall::new(0, Usage::new()),
CompletionCall::new(1, usage(3, 4)),
],
None,
);
if let MultiTurnStreamItem::FinalResponse(response) = &item {
assert_eq!(response.requests(), 2);
}
let value = serde_json::to_value(&item).expect("serialize final response");
assert_eq!(
value.get("completion_calls"),
Some(&serde_json::json!([
{
"call_index": 0,
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"cached_input_tokens": 0,
"cache_creation_input_tokens": 0,
"tool_use_prompt_tokens": 0,
"reasoning_tokens": 0,
}
},
{
"call_index": 1,
"usage": {
"input_tokens": 3,
"output_tokens": 4,
"total_tokens": 7,
"cached_input_tokens": 0,
"cache_creation_input_tokens": 0,
"tool_use_prompt_tokens": 0,
"reasoning_tokens": 0,
}
}
]))
);
}
fn streaming_text_then_final_model() -> MockCompletionModel {
MockCompletionModel::from_stream_turns([[
MockStreamEvent::text("hello"),
MockStreamEvent::text(" world"),
MockStreamEvent::final_response_with_total_tokens(3),
]])
}
fn citation_metadata() -> serde_json::Value {
serde_json::json!({
"citations": [{
"type": "web_search_result_location",
"cited_text": "Claude Shannon was born in 1916.",
"url": "https://example.com/shannon",
"title": "Claude Shannon",
"encrypted_index": "encrypted-reference"
}]
})
}
fn streaming_cited_text_then_final_model() -> MockCompletionModel {
MockCompletionModel::from_stream_turns([[
MockStreamEvent::text_start(Some(citation_metadata())),
MockStreamEvent::text("cited "),
MockStreamEvent::text_start(None),
MockStreamEvent::text("answer"),
MockStreamEvent::final_response_with_total_tokens(3),
]])
}
fn streaming_cited_text_then_tool_model() -> MockCompletionModel {
MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::text_start(Some(citation_metadata())),
MockStreamEvent::text("I need a tool. "),
MockStreamEvent::tool_call(
"tool_call_1",
"add",
serde_json::json!({"x": 1, "y": 2}),
)
.with_call_id("call_1"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("done"),
MockStreamEvent::final_response_with_total_tokens(6),
],
])
}
fn streaming_final_only_model() -> MockCompletionModel {
MockCompletionModel::from_stream_turns([[
MockStreamEvent::final_response_with_total_tokens(1),
]])
}
#[derive(Clone)]
struct TerminateOnStreamFinish;
impl AgentHook<MockCompletionModel> for TerminateOnStreamFinish {
async fn on_event(
&self,
_ctx: &HookContext,
event: StepEvent<'_, MockCompletionModel>,
) -> Flow {
match event {
StepEvent::StreamResponseFinish { .. } => {
Flow::terminate("stop after completion call")
}
_ => Flow::cont(),
}
}
}
type RecordedToolCallDelta = (String, String, Option<String>, String);
#[derive(Clone)]
struct RepairDefaultApiHook;
impl AgentHook<MockCompletionModel> for RepairDefaultApiHook {
async fn on_event(
&self,
_ctx: &HookContext,
event: StepEvent<'_, MockCompletionModel>,
) -> Flow {
match event {
StepEvent::InvalidToolCall(context) => {
assert_eq!(context.tool_name, "default_api");
Flow::repair("add")
}
_ => Flow::cont(),
}
}
}
#[derive(Clone)]
struct RetryDefaultApiHook;
impl AgentHook<MockCompletionModel> for RetryDefaultApiHook {
async fn on_event(
&self,
_ctx: &HookContext,
event: StepEvent<'_, MockCompletionModel>,
) -> Flow {
match event {
StepEvent::InvalidToolCall(context) => {
assert_eq!(context.tool_name, "default_api");
if let Some(args) = context.args.as_deref() {
assert!(!args.is_empty());
}
Flow::retry("Use the add tool instead")
}
_ => Flow::cont(),
}
}
}
#[derive(Clone)]
struct SkipDefaultApiHook;
impl AgentHook<MockCompletionModel> for SkipDefaultApiHook {
async fn on_event(
&self,
_ctx: &HookContext,
event: StepEvent<'_, MockCompletionModel>,
) -> Flow {
match event {
StepEvent::InvalidToolCall(context) => {
assert_eq!(context.tool_name, "default_api");
Flow::skip("default_api was skipped")
}
_ => Flow::cont(),
}
}
}
#[derive(Clone, Default)]
struct RecordingInvalidToolCallHook {
contexts: Arc<Mutex<Vec<InvalidToolCallContext>>>,
}
impl RecordingInvalidToolCallHook {
fn observed(&self) -> Vec<InvalidToolCallContext> {
self.contexts
.lock()
.expect("invalid tool context records mutex was poisoned")
.clone()
}
}
impl AgentHook<MockCompletionModel> for RecordingInvalidToolCallHook {
async fn on_event(
&self,
_ctx: &HookContext,
event: StepEvent<'_, MockCompletionModel>,
) -> Flow {
match event {
StepEvent::InvalidToolCall(context) => {
self.contexts
.lock()
.expect("invalid tool context records mutex was poisoned")
.push(context.clone());
Flow::fail()
}
_ => Flow::cont(),
}
}
}
#[derive(Clone, Default)]
struct RecordingToolCallDeltaHook {
deltas: Arc<Mutex<Vec<RecordedToolCallDelta>>>,
}
impl RecordingToolCallDeltaHook {
fn observed(&self) -> Vec<RecordedToolCallDelta> {
self.deltas
.lock()
.expect("tool call delta hook records mutex was poisoned")
.clone()
}
}
impl AgentHook<MockCompletionModel> for RecordingToolCallDeltaHook {
async fn on_event(
&self,
_ctx: &HookContext,
event: StepEvent<'_, MockCompletionModel>,
) -> Flow {
match event {
StepEvent::ToolCallDelta {
tool_call_id,
internal_call_id,
tool_name,
delta,
} => {
let record = (
tool_call_id.to_string(),
internal_call_id.to_string(),
tool_name.map(str::to_string),
delta.to_string(),
);
self.deltas
.lock()
.expect("tool call delta hook records mutex was poisoned")
.push(record);
Flow::cont()
}
_ => Flow::cont(),
}
}
}
#[derive(Clone, Default)]
struct RecordingTextDeltaHook {
deltas: Arc<Mutex<Vec<(String, String)>>>,
}
impl RecordingTextDeltaHook {
fn observed(&self) -> Vec<(String, String)> {
self.deltas
.lock()
.expect("text delta hook records mutex was poisoned")
.clone()
}
}
impl AgentHook<MockCompletionModel> for RecordingTextDeltaHook {
async fn on_event(
&self,
_ctx: &HookContext,
event: StepEvent<'_, MockCompletionModel>,
) -> Flow {
match event {
StepEvent::TextDelta { delta, aggregated } => {
let record = (delta.to_string(), aggregated.to_string());
self.deltas
.lock()
.expect("text delta hook records mutex was poisoned")
.push(record);
Flow::cont()
}
_ => Flow::cont(),
}
}
}
#[derive(Clone)]
struct RecordingTextAndSkipInvalidToolHook {
text: RecordingTextDeltaHook,
}
impl AgentHook<MockCompletionModel> for RecordingTextAndSkipInvalidToolHook {
async fn on_event(
&self,
_ctx: &HookContext,
event: StepEvent<'_, MockCompletionModel>,
) -> Flow {
match event {
event @ StepEvent::TextDelta { .. } => self.text.on_event(_ctx, event).await,
event @ StepEvent::InvalidToolCall(_) => {
SkipDefaultApiHook.on_event(_ctx, event).await
}
_ => Flow::cont(),
}
}
}
#[derive(Clone)]
struct RecordingTextAndRetryInvalidToolHook {
text: RecordingTextDeltaHook,
}
impl AgentHook<MockCompletionModel> for RecordingTextAndRetryInvalidToolHook {
async fn on_event(
&self,
_ctx: &HookContext,
event: StepEvent<'_, MockCompletionModel>,
) -> Flow {
match event {
event @ StepEvent::TextDelta { .. } => self.text.on_event(_ctx, event).await,
event @ StepEvent::InvalidToolCall(_) => {
RetryDefaultApiHook.on_event(_ctx, event).await
}
_ => Flow::cont(),
}
}
}
#[derive(Clone)]
struct RecordingDeltaAndRetryInvalidToolHook {
delta: RecordingToolCallDeltaHook,
}
impl AgentHook<MockCompletionModel> for RecordingDeltaAndRetryInvalidToolHook {
async fn on_event(
&self,
_ctx: &HookContext,
event: StepEvent<'_, MockCompletionModel>,
) -> Flow {
match event {
event @ StepEvent::ToolCallDelta { .. } => self.delta.on_event(_ctx, event).await,
event @ StepEvent::InvalidToolCall(_) => {
RetryDefaultApiHook.on_event(_ctx, event).await
}
_ => Flow::cont(),
}
}
}
#[derive(Clone)]
struct RecordingDeltaAndSkipInvalidToolHook {
delta: RecordingToolCallDeltaHook,
}
impl AgentHook<MockCompletionModel> for RecordingDeltaAndSkipInvalidToolHook {
async fn on_event(
&self,
_ctx: &HookContext,
event: StepEvent<'_, MockCompletionModel>,
) -> Flow {
match event {
event @ StepEvent::ToolCallDelta { .. } => self.delta.on_event(_ctx, event).await,
event @ StepEvent::InvalidToolCall(_) => {
SkipDefaultApiHook.on_event(_ctx, event).await
}
_ => Flow::cont(),
}
}
}
#[derive(Clone, Default)]
struct TerminatingToolCallDeltaHook {
deltas: Arc<Mutex<Vec<RecordedToolCallDelta>>>,
}
impl TerminatingToolCallDeltaHook {
fn observed(&self) -> Vec<RecordedToolCallDelta> {
self.deltas
.lock()
.expect("tool call delta hook records mutex was poisoned")
.clone()
}
}
impl AgentHook<MockCompletionModel> for TerminatingToolCallDeltaHook {
async fn on_event(
&self,
_ctx: &HookContext,
event: StepEvent<'_, MockCompletionModel>,
) -> Flow {
match event {
StepEvent::ToolCallDelta {
tool_call_id,
internal_call_id,
tool_name,
delta,
} => {
let record = (
tool_call_id.to_string(),
internal_call_id.to_string(),
tool_name.map(str::to_string),
delta.to_string(),
);
self.deltas
.lock()
.expect("tool call delta hook records mutex was poisoned")
.push(record);
Flow::terminate("stop on tool call delta")
}
_ => Flow::cont(),
}
}
}
fn text_metadata(content: &OneOrMany<AssistantContent>) -> Option<&serde_json::Value> {
content.iter().find_map(|item| match item {
AssistantContent::Text(text) => text.additional_params.as_ref(),
_ => None,
})
}
#[tokio::test]
async fn stream_prompt_continues_after_tool_call_turn() {
let model = streaming_tool_then_text_model();
let recorded = model.clone();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let empty_history: &[Message] = &[];
let mut stream = agent
.stream_prompt("do tool work")
.history(empty_history)
.max_turns(3)
.await;
let mut saw_tool_call = false;
let mut saw_tool_result = false;
let mut saw_final_response = false;
let mut final_text = String::new();
let mut final_response_text = None;
let mut final_history = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCall { .. },
)) => {
saw_tool_call = true;
}
Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
..
})) => {
saw_tool_result = true;
}
Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
text,
))) => {
final_text.push_str(&text.text);
}
Ok(MultiTurnStreamItem::FinalResponse(res)) => {
saw_final_response = true;
final_response_text = Some(res.output().to_owned());
final_history = res.messages().map(|history| history.to_vec());
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
assert!(saw_tool_call);
assert!(saw_tool_result);
assert!(saw_final_response);
assert_eq!(final_text, "done");
assert_eq!(final_response_text.as_deref(), Some("done"));
let history = final_history.expect("expected final response history");
assert!(history.iter().any(|message| matches!(
message,
Message::Assistant { content, .. }
if content.iter().any(|item| matches!(
item,
AssistantContent::Text(text) if text.text == "done"
))
)));
let requests = recorded.requests();
assert_eq!(requests.len(), 2);
assert!(validate_follow_up_tool_history(&requests[1]).is_ok());
}
#[tokio::test]
async fn streaming_prompt_request_tool_concurrency_runs_tools_concurrently() {
let barrier = Arc::new(tokio::sync::Barrier::new(2));
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call("b1", "barrier_tool", serde_json::json!({})),
MockStreamEvent::tool_call("b2", "barrier_tool", serde_json::json!({})),
MockStreamEvent::final_response_with_total_tokens(0),
],
vec![
MockStreamEvent::text("done"),
MockStreamEvent::final_response_with_total_tokens(0),
],
]);
let agent = AgentBuilder::new(model)
.tool(MockBarrierTool::new(barrier))
.build();
let drive = async {
let mut stream = agent
.stream_prompt("hit the barrier twice")
.max_turns(3)
.tool_concurrency(2)
.await;
while let Some(item) = stream.next().await {
item.unwrap_or_else(|err| panic!("unexpected streaming error: {err:?}"));
}
};
tokio::time::timeout(Duration::from_secs(5), drive)
.await
.expect("streamed tools must run concurrently, not deadlock at the barrier");
}
#[tokio::test]
async fn tool_extensions_reach_tool_through_streaming_loop() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call("tool_call_1", "context_probe", serde_json::json!({}))
.with_call_id("call_1"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("done"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let probe = MockExtensionsProbeTool::default();
let agent = AgentBuilder::new(model).tool(probe.clone()).build();
let empty_history: &[Message] = &[];
let mut extensions = ToolCallExtensions::new();
extensions.insert(SessionId("xyz-789".to_string()));
let mut stream = agent
.stream_prompt("do tool work")
.tool_extensions(extensions)
.history(empty_history)
.max_turns(3)
.await;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
Err(err) => panic!("unexpected streaming error: {err:?}"),
Ok(_) => {}
}
}
assert_eq!(probe.observed().as_deref(), Some("session:xyz-789"));
}
#[tokio::test]
async fn streaming_tool_runs_with_empty_context_when_none_supplied() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call("tool_call_1", "context_probe", serde_json::json!({}))
.with_call_id("call_1"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("done"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let probe = MockExtensionsProbeTool::default();
let agent = AgentBuilder::new(model).tool(probe.clone()).build();
let empty_history: &[Message] = &[];
let mut stream = agent
.stream_prompt("do tool work")
.history(empty_history)
.max_turns(3)
.await;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
Err(err) => panic!("unexpected streaming error: {err:?}"),
Ok(_) => {}
}
}
assert_eq!(probe.observed().as_deref(), Some("no-session"));
}
#[tokio::test]
async fn unknown_tool_call_fails_before_streaming_second_request() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"default_api",
serde_json::json!({"x": 1, "y": 2}),
),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(PanicOnUnknownToolHook)
.max_turns(3)
.await;
let mut saw_tool_call = false;
let mut error = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCall { .. },
)) => {
saw_tool_call = true;
}
Ok(_) => {}
Err(err) => {
error = Some(err);
break;
}
}
}
assert!(!saw_tool_call);
let error = error.expect("unknown model-emitted tool should fail");
match error {
StreamingError::Prompt(err) => match *err {
PromptError::UnknownToolCall {
tool_name,
available_tools,
allowed_tools,
chat_history,
} => {
assert_eq!(tool_name, "default_api");
assert_eq!(available_tools, vec!["add".to_string()]);
assert_eq!(allowed_tools, vec!["add".to_string()]);
assert!(history_contains_tool_call(&chat_history, "default_api"));
}
other => panic!("expected UnknownToolCall, got {other:?}"),
},
other => panic!("expected prompt streaming error, got {other:?}"),
}
assert_eq!(recorded.request_count(), 1);
}
#[tokio::test]
async fn invalid_tool_call_hook_can_repair_streaming_tool_name() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"default_api",
serde_json::json!({"x": 2, "y": 3}),
),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("done"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(RepairDefaultApiHook)
.max_turns(3)
.history(Vec::<Message>::new())
.await;
let mut saw_repaired_tool_call = false;
let mut saw_tool_result = false;
let mut final_response_text = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCall { tool_call, .. },
)) => {
assert_eq!(tool_call.function.name, "add");
saw_repaired_tool_call = true;
}
Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
tool_result,
..
})) => {
assert!(tool_result.content.iter().any(|content| {
matches!(
content,
ToolResultContent::Text(text) if text.text == "5"
)
}));
saw_tool_result = true;
}
Ok(MultiTurnStreamItem::FinalResponse(response)) => {
final_response_text = Some(response.output().to_string());
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
assert!(saw_repaired_tool_call);
assert!(saw_tool_result);
assert_eq!(final_response_text.as_deref(), Some("done"));
assert_eq!(recorded.request_count(), 2);
}
#[tokio::test]
async fn invalid_tool_call_context_uses_completed_streaming_tool_call_provider_id() {
let invalid_hook = RecordingInvalidToolCallHook::default();
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"default_api",
serde_json::json!({"x": 2, "y": 3}),
)
.with_call_id("provider_call_1"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(invalid_hook.clone())
.max_turns(3)
.await;
let mut error = None;
while let Some(item) = stream.next().await {
if let Err(err) = item {
error = Some(err);
break;
}
}
assert!(error.is_some(), "invalid tool should fail");
assert_eq!(recorded.request_count(), 1);
let contexts = invalid_hook.observed();
assert_eq!(contexts.len(), 1);
let context = &contexts[0];
assert_eq!(context.tool_name, "default_api");
assert_eq!(context.tool_call_id.as_deref(), Some("tool_call_1"));
assert!(context.internal_call_id.is_some());
assert!(context.is_streaming);
}
#[tokio::test]
async fn invalid_tool_call_hook_skip_emits_streaming_tool_result() {
let add_calls = Arc::new(AtomicU32::new(0));
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"default_api",
serde_json::json!({"x": 2, "y": 3}),
)
.with_call_id("call_1"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("continued"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model)
.tool(CountingAddTool {
calls: add_calls.clone(),
})
.build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(SkipDefaultApiHook)
.max_turns(3)
.history(Vec::<Message>::new())
.await;
let mut skipped_tool_result = None;
let mut final_response_text = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
tool_result,
internal_call_id,
})) => {
assert!(!internal_call_id.is_empty());
skipped_tool_result = Some(tool_result);
}
Ok(MultiTurnStreamItem::FinalResponse(response)) => {
final_response_text = Some(response.output().to_string());
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
let skipped_tool_result =
skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
assert_eq!(skipped_tool_result.id, "tool_call_1");
assert_eq!(skipped_tool_result.call_id.as_deref(), Some("call_1"));
assert!(skipped_tool_result.content.iter().any(|content| matches!(
content,
ToolResultContent::Text(text) if text.text == "default_api was skipped"
)));
assert_eq!(final_response_text.as_deref(), Some("continued"));
assert_eq!(add_calls.load(Ordering::SeqCst), 0);
let requests = recorded.requests();
assert_eq!(requests.len(), 2);
let follow_up_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
assert!(matches!(
follow_up_history.get(2),
Some(Message::User { content })
if content.iter().any(|item| matches!(
item,
UserContent::ToolResult(result)
if result.id == "tool_call_1"
&& result.content.iter().any(|content| matches!(
content,
ToolResultContent::Text(text)
if text.text == "default_api was skipped"
))
))
));
}
#[tokio::test]
async fn invalid_tool_call_hook_retries_mixed_streaming_turn_without_executing_valid_call() {
let add_calls = Arc::new(AtomicU32::new(0));
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::text("checking "),
MockStreamEvent::tool_call(
"tool_call_1",
"add",
serde_json::json!({"x": 2, "y": 3}),
)
.with_call_id("call_1"),
MockStreamEvent::tool_call(
"tool_call_2",
"default_api",
serde_json::json!({"x": 4, "y": 5}),
)
.with_call_id("call_2"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("retried"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model)
.tool(CountingAddTool {
calls: add_calls.clone(),
})
.build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(RetryDefaultApiHook)
.max_turns(3)
.history(Vec::<Message>::new())
.max_invalid_tool_call_retries(1)
.await;
let mut completion_call_events = Vec::new();
let mut final_response_text = None;
let mut final_response_usage = Usage::new();
let mut final_completion_calls = Vec::new();
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
completion_call_events.push(completion_call);
}
Ok(MultiTurnStreamItem::FinalResponse(response)) => {
final_response_text = Some(response.output().to_string());
final_response_usage = response.usage();
final_completion_calls = response.completion_calls().to_vec();
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
assert_eq!(final_response_text.as_deref(), Some("retried"));
assert_eq!(add_calls.load(Ordering::SeqCst), 0);
let mut first_usage = Usage::new();
first_usage.total_tokens = 4;
let mut second_usage = Usage::new();
second_usage.total_tokens = 6;
let expected_completion_calls = vec![
CompletionCall::new(0, first_usage),
CompletionCall::new(1, second_usage),
];
assert_eq!(completion_call_events, expected_completion_calls);
assert_eq!(final_completion_calls, expected_completion_calls);
assert_eq!(final_response_usage.total_tokens, 10);
let requests = recorded.requests();
assert_eq!(requests.len(), 2);
let retry_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
assert_eq!(retry_history.len(), 3);
assert!(matches!(
retry_history.get(1),
Some(Message::Assistant { content, .. })
if content.iter().any(|item| matches!(
item,
AssistantContent::Text(text) if text.text == "checking "
))
&& content.iter().any(|item| matches!(
item,
AssistantContent::ToolCall(tool_call)
if tool_call.id == "tool_call_1"
&& tool_call.function.name == "add"
))
&& content.iter().any(|item| matches!(
item,
AssistantContent::ToolCall(tool_call)
if tool_call.id == "tool_call_2"
&& tool_call.function.name == "default_api"
))
));
assert!(matches!(
retry_history.get(2),
Some(Message::User { content })
if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
&& content.iter().any(|item| matches!(
item,
UserContent::ToolResult(result)
if result.id == "tool_call_1"
&& result.content.iter().any(|content| matches!(
content,
ToolResultContent::Text(text)
if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
))
))
&& content.iter().any(|item| matches!(
item,
UserContent::ToolResult(result)
if result.id == "tool_call_2"
&& result.content.iter().any(|content| matches!(
content,
ToolResultContent::Text(text)
if text.text == "Use the add tool instead"
))
))
));
}
#[tokio::test]
async fn invalid_tool_call_hook_skips_mixed_streaming_turn_without_executing_valid_call() {
let add_calls = Arc::new(AtomicU32::new(0));
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::text("checking "),
MockStreamEvent::tool_call(
"tool_call_1",
"add",
serde_json::json!({"x": 2, "y": 3}),
)
.with_call_id("call_1"),
MockStreamEvent::tool_call(
"tool_call_2",
"default_api",
serde_json::json!({"x": 4, "y": 5}),
)
.with_call_id("call_2"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("continued"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model)
.tool(CountingAddTool {
calls: add_calls.clone(),
})
.build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(SkipDefaultApiHook)
.max_turns(3)
.history(Vec::<Message>::new())
.await;
let mut skipped_tool_result = None;
let mut final_response_text = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
tool_result,
..
})) => {
skipped_tool_result = Some(tool_result);
}
Ok(MultiTurnStreamItem::FinalResponse(response)) => {
final_response_text = Some(response.output().to_string());
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
let skipped_tool_result =
skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
assert_eq!(skipped_tool_result.id, "tool_call_2");
assert_eq!(skipped_tool_result.call_id.as_deref(), Some("call_2"));
assert_eq!(final_response_text.as_deref(), Some("continued"));
assert_eq!(add_calls.load(Ordering::SeqCst), 0);
let requests = recorded.requests();
assert_eq!(requests.len(), 2);
let follow_up_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
assert_eq!(follow_up_history.len(), 3);
assert!(matches!(
follow_up_history.get(1),
Some(Message::Assistant { content, .. })
if content.iter().any(|item| matches!(
item,
AssistantContent::Text(text) if text.text == "checking "
))
&& content.iter().any(|item| matches!(
item,
AssistantContent::ToolCall(tool_call)
if tool_call.id == "tool_call_1"
&& tool_call.function.name == "add"
))
&& content.iter().any(|item| matches!(
item,
AssistantContent::ToolCall(tool_call)
if tool_call.id == "tool_call_2"
&& tool_call.function.name == "default_api"
))
));
assert!(matches!(
follow_up_history.get(2),
Some(Message::User { content })
if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
&& content.iter().any(|item| matches!(
item,
UserContent::ToolResult(result)
if result.id == "tool_call_1"
&& result.call_id.as_deref() == Some("call_1")
&& result.content.iter().any(|content| matches!(
content,
ToolResultContent::Text(text)
if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
))
))
&& content.iter().any(|item| matches!(
item,
UserContent::ToolResult(result)
if result.id == "tool_call_2"
&& result.call_id.as_deref() == Some("call_2")
&& result.content.iter().any(|content| matches!(
content,
ToolResultContent::Text(text)
if text.text == "default_api was skipped"
))
))
));
}
#[tokio::test]
async fn invalid_completed_tool_call_skip_preserves_streaming_reasoning_history() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::text("checking "),
MockStreamEvent::reasoning("reasoned step").with_reasoning_id("rs_1"),
MockStreamEvent::tool_call(
"tool_call_1",
"default_api",
serde_json::json!({"x": 2, "y": 3}),
),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("continued"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(SkipDefaultApiHook)
.max_turns(3)
.history(Vec::<Message>::new())
.await;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
let requests = recorded.requests();
assert_eq!(requests.len(), 2);
let follow_up_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
assert!(history_contains_text(&follow_up_history, "checking "));
assert!(assistant_reasoning_precedes_tool_call(
&follow_up_history,
"reasoned step",
"default_api"
));
assert!(
assistant_reasoning_precedes_text_and_tool_call(
&follow_up_history,
"reasoned step",
"checking ",
"default_api"
),
"{follow_up_history:?}"
);
}
#[tokio::test]
async fn invalid_name_delta_retry_preserves_streaming_reasoning_history() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::reasoning_delta(Some("rs_1"), "delta reason"),
MockStreamEvent::tool_call_arguments_delta(
"tool_call_1",
"internal_1",
r#"{"x":2,"y":3}"#,
),
MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("retried"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(RetryDefaultApiHook)
.max_turns(3)
.history(Vec::<Message>::new())
.max_invalid_tool_call_retries(1)
.await;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
let requests = recorded.requests();
assert_eq!(requests.len(), 2);
let retry_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
assert!(assistant_reasoning_precedes_tool_call(
&retry_history,
"delta reason",
"default_api"
));
}
#[tokio::test]
async fn invalid_tool_call_hook_skip_resets_streaming_text_delta_state() {
let text_hook = RecordingTextDeltaHook::default();
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::text("stale "),
MockStreamEvent::tool_call(
"tool_call_1",
"default_api",
serde_json::json!({"x": 2, "y": 3}),
),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("fresh"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(RecordingTextAndSkipInvalidToolHook {
text: text_hook.clone(),
})
.max_turns(3)
.history(Vec::<Message>::new())
.await;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
assert_eq!(
text_hook.observed(),
vec![
("stale ".to_string(), "stale ".to_string()),
("fresh".to_string(), "fresh".to_string()),
]
);
}
#[tokio::test]
async fn invalid_tool_call_delta_retry_uses_structured_tool_feedback() {
let delta_hook = RecordingToolCallDeltaHook::default();
let add_calls = Arc::new(AtomicU32::new(0));
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::text("checking "),
MockStreamEvent::reasoning_delta(Some("rs_1"), "diagnostic reason"),
MockStreamEvent::tool_call(
"tool_call_0",
"add",
serde_json::json!({"x": 1, "y": 2}),
)
.with_call_id("call_0"),
MockStreamEvent::tool_call_arguments_delta(
"tool_call_1",
"internal_1",
r#"{"x":2,"y":3}"#,
),
MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("retried"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model)
.tool(CountingAddTool {
calls: add_calls.clone(),
})
.build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(RecordingDeltaAndRetryInvalidToolHook {
delta: delta_hook.clone(),
})
.max_turns(3)
.history(Vec::<Message>::new())
.max_invalid_tool_call_retries(1)
.await;
let mut completion_call_events = Vec::new();
let mut final_response_text = None;
let mut final_response_usage = Usage::new();
let mut final_completion_calls = Vec::new();
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
completion_call_events.push(completion_call);
}
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta { .. },
)) => panic!("invalid tool-call delta should not be emitted"),
Ok(MultiTurnStreamItem::FinalResponse(response)) => {
final_response_text = Some(response.output().to_string());
final_response_usage = response.usage();
final_completion_calls = response.completion_calls().to_vec();
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
assert_eq!(final_response_text.as_deref(), Some("retried"));
assert!(delta_hook.observed().is_empty());
assert_eq!(add_calls.load(Ordering::SeqCst), 0);
let mut first_usage = Usage::new();
first_usage.total_tokens = 4;
let mut second_usage = Usage::new();
second_usage.total_tokens = 6;
let expected_completion_calls = vec![
CompletionCall::new(0, first_usage),
CompletionCall::new(1, second_usage),
];
assert_eq!(completion_call_events, expected_completion_calls);
assert_eq!(final_completion_calls, expected_completion_calls);
assert_eq!(final_response_usage.total_tokens, 10);
let requests = recorded.requests();
assert_eq!(requests.len(), 2);
let retry_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
assert!(matches!(
retry_history.get(1),
Some(Message::Assistant { content, .. })
if content.iter().any(|item| matches!(
item,
AssistantContent::Text(text) if text.text == "checking "
))
&& content.iter().any(|item| matches!(
item,
AssistantContent::ToolCall(tool_call)
if tool_call.id == "tool_call_0"
&& tool_call.function.name == "add"
))
&& content.iter().any(|item| matches!(
item,
AssistantContent::ToolCall(tool_call)
if tool_call.id == "tool_call_1"
&& tool_call.function.name == "default_api"
&& tool_call.function.arguments == serde_json::json!({"x": 2, "y": 3})
))
));
assert!(matches!(
retry_history.get(2),
Some(Message::User { content })
if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
&& content.iter().any(|item| matches!(
item,
UserContent::ToolResult(result)
if result.id == "tool_call_0"
&& result.call_id.as_deref() == Some("call_0")
&& result.content.iter().any(|content| matches!(
content,
ToolResultContent::Text(text)
if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
))
))
&& content.iter().any(|item| matches!(
item,
UserContent::ToolResult(result)
if result.id == "tool_call_1"
&& result.content.iter().any(|content| matches!(
content,
ToolResultContent::Text(text)
if text.text == "Use the add tool instead"
))
))
));
}
#[tokio::test]
async fn invalid_tool_call_delta_context_includes_same_turn_history_and_tool_call_id() {
let invalid_hook = RecordingInvalidToolCallHook::default();
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::text("checking "),
MockStreamEvent::reasoning_delta(Some("rs_1"), "diagnostic reason"),
MockStreamEvent::tool_call(
"tool_call_0",
"add",
serde_json::json!({"x": 1, "y": 2}),
)
.with_call_id("call_0"),
MockStreamEvent::tool_call_arguments_delta(
"tool_call_1",
"internal_1",
r#"{"x":2,"y":3}"#,
),
MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(invalid_hook.clone())
.max_turns(3)
.await;
let mut error = None;
while let Some(item) = stream.next().await {
if let Err(err) = item {
error = Some(err);
break;
}
}
assert!(error.is_some(), "invalid name delta should fail");
assert_eq!(recorded.request_count(), 1);
let contexts = invalid_hook.observed();
assert_eq!(contexts.len(), 1);
let context = &contexts[0];
assert_eq!(context.tool_name, "default_api");
assert_eq!(context.tool_call_id.as_deref(), Some("tool_call_1"));
assert_eq!(context.internal_call_id.as_deref(), Some("internal_1"));
assert!(context.is_streaming);
assert!(history_contains_text(&context.chat_history, "checking "));
assert!(
assistant_reasoning_precedes_tool_call(
&context.chat_history,
"diagnostic reason",
"add"
),
"{:?}",
context.chat_history
);
assert!(history_contains_tool_call(&context.chat_history, "add"));
assert!(history_contains_tool_call(
&context.chat_history,
"default_api"
));
}
#[tokio::test]
async fn invalid_tool_call_delta_retry_resets_streaming_text_delta_state() {
let text_hook = RecordingTextDeltaHook::default();
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::text("stale "),
MockStreamEvent::tool_call_arguments_delta(
"tool_call_1",
"internal_1",
r#"{"x":2,"y":3}"#,
),
MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("fresh"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(RecordingTextAndRetryInvalidToolHook {
text: text_hook.clone(),
})
.max_turns(3)
.history(Vec::<Message>::new())
.max_invalid_tool_call_retries(1)
.await;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
assert_eq!(
text_hook.observed(),
vec![
("stale ".to_string(), "stale ".to_string()),
("fresh".to_string(), "fresh".to_string()),
]
);
}
#[tokio::test]
async fn invalid_tool_call_delta_skip_uses_structured_tool_feedback() {
let delta_hook = RecordingToolCallDeltaHook::default();
let add_calls = Arc::new(AtomicU32::new(0));
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::text("checking "),
MockStreamEvent::tool_call(
"tool_call_0",
"add",
serde_json::json!({"x": 1, "y": 2}),
)
.with_call_id("call_0"),
MockStreamEvent::tool_call_arguments_delta(
"tool_call_1",
"internal_1",
r#"{"x":2,"y":3}"#,
),
MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("continued"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model)
.tool(CountingAddTool {
calls: add_calls.clone(),
})
.build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(RecordingDeltaAndSkipInvalidToolHook {
delta: delta_hook.clone(),
})
.max_turns(3)
.history(Vec::<Message>::new())
.await;
let mut skipped_tool_result = None;
let mut final_response_text = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta { .. },
)) => panic!("invalid tool-call delta should not be emitted"),
Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
tool_result,
internal_call_id,
})) => {
assert_eq!(internal_call_id, "internal_1");
skipped_tool_result = Some(tool_result);
}
Ok(MultiTurnStreamItem::FinalResponse(response)) => {
final_response_text = Some(response.output().to_string());
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
let skipped_tool_result =
skipped_tool_result.expect("skip recovery should emit a synthetic tool result");
assert_eq!(skipped_tool_result.id, "tool_call_1");
assert!(skipped_tool_result.call_id.is_none());
assert!(skipped_tool_result.content.iter().any(|content| matches!(
content,
ToolResultContent::Text(text) if text.text == "default_api was skipped"
)));
assert_eq!(final_response_text.as_deref(), Some("continued"));
assert!(delta_hook.observed().is_empty());
assert_eq!(add_calls.load(Ordering::SeqCst), 0);
let requests = recorded.requests();
assert_eq!(requests.len(), 2);
let follow_up_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
assert!(matches!(
follow_up_history.get(1),
Some(Message::Assistant { content, .. })
if content.iter().any(|item| matches!(
item,
AssistantContent::Text(text) if text.text == "checking "
))
&& content.iter().any(|item| matches!(
item,
AssistantContent::ToolCall(tool_call)
if tool_call.id == "tool_call_0"
&& tool_call.function.name == "add"
))
&& content.iter().any(|item| matches!(
item,
AssistantContent::ToolCall(tool_call)
if tool_call.id == "tool_call_1"
&& tool_call.function.name == "default_api"
&& tool_call.function.arguments == serde_json::json!({"x": 2, "y": 3})
))
));
assert!(matches!(
follow_up_history.get(2),
Some(Message::User { content })
if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
&& content.iter().any(|item| matches!(
item,
UserContent::ToolResult(result)
if result.id == "tool_call_0"
&& result.call_id.as_deref() == Some("call_0")
&& result.content.iter().any(|content| matches!(
content,
ToolResultContent::Text(text)
if text.text == TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
))
))
&& content.iter().any(|item| matches!(
item,
UserContent::ToolResult(result)
if result.id == "tool_call_1"
&& result.content.iter().any(|content| matches!(
content,
ToolResultContent::Text(text)
if text.text == "default_api was skipped"
))
))
));
}
#[tokio::test]
async fn streaming_retry_budget_exhaustion_history_contains_invalid_tool_call() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"default_api",
serde_json::json!({"x": 1, "y": 2}),
),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(RetryDefaultApiHook)
.max_turns(3)
.max_invalid_tool_call_retries(0)
.await;
let mut error = None;
while let Some(item) = stream.next().await {
if let Err(err) = item {
error = Some(err);
break;
}
}
let error = error.expect("retry budget exhaustion should fail");
match error {
StreamingError::Prompt(err) => match *err {
PromptError::UnknownToolCall {
tool_name,
chat_history,
..
} => {
assert_eq!(tool_name, "default_api");
assert!(history_contains_tool_call(&chat_history, "default_api"));
}
other => panic!("expected UnknownToolCall, got {other:?}"),
},
other => panic!("expected prompt streaming error, got {other:?}"),
}
assert_eq!(recorded.request_count(), 1);
}
#[tokio::test]
async fn streaming_name_delta_retry_budget_exhaustion_history_includes_same_turn_context() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::text("checking "),
MockStreamEvent::tool_call(
"tool_call_0",
"add",
serde_json::json!({"x": 1, "y": 2}),
)
.with_call_id("call_0"),
MockStreamEvent::tool_call_arguments_delta(
"tool_call_1",
"internal_1",
r#"{"x":2,"y":3}"#,
),
MockStreamEvent::tool_call_name_delta("tool_call_1", "internal_1", "default_api"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(RetryDefaultApiHook)
.max_turns(3)
.max_invalid_tool_call_retries(0)
.await;
let mut error = None;
while let Some(item) = stream.next().await {
if let Err(err) = item {
error = Some(err);
break;
}
}
let error = error.expect("retry budget exhaustion should fail");
match error {
StreamingError::Prompt(err) => match *err {
PromptError::UnknownToolCall {
tool_name,
chat_history,
..
} => {
assert_eq!(tool_name, "default_api");
assert!(history_contains_text(&chat_history, "checking "));
assert!(history_contains_tool_call(&chat_history, "add"));
assert!(history_contains_tool_call(&chat_history, "default_api"));
}
other => panic!("expected UnknownToolCall, got {other:?}"),
},
other => panic!("expected prompt streaming error, got {other:?}"),
}
assert_eq!(recorded.request_count(), 1);
}
#[tokio::test]
async fn completed_unknown_tool_call_after_text_fails_before_finish_hook_or_later_emit() {
let add_calls = Arc::new(AtomicU32::new(0));
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::text("thinking "),
MockStreamEvent::tool_call(
"tool_call_1",
"default_api",
serde_json::json!({"x": 1, "y": 2}),
),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model)
.tool(CountingAddTool {
calls: add_calls.clone(),
})
.build();
let mut stream = agent
.stream_prompt("use the tool")
.add_hook(PanicOnUnknownToolHook)
.max_turns(3)
.await;
let mut saw_text = false;
let mut saw_completion_call = false;
let mut saw_final_response = false;
let mut saw_tool_call = false;
let mut saw_tool_result = false;
let mut error = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(_))) => {
saw_text = true;
}
Ok(MultiTurnStreamItem::CompletionCall(_)) => {
saw_completion_call = true;
}
Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Final(
_,
)))
| Ok(MultiTurnStreamItem::FinalResponse(_)) => {
saw_final_response = true;
}
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCall { .. },
)) => {
saw_tool_call = true;
}
Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
..
})) => {
saw_tool_result = true;
}
Ok(_) => {}
Err(err) => {
error = Some(err);
break;
}
}
}
assert!(saw_text);
assert!(!saw_completion_call);
assert!(!saw_final_response);
assert!(!saw_tool_call);
assert!(!saw_tool_result);
assert_eq!(add_calls.load(Ordering::SeqCst), 0);
let error = error.expect("completed unknown tool call should fail immediately");
match error {
StreamingError::Prompt(err) => match *err {
PromptError::UnknownToolCall {
tool_name,
available_tools,
allowed_tools,
chat_history,
} => {
assert_eq!(tool_name, "default_api");
assert_eq!(available_tools, vec!["add".to_string()]);
assert_eq!(allowed_tools, vec!["add".to_string()]);
assert!(history_contains_tool_call(&chat_history, "default_api"));
}
other => panic!("expected UnknownToolCall, got {other:?}"),
},
other => panic!("expected prompt streaming error, got {other:?}"),
}
assert_eq!(recorded.request_count(), 1);
}
#[tokio::test]
async fn mixed_streaming_tool_calls_fail_before_any_tool_execution() {
let add_calls = Arc::new(AtomicU32::new(0));
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"add",
serde_json::json!({"x": 1, "y": 2}),
)
.with_call_id("call_1"),
MockStreamEvent::tool_call(
"tool_call_2",
"default_api",
serde_json::json!({"x": 3, "y": 4}),
),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model)
.tool(CountingAddTool {
calls: add_calls.clone(),
})
.build();
let mut stream = agent
.stream_prompt("use tools")
.add_hook(PanicOnUnknownToolHook)
.max_turns(3)
.await;
let mut saw_completion_call = false;
let mut saw_tool_call = false;
let mut saw_tool_result = false;
let mut error = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::CompletionCall(_)) => {
saw_completion_call = true;
}
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCall { .. },
)) => {
saw_tool_call = true;
}
Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
..
})) => {
saw_tool_result = true;
}
Ok(_) => {}
Err(err) => {
error = Some(err);
break;
}
}
}
assert!(!saw_completion_call);
assert!(!saw_tool_call);
assert!(!saw_tool_result);
assert_eq!(add_calls.load(Ordering::SeqCst), 0);
let error = error.expect("mixed unknown streamed tool call should fail");
match error {
StreamingError::Prompt(err) => match *err {
PromptError::UnknownToolCall {
tool_name,
available_tools,
allowed_tools,
chat_history,
} => {
assert_eq!(tool_name, "default_api");
assert_eq!(available_tools, vec!["add".to_string()]);
assert_eq!(allowed_tools, vec!["add".to_string()]);
assert!(history_contains_tool_call(&chat_history, "default_api"));
}
other => panic!("expected UnknownToolCall, got {other:?}"),
},
other => panic!("expected prompt streaming error, got {other:?}"),
}
assert_eq!(recorded.request_count(), 1);
}
#[tokio::test]
async fn multiple_valid_streaming_tool_calls_execute_after_batch_validation() {
let add_calls = Arc::new(AtomicU32::new(0));
let subtract_calls = Arc::new(AtomicU32::new(0));
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"add",
serde_json::json!({"x": 1, "y": 2}),
)
.with_call_id("call_1"),
MockStreamEvent::tool_call(
"tool_call_2",
"subtract",
serde_json::json!({"x": 8, "y": 3}),
)
.with_call_id("call_2"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("done"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model)
.tool(CountingAddTool {
calls: add_calls.clone(),
})
.tool(CountingSubtractTool {
calls: subtract_calls.clone(),
})
.build();
let mut stream = agent.stream_prompt("use tools").max_turns(3).await;
let mut tool_call_names = Vec::new();
let mut tool_result_ids = Vec::new();
let mut final_response_text = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCall { tool_call, .. },
)) => {
tool_call_names.push(tool_call.function.name);
}
Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
tool_result,
..
})) => {
tool_result_ids.push(tool_result.id);
}
Ok(MultiTurnStreamItem::FinalResponse(response)) => {
final_response_text = Some(response.output().to_owned());
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
assert_eq!(
tool_call_names,
vec!["add".to_string(), "subtract".to_string()]
);
assert_eq!(
tool_result_ids,
vec!["tool_call_1".to_string(), "tool_call_2".to_string()]
);
assert_eq!(add_calls.load(Ordering::SeqCst), 1);
assert_eq!(subtract_calls.load(Ordering::SeqCst), 1);
assert_eq!(final_response_text.as_deref(), Some("done"));
assert_eq!(recorded.request_count(), 2);
}
#[tokio::test]
async fn disallowed_specific_tool_call_fails_before_streaming_second_request() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"subtract",
serde_json::json!({"x": 3, "y": 1}),
),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model)
.tool(MockAddTool)
.tool(MockSubtractTool)
.tool_choice(ToolChoice::Specific {
function_names: vec!["add".to_string()],
})
.build();
let mut stream = agent
.stream_prompt("use the allowed tool")
.add_hook(PanicOnUnknownToolHook)
.max_turns(3)
.await;
let mut saw_tool_call = false;
let mut error = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCall { .. },
)) => {
saw_tool_call = true;
}
Ok(_) => {}
Err(err) => {
error = Some(err);
break;
}
}
}
assert!(!saw_tool_call);
let error = error.expect("disallowed model-emitted tool should fail");
match error {
StreamingError::Prompt(err) => match *err {
PromptError::UnknownToolCall {
tool_name,
available_tools,
allowed_tools,
chat_history,
} => {
assert_eq!(tool_name, "subtract");
assert_eq!(
available_tools,
vec!["add".to_string(), "subtract".to_string()]
);
assert_eq!(allowed_tools, vec!["add".to_string()]);
assert!(history_contains_tool_call(&chat_history, "subtract"));
}
other => panic!("expected UnknownToolCall, got {other:?}"),
},
other => panic!("expected prompt streaming error, got {other:?}"),
}
assert_eq!(recorded.request_count(), 1);
}
#[tokio::test]
async fn mixed_specific_tool_calls_fail_before_any_tool_execution() {
let add_calls = Arc::new(AtomicU32::new(0));
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"add",
serde_json::json!({"x": 1, "y": 2}),
),
MockStreamEvent::tool_call(
"tool_call_2",
"subtract",
serde_json::json!({"x": 3, "y": 1}),
),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model)
.tool(CountingAddTool {
calls: add_calls.clone(),
})
.tool(MockSubtractTool)
.tool_choice(ToolChoice::Specific {
function_names: vec!["add".to_string()],
})
.build();
let mut stream = agent
.stream_prompt("use the allowed tool")
.add_hook(PanicOnUnknownToolHook)
.max_turns(3)
.await;
let mut saw_tool_call = false;
let mut saw_tool_result = false;
let mut error = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCall { .. },
)) => {
saw_tool_call = true;
}
Ok(MultiTurnStreamItem::StreamUserItem(StreamedUserContent::ToolResult {
..
})) => {
saw_tool_result = true;
}
Ok(_) => {}
Err(err) => {
error = Some(err);
break;
}
}
}
assert!(!saw_tool_call);
assert!(!saw_tool_result);
assert_eq!(add_calls.load(Ordering::SeqCst), 0);
let error = error.expect("mixed disallowed streamed tool call should fail");
match error {
StreamingError::Prompt(err) => match *err {
PromptError::UnknownToolCall {
tool_name,
available_tools,
allowed_tools,
chat_history,
} => {
assert_eq!(tool_name, "subtract");
assert_eq!(
available_tools,
vec!["add".to_string(), "subtract".to_string()]
);
assert_eq!(allowed_tools, vec!["add".to_string()]);
assert!(history_contains_tool_call(&chat_history, "subtract"));
}
other => panic!("expected UnknownToolCall, got {other:?}"),
},
other => panic!("expected prompt streaming error, got {other:?}"),
}
assert_eq!(recorded.request_count(), 1);
}
#[tokio::test]
async fn tool_choice_none_rejects_streaming_tool_call() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"add",
serde_json::json!({"x": 1, "y": 2}),
),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model)
.tool(MockAddTool)
.tool_choice(ToolChoice::None)
.build();
let mut stream = agent
.stream_prompt("do not use tools")
.add_hook(PanicOnUnknownToolHook)
.max_turns(3)
.await;
let mut saw_tool_call = false;
let mut error = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCall { .. },
)) => {
saw_tool_call = true;
}
Ok(_) => {}
Err(err) => {
error = Some(err);
break;
}
}
}
assert!(!saw_tool_call);
let error = error.expect("ToolChoice::None should reject returned tool calls");
match error {
StreamingError::Prompt(err) => match *err {
PromptError::UnknownToolCall {
tool_name,
available_tools,
allowed_tools,
chat_history,
} => {
assert_eq!(tool_name, "add");
assert_eq!(available_tools, vec!["add".to_string()]);
assert!(allowed_tools.is_empty());
assert!(history_contains_tool_call(&chat_history, "add"));
}
other => panic!("expected UnknownToolCall, got {other:?}"),
},
other => panic!("expected prompt streaming error, got {other:?}"),
}
assert_eq!(recorded.request_count(), 1);
}
#[tokio::test]
async fn tool_choice_none_rejects_streaming_tool_call_name_delta_before_hook_or_emit() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model)
.tool(MockAddTool)
.tool_choice(ToolChoice::None)
.build();
let mut stream = agent
.stream_prompt("do not use tools")
.add_hook(PanicOnUnknownToolHook)
.max_turns(3)
.await;
let mut saw_delta = false;
let mut error = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta { .. },
)) => {
saw_delta = true;
}
Ok(_) => {}
Err(err) => {
error = Some(err);
break;
}
}
}
assert!(!saw_delta);
let error = error.expect("ToolChoice::None should reject returned tool-call deltas");
match error {
StreamingError::Prompt(err) => match *err {
PromptError::UnknownToolCall {
tool_name,
available_tools,
allowed_tools,
chat_history,
} => {
assert_eq!(tool_name, "add");
assert_eq!(available_tools, vec!["add".to_string()]);
assert!(allowed_tools.is_empty());
assert!(history_contains_tool_call(&chat_history, "add"));
}
other => panic!("expected UnknownToolCall, got {other:?}"),
},
other => panic!("expected prompt streaming error, got {other:?}"),
}
assert_eq!(recorded.request_count(), 1);
}
#[tokio::test]
async fn unknown_tool_call_name_delta_fails_before_streaming_delta_hook_or_emit() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "default_api"),
MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("stream a bad tool call")
.add_hook(PanicOnUnknownToolHook)
.max_turns(3)
.await;
let mut saw_delta = false;
let mut error = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta { .. },
)) => {
saw_delta = true;
}
Ok(_) => {}
Err(err) => {
error = Some(err);
break;
}
}
}
assert!(!saw_delta);
let error = error.expect("unknown tool-call name delta should fail");
match error {
StreamingError::Prompt(err) => match *err {
PromptError::UnknownToolCall {
tool_name,
available_tools,
allowed_tools,
chat_history,
} => {
assert_eq!(tool_name, "default_api");
assert_eq!(available_tools, vec!["add".to_string()]);
assert_eq!(allowed_tools, vec!["add".to_string()]);
assert!(history_contains_tool_call(&chat_history, "default_api"));
}
other => panic!("expected UnknownToolCall, got {other:?}"),
},
other => panic!("expected prompt streaming error, got {other:?}"),
}
assert_eq!(recorded.request_count(), 1);
}
#[tokio::test]
async fn tool_call_args_delta_before_unknown_name_fails_before_hook_or_emit() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "default_api"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("stream a bad tool call")
.add_hook(PanicOnUnknownToolHook)
.max_turns(3)
.await;
let mut saw_delta = false;
let mut error = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta { .. },
)) => {
saw_delta = true;
}
Ok(_) => {}
Err(err) => {
error = Some(err);
break;
}
}
}
assert!(!saw_delta);
let error = error.expect("unknown tool-call name should reject buffered args");
match error {
StreamingError::Prompt(err) => match *err {
PromptError::UnknownToolCall {
tool_name,
available_tools,
allowed_tools,
chat_history,
} => {
assert_eq!(tool_name, "default_api");
assert_eq!(available_tools, vec!["add".to_string()]);
assert_eq!(allowed_tools, vec!["add".to_string()]);
assert!(history_contains_tool_call(&chat_history, "default_api"));
}
other => panic!("expected UnknownToolCall, got {other:?}"),
},
other => panic!("expected prompt streaming error, got {other:?}"),
}
assert_eq!(recorded.request_count(), 1);
}
#[tokio::test]
async fn tool_call_args_delta_before_valid_name_buffers_then_emits_in_safe_order() {
let model = MockCompletionModel::from_stream_turns([[
MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":"),
MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "1}"),
MockStreamEvent::final_response_with_total_tokens(3),
]]);
let hook = RecordingToolCallDeltaHook::default();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("stream a tool call")
.add_hook(hook.clone())
.await;
let mut stream_deltas = Vec::new();
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta {
id,
internal_call_id,
content,
},
)) => {
stream_deltas.push((id, internal_call_id, content));
}
Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
assert_eq!(
hook.observed(),
vec![
(
"tool_1".to_string(),
"internal_1".to_string(),
Some("add".to_string()),
String::new()
),
(
"tool_1".to_string(),
"internal_1".to_string(),
None,
"{\"x\":".to_string()
),
(
"tool_1".to_string(),
"internal_1".to_string(),
None,
"1}".to_string()
),
]
);
assert_eq!(
stream_deltas,
vec![
(
"tool_1".to_string(),
"internal_1".to_string(),
ToolCallDeltaContent::Name("add".to_string())
),
(
"tool_1".to_string(),
"internal_1".to_string(),
ToolCallDeltaContent::Delta("{\"x\":".to_string())
),
(
"tool_1".to_string(),
"internal_1".to_string(),
ToolCallDeltaContent::Delta("1}".to_string())
),
]
);
}
#[tokio::test]
async fn tool_call_args_delta_without_name_errors_at_stream_end() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("stream an incomplete tool call")
.add_hook(PanicOnUnknownToolHook)
.max_turns(3)
.await;
let mut saw_delta = false;
let mut saw_completion_call = false;
let mut saw_final_response = false;
let mut error = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta { .. },
)) => {
saw_delta = true;
}
Ok(MultiTurnStreamItem::CompletionCall(_)) => {
saw_completion_call = true;
}
Ok(MultiTurnStreamItem::FinalResponse(_)) => {
saw_final_response = true;
}
Ok(_) => {}
Err(err) => {
error = Some(err);
break;
}
}
}
assert!(!saw_delta);
assert!(!saw_completion_call);
assert!(!saw_final_response);
let error = error.expect("unterminated tool-call args delta should fail");
match error {
StreamingError::Completion(CompletionError::ResponseError(message)) => {
assert!(
message.contains("streamed tool call arguments"),
"{message}"
);
assert!(message.contains("tool_1"), "{message}");
assert!(message.contains("internal_1"), "{message}");
}
other => panic!("expected completion response error, got {other:?}"),
}
assert_eq!(recorded.request_count(), 1);
}
#[tokio::test]
async fn tool_choice_none_buffers_args_then_rejects_name_without_emit() {
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":1}"),
MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
MockStreamEvent::final_response_with_total_tokens(4),
],
vec![
MockStreamEvent::text("should not be requested"),
MockStreamEvent::final_response_with_total_tokens(6),
],
]);
let recorded = model.clone();
let agent = AgentBuilder::new(model)
.tool(MockAddTool)
.tool_choice(ToolChoice::None)
.build();
let mut stream = agent
.stream_prompt("do not use tools")
.add_hook(PanicOnUnknownToolHook)
.max_turns(3)
.await;
let mut saw_delta = false;
let mut error = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta { .. },
)) => {
saw_delta = true;
}
Ok(_) => {}
Err(err) => {
error = Some(err);
break;
}
}
}
assert!(!saw_delta);
let error = error.expect("ToolChoice::None should reject buffered tool-call deltas");
match error {
StreamingError::Prompt(err) => match *err {
PromptError::UnknownToolCall {
tool_name,
available_tools,
allowed_tools,
chat_history,
} => {
assert_eq!(tool_name, "add");
assert_eq!(available_tools, vec!["add".to_string()]);
assert!(allowed_tools.is_empty());
assert!(history_contains_tool_call(&chat_history, "add"));
}
other => panic!("expected UnknownToolCall, got {other:?}"),
},
other => panic!("expected prompt streaming error, got {other:?}"),
}
assert_eq!(recorded.request_count(), 1);
}
#[tokio::test]
async fn stream_prompt_emits_tool_call_deltas_without_hook() {
let model = MockCompletionModel::from_stream_turns([[
MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":"),
MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "1}"),
MockStreamEvent::final_response_with_total_tokens(3),
]]);
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent.stream_prompt("stream a tool call").await;
let mut deltas = Vec::new();
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta {
id,
internal_call_id,
content,
},
)) => {
deltas.push((id, internal_call_id, content));
}
Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
assert_eq!(
deltas,
vec![
(
"tool_1".to_string(),
"internal_1".to_string(),
ToolCallDeltaContent::Name("add".to_string())
),
(
"tool_1".to_string(),
"internal_1".to_string(),
ToolCallDeltaContent::Delta("{\"x\":".to_string())
),
(
"tool_1".to_string(),
"internal_1".to_string(),
ToolCallDeltaContent::Delta("1}".to_string())
),
]
);
}
#[tokio::test]
async fn stream_prompt_emits_tool_call_deltas_after_hook_continue() {
let model = MockCompletionModel::from_stream_turns([[
MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":"),
MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "1}"),
MockStreamEvent::final_response_with_total_tokens(3),
]]);
let hook = RecordingToolCallDeltaHook::default();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("stream a tool call")
.add_hook(hook.clone())
.await;
let mut stream_deltas = Vec::new();
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta {
id,
internal_call_id,
content,
},
)) => {
stream_deltas.push((id, internal_call_id, content));
}
Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
assert_eq!(
hook.observed(),
vec![
(
"tool_1".to_string(),
"internal_1".to_string(),
Some("add".to_string()),
String::new()
),
(
"tool_1".to_string(),
"internal_1".to_string(),
None,
"{\"x\":".to_string()
),
(
"tool_1".to_string(),
"internal_1".to_string(),
None,
"1}".to_string()
),
]
);
assert_eq!(
stream_deltas,
vec![
(
"tool_1".to_string(),
"internal_1".to_string(),
ToolCallDeltaContent::Name("add".to_string())
),
(
"tool_1".to_string(),
"internal_1".to_string(),
ToolCallDeltaContent::Delta("{\"x\":".to_string())
),
(
"tool_1".to_string(),
"internal_1".to_string(),
ToolCallDeltaContent::Delta("1}".to_string())
),
]
);
}
#[tokio::test]
async fn stream_prompt_tool_call_deltas_hook_termination_prevents_delta_emit() {
let model = MockCompletionModel::from_stream_turns([[
MockStreamEvent::tool_call_name_delta("tool_1", "internal_1", "add"),
MockStreamEvent::tool_call_arguments_delta("tool_1", "internal_1", "{\"x\":"),
MockStreamEvent::final_response_with_total_tokens(3),
]]);
let hook = TerminatingToolCallDeltaHook::default();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let mut stream = agent
.stream_prompt("stream a tool call")
.add_hook(hook.clone())
.await;
let mut saw_delta = false;
let mut saw_final_response = false;
let mut error_message = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(
StreamedAssistantContent::ToolCallDelta { .. },
)) => {
saw_delta = true;
}
Ok(MultiTurnStreamItem::FinalResponse(_)) => {
saw_final_response = true;
}
Ok(_) => {}
Err(err) => {
error_message = Some(err.to_string());
break;
}
}
}
assert_eq!(
hook.observed(),
vec![(
"tool_1".to_string(),
"internal_1".to_string(),
Some("add".to_string()),
String::new()
)]
);
assert!(!saw_delta);
assert!(!saw_final_response);
assert!(
error_message
.as_deref()
.is_some_and(|message| message.contains("PromptCancelled: stop on tool call delta")),
"expected hook termination error, got {error_message:?}"
);
}
#[tokio::test]
async fn stream_prompt_exposes_completion_calls() {
let first_call_usage = usage(10, 2);
let second_call_usage = usage(25, 5);
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"add",
serde_json::json!({"x": 1, "y": 2}),
)
.with_call_id("call_1"),
MockStreamEvent::final_response(first_call_usage),
],
vec![
MockStreamEvent::text("done"),
MockStreamEvent::final_response(second_call_usage),
],
]);
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let empty_history: &[Message] = &[];
let mut stream = agent
.stream_prompt("do tool work")
.history(empty_history)
.max_turns(3)
.await;
let mut completion_calls_events = Vec::new();
let mut final_response = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::CompletionCall(call_usage)) => {
completion_calls_events.push(call_usage);
}
Ok(MultiTurnStreamItem::FinalResponse(response)) => {
final_response = Some(response);
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
assert_eq!(
completion_calls_events,
vec![
CompletionCall::new(0, first_call_usage),
CompletionCall::new(1, second_call_usage)
]
);
let final_response = final_response.expect("expected final response");
assert_eq!(
final_response.usage(),
Usage {
input_tokens: 35,
output_tokens: 7,
total_tokens: 42,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
}
);
assert_eq!(
final_response.completion_calls(),
&[
CompletionCall::new(0, first_call_usage),
CompletionCall::new(1, second_call_usage)
]
);
}
#[tokio::test(flavor = "current_thread")]
async fn stream_prompt_records_single_call_usage_on_chat_span_under_outer_span() {
let call_usage = usage(10, 2);
let model = MockCompletionModel::from_stream_turns([[
MockStreamEvent::text("done"),
MockStreamEvent::final_response(call_usage),
]]);
let agent = AgentBuilder::new(model).build();
assert_stream_usage_recorded_on_chat_spans(agent, "say done", 1, &[call_usage]).await;
}
#[tokio::test(flavor = "current_thread")]
async fn stream_prompt_records_multi_turn_usage_on_chat_spans_under_outer_span() {
let first_call_usage = usage(10, 2);
let second_call_usage = usage(25, 5);
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"add",
serde_json::json!({"x": 1, "y": 2}),
)
.with_call_id("call_1"),
MockStreamEvent::final_response(first_call_usage),
],
vec![
MockStreamEvent::text("done"),
MockStreamEvent::final_response(second_call_usage),
],
]);
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
assert_stream_usage_recorded_on_chat_spans(
agent,
"do tool work",
3,
&[first_call_usage, second_call_usage],
)
.await;
}
#[tokio::test]
async fn stream_prompt_emits_completion_call_before_finish_hook_termination() {
let call_usage = usage(10, 2);
let model = MockCompletionModel::from_stream_turns([[
MockStreamEvent::text("done"),
MockStreamEvent::final_response(call_usage),
]]);
let agent = AgentBuilder::new(model).build();
let mut stream = agent
.stream_prompt("say done")
.add_hook(TerminateOnStreamFinish)
.await;
let mut completion_calls = Vec::new();
let mut saw_error = false;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::CompletionCall(completion_call)) => {
completion_calls.push(completion_call);
}
Ok(MultiTurnStreamItem::FinalResponse(response)) => {
panic!("unexpected final response after hook termination: {response:?}");
}
Ok(_) => {}
Err(_) => {
saw_error = true;
break;
}
}
}
assert_eq!(completion_calls, vec![CompletionCall::new(0, call_usage)]);
assert!(saw_error);
}
#[tokio::test]
async fn stream_prompt_completion_calls_records_unreported_usage() {
let second_call_usage = usage(25, 5);
let model = MockCompletionModel::from_stream_turns([
vec![
MockStreamEvent::tool_call(
"tool_call_1",
"add",
serde_json::json!({"x": 1, "y": 2}),
)
.with_call_id("call_1"),
],
vec![
MockStreamEvent::text("done"),
MockStreamEvent::final_response(second_call_usage),
],
]);
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let empty_history: &[Message] = &[];
let mut stream = agent
.stream_prompt("do tool work")
.history(empty_history)
.max_turns(3)
.await;
let mut completion_calls_events = Vec::new();
let mut final_response = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::CompletionCall(call_usage)) => {
completion_calls_events.push(call_usage);
}
Ok(MultiTurnStreamItem::FinalResponse(response)) => {
final_response = Some(response);
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
let expected_usage = vec![
CompletionCall::new(0, Usage::new()),
CompletionCall::new(1, second_call_usage),
];
assert_eq!(completion_calls_events, expected_usage);
let final_response = final_response.expect("expected final response");
assert_eq!(final_response.completion_calls(), expected_usage.as_slice());
}
#[tokio::test]
async fn final_response_matches_streamed_text_when_provider_final_is_textless() {
let agent = AgentBuilder::new(streaming_text_then_final_model()).build();
let mut stream = agent.stream_prompt("say hello").await;
let mut streamed_text = String::new();
let mut final_response_text = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
text,
))) => streamed_text.push_str(&text.text),
Ok(MultiTurnStreamItem::FinalResponse(res)) => {
final_response_text = Some(res.output().to_owned());
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
assert_eq!(streamed_text, "hello world");
assert_eq!(final_response_text.as_deref(), Some("hello world"));
}
#[tokio::test]
async fn final_response_preserves_structured_text_metadata() {
let agent = AgentBuilder::new(streaming_cited_text_then_final_model()).build();
let mut stream = agent.stream_prompt("answer with citations").await;
let mut final_response = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::FinalResponse(res)) => {
final_response = Some(res);
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
let final_response = final_response.expect("expected final response");
assert_eq!(final_response.output(), "cited answer");
let metadata = text_metadata(final_response.content())
.expect("expected text metadata in final content");
assert_eq!(
metadata["citations"][0]["encrypted_index"],
"encrypted-reference"
);
}
#[tokio::test]
async fn final_response_history_preserves_structured_text_metadata() {
let agent = AgentBuilder::new(streaming_cited_text_then_final_model()).build();
let empty_history: &[Message] = &[];
let mut stream = agent
.stream_prompt("answer with citations")
.history(empty_history)
.await;
let mut final_response = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::FinalResponse(res)) => {
final_response = Some(res);
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
let final_response = final_response.expect("expected final response");
let history = final_response
.messages()
.expect("with_history should include final history");
let assistant_content = history
.iter()
.find_map(|message| match message {
Message::Assistant { content, .. } => Some(content),
_ => None,
})
.expect("expected assistant message in history");
let metadata =
text_metadata(assistant_content).expect("expected text metadata in assistant history");
assert_eq!(
metadata["citations"][0]["encrypted_index"],
"encrypted-reference"
);
}
#[tokio::test]
async fn tool_follow_up_history_preserves_structured_text_metadata() {
let model = streaming_cited_text_then_tool_model();
let recorded = model.clone();
let agent = AgentBuilder::new(model).tool(MockAddTool).build();
let empty_history: &[Message] = &[];
let mut stream = agent
.stream_prompt("use a tool with citations")
.history(empty_history)
.max_turns(3)
.await;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
let requests = recorded.requests();
assert_eq!(requests.len(), 2);
let follow_up_history = requests[1].chat_history.iter().collect::<Vec<_>>();
let assistant_content = follow_up_history
.iter()
.find_map(|message| match message {
Message::Assistant { content, .. } => Some(content),
_ => None,
})
.expect("expected assistant message in follow-up history");
let metadata = text_metadata(assistant_content)
.expect("expected citation metadata in follow-up assistant history");
assert_eq!(
metadata["citations"][0]["encrypted_index"],
"encrypted-reference"
);
}
#[tokio::test]
async fn final_response_can_remain_empty_for_truly_textless_turns() {
let agent = AgentBuilder::new(streaming_final_only_model()).build();
let mut stream = agent.stream_prompt("say nothing").await;
let mut streamed_text = String::new();
let mut final_response_text = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
text,
))) => streamed_text.push_str(&text.text),
Ok(MultiTurnStreamItem::FinalResponse(res)) => {
final_response_text = Some(res.output().to_owned());
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
assert!(streamed_text.is_empty());
assert_eq!(final_response_text.as_deref(), Some(""));
}
async fn background_logger(stop: Arc<AtomicBool>, leak_count: Arc<AtomicU32>) {
let mut interval = tokio::time::interval(Duration::from_millis(50));
let mut count = 0u32;
while !stop.load(Ordering::Relaxed) {
interval.tick().await;
count += 1;
tracing::event!(
target: "background_logger",
tracing::Level::INFO,
count = count,
"Background tick"
);
let current = tracing::Span::current();
if !current.is_disabled() && !current.is_none() {
leak_count.fetch_add(1, Ordering::Relaxed);
}
}
tracing::info!(target: "background_logger", total_ticks = count, "Background logger stopped");
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "This requires an API key"]
async fn test_span_context_isolation() -> anyhow::Result<()> {
let stop = Arc::new(AtomicBool::new(false));
let leak_count = Arc::new(AtomicU32::new(0));
let bg_stop = stop.clone();
let bg_leak = leak_count.clone();
let bg_handle = tokio::spawn(async move {
background_logger(bg_stop, bg_leak).await;
});
tokio::time::sleep(Duration::from_millis(100)).await;
let client = anthropic::Client::from_env()?;
let agent = client
.agent(anthropic::completion::CLAUDE_HAIKU_4_5)
.preamble("You are a helpful assistant.")
.temperature(0.1)
.max_tokens(100)
.build();
let mut stream = agent
.stream_prompt("Say 'hello world' and nothing else.")
.await;
let mut full_content = String::new();
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
text,
))) => {
full_content.push_str(&text.text);
}
Ok(MultiTurnStreamItem::FinalResponse(_)) => {
break;
}
Err(e) => {
tracing::warn!("Error: {:?}", e);
break;
}
_ => {}
}
}
tracing::info!("Got response: {:?}", full_content);
stop.store(true, Ordering::Relaxed);
bg_handle.await?;
let leaks = leak_count.load(Ordering::Relaxed);
anyhow::ensure!(
leaks == 0,
"SPAN LEAK DETECTED: Background logger was inside unexpected spans {leaks} times. \
This indicates that span.enter() is being used inside async_stream instead of .instrument()"
);
Ok(())
}
#[tokio::test]
#[ignore = "This requires an API key"]
async fn test_chat_history_in_final_response() -> anyhow::Result<()> {
use crate::message::Message;
let client = anthropic::Client::from_env()?;
let agent = client
.agent(anthropic::completion::CLAUDE_HAIKU_4_5)
.preamble("You are a helpful assistant. Keep responses brief.")
.temperature(0.1)
.max_tokens(50)
.build();
let empty_history: &[Message] = &[];
let mut stream = agent
.stream_prompt("Say 'hello' and nothing else.")
.history(empty_history)
.await;
let mut response_text = String::new();
let mut final_history = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(
text,
))) => {
response_text.push_str(&text.text);
}
Ok(MultiTurnStreamItem::FinalResponse(res)) => {
final_history = res.messages().map(|h| h.to_vec());
break;
}
Err(e) => {
return Err(e.into());
}
_ => {}
}
}
let history = final_history
.ok_or_else(|| anyhow::anyhow!("final response should include history"))?;
anyhow::ensure!(
history.iter().any(|m| matches!(m, Message::User { .. })),
"History should contain the user message"
);
anyhow::ensure!(
history
.iter()
.any(|m| matches!(m, Message::Assistant { .. })),
"History should contain the assistant response"
);
tracing::info!(
"History after streaming: {} messages, response: {:?}",
history.len(),
response_text
);
Ok(())
}
#[tokio::test]
async fn streaming_appends_to_memory_after_final_response() {
use crate::memory::{ConversationMemory, InMemoryConversationMemory};
let memory = InMemoryConversationMemory::new();
let agent = AgentBuilder::new(streaming_text_then_final_model())
.memory(memory.clone())
.build();
let mut stream = agent
.stream_prompt("hi there")
.conversation("stream-thread")
.await;
let mut history_in_final = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::FinalResponse(res)) => {
history_in_final = res.messages().map(|h| h.to_vec());
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
let final_history = history_in_final
.expect("PromptResponse.messages should be populated when memory is configured");
assert_eq!(
final_history.len(),
2,
"user prompt + assistant response in final history: {final_history:?}"
);
let stored = memory.load("stream-thread").await.unwrap();
assert_eq!(stored.len(), 2, "memory should contain user + assistant");
}
#[tokio::test]
async fn streaming_reasoning_without_tools_does_not_duplicate_final_history() {
let agent = AgentBuilder::new(MockCompletionModel::from_stream_turns([[
MockStreamEvent::text("final answer"),
MockStreamEvent::reasoning("reasoned step").with_reasoning_id("rs_1"),
MockStreamEvent::final_response_with_total_tokens(3),
]]))
.build();
let mut stream = agent
.stream_prompt("think before answering")
.history(Vec::<Message>::new())
.await;
let mut history_in_final = None;
while let Some(item) = stream.next().await {
match item {
Ok(MultiTurnStreamItem::FinalResponse(res)) => {
history_in_final = res.messages().map(|h| h.to_vec());
break;
}
Ok(_) => {}
Err(err) => panic!("unexpected streaming error: {err:?}"),
}
}
let final_history = history_in_final
.expect("PromptResponse.messages should be populated when with_history is used");
assert_eq!(
final_history.len(),
2,
"user prompt + one assistant response in final history: {final_history:?}"
);
assert!(matches!(
final_history.first(),
Some(Message::User { content })
if matches!(
content.first(),
UserContent::Text(text) if text.text == "think before answering"
)
));
let assistant_messages = final_history
.iter()
.filter_map(|message| match message {
Message::Assistant { content, .. } => Some(content),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
assistant_messages.len(),
1,
"reasoning turn should produce exactly one assistant history message: {final_history:?}"
);
let assistant_content = assistant_messages
.first()
.expect("expected assistant history message");
assert!(assistant_content.iter().any(|item| matches!(
item,
AssistantContent::Text(text) if text.text == "final answer"
)));
assert!(assistant_content.iter().any(|item| matches!(
item,
AssistantContent::Reasoning(reasoning)
if reasoning.id.as_deref() == Some("rs_1")
&& reasoning.content.iter().any(|content| matches!(
content,
ReasoningContent::Text { text, .. } if text == "reasoned step"
))
)));
let reasoning_index = assistant_content
.iter()
.position(|item| matches!(item, AssistantContent::Reasoning(_)))
.expect("assistant history should contain reasoning");
let text_index = assistant_content
.iter()
.position(|item| matches!(item, AssistantContent::Text(_)))
.expect("assistant history should contain text");
assert!(
reasoning_index < text_index,
"assistant reasoning must be stored before assistant text: {assistant_content:?}"
);
}
#[tokio::test]
async fn streaming_with_history_overrides_memory() {
use crate::memory::{ConversationMemory, InMemoryConversationMemory};
let memory = InMemoryConversationMemory::new();
memory
.append("t1", vec![Message::user("from-memory")])
.await
.unwrap();
let agent = AgentBuilder::new(streaming_text_then_final_model())
.memory(memory.clone())
.build();
let mut stream = agent
.stream_prompt("hi")
.conversation("t1")
.history(vec![Message::user("from-caller")])
.await;
while let Some(item) = stream.next().await {
if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
break;
}
}
let stored = memory.load("t1").await.unwrap();
assert_eq!(
stored.len(),
1,
"with_history bypasses memory; only the pre-seeded entry remains: {stored:?}"
);
}
#[tokio::test]
async fn streaming_without_memory_disables_for_request() {
use crate::memory::{ConversationMemory, InMemoryConversationMemory};
let memory = InMemoryConversationMemory::new();
let agent = AgentBuilder::new(streaming_text_then_final_model())
.memory(memory.clone())
.conversation("default")
.build();
let mut stream = agent.stream_prompt("hi").without_memory().await;
while let Some(item) = stream.next().await {
if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
break;
}
}
let stored = memory.load("default").await.unwrap();
assert!(stored.is_empty(), "without_memory disables save");
}
#[tokio::test]
async fn streaming_load_error_yields_memory_error() {
let agent = AgentBuilder::new(streaming_text_then_final_model())
.memory(FailingMemory::default())
.build();
let mut stream = agent.stream_prompt("hi").conversation("t1").await;
let first = stream.next().await.expect("at least one item");
match first {
Err(err) => {
let msg = format!("{err:?}");
assert!(
msg.contains("Memory") || msg.contains("memory") || msg.contains("load boom"),
"expected memory error, got: {msg}"
);
}
Ok(other) => panic!("expected memory error, got {other:?}"),
}
}
#[tokio::test]
async fn streaming_with_filter_shapes_loaded_history() {
use crate::memory::{ConversationMemory, InMemoryConversationMemory};
let memory = InMemoryConversationMemory::new()
.with_filter(|msgs: Vec<Message>| msgs.into_iter().rev().take(2).rev().collect());
memory
.append(
"t1",
vec![
Message::user("1"),
Message::assistant("2"),
Message::user("3"),
Message::assistant("4"),
],
)
.await
.unwrap();
let model = MockCompletionModel::from_stream_turns([[
MockStreamEvent::text("ok"),
MockStreamEvent::final_response_with_total_tokens(1),
]]);
let recorded = model.clone();
let agent = AgentBuilder::new(model).memory(memory).build();
let mut stream = agent.stream_prompt("ping").conversation("t1").await;
while let Some(item) = stream.next().await {
if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
break;
}
}
let received = recorded.requests()[0]
.chat_history
.iter()
.cloned()
.collect::<Vec<_>>();
assert_eq!(
received.len(),
3,
"window-truncated history (2) + current prompt: {received:?}"
);
}
#[tokio::test]
async fn streaming_append_error_does_not_suppress_final_response() {
let agent = AgentBuilder::new(streaming_text_then_final_model())
.memory(AppendFailingMemory::default())
.build();
let mut stream = agent.stream_prompt("hi").conversation("t1").await;
let mut saw_final = false;
while let Some(item) = stream.next().await {
if let Ok(MultiTurnStreamItem::FinalResponse(_)) = item {
saw_final = true;
break;
}
}
assert!(
saw_final,
"FinalResponse must be yielded even when memory.append fails"
);
}
}