use std::sync::Arc;
use futures::future;
use uuid::Uuid;
use crate::core::CancelToken;
use crate::generative_model::{
self, Content, ContentDelta, GenerateError, GenerateOutput, GenerativeModel, Message,
MessagePart, TokenUsage, ToolResult, ToolUse, TurnEndReason, answer_content,
};
use crate::harness::Harness;
pub fn uuid_simple_hex(id: Uuid) -> String {
id.as_simple().to_string()
}
#[derive(Debug, Clone)]
pub struct TraceContext {
pub agent_id: Uuid,
pub depth: usize,
pub parent_tool_use_id: Option<String>,
}
impl Default for TraceContext {
fn default() -> Self {
Self {
agent_id: Uuid::nil(),
depth: 0,
parent_tool_use_id: None,
}
}
}
impl TraceContext {
pub fn root() -> Self {
Self {
agent_id: Uuid::new_v4(),
depth: 0,
parent_tool_use_id: None,
}
}
pub fn child_agent(&self, agent_id: Uuid, parent_tool_use_id: Option<String>) -> Self {
Self {
agent_id,
depth: self.depth + 1,
parent_tool_use_id,
}
}
}
#[derive(Debug, Clone)]
pub enum AgentEvent {
AgentStarted {
agent_id: Uuid,
model: String,
parent_agent_id: Option<Uuid>,
parent_tool_use_id: Option<String>,
depth: usize,
},
TextDelta { text: String, context: TraceContext },
ThinkingDelta { text: String, context: TraceContext },
ToolStarted {
tool_use: ToolUse,
context: TraceContext,
},
ToolFinished {
tool_use_id: String,
is_error: bool,
context: TraceContext,
},
TurnFinished {
reason: TurnEndReason,
context: TraceContext,
},
Usage {
usage: TokenUsage,
context: TraceContext,
},
AgentFinished {
log_path: Option<String>,
is_error: bool,
context: TraceContext,
},
}
pub trait EventSink: Send + Sync {
fn emit(&self, event: AgentEvent);
}
#[derive(Debug, Default)]
pub struct NullEventSink;
impl EventSink for NullEventSink {
fn emit(&self, _event: AgentEvent) {}
}
pub struct Agent {
model: Arc<dyn GenerativeModel>,
harness: Arc<Harness>,
sink: Arc<dyn EventSink>,
context: TraceContext,
history: Vec<Message>,
last_usage: Option<TokenUsage>,
context_window_tokens: u64,
}
impl Agent {
pub fn new(
model: Arc<dyn GenerativeModel>,
harness: Arc<Harness>,
sink: Arc<dyn EventSink>,
) -> Self {
Self::with_context(model, harness, sink, TraceContext::root())
}
pub fn with_context(
model: Arc<dyn GenerativeModel>,
harness: Arc<Harness>,
sink: Arc<dyn EventSink>,
context: TraceContext,
) -> Self {
Self {
model,
harness,
sink,
context,
history: Vec::new(),
last_usage: None,
context_window_tokens: 200_000,
}
}
pub fn history(&self) -> &[Message] {
&self.history
}
pub fn set_history(&mut self, history: Vec<Message>) {
self.history = history;
}
pub fn set_model(&mut self, model: Arc<dyn GenerativeModel>) {
self.model = model;
}
pub fn set_context_window_tokens(&mut self, tokens: u64) {
self.context_window_tokens = tokens.max(1);
}
pub fn context_window_tokens(&self) -> u64 {
self.context_window_tokens
}
pub fn last_usage(&self) -> Option<TokenUsage> {
self.last_usage
}
pub fn context(&self) -> &TraceContext {
&self.context
}
pub async fn interact(
&mut self,
user_input: Vec<Content>,
cancel: CancelToken,
) -> Result<Vec<Content>, AgentInteractionError> {
self.history.push(Message::UserMessage {
content: user_input,
});
loop {
if cancel.is_cancelled() {
return self.finish_cancelled();
}
let stream = self.model.generate(&self.history);
let sink = self.sink.clone();
let context = self.context.clone();
let output = match accumulate_generate(stream, sink, context, cancel.clone()).await {
Ok(output) => output,
Err(GenerateOrCancel::Cancelled) => return self.finish_cancelled(),
Err(GenerateOrCancel::Generate(e)) => return self.finish_generate_error(e),
};
if let Some(usage) = output.usage {
self.last_usage = Some(usage);
self.sink.emit(AgentEvent::Usage {
usage,
context: self.context.clone(),
});
}
match output.turn_end_reason {
TurnEndReason::ToolUse if output.tool_uses.is_empty() => {
self.history.push(Message::AssistantMessage {
content: output.content,
tool_uses: vec![],
turn_end_reason: Some(TurnEndReason::ToolUse),
});
return self.finish_generate_error(GenerateError::MalformedResponseError(
"turn ended in tool_use but streamed zero tool uses".into(),
));
}
TurnEndReason::ToolUse => {
self.history.push(Message::AssistantMessage {
content: output.content,
tool_uses: output.tool_uses.clone(),
turn_end_reason: Some(TurnEndReason::ToolUse),
});
let tool_results = future::join_all(
output
.tool_uses
.into_iter()
.map(|tool_use| self.dispatch_tool_use(tool_use, cancel.clone())),
)
.await;
self.history.push(Message::ToolResults {
tool_use_results: tool_results,
});
if cancel.is_cancelled() {
return self.finish_cancelled();
}
}
reason => {
let content = answer_content(&output.content);
self.history.push(Message::AssistantMessage {
content: output.content,
tool_uses: output.tool_uses,
turn_end_reason: Some(reason.clone()),
});
self.sink.emit(AgentEvent::TurnFinished {
reason,
context: self.context.clone(),
});
return Ok(content);
}
}
}
}
fn finish_cancelled(&self) -> Result<Vec<Content>, AgentInteractionError> {
self.sink.emit(AgentEvent::TurnFinished {
reason: TurnEndReason::Other("cancelled".into()),
context: self.context.clone(),
});
Err(AgentInteractionError::Cancelled)
}
fn finish_generate_error(
&self,
error: GenerateError,
) -> Result<Vec<Content>, AgentInteractionError> {
self.sink.emit(AgentEvent::TurnFinished {
reason: TurnEndReason::Other("generate_error".into()),
context: self.context.clone(),
});
Err(AgentInteractionError::GenerateError(error))
}
async fn dispatch_tool_use(&self, tool_use: ToolUse, cancel: CancelToken) -> ToolResult {
self.sink.emit(AgentEvent::ToolStarted {
tool_use: tool_use.clone(),
context: self.context.clone(),
});
let dispatch_context = TraceContext {
agent_id: self.context.agent_id,
depth: self.context.depth,
parent_tool_use_id: Some(tool_use.id.clone()),
};
let work = self.harness.clone().dispatch_tool_use(
tool_use.clone(),
self.sink.clone(),
dispatch_context,
cancel.clone(),
);
let result = tokio::select! {
biased;
_ = cancel.cancelled() => {
ToolResult::err("cancelled").with_id(tool_use.id.clone())
}
result = work => result,
};
self.sink.emit(AgentEvent::ToolFinished {
tool_use_id: tool_use.id,
is_error: result.is_error,
context: self.context.clone(),
});
result
}
}
enum GenerateOrCancel {
Cancelled,
Generate(GenerateError),
}
async fn accumulate_generate(
stream: impl futures::Stream<Item = Result<MessagePart, GenerateError>> + Unpin,
sink: Arc<dyn EventSink>,
context: TraceContext,
cancel: CancelToken,
) -> Result<GenerateOutput, GenerateOrCancel> {
let accumulate = GenerateOutput::from_stream_with_hook(stream, |part| match part {
MessagePart::ContentDelta(ContentDelta::Text { delta, .. }) => {
sink.emit(AgentEvent::TextDelta {
text: delta.clone(),
context: context.clone(),
});
}
MessagePart::ContentDelta(ContentDelta::Thinking { delta, .. }) if !delta.is_empty() => {
sink.emit(AgentEvent::ThinkingDelta {
text: delta.clone(),
context: context.clone(),
});
}
_ => {}
});
tokio::select! {
biased;
_ = cancel.cancelled() => Err(GenerateOrCancel::Cancelled),
result = accumulate => result.map_err(GenerateOrCancel::Generate),
}
}
impl Drop for Agent {
fn drop(&mut self) {
if self.context.agent_id.is_nil() {
return;
}
self.harness.notify_agent_finished(self.context.agent_id);
}
}
#[derive(thiserror::Error, Debug)]
pub enum AgentInteractionError {
#[error("Error during generation: {0}")]
GenerateError(#[from] generative_model::GenerateError),
#[error("cancelled")]
Cancelled,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Async;
use crate::generative_model::{GenerateError, MessagePart, ToolSpec};
use crate::tool_services::{HostDispatchContext, ToolService};
use futures::stream;
use serde_json::json;
use std::sync::Mutex;
use std::time::{Duration, Instant};
struct ScriptedModel {
scripts: Mutex<std::collections::VecDeque<GenerateOutput>>,
}
impl ScriptedModel {
fn new(scripts: Vec<GenerateOutput>) -> Arc<Self> {
Arc::new(Self {
scripts: Mutex::new(scripts.into()),
})
}
}
impl GenerativeModel for ScriptedModel {
fn generate(
&self,
_input: &[Message],
) -> crate::core::AsyncStream<Result<MessagePart, GenerateError>> {
let output = self
.scripts
.lock()
.expect("scripts lock")
.pop_front()
.expect("scripted model ran out of outputs");
let mut parts = vec![MessagePart::MessageStart];
for (i, c) in output.content.iter().enumerate() {
match c {
Content::Text { text } => {
parts.push(MessagePart::ContentStart(
generative_model::ContentStart::Text { index: i },
));
parts.push(MessagePart::ContentDelta(ContentDelta::Text {
index: i,
delta: text.clone(),
}));
}
Content::Image { source } => {
parts.push(MessagePart::ContentStart(
generative_model::ContentStart::Image { index: i },
));
parts.push(MessagePart::ContentDelta(ContentDelta::Image {
index: i,
delta: source.clone(),
}));
}
Content::Thinking {
text,
signature,
redacted,
} => {
parts.push(MessagePart::ContentStart(
generative_model::ContentStart::Thinking {
index: i,
signature: signature.clone(),
redacted: *redacted,
},
));
if !text.is_empty() && !*redacted {
parts.push(MessagePart::ContentDelta(ContentDelta::Thinking {
index: i,
delta: text.clone(),
}));
}
}
}
}
for (i, tu) in output.tool_uses.iter().enumerate() {
parts.push(MessagePart::ToolUseStart(generative_model::ToolUseStart {
index: i,
id: tu.id.clone(),
name: tu.name.clone(),
}));
parts.push(MessagePart::ToolUseDelta(generative_model::ToolUseDelta {
index: i,
input_json_delta: tu.input.to_string(),
}));
}
parts.push(MessagePart::TurnEndReason(output.turn_end_reason));
Box::pin(stream::iter(parts.into_iter().map(Ok)))
}
}
struct SlowService {
name: String,
delay: Duration,
starts: Arc<Mutex<Vec<(String, Instant)>>>,
ends: Arc<Mutex<Vec<(String, Instant)>>>,
}
impl ToolService for SlowService {
fn tool_specs(&self) -> Vec<ToolSpec> {
vec![ToolSpec {
name: self.name.clone(),
description: format!("slow test tool {}", self.name),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}),
}]
}
fn dispatch_tool_use(
self: Arc<Self>,
tool_use: ToolUse,
_ctx: HostDispatchContext,
) -> Async<ToolResult> {
Box::pin(async move {
let started = Instant::now();
self.starts
.lock()
.unwrap()
.push((tool_use.id.clone(), started));
tokio::time::sleep(self.delay).await;
let ended = Instant::now();
self.ends.lock().unwrap().push((tool_use.id.clone(), ended));
ToolResult::text(format!("done:{}", tool_use.id))
})
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_tool_uses_overlap_and_preserve_order() {
let starts = Arc::new(Mutex::new(Vec::new()));
let ends = Arc::new(Mutex::new(Vec::new()));
let delay = Duration::from_millis(300);
let slow_a = Arc::new(SlowService {
name: "slow_a".into(),
delay,
starts: starts.clone(),
ends: ends.clone(),
});
let slow_b = Arc::new(SlowService {
name: "slow_b".into(),
delay,
starts: starts.clone(),
ends: ends.clone(),
});
let harness = Harness::local_with_services(vec![
slow_a as Arc<dyn ToolService>,
slow_b as Arc<dyn ToolService>,
]);
let model = ScriptedModel::new(vec![
GenerateOutput {
content: vec![],
tool_uses: vec![
ToolUse {
id: "call_slow".into(),
name: "slow_a".into(),
input: json!({}),
},
ToolUse {
id: "call_fast".into(),
name: "slow_b".into(),
input: json!({}),
},
],
turn_end_reason: TurnEndReason::ToolUse,
usage: None,
},
GenerateOutput {
content: vec![Content::Text {
text: "all done".into(),
}],
tool_uses: vec![],
turn_end_reason: TurnEndReason::EndTurn,
usage: None,
},
]);
let mut agent = Agent::new(model, harness, Arc::new(NullEventSink));
let wall_start = Instant::now();
let reply = agent
.interact(
vec![Content::Text {
text: "run both".into(),
}],
crate::core::CancelToken::new(),
)
.await
.expect("interact");
let wall = wall_start.elapsed();
assert_eq!(reply.len(), 1);
match &reply[0] {
Content::Text { text } => assert_eq!(text, "all done"),
other => panic!("expected text reply, got {other:?}"),
}
let history = agent.history();
assert_eq!(history.len(), 4);
match &history[2] {
Message::ToolResults { tool_use_results } => {
assert_eq!(tool_use_results.len(), 2);
assert_eq!(tool_use_results[0].id, "call_slow");
assert_eq!(tool_use_results[1].id, "call_fast");
assert!(!tool_use_results[0].is_error);
assert!(!tool_use_results[1].is_error);
}
other => panic!("expected ToolResults, got {other:?}"),
}
let starts = starts.lock().unwrap().clone();
let ends = ends.lock().unwrap().clone();
assert_eq!(starts.len(), 2);
assert_eq!(ends.len(), 2);
let first_end = ends.iter().map(|(_, t)| *t).min().unwrap();
let last_start = starts.iter().map(|(_, t)| *t).max().unwrap();
assert!(
last_start < first_end,
"expected overlapping execution: last_start={last_start:?} first_end={first_end:?} starts={starts:?} ends={ends:?}"
);
assert!(
wall < delay * 6 + Duration::from_secs(1),
"expected concurrent wall time ~1 delay, got {wall:?} (delay={delay:?})"
);
}
struct SlowStreamModel {
delay: Duration,
chunks: usize,
}
impl GenerativeModel for SlowStreamModel {
fn generate(
&self,
_input: &[Message],
) -> crate::core::AsyncStream<Result<MessagePart, GenerateError>> {
let delay = self.delay;
let chunks = self.chunks;
Box::pin(stream::unfold(0usize, move |step| {
let delay = delay;
async move {
let last = chunks + 2;
if step > last {
return None;
}
let part = if step == 0 {
MessagePart::MessageStart
} else if step == 1 {
MessagePart::ContentStart(generative_model::ContentStart::Text { index: 0 })
} else if step <= chunks + 1 {
tokio::time::sleep(delay).await;
MessagePart::ContentDelta(ContentDelta::Text {
index: 0,
delta: format!("chunk{}", step - 2),
})
} else {
MessagePart::TurnEndReason(TurnEndReason::EndTurn)
};
Some((Ok(part), step + 1))
}
}))
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_during_generate_returns_cancelled() {
let harness = Harness::local_with_services(vec![]);
let model = Arc::new(SlowStreamModel {
delay: Duration::from_millis(200),
chunks: 20,
});
let mut agent = Agent::new(model, harness, Arc::new(NullEventSink));
let cancel = crate::core::CancelToken::new();
let cancel2 = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
cancel2.cancel();
});
let t0 = Instant::now();
let err = agent
.interact(vec![Content::Text { text: "go".into() }], cancel)
.await
.expect_err("should cancel");
let elapsed = t0.elapsed();
assert!(
matches!(err, AgentInteractionError::Cancelled),
"got {err:?}"
);
assert!(
elapsed < Duration::from_secs(2),
"cancel should be prompt, took {elapsed:?}"
);
assert_eq!(agent.history().len(), 1);
assert!(matches!(agent.history()[0], Message::UserMessage { .. }));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_during_slow_tool_records_cancelled_result() {
let starts = Arc::new(Mutex::new(Vec::new()));
let ends = Arc::new(Mutex::new(Vec::new()));
let slow = Arc::new(SlowService {
name: "slow_a".into(),
delay: Duration::from_secs(5),
starts: starts.clone(),
ends: ends.clone(),
});
let harness = Harness::local_with_services(vec![slow as Arc<dyn ToolService>]);
let model = ScriptedModel::new(vec![GenerateOutput {
content: vec![],
tool_uses: vec![ToolUse {
id: "call_slow".into(),
name: "slow_a".into(),
input: json!({}),
}],
turn_end_reason: TurnEndReason::ToolUse,
usage: None,
}]);
let mut agent = Agent::new(model, harness, Arc::new(NullEventSink));
let cancel = crate::core::CancelToken::new();
let cancel2 = cancel.clone();
let starts_bg = starts.clone();
tokio::spawn(async move {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
if !starts_bg.lock().unwrap().is_empty() {
break;
}
if Instant::now() > deadline {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
cancel2.cancel();
});
let t0 = Instant::now();
let err = agent
.interact(vec![Content::Text { text: "run".into() }], cancel)
.await
.expect_err("should cancel");
let elapsed = t0.elapsed();
assert!(matches!(err, AgentInteractionError::Cancelled));
assert!(
elapsed < Duration::from_secs(2),
"should not wait full tool delay, took {elapsed:?}"
);
let history = agent.history();
assert_eq!(history.len(), 3);
match &history[2] {
Message::ToolResults { tool_use_results } => {
assert_eq!(tool_use_results.len(), 1);
assert!(tool_use_results[0].is_error);
let text = tool_use_results[0]
.content
.iter()
.filter_map(|c| match c {
Content::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
assert!(text.contains("cancelled"), "{text}");
}
other => panic!("expected ToolResults, got {other:?}"),
}
}
struct FailAfterScriptsModel {
scripts: Mutex<std::collections::VecDeque<GenerateOutput>>,
fail_message: String,
}
impl FailAfterScriptsModel {
fn new(scripts: Vec<GenerateOutput>, fail_message: impl Into<String>) -> Arc<Self> {
Arc::new(Self {
scripts: Mutex::new(scripts.into()),
fail_message: fail_message.into(),
})
}
}
impl GenerativeModel for FailAfterScriptsModel {
fn generate(
&self,
_input: &[Message],
) -> crate::core::AsyncStream<Result<MessagePart, GenerateError>> {
let maybe = self.scripts.lock().expect("scripts lock").pop_front();
match maybe {
Some(output) => {
let one = ScriptedModel::new(vec![output]);
one.generate(&[])
}
None => {
let msg = self.fail_message.clone();
Box::pin(stream::once(async move {
Err(GenerateError::ExecutionError(msg))
}))
}
}
}
}
#[tokio::test]
async fn generate_error_after_tool_results_keeps_well_formed_history() {
let slow = Arc::new(SlowService {
name: "slow_a".into(),
delay: Duration::from_millis(1),
starts: Arc::new(Mutex::new(Vec::new())),
ends: Arc::new(Mutex::new(Vec::new())),
});
let harness = Harness::local_with_services(vec![slow as Arc<dyn ToolService>]);
let model = FailAfterScriptsModel::new(
vec![GenerateOutput {
content: vec![],
tool_uses: vec![ToolUse {
id: "call_1".into(),
name: "slow_a".into(),
input: json!({}),
}],
turn_end_reason: TurnEndReason::ToolUse,
usage: None,
}],
"provider 500 after tools",
);
let mut agent = Agent::new(model, harness, Arc::new(NullEventSink));
let err = agent
.interact(
vec![Content::Text {
text: "run tool then fail".into(),
}],
crate::core::CancelToken::new(),
)
.await
.expect_err("second generate should fail");
assert!(
matches!(err, AgentInteractionError::GenerateError(_)),
"got {err:?}"
);
let history = agent.history();
assert_eq!(history.len(), 3, "history={history:?}");
assert!(matches!(history[0], Message::UserMessage { .. }));
match &history[1] {
Message::AssistantMessage {
tool_uses,
turn_end_reason,
..
} => {
assert_eq!(tool_uses.len(), 1);
assert_eq!(tool_uses[0].id, "call_1");
assert_eq!(*turn_end_reason, Some(TurnEndReason::ToolUse));
}
other => panic!("expected assistant tool_use, got {other:?}"),
}
match &history[2] {
Message::ToolResults { tool_use_results } => {
assert_eq!(tool_use_results.len(), 1);
assert_eq!(tool_use_results[0].id, "call_1");
assert!(!tool_use_results[0].is_error);
}
other => panic!("expected ToolResults, got {other:?}"),
}
}
#[tokio::test]
async fn generate_error_before_assistant_keeps_only_user() {
let harness = Harness::local_with_services(vec![]);
let model = FailAfterScriptsModel::new(vec![], "boom on first generate");
let mut agent = Agent::new(model, harness, Arc::new(NullEventSink));
let err = agent
.interact(
vec![Content::Text { text: "hi".into() }],
crate::core::CancelToken::new(),
)
.await
.expect_err("generate should fail");
assert!(matches!(err, AgentInteractionError::GenerateError(_)));
assert_eq!(agent.history().len(), 1);
assert!(matches!(agent.history()[0], Message::UserMessage { .. }));
}
#[tokio::test]
async fn tool_use_stop_with_zero_tool_uses_errors_not_loops() {
let harness = Harness::local_with_services(vec![]);
let model = ScriptedModel::new(vec![GenerateOutput {
content: vec![Content::Text { text: "hmm".into() }],
tool_uses: vec![],
turn_end_reason: TurnEndReason::ToolUse,
usage: None,
}]);
let mut agent = Agent::new(model, harness, Arc::new(NullEventSink));
let err = agent
.interact(
vec![Content::Text { text: "hi".into() }],
crate::core::CancelToken::new(),
)
.await
.expect_err("malformed turn should error");
assert!(matches!(err, AgentInteractionError::GenerateError(_)));
assert_eq!(agent.history().len(), 2);
assert!(matches!(
agent.history()[1],
Message::AssistantMessage { .. }
));
}
#[tokio::test]
async fn resume_after_tools_mid_turn_continues_cleanly() {
let slow = Arc::new(SlowService {
name: "slow_a".into(),
delay: Duration::from_millis(1),
starts: Arc::new(Mutex::new(Vec::new())),
ends: Arc::new(Mutex::new(Vec::new())),
});
let harness = Harness::local_with_services(vec![slow.clone() as Arc<dyn ToolService>]);
let model = FailAfterScriptsModel::new(
vec![GenerateOutput {
content: vec![],
tool_uses: vec![ToolUse {
id: "call_mid".into(),
name: "slow_a".into(),
input: json!({}),
}],
turn_end_reason: TurnEndReason::ToolUse,
usage: None,
}],
"simulated crash after tools",
);
let mut agent = Agent::new(model, harness, Arc::new(NullEventSink));
let _ = agent
.interact(
vec![Content::Text {
text: "mid turn".into(),
}],
crate::core::CancelToken::new(),
)
.await
.expect_err("fail after tools");
let snapshot = agent.history().to_vec();
assert_eq!(snapshot.len(), 3);
let harness2 = Harness::local_with_services(vec![slow as Arc<dyn ToolService>]);
let resume_model = ScriptedModel::new(vec![GenerateOutput {
content: vec![Content::Text {
text: "recovered".into(),
}],
tool_uses: vec![],
turn_end_reason: TurnEndReason::EndTurn,
usage: None,
}]);
let mut resumed = Agent::new(resume_model, harness2, Arc::new(NullEventSink));
resumed.set_history(snapshot);
let reply = resumed
.interact(
vec![Content::Text {
text: "continue".into(),
}],
crate::core::CancelToken::new(),
)
.await
.expect("resume interact");
assert_eq!(reply.len(), 1);
match &reply[0] {
Content::Text { text } => assert_eq!(text, "recovered"),
other => panic!("expected text, got {other:?}"),
}
let history = resumed.history();
assert_eq!(history.len(), 5);
assert!(matches!(history[0], Message::UserMessage { .. }));
assert!(matches!(history[1], Message::AssistantMessage { .. }));
assert!(matches!(history[2], Message::ToolResults { .. }));
assert!(matches!(history[3], Message::UserMessage { .. }));
assert!(matches!(history[4], Message::AssistantMessage { .. }));
}
}