#![allow(warnings)]
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use talos_core::message::{
AgentEvent, AssistantReasoning, ContentDigest, ContentPart, Message, MessageToolResult,
ReasoningBlock, StopReason, ToolCall, Usage,
};
use talos_core::provider::{LanguageModel, ProviderError, ProviderResult, ToolDefinition};
use talos_core::tool::{
AgentTool, ToolBackend, ToolContinuation, ToolExecutionOutput, ToolFamily, ToolNature,
ToolPermissionFacet, ToolPresentationPolicy, ToolRegistry, ToolResourceKind,
ToolResult as ToolExecutionResult,
};
use talos_permission::{PermissionDecision, PermissionEngine};
use talos_plugin::{
HookContext, HookEvent, HookEventKind, HookHandler, HookRegistry, HookResult, TurnId,
};
use talos_sandbox::{SandboxConfig, SandboxError, SandboxProvider, SandboxResult};
use talos_skill::SkillIndex;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
use crate::{
Agent, AgentError, AgentResult, PendingToolCall, SandboxFallbackContext,
SandboxFallbackDecision, SandboxFallbackHandler, SandboxFallbackPolicy, ToolDescription,
};
type Receiver<T> = mpsc::Receiver<T>;
struct MockModel {
responses: Arc<Mutex<Vec<Vec<AgentEvent>>>>,
}
impl MockModel {
fn new(responses: Vec<Vec<AgentEvent>>) -> Self {
Self {
responses: Arc::new(Mutex::new(responses)),
}
}
}
#[async_trait]
impl LanguageModel for MockModel {
async fn stream(&self, _messages: &[Message]) -> ProviderResult<Receiver<AgentEvent>> {
let (tx, rx) = mpsc::channel(64);
let responses = self.responses.clone();
tokio::spawn(async move {
let mut responses = responses.lock().await;
let events = responses.pop_front().unwrap_or_default();
for event in events {
tx.send(event).await.expect("receiver dropped");
}
});
Ok(rx)
}
}
struct ErroringModel {
message: String,
}
impl ErroringModel {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
#[async_trait]
impl LanguageModel for ErroringModel {
async fn stream(&self, _messages: &[Message]) -> ProviderResult<Receiver<AgentEvent>> {
Err(ProviderError::NetworkError(self.message.clone()))
}
}
trait VecDequeExt<T> {
fn pop_front(&mut self) -> Option<T>;
}
impl<T> VecDequeExt<T> for Vec<T> {
fn pop_front(&mut self) -> Option<T> {
if self.is_empty() {
None
} else {
Some(self.remove(0))
}
}
}
#[tokio::test]
async fn run_streaming_emits_error_event_on_provider_dispatch_timeout() {
let provider: Arc<dyn LanguageModel> = Arc::new(ErroringModel::new(
"request dispatch timeout: no response headers within 1s",
));
let agent = Agent::new(provider, ToolRegistry::new());
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let result = agent
.run_streaming("hello".to_string(), vec![], event_tx)
.await;
assert!(matches!(result, Err(AgentError::ProviderError(_))));
let event = tokio::time::timeout(std::time::Duration::from_secs(1), event_rx.recv())
.await
.expect("error event should be emitted")
.expect("error event should be present");
match event {
AgentEvent::Error { message } => {
assert!(
message.contains("request dispatch timeout"),
"error event should preserve dispatch timeout detail, got: {message}"
);
}
other => panic!("expected AgentEvent::Error, got {other:?}"),
}
}
struct TimedMockTool {
tool_name: String,
read_only: bool,
delay_ms: u64,
result: ToolExecutionResult,
execution_log: Arc<Mutex<Vec<String>>>,
}
struct ProjectedMockTool;
#[async_trait]
impl AgentTool for ProjectedMockTool {
fn name(&self) -> &str {
"projected_read"
}
fn description(&self) -> &str {
"Projection fixture"
}
fn parameters(&self) -> Value {
serde_json::json!({})
}
fn is_read_only(&self) -> bool {
true
}
async fn execute(&self, _input: Value) -> ToolExecutionResult {
ToolExecutionResult::success("[snapshot:s1]\n1:aa|secret model line")
}
fn project_input(&self, input: &Value) -> Value {
let mut input = input.clone();
if let Some(object) = input.as_object_mut() {
object.remove("snapshot_id");
}
input
}
fn project_result(
&self,
result: &ToolExecutionResult,
) -> talos_core::tool::ToolResultProjection {
talos_core::tool::ToolResultProjection {
model_content: result.content.clone(),
display_content: "read 1 line".into(),
persistence_content: "read 1 line".into(),
}
}
}
#[async_trait]
impl AgentTool for TimedMockTool {
fn name(&self) -> &str {
&self.tool_name
}
fn description(&self) -> &str {
"Mock tool for testing"
}
fn parameters(&self) -> Value {
serde_json::json!({})
}
fn is_read_only(&self) -> bool {
self.read_only
}
async fn execute(&self, input: Value) -> ToolExecutionResult {
self.execution_log
.lock()
.await
.push(format!("start:{}:{}", self.tool_name, input));
tokio::time::sleep(tokio::time::Duration::from_millis(self.delay_ms)).await;
self.execution_log
.lock()
.await
.push(format!("end:{}:{}", self.tool_name, input));
self.result.clone()
}
}
struct FamilyMockTool {
tool_name: String,
family: ToolFamily,
always_on: bool,
execution_log: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl AgentTool for FamilyMockTool {
fn name(&self) -> &str {
&self.tool_name
}
fn description(&self) -> &str {
"Mock tool with family metadata"
}
fn parameters(&self) -> Value {
serde_json::json!({})
}
fn family(&self) -> ToolFamily {
self.family
}
fn is_always_on(&self) -> bool {
self.always_on
}
async fn execute(&self, input: Value) -> ToolExecutionResult {
self.execution_log
.lock()
.await
.push(format!("{}:{input}", self.tool_name));
ToolExecutionResult::success("executed")
}
}
struct BackendMockTool {
execution_log: Arc<Mutex<Vec<String>>>,
}
struct AdvancedHttpDisclosureTool;
#[async_trait]
impl AgentTool for AdvancedHttpDisclosureTool {
fn name(&self) -> &str {
"fetch_url"
}
fn description(&self) -> &str {
"Fetch a URL"
}
fn parameters(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"url": { "type": "string" }
},
"required": ["url"]
})
}
fn family(&self) -> ToolFamily {
ToolFamily::Network
}
async fn execute(&self, _input: Value) -> ToolExecutionResult {
ToolExecutionResult::success("advanced HTTP required").with_continuation(
ToolContinuation::disclose_tool("http_request", "advanced_http_required"),
)
}
}
#[async_trait]
impl AgentTool for BackendMockTool {
fn name(&self) -> &str {
"fetch_url"
}
fn description(&self) -> &str {
"Fetch a URL with the base HTTP backend"
}
fn parameters(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"url": { "type": "string" },
"access": {
"type": "string",
"enum": ["http"]
}
},
"required": ["url"]
})
}
fn family(&self) -> ToolFamily {
ToolFamily::Network
}
fn nature(&self) -> ToolNature {
ToolNature::Network
}
fn permission_profile(&self, input: &Value) -> Vec<ToolPermissionFacet> {
if let Some(url) = input.get("url").and_then(Value::as_str)
&& let Some(host) = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.and_then(|rest| rest.split('/').next())
&& !host.is_empty()
{
return vec![ToolPermissionFacet::with_resource(
ToolNature::Network,
host.to_lowercase(),
ToolResourceKind::Domain,
)];
}
vec![ToolPermissionFacet::new(ToolNature::Network)]
}
fn conditional_backends(&self) -> Vec<ToolBackend> {
vec![ToolBackend::new(
"browser_page",
"Read a user-approved browser page context",
)]
}
fn backend_for_input(&self, input: &Value) -> Option<String> {
match input.get("access").and_then(Value::as_str) {
Some("browser") => Some("browser_page".to_string()),
_ => None,
}
}
fn description_for_backends(&self, backends: &std::collections::HashSet<String>) -> String {
if backends.contains("browser_page") {
"Fetch a URL or read a user-approved browser page context".to_string()
} else {
self.description().to_string()
}
}
fn parameters_for_backends(&self, backends: &std::collections::HashSet<String>) -> Value {
if backends.contains("browser_page") {
serde_json::json!({
"type": "object",
"properties": {
"url": { "type": "string" },
"access": {
"type": "string",
"enum": ["http", "browser"]
}
},
"required": ["url"]
})
} else {
self.parameters()
}
}
async fn execute(&self, input: Value) -> ToolExecutionResult {
self.execution_log
.lock()
.await
.push(format!("fetch_url:{input}"));
if input
.get("needs_browser")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return ToolExecutionResult::success("browser backend required").with_continuation(
ToolContinuation::disclose_backend(
"fetch_url",
"browser_page",
"browser_context_required",
)
.with_permission_preview(
"read visible text and links from an approved browser page",
),
);
}
ToolExecutionResult::success("executed")
}
}
struct ToolDefinitionCapturingModel {
responses: Mutex<Vec<Vec<AgentEvent>>>,
captured_tools: Arc<std::sync::Mutex<Vec<Vec<ToolDefinition>>>>,
}
impl ToolDefinitionCapturingModel {
fn new(
responses: Vec<Vec<AgentEvent>>,
) -> (Self, Arc<std::sync::Mutex<Vec<Vec<ToolDefinition>>>>) {
let captured_tools = Arc::new(std::sync::Mutex::new(Vec::new()));
(
Self {
responses: Mutex::new(responses),
captured_tools: captured_tools.clone(),
},
captured_tools,
)
}
}
#[async_trait]
impl LanguageModel for ToolDefinitionCapturingModel {
async fn stream(&self, _messages: &[Message]) -> ProviderResult<Receiver<AgentEvent>> {
let mut responses = self.responses.lock().await;
let events = if responses.is_empty() {
Vec::new()
} else {
responses.remove(0)
};
let (tx, rx) = mpsc::channel(32);
tokio::spawn(async move {
for event in events {
let _ = tx.send(event).await;
}
});
Ok(rx)
}
async fn stream_with_tools(
&self,
messages: &[Message],
tools: &[ToolDefinition],
) -> ProviderResult<Receiver<AgentEvent>> {
self.captured_tools
.lock()
.expect("lock poisoned")
.push(tools.to_vec());
self.stream(messages).await
}
}
struct CountingHook {
events: Arc<Mutex<Vec<talos_plugin::HookEventKind>>>,
}
struct ProjectionCaptureHook {
payloads: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl HookHandler for ProjectionCaptureHook {
fn name(&self) -> &str {
"projection-capture"
}
fn subscribed(&self) -> &'static [HookEventKind] {
&[
HookEventKind::BeforeProviderCall,
HookEventKind::OnToolCallProposed,
HookEventKind::BeforeToolBatch,
HookEventKind::BeforeToolCall,
HookEventKind::BeforePermissionCheck,
HookEventKind::AfterPermissionCheck,
HookEventKind::AfterToolCall,
HookEventKind::OnToolResultObserved,
HookEventKind::AfterToolBatch,
]
}
async fn on_event(&self, _ctx: &HookContext, event: &mut HookEvent<'_>) -> HookResult {
let payload = match event {
HookEvent::BeforeProviderCall { messages } => {
serde_json::to_string(messages).expect("serialize hook messages")
}
HookEvent::OnToolCallProposed { call }
| HookEvent::BeforeToolCall { call }
| HookEvent::BeforePermissionCheck { call }
| HookEvent::AfterPermissionCheck { call, .. } => {
serde_json::to_string(call).expect("serialize hook call")
}
HookEvent::BeforeToolBatch { calls } => {
serde_json::to_string(calls).expect("serialize hook batch")
}
HookEvent::AfterToolCall { call, result } => format!(
"{}:{}",
serde_json::to_string(call).expect("serialize hook call"),
result.content
),
HookEvent::OnToolResultObserved { observation } => format!(
"{}:{}",
serde_json::to_string(&observation.call).expect("serialize hook observation"),
observation.result.content
),
HookEvent::AfterToolBatch { results } => results
.iter()
.map(|result| result.content.as_str())
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
};
self.payloads.lock().await.push(payload);
HookResult::Continue
}
}
#[async_trait]
impl talos_plugin::HookHandler for CountingHook {
fn name(&self) -> &str {
"counting"
}
fn subscribed(&self) -> &'static [talos_plugin::HookEventKind] {
&[
talos_plugin::HookEventKind::TurnStart,
talos_plugin::HookEventKind::BeforeProviderCall,
talos_plugin::HookEventKind::TurnComplete,
]
}
async fn on_event(
&self,
_ctx: &talos_plugin::HookContext,
event: &mut talos_plugin::HookEvent<'_>,
) -> talos_plugin::HookResult {
self.events.lock().await.push(event.kind());
talos_plugin::HookResult::Continue
}
}
#[tokio::test]
#[allow(deprecated)] async fn test_run_collects_text_deltas() {
let events = vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Hello, ".into(),
},
AgentEvent::TextDelta {
delta: "world!".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
];
let agent = Agent::new(Arc::new(MockModel::new(vec![events])), ToolRegistry::new());
let response = agent
.run("Hi".into())
.await
.expect("operation should succeed");
assert_eq!(response, "Hello, world!");
}
#[tokio::test]
async fn test_turn_start_hook_fires_once_for_tool_turn() {
let call = ToolCall {
id: "call-1".into(),
name: "read".into(),
input: serde_json::json!({}),
};
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call,
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let events = Arc::new(Mutex::new(Vec::new()));
let mut hooks = HookRegistry::new();
hooks.register(Arc::new(CountingHook {
events: events.clone(),
}));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "read".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("file content"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::with_security_and_hooks(
Arc::new(MockModel::new(responses)),
registry,
Some(Arc::new(PermissionEngine::new())),
None,
PathBuf::from("/tmp"),
Arc::new(hooks),
);
let response = agent
.run("read file".into())
.await
.expect("operation should succeed");
assert_eq!(response, "done");
let events = events.lock().await;
let turn_start_count = events
.iter()
.filter(|kind| **kind == talos_plugin::HookEventKind::TurnStart)
.count();
let provider_call_count = events
.iter()
.filter(|kind| **kind == talos_plugin::HookEventKind::BeforeProviderCall)
.count();
assert_eq!(
turn_start_count, 1,
"TurnStart should fire once per user turn"
);
assert_eq!(
provider_call_count, 2,
"provider can be called multiple times in one user turn"
);
}
#[tokio::test]
#[allow(deprecated)] async fn test_run_handles_error_event() {
let events = vec![
AgentEvent::TurnStart,
AgentEvent::Error {
message: "something went wrong".into(),
},
];
let agent = Agent::new(Arc::new(MockModel::new(vec![events])), ToolRegistry::new());
let result = agent.run("Hi".into()).await;
assert!(result.is_err());
let err = result.expect_err("operation should fail");
assert!(matches!(err, AgentError::UnexpectedEvent(_)));
}
#[tokio::test]
#[allow(deprecated)] async fn test_run_handles_channel_close_without_turn_end() {
let agent = Agent::new(Arc::new(MockModel::new(vec![])), ToolRegistry::new());
let result = agent.run("Hi".into()).await;
assert!(result.is_err());
let err = result.expect_err("operation should fail");
assert!(matches!(err, AgentError::UnexpectedEvent(_)));
}
#[tokio::test]
#[allow(deprecated)] async fn test_run_rejects_tool_use_without_tool_calls() {
let events = vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "I will inspect files.".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
];
let agent = Agent::new(Arc::new(MockModel::new(vec![events])), ToolRegistry::new());
let result = agent.run("Analyze".into()).await;
let err = result.expect_err("tool_use without tool calls must be terminal error");
assert!(
matches!(err, AgentError::UnexpectedEvent(message) if message.contains("emitted no tool calls"))
);
}
#[tokio::test]
#[allow(deprecated)]
async fn test_run_rejects_duplicate_tool_call_ids() {
let events = vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "dup_id".into(),
name: "read".into(),
input: serde_json::json!({ "path": "a" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::ToolCall {
call: ToolCall {
id: "dup_id".into(),
name: "read".into(),
input: serde_json::json!({ "path": "b" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "read".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("ok"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::new(Arc::new(MockModel::new(vec![events])), registry);
let result = agent.run("dup".into()).await;
let err = result.expect_err("duplicate tool call ids must be terminal error");
assert!(
matches!(&err, AgentError::UnexpectedEvent(msg) if msg.contains("duplicate tool call id")),
"expected duplicate-id error, got: {err:?}"
);
}
#[tokio::test]
#[allow(deprecated)]
async fn test_run_rejects_tool_call_with_empty_name() {
let events = vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_a".into(),
name: "".into(),
input: serde_json::json!({}),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
];
let agent = Agent::new(Arc::new(MockModel::new(vec![events])), ToolRegistry::new());
let result = agent.run("empty-name".into()).await;
let err = result.expect_err("empty tool call name must be terminal error");
assert!(
matches!(&err, AgentError::UnexpectedEvent(msg) if msg.contains("empty id or name")),
"expected empty-name error, got: {err:?}"
);
}
#[tokio::test]
#[allow(deprecated)]
async fn test_run_rejects_tool_call_with_empty_id() {
let events = vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "".into(),
name: "read".into(),
input: serde_json::json!({ "path": "a" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
];
let agent = Agent::new(Arc::new(MockModel::new(vec![events])), ToolRegistry::new());
let result = agent.run("empty-id".into()).await;
let err = result.expect_err("empty tool call id must be terminal error");
assert!(
matches!(&err, AgentError::UnexpectedEvent(msg) if msg.contains("empty id or name")),
"expected empty-id error, got: {err:?}"
);
}
#[tokio::test]
#[allow(deprecated)]
async fn test_run_rejects_whitespace_only_tool_call_name() {
let events = vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_w".into(),
name: " ".into(),
input: serde_json::json!({}),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
];
let agent = Agent::new(Arc::new(MockModel::new(vec![events])), ToolRegistry::new());
let result = agent.run("ws-name".into()).await;
let err = result.expect_err("whitespace-only tool call name must be terminal error");
assert!(
matches!(&err, AgentError::UnexpectedEvent(msg) if msg.contains("empty id or name")),
"expected whitespace-name error, got: {err:?}"
);
}
#[tokio::test]
#[allow(deprecated)]
async fn test_run_max_tokens_stop_reason_without_tool_calls_is_terminal_success() {
let events = vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "truncated".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::MaxTokens,
usage: talos_core::message::Usage::default(),
},
];
let agent = Agent::new(Arc::new(MockModel::new(vec![events])), ToolRegistry::new());
let result = agent.run("max-tokens".into()).await;
let response = result.expect("MaxTokens without tool calls is not an agent error");
assert_eq!(response, "truncated");
}
#[tokio::test]
#[allow(deprecated)]
async fn test_run_end_turn_with_tool_calls_executes_recoverably() {
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "echo".into(),
input: serde_json::json!({ "message": "hi" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "recovered".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "echo".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("ok"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::new(Arc::new(MockModel::new(responses)), registry);
let result = agent.run("mixed".into()).await;
let response = result.expect("EndTurn + tool_calls should recover by executing tools");
assert_eq!(
response, "recovered",
"turn should continue after tool execution despite EndTurn stop reason"
);
}
#[tokio::test]
#[allow(deprecated)]
async fn test_run_provider_error_after_tool_results_is_terminal() {
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "echo".into(),
input: serde_json::json!({ "message": "probe" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::Error {
message: "provider rejected request after tool results".into(),
},
],
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "echo".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("ok"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::new(Arc::new(MockModel::new(responses)), registry);
let result = agent.run("probe".into()).await;
let err = result.expect_err("provider rejection after tool results must be terminal");
assert!(
matches!(&err, AgentError::UnexpectedEvent(msg) if msg.contains("provider rejected")),
"expected UnexpectedEvent for post-tool-result provider rejection, got: {err:?}"
);
}
#[tokio::test]
#[allow(deprecated)] async fn test_run_streaming_forwards_events() {
let events = vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Streaming".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
];
let agent = Agent::new(
Arc::new(MockModel::new(vec![events.clone()])),
ToolRegistry::new(),
);
let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
let (response, _) = agent
.run_streaming("Hi".into(), vec![], tx)
.await
.expect("operation should succeed");
assert_eq!(response, "Streaming");
let mut received = Vec::new();
while let Ok(event) = rx.try_recv() {
received.push(event);
}
assert_eq!(received.len(), events.len());
assert_eq!(received, events);
}
#[tokio::test]
#[allow(deprecated)] async fn test_run_streaming_keeps_thinking_out_of_final_history() {
let events = vec![
AgentEvent::TurnStart,
AgentEvent::ThinkingDelta {
delta: "private reasoning".into(),
},
AgentEvent::TextDelta {
delta: "Final answer".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
];
let agent = Agent::new(
Arc::new(MockModel::new(vec![events.clone()])),
ToolRegistry::new(),
);
let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
let (response, new_messages) = agent
.run_streaming("Hi".into(), vec![], tx)
.await
.expect("operation should succeed");
assert_eq!(response, "Final answer");
assert!(!response.contains("private reasoning"));
assert!(matches!(
new_messages.last(),
Some(Message::Assistant { content, .. }) if content == "Final answer"
));
let mut received = Vec::new();
while let Ok(event) = rx.try_recv() {
received.push(event);
}
assert!(
received
.iter()
.any(|event| matches!(event, AgentEvent::ThinkingDelta { .. }))
);
}
#[tokio::test]
async fn test_replay_reasoning_false_strips_reasoning_for_provider_messages_only() {
struct MessageCapturingModel {
responses: Arc<Mutex<Vec<Vec<AgentEvent>>>>,
captured_messages: Arc<std::sync::Mutex<Vec<Vec<Message>>>>,
}
#[async_trait]
impl LanguageModel for MessageCapturingModel {
async fn stream(&self, _messages: &[Message]) -> ProviderResult<Receiver<AgentEvent>> {
let (tx, rx) = mpsc::channel(64);
let responses = self.responses.clone();
tokio::spawn(async move {
let mut responses = responses.lock().await;
let events = responses.pop_front().unwrap_or_default();
for event in events {
tx.send(event).await.expect("receiver dropped");
}
});
Ok(rx)
}
async fn stream_with_tools(
&self,
messages: &[Message],
_tools: &[ToolDefinition],
) -> ProviderResult<Receiver<AgentEvent>> {
self.captured_messages
.lock()
.expect("lock poisoned")
.push(messages.to_vec());
self.stream(messages).await
}
}
let responses = vec![vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta { delta: "ok".into() },
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
]];
let captured_messages = Arc::new(std::sync::Mutex::new(Vec::<Vec<Message>>::new()));
let model = MessageCapturingModel {
responses: Arc::new(Mutex::new(responses)),
captured_messages: captured_messages.clone(),
};
let history = vec![Message::Assistant {
content: "prior answer".into(),
tool_calls: vec![],
reasoning: Some(AssistantReasoning {
provider: "anthropic".into(),
model: "claude-sonnet-4-5".into(),
blocks: vec![ReasoningBlock::Plain {
text: "private chain-of-thought".into(),
}],
}),
}];
let history_for_run = history.clone();
let agent = Agent::with_security(
Arc::new(model),
ToolRegistry::new(),
None,
None,
PathBuf::from("/tmp"),
)
.with_reasoning_identity(None, None, false);
let (tx, _rx) = mpsc::unbounded_channel::<AgentEvent>();
let _ = agent
.run_streaming("continue".into(), history_for_run, tx)
.await
.expect("operation should succeed");
let captured = captured_messages.lock().expect("lock poisoned");
let first_call_messages = &captured[0];
let prior_message = first_call_messages
.iter()
.find(|message| {
matches!(
message,
Message::Assistant {
content,
reasoning: _,
..
} if content == "prior answer"
)
})
.expect("prior assistant message should be forwarded");
assert!(matches!(
prior_message,
Message::Assistant {
reasoning: None,
..
}
));
assert!(matches!(
&history[0],
Message::Assistant {
reasoning: Some(_),
..
}
));
}
#[tokio::test]
async fn test_reasoning_complete_blocks_stamped_with_identity_on_assistant_message() {
let reasoning_blocks = vec![ReasoningBlock::Plain {
text: "reasoning payload".into(),
}];
let events = vec![
AgentEvent::TurnStart,
AgentEvent::ReasoningComplete {
blocks: reasoning_blocks.clone(),
},
AgentEvent::TextDelta {
delta: "Final answer".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
];
let agent = Agent::with_security(
Arc::new(MockModel::new(vec![events])),
ToolRegistry::new(),
None,
None,
PathBuf::from("/tmp"),
)
.with_reasoning_identity(
Some("anthropic".to_string()),
Some("claude-sonnet-4-5".to_string()),
true,
);
let (tx, _rx) = mpsc::unbounded_channel::<AgentEvent>();
let (_response, new_messages) = agent
.run_streaming("Hi".into(), vec![], tx)
.await
.expect("operation should succeed");
let assistant = new_messages
.iter()
.find(|message| matches!(message, Message::Assistant { .. }))
.expect("assistant message should be persisted");
assert!(matches!(
assistant,
Message::Assistant {
content,
reasoning: Some(AssistantReasoning { provider, model, blocks }),
..
} if content == "Final answer"
&& provider == "anthropic"
&& model == "claude-sonnet-4-5"
&& blocks == &reasoning_blocks
));
}
#[tokio::test]
#[allow(deprecated)] async fn test_tool_execution_loop_single_call() {
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Let me check ".into(),
},
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "echo".into(),
input: serde_json::json!({ "message": "hello" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "The result is: hello".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "echo".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("hello"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::new(Arc::new(MockModel::new(responses)), registry);
let response = agent
.run("Echo hello".into())
.await
.expect("operation should succeed");
assert_eq!(response, "The result is: hello");
}
#[tokio::test]
#[allow(deprecated)] async fn test_tool_execution_loop_multiple_calls() {
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "read".into(),
input: serde_json::json!({ "path": "a.txt" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::ToolCall {
call: ToolCall {
id: "call_2".into(),
name: "read".into(),
input: serde_json::json!({ "path": "b.txt" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Done reading both files".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "read".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("file content"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::new(Arc::new(MockModel::new(responses)), registry);
let response = agent
.run("Read files".into())
.await
.expect("operation should succeed");
assert_eq!(response, "Done reading both files");
}
#[tokio::test]
#[allow(deprecated)] async fn test_concurrent_read_only_tools() {
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "fast_read".into(),
input: serde_json::json!({ "path": "a.txt" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::ToolCall {
call: ToolCall {
id: "call_2".into(),
name: "fast_read".into(),
input: serde_json::json!({ "path": "b.txt" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::ToolCall {
call: ToolCall {
id: "call_3".into(),
name: "fast_read".into(),
input: serde_json::json!({ "path": "c.txt" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "All done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let log = Arc::new(Mutex::new(Vec::new()));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "fast_read".into(),
read_only: true,
delay_ms: 50,
result: ToolExecutionResult::success("ok"),
execution_log: log.clone(),
}));
let agent = Agent::new(Arc::new(MockModel::new(responses)), registry);
let _response = agent
.run("Read files".into())
.await
.expect("operation should succeed");
let log_entries = log.lock().await;
let starts: Vec<_> = log_entries
.iter()
.filter(|e| e.starts_with("start:"))
.collect();
let ends: Vec<_> = log_entries
.iter()
.filter(|e| e.starts_with("end:"))
.collect();
assert_eq!(starts.len(), 3);
assert_eq!(ends.len(), 3);
let last_start_idx = log_entries
.iter()
.position(|e| e.starts_with("end:"))
.unwrap_or(3);
assert!(
last_start_idx >= 3,
"Expected all starts before any end, but log was: {:?}",
log_entries
);
}
#[tokio::test]
#[allow(deprecated)] async fn test_serial_write_tools() {
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "write".into(),
input: serde_json::json!({ "path": "a.txt", "content": "a" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::ToolCall {
call: ToolCall {
id: "call_2".into(),
name: "write".into(),
input: serde_json::json!({ "path": "b.txt", "content": "b" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Files written".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let log = Arc::new(Mutex::new(Vec::new()));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "write".into(),
read_only: false,
delay_ms: 30,
result: ToolExecutionResult::success("written"),
execution_log: log.clone(),
}));
let agent = Agent::new(Arc::new(MockModel::new(responses)), registry);
let _response = agent
.run("Write files".into())
.await
.expect("operation should succeed");
let log_entries = log.lock().await;
assert_eq!(log_entries.len(), 4);
assert!(log_entries[0].starts_with("start:"));
assert!(log_entries[1].starts_with("end:"));
assert!(log_entries[2].starts_with("start:"));
assert!(log_entries[3].starts_with("end:"));
}
#[tokio::test]
#[allow(deprecated)] async fn test_turn_budget_enforcement() {
let mut events = vec![AgentEvent::TurnStart];
for i in 0..51 {
events.push(AgentEvent::ToolCall {
call: ToolCall {
id: format!("call_{i}"),
name: "echo".into(),
input: serde_json::json!({ "message": format!("msg_{i}") }),
},
provenance: Default::default(),
summary_fields: vec![],
});
}
events.push(AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
});
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "echo".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("ok"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::new(Arc::new(MockModel::new(vec![events])), registry);
let result = agent.run("Many tools".into()).await;
assert!(
result.is_ok(),
"budget exceeded should return Ok with messages, not Err"
);
let text = result.expect("operation should succeed");
assert!(
text.contains("limit") || text.contains("preserved"),
"final text should mention tool call limit: got {text}"
);
}
#[tokio::test]
#[allow(deprecated)] async fn test_turn_budget_allows_50_calls() {
let mut tool_events = vec![AgentEvent::TurnStart];
for i in 0..50 {
tool_events.push(AgentEvent::ToolCall {
call: ToolCall {
id: format!("call_{i}"),
name: "echo".into(),
input: serde_json::json!({ "message": format!("msg_{i}") }),
},
provenance: Default::default(),
summary_fields: vec![],
});
}
tool_events.push(AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
});
let text_events = vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "echo".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("ok"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::new(
Arc::new(MockModel::new(vec![tool_events, text_events])),
registry,
);
let result = agent.run("50 tools".into()).await;
assert!(result.is_ok());
assert_eq!(result.expect("operation should succeed"), "Done");
}
#[tokio::test]
#[allow(deprecated)] async fn test_doom_loop_detection() {
let tool_call_event = AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "echo".into(),
input: serde_json::json!({ "message": "same" }),
},
provenance: Default::default(),
summary_fields: vec![],
};
let responses = vec![
vec![
AgentEvent::TurnStart,
tool_call_event.clone(),
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
tool_call_event.clone(),
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
tool_call_event,
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "echo".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("same"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::new(Arc::new(MockModel::new(responses)), registry);
let result = agent.run("Loop".into()).await;
assert!(
result.is_ok(),
"doom loop should return Ok with messages, not Err"
);
let text = result.expect("operation should succeed");
assert!(
text.contains("repeated") || text.contains("paused"),
"final text should mention repeat pattern: got {text}"
);
}
#[tokio::test]
#[allow(deprecated)] async fn test_doom_loop_different_args_allowed() {
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "echo".into(),
input: serde_json::json!({ "message": "first" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_2".into(),
name: "echo".into(),
input: serde_json::json!({ "message": "second" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "echo".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("ok"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::new(Arc::new(MockModel::new(responses)), registry);
let result = agent.run("Different args".into()).await;
assert!(result.is_ok());
}
#[tokio::test]
#[allow(deprecated)] async fn test_tool_not_found_returns_error_result() {
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "nonexistent_tool".into(),
input: serde_json::json!({}),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Tool not available".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let agent = Agent::new(Arc::new(MockModel::new(responses)), ToolRegistry::new());
let result = agent.run("Missing tool".into()).await;
assert!(result.is_ok());
assert_eq!(
result.expect("operation should succeed"),
"Tool not available"
);
}
#[tokio::test]
#[allow(deprecated)] async fn test_tool_execution_error_feeds_back_to_provider() {
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "failing".into(),
input: serde_json::json!({}),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Tool failed, trying alternative".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "failing".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::error("internal failure"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::new(Arc::new(MockModel::new(responses)), registry);
let result = agent.run("Failing tool".into()).await;
assert!(result.is_ok());
assert_eq!(
result.expect("operation should succeed"),
"Tool failed, trying alternative"
);
}
#[tokio::test]
#[allow(deprecated)] async fn test_mixed_read_only_and_write_tools() {
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "read".into(),
input: serde_json::json!({ "path": "a.txt" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::ToolCall {
call: ToolCall {
id: "call_2".into(),
name: "write".into(),
input: serde_json::json!({ "path": "b.txt", "content": "b" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::ToolCall {
call: ToolCall {
id: "call_3".into(),
name: "read".into(),
input: serde_json::json!({ "path": "c.txt" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Mixed tools done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let log = Arc::new(Mutex::new(Vec::new()));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "read".into(),
read_only: true,
delay_ms: 20,
result: ToolExecutionResult::success("read ok"),
execution_log: log.clone(),
}));
registry.register(Arc::new(TimedMockTool {
tool_name: "write".into(),
read_only: false,
delay_ms: 20,
result: ToolExecutionResult::success("write ok"),
execution_log: log.clone(),
}));
let agent = Agent::new(Arc::new(MockModel::new(responses)), registry);
let result = agent.run("Mixed".into()).await;
assert!(result.is_ok());
assert_eq!(
result.expect("operation should succeed"),
"Mixed tools done"
);
let log_entries = log.lock().await;
let write_start_idx = log_entries
.iter()
.position(|e| e.starts_with("start:write:"))
.expect("operation should succeed");
let write_end_idx = log_entries
.iter()
.position(|e| e.starts_with("end:write:"))
.expect("operation should succeed");
assert_eq!(
write_end_idx,
write_start_idx + 1,
"Write tool should be serial: {:?}",
log_entries
);
}
#[tokio::test]
#[allow(deprecated)] async fn test_cancellation_token_is_created() {
let agent = Agent::new(Arc::new(MockModel::new(vec![])), ToolRegistry::new());
let token = agent.cancellation_token();
assert!(!token.is_cancelled());
token.cancel();
assert!(token.is_cancelled());
}
#[tokio::test]
#[allow(deprecated)] async fn test_tool_result_events_broadcast() {
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "echo".into(),
input: serde_json::json!({ "message": "test" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "echo".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("test result"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::new(Arc::new(MockModel::new(responses)), registry);
let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
let _response = agent
.run_streaming("Echo test".into(), vec![], tx)
.await
.expect("operation should succeed");
let mut events = Vec::new();
while let Ok(event) = rx.try_recv() {
events.push(event);
}
let tool_result_events: Vec<_> = events
.iter()
.filter(|e| matches!(e, AgentEvent::ToolResult { .. }))
.collect();
assert_eq!(
tool_result_events.len(),
1,
"Expected 1 ToolResult event, got: {:?}",
events
);
}
#[tokio::test]
#[allow(deprecated)]
async fn model_private_projection_reaches_model_but_not_events_or_returned_history() {
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "private_call".into(),
name: "projected_read".into(),
input: serde_json::json!({"path": "src/lib.rs", "snapshot_id": "s1"}),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
],
];
let (model, captured_messages) = CapturingMessagesModel::new(responses);
let mut registry = ToolRegistry::new();
registry.register(Arc::new(ProjectedMockTool));
let hook_payloads = Arc::new(Mutex::new(Vec::new()));
let mut hooks = HookRegistry::new();
hooks.register(Arc::new(ProjectionCaptureHook {
payloads: hook_payloads.clone(),
}));
let agent = Agent::with_security_and_hooks(
Arc::new(model),
registry,
Some(Arc::new(PermissionEngine::new())),
None,
PathBuf::from("/tmp"),
Arc::new(hooks),
);
let (tx, mut rx) = mpsc::unbounded_channel();
let (_, returned) = agent
.run_streaming("read".into(), vec![], tx)
.await
.expect("turn succeeds");
let captured = captured_messages.lock().expect("capture lock");
let model_text = captured[1]
.iter()
.find_map(|message| match message {
Message::Tool { result } => Some(result.content.as_str()),
_ => None,
})
.expect("model tool result");
assert!(model_text.contains("snapshot:s1"));
assert!(model_text.contains("1:aa|"));
drop(captured);
let serialized = serde_json::to_string(&returned).expect("serialize returned messages");
assert!(!serialized.contains("snapshot_id"));
assert!(!serialized.contains("snapshot:s1"));
assert!(!serialized.contains("1:aa|"));
assert!(serialized.contains("read 1 line"));
while let Ok(event) = rx.try_recv() {
let event = serde_json::to_string(&event).expect("serialize event");
assert!(!event.contains("snapshot_id"));
assert!(!event.contains("snapshot:s1"));
assert!(!event.contains("1:aa|"));
}
let hook_payloads = hook_payloads.lock().await;
assert!(!hook_payloads.is_empty());
for payload in hook_payloads.iter() {
assert!(!payload.contains("snapshot_id"));
assert!(!payload.contains("snapshot:s1"));
assert!(!payload.contains("1:aa|"));
}
}
#[tokio::test]
#[allow(deprecated)] async fn test_streaming_tool_events_are_interleaved_per_tool() {
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "echo".into(),
input: serde_json::json!({ "message": "first" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::ToolCall {
call: ToolCall {
id: "call_2".into(),
name: "echo".into(),
input: serde_json::json!({ "message": "second" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "echo".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("ok"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::new(Arc::new(MockModel::new(responses)), registry);
let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
let _response = agent
.run_streaming("Echo twice".into(), vec![], tx)
.await
.expect("operation should succeed");
let mut sequence = Vec::new();
while let Ok(event) = rx.try_recv() {
match event {
AgentEvent::ToolCall { call, .. } => {
sequence.push(format!("call:{}", call.id));
}
AgentEvent::ToolResult { result } => {
sequence.push(format!("result:{}", result.tool_use_id));
}
_ => {}
}
}
assert_eq!(
sequence,
vec![
"call:call_1",
"result:call_1",
"call:call_2",
"result:call_2"
]
);
}
struct MockSandbox {
available: bool,
execution_log: Arc<Mutex<Vec<String>>>,
result: Option<SandboxResult>,
}
impl MockSandbox {
fn new(available: bool, result: SandboxResult) -> Self {
Self {
available,
execution_log: Arc::new(Mutex::new(Vec::new())),
result: Some(result),
}
}
fn unavailable() -> Self {
Self {
available: false,
execution_log: Arc::new(Mutex::new(Vec::new())),
result: None,
}
}
}
#[async_trait]
impl SandboxProvider for MockSandbox {
async fn execute(
&self,
command: &str,
_config: &SandboxConfig,
) -> Result<SandboxResult, SandboxError> {
self.execution_log
.lock()
.await
.push(format!("sandbox_execute:{command}"));
if !self.available {
return Err(SandboxError::NotAvailable);
}
Ok(self.result.clone().unwrap_or_else(|| SandboxResult {
stdout: "sandboxed".into(),
stderr: String::new(),
exit_code: 0,
}))
}
fn is_available(&self) -> bool {
self.available
}
}
struct MockSandboxFallbackHandler {
decision: SandboxFallbackDecision,
requests: Arc<Mutex<Vec<SandboxFallbackContext>>>,
}
struct ProfiledBashTool {
profile: Vec<ToolPermissionFacet>,
execution_log: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl AgentTool for ProfiledBashTool {
fn name(&self) -> &str {
"bash"
}
fn description(&self) -> &str {
"Permission-profile sandbox fallback fixture"
}
fn parameters(&self) -> Value {
serde_json::json!({"type": "object"})
}
fn permission_profile(&self, _input: &Value) -> Vec<ToolPermissionFacet> {
self.profile.clone()
}
async fn execute(&self, input: Value) -> ToolExecutionResult {
self.execution_log
.lock()
.await
.push(format!("profiled-bash:{input}"));
ToolExecutionResult::success("direct execution")
}
}
#[async_trait]
impl SandboxFallbackHandler for MockSandboxFallbackHandler {
async fn request_fallback(&self, context: SandboxFallbackContext) -> SandboxFallbackDecision {
self.requests.lock().await.push(context);
self.decision
}
}
fn sandbox_fallback_responses(final_text: &str) -> Vec<Vec<AgentEvent>> {
vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "bash".into(),
input: serde_json::json!({ "command": "echo hello" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: final_text.into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
]
}
fn bash_registry(log: Arc<Mutex<Vec<String>>>) -> ToolRegistry {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "bash".into(),
read_only: false,
delay_ms: 0,
result: ToolExecutionResult::success("direct execution"),
execution_log: log,
}));
registry
}
#[tokio::test]
async fn sandbox_fallback_defaults_to_deny() {
let log = Arc::new(Mutex::new(Vec::new()));
let agent = Agent::with_security(
Arc::new(MockModel::new(sandbox_fallback_responses(
"Denied fallback",
))),
bash_registry(log.clone()),
None,
Some(Box::new(MockSandbox::unavailable())),
PathBuf::from("/tmp"),
);
assert_eq!(agent.run("Test".into()).await.unwrap(), "Denied fallback");
assert!(log.lock().await.is_empty());
}
#[tokio::test]
async fn sandbox_fallback_ask_requires_dedicated_scoped_approval() {
let log = Arc::new(Mutex::new(Vec::new()));
let requests = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(MockSandboxFallbackHandler {
decision: SandboxFallbackDecision::ApproveOnce,
requests: requests.clone(),
});
let agent = Agent::with_security_and_sandbox_fallback(
Arc::new(MockModel::new(sandbox_fallback_responses(
"Fallback approved",
))),
bash_registry(log.clone()),
None,
Some(Box::new(MockSandbox::unavailable())),
PathBuf::from("/tmp"),
SandboxFallbackPolicy::Ask,
Some(handler),
);
assert_eq!(agent.run("Test".into()).await.unwrap(), "Fallback approved");
assert!(
log.lock()
.await
.iter()
.any(|entry| entry.starts_with("start:bash:"))
);
let requests = requests.lock().await;
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].tool_name, "bash");
assert_eq!(requests[0].arguments["redacted"], true);
assert_eq!(
requests[0].arguments["fields"],
serde_json::json!(["command"])
);
assert!(requests[0].arguments.get("command").is_none());
}
#[tokio::test]
async fn sandbox_fallback_ask_without_handler_denies() {
let log = Arc::new(Mutex::new(Vec::new()));
let agent = Agent::with_security_and_sandbox_fallback(
Arc::new(MockModel::new(sandbox_fallback_responses("Denied"))),
bash_registry(log.clone()),
None,
Some(Box::new(MockSandbox::unavailable())),
PathBuf::from("/tmp"),
SandboxFallbackPolicy::Ask,
None,
);
assert_eq!(agent.run("Test".into()).await.unwrap(), "Denied");
assert!(log.lock().await.is_empty());
}
#[tokio::test]
async fn sandbox_fallback_ask_rejection_denies() {
let log = Arc::new(Mutex::new(Vec::new()));
let requests = Arc::new(Mutex::new(Vec::new()));
let handler = Arc::new(MockSandboxFallbackHandler {
decision: SandboxFallbackDecision::Deny,
requests: requests.clone(),
});
let agent = Agent::with_security_and_sandbox_fallback(
Arc::new(MockModel::new(sandbox_fallback_responses("Denied"))),
bash_registry(log.clone()),
None,
Some(Box::new(MockSandbox::unavailable())),
PathBuf::from("/tmp"),
SandboxFallbackPolicy::Ask,
Some(handler),
);
assert_eq!(agent.run("Test".into()).await.unwrap(), "Denied");
assert!(log.lock().await.is_empty());
assert_eq!(requests.lock().await.len(), 1);
}
#[tokio::test]
async fn ordinary_always_approve_rule_does_not_approve_sandbox_fallback() {
let log = Arc::new(Mutex::new(Vec::new()));
let mut engine = PermissionEngine {
rules: Vec::new(),
workspace_root: None,
trusted_workspace: false,
};
engine.add_runtime_allow_rule(talos_permission::PermissionRule::new(
"bash",
None,
PermissionDecision::Allow,
));
let agent = Agent::with_security_and_sandbox_fallback(
Arc::new(MockModel::new(sandbox_fallback_responses("Denied"))),
bash_registry(log.clone()),
Some(Arc::new(engine)),
Some(Box::new(MockSandbox::unavailable())),
PathBuf::from("/tmp"),
SandboxFallbackPolicy::Ask,
None,
);
assert_eq!(agent.run("Test".into()).await.unwrap(), "Denied");
assert!(log.lock().await.is_empty());
}
#[tokio::test]
async fn sandbox_fallback_allow_unsandboxed_does_not_bypass_permission_deny() {
let log = Arc::new(Mutex::new(Vec::new()));
let mut engine = PermissionEngine {
rules: Vec::new(),
workspace_root: None,
trusted_workspace: false,
};
engine.add_rule(talos_permission::PermissionRule {
tool_name: "bash".into(),
path_pattern: None,
decision: PermissionDecision::Deny("blocked by test".into()),
nature: None,
resource: None,
resource_kind: None,
});
let agent = Agent::with_security_and_sandbox_fallback(
Arc::new(MockModel::new(sandbox_fallback_responses(
"Permission denied",
))),
bash_registry(log.clone()),
Some(Arc::new(engine)),
Some(Box::new(MockSandbox::unavailable())),
PathBuf::from("/tmp"),
SandboxFallbackPolicy::AllowUnsandboxed,
None,
);
assert_eq!(agent.run("Test".into()).await.unwrap(), "Permission denied");
assert!(log.lock().await.is_empty());
}
#[tokio::test]
async fn sandbox_fallback_allow_unsandboxed_denies_unresolved_permission() {
let log = Arc::new(Mutex::new(Vec::new()));
let mut engine = PermissionEngine {
rules: Vec::new(),
workspace_root: None,
trusted_workspace: false,
};
engine.add_rule(talos_permission::PermissionRule {
tool_name: "bash".into(),
path_pattern: None,
decision: PermissionDecision::Ask,
nature: None,
resource: None,
resource_kind: None,
});
let agent = Agent::with_security_and_sandbox_fallback(
Arc::new(MockModel::new(sandbox_fallback_responses(
"Permission remains unresolved",
))),
bash_registry(log.clone()),
Some(Arc::new(engine)),
Some(Box::new(MockSandbox::unavailable())),
PathBuf::from("/tmp"),
SandboxFallbackPolicy::AllowUnsandboxed,
None,
);
assert_eq!(
agent.run("Test".into()).await.unwrap(),
"Permission remains unresolved"
);
assert!(log.lock().await.is_empty());
}
#[tokio::test]
async fn allow_unsandboxed_preserves_path_network_and_execute_denials() {
let cases = [
ToolPermissionFacet::with_resource(
ToolNature::Write,
"workspace/output.txt",
ToolResourceKind::Path,
),
ToolPermissionFacet::with_resource(
ToolNature::Network,
"example.com",
ToolResourceKind::Domain,
),
ToolPermissionFacet::new(ToolNature::Execute),
];
for facet in cases {
let log = Arc::new(Mutex::new(Vec::new()));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(ProfiledBashTool {
profile: vec![facet.clone()],
execution_log: log.clone(),
}));
let mut engine = PermissionEngine {
rules: Vec::new(),
workspace_root: None,
trusted_workspace: false,
};
engine.add_rule(talos_permission::PermissionRule::new_nature(
facet.nature,
None,
None,
PermissionDecision::Deny("blocked by variant test".into()),
));
let agent = Agent::with_security_and_sandbox_fallback(
Arc::new(MockModel::new(sandbox_fallback_responses("Denied"))),
registry,
Some(Arc::new(engine)),
Some(Box::new(MockSandbox::unavailable())),
PathBuf::from("/tmp"),
SandboxFallbackPolicy::AllowUnsandboxed,
None,
);
assert_eq!(agent.run("Test".into()).await.unwrap(), "Denied");
assert!(
log.lock().await.is_empty(),
"fallback executed despite {:?} denial",
facet.nature
);
}
}
#[tokio::test]
async fn test_permission_check_blocks_denied_tool() {
let mut engine = PermissionEngine {
rules: Vec::new(),
workspace_root: None,
trusted_workspace: false,
};
engine.add_rule(talos_permission::PermissionRule {
tool_name: "echo".into(),
path_pattern: None,
decision: PermissionDecision::Deny("not allowed".into()),
nature: None,
resource: None,
resource_kind: None,
});
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "echo".into(),
input: serde_json::json!({ "message": "hello" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "echo".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("should not reach"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::with_security(
Arc::new(MockModel::new(responses)),
registry,
Some(Arc::new(engine)),
None,
PathBuf::from("/tmp"),
);
let result = agent.run("Test".into()).await;
assert!(result.is_ok());
assert_eq!(result.expect("operation should succeed"), "Done");
}
#[tokio::test]
async fn test_permission_check_allows_permitted_tool() {
let mut engine = PermissionEngine {
rules: Vec::new(),
workspace_root: None,
trusted_workspace: false,
};
engine.add_rule(talos_permission::PermissionRule {
tool_name: "echo".into(),
path_pattern: None,
decision: PermissionDecision::Allow,
nature: None,
resource: None,
resource_kind: None,
});
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "echo".into(),
input: serde_json::json!({ "message": "hello" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Result: hello".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "echo".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("hello"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::with_security(
Arc::new(MockModel::new(responses)),
registry,
Some(Arc::new(engine)),
None,
PathBuf::from("/tmp"),
);
let result = agent.run("Test".into()).await;
assert!(result.is_ok());
assert_eq!(result.expect("operation should succeed"), "Result: hello");
}
#[tokio::test]
async fn test_permission_ask_defaults_to_deny() {
let mut engine = PermissionEngine {
rules: Vec::new(),
workspace_root: None,
trusted_workspace: false,
};
engine.add_rule(talos_permission::PermissionRule {
tool_name: "echo".into(),
path_pattern: None,
decision: PermissionDecision::Ask,
nature: None,
resource: None,
resource_kind: None,
});
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "echo".into(),
input: serde_json::json!({ "message": "hello" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Denied".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "echo".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("should not reach"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::with_security(
Arc::new(MockModel::new(responses)),
registry,
Some(Arc::new(engine)),
None,
PathBuf::from("/tmp"),
);
let result = agent.run("Test".into()).await;
assert!(result.is_ok());
assert_eq!(result.expect("operation should succeed"), "Denied");
}
#[tokio::test]
async fn unresolved_permission_ask_reaches_wrapped_tool_without_sandbox_fallback() {
let log = Arc::new(Mutex::new(Vec::new()));
let agent = Agent::with_security(
Arc::new(MockModel::new(sandbox_fallback_responses("Done"))),
bash_registry(log.clone()),
Some(Arc::new(PermissionEngine::new())),
None,
PathBuf::from("/tmp"),
);
assert_eq!(agent.run("Test".into()).await.unwrap(), "Done");
assert!(
log.lock()
.await
.iter()
.any(|entry| entry.starts_with("start:bash:")),
"unresolved Ask should remain available to the permission-aware wrapper"
);
}
#[tokio::test]
async fn test_sandbox_execution_for_bash_tool() {
let sandbox_result = SandboxResult {
stdout: "sandboxed output".into(),
stderr: String::new(),
exit_code: 0,
};
let sandbox = MockSandbox::new(true, sandbox_result);
let log = sandbox.execution_log.clone();
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "bash".into(),
input: serde_json::json!({ "command": "echo hello" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "bash".into(),
read_only: false,
delay_ms: 0,
result: ToolExecutionResult::success("direct execution"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::with_security(
Arc::new(MockModel::new(responses)),
registry,
None,
Some(Box::new(sandbox)),
PathBuf::from("/tmp"),
);
let result = agent.run("Test".into()).await;
assert!(result.is_ok());
let log_entries = log.lock().await;
assert!(
log_entries.iter().any(|e| e.contains("echo hello")),
"Sandbox should have been called with the command, log: {:?}",
log_entries
);
}
#[tokio::test]
async fn test_sandbox_fallback_when_not_available() {
let sandbox = MockSandbox::unavailable();
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "bash".into(),
input: serde_json::json!({ "command": "echo hello" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: talos_core::message::Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "Fallback worked".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: talos_core::message::Usage::default(),
},
],
];
let log = Arc::new(Mutex::new(Vec::new()));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "bash".into(),
read_only: false,
delay_ms: 0,
result: ToolExecutionResult::success("direct execution"),
execution_log: log.clone(),
}));
let agent = Agent::with_security_and_sandbox_fallback(
Arc::new(MockModel::new(responses)),
registry,
None,
Some(Box::new(sandbox)),
PathBuf::from("/tmp"),
SandboxFallbackPolicy::AllowUnsandboxed,
None,
);
let result = agent.run("Test".into()).await;
assert!(result.is_ok());
assert_eq!(result.expect("operation should succeed"), "Fallback worked");
let log_entries = log.lock().await;
assert!(
log_entries.iter().any(|e| e.starts_with("start:bash:")),
"Direct execution should have been used as fallback, log: {:?}",
log_entries
);
}
#[test]
fn test_agent_with_security_constructor() {
let provider: Arc<dyn LanguageModel> = Arc::new(MockModel::new(vec![]));
let tools = ToolRegistry::new();
let permission = PermissionEngine::new();
let agent = Agent::with_security(
provider.clone(),
tools,
Some(Arc::new(permission)),
None,
PathBuf::from("/tmp/workspace"),
);
let _token = agent.cancellation_token();
assert!(!_token.is_cancelled());
}
#[test]
#[allow(deprecated)] fn test_agent_new_has_no_security() {
let provider: Arc<dyn LanguageModel> = Arc::new(MockModel::new(vec![]));
let tools = ToolRegistry::new();
let agent = Agent::new(provider, tools);
let _token = agent.cancellation_token();
assert!(!_token.is_cancelled());
}
#[tokio::test]
async fn test_sandbox_result_to_tool_result_success() {
let sandbox_result = SandboxResult {
stdout: "hello".into(),
stderr: "warning".into(),
exit_code: 0,
};
let tool_result = Agent::sandbox_result_to_tool_result(sandbox_result);
assert!(!tool_result.is_error);
assert!(tool_result.content.contains("hello"));
assert!(tool_result.content.contains("warning"));
}
#[tokio::test]
async fn test_sandbox_result_to_tool_result_error() {
let sandbox_result = SandboxResult {
stdout: String::new(),
stderr: "error occurred".into(),
exit_code: 1,
};
let tool_result = Agent::sandbox_result_to_tool_result(sandbox_result);
assert!(tool_result.is_error);
assert!(tool_result.content.contains("error occurred"));
}
#[tokio::test]
async fn test_execute_bash_in_sandbox_missing_command() {
let sandbox = MockSandbox::new(
true,
SandboxResult {
stdout: String::new(),
stderr: String::new(),
exit_code: 0,
},
);
let input = serde_json::json!({});
let agent = Agent::with_security(
Arc::new(MockModel::new(vec![])),
ToolRegistry::new(),
None,
Some(Box::new(sandbox)),
PathBuf::from("/tmp"),
);
let hook_ctx = HookContext::new(TurnId::new(), PathBuf::from("/tmp"));
let result = agent
.execute_bash_in_sandbox(
&hook_ctx,
agent.sandbox.as_deref().expect("sandbox should be present"),
&input,
)
.await;
assert!(result.is_error);
assert!(result.content.contains("missing required field 'command'"));
}
#[test]
#[allow(deprecated)] fn test_clear_append_prompt() {
let provider: Arc<dyn LanguageModel> = Arc::new(MockModel::new(vec![]));
let tools = ToolRegistry::new();
let mut agent = Agent::new(provider, tools);
agent.set_append_prompt("test".to_string());
let prompt = agent.prompt_builder.build();
assert!(prompt.contains("test"), "Append prompt should be set");
agent.clear_append_prompt();
let prompt = agent.prompt_builder.build();
assert!(
!prompt.contains("Additional Instructions"),
"Append prompt section should be gone after clear"
);
}
#[test]
#[allow(deprecated)] fn test_set_append_prompt_opt_none() {
let provider: Arc<dyn LanguageModel> = Arc::new(MockModel::new(vec![]));
let tools = ToolRegistry::new();
let mut agent = Agent::new(provider, tools);
agent.set_append_prompt("test".to_string());
let prompt = agent.prompt_builder.build();
assert!(prompt.contains("test"), "Append prompt should be set");
agent.set_append_prompt_opt(None);
let prompt = agent.prompt_builder.build();
assert!(
!prompt.contains("Additional Instructions"),
"Append prompt section should be gone after set_append_prompt_opt(None)"
);
}
#[tokio::test]
async fn test_todo_section_provider_reaches_system_prompt() {
let response_events = vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta { delta: "OK".into() },
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
];
let (model, captured_prompts) = CapturingModel::new(vec![response_events]);
let mut agent = Agent::with_security(
Arc::new(model),
ToolRegistry::new(),
None,
None,
PathBuf::from("/tmp"),
);
agent.set_todo_section_provider(Arc::new(|| {
Some("- [in_progress][high] abc123 Finish bounded todo prompt".to_string())
}));
let response = agent
.run("continue".into())
.await
.expect("operation should succeed");
assert_eq!(response, "OK");
let prompts = captured_prompts.lock().expect("lock poisoned");
assert_eq!(prompts.len(), 1);
assert!(prompts[0].contains("# Session Todos"));
assert!(prompts[0].contains("Finish bounded todo prompt"));
}
struct CapturingModel {
responses: Arc<Mutex<Vec<Vec<AgentEvent>>>>,
captured_system_prompts: Arc<std::sync::Mutex<Vec<String>>>,
captured_tool_names: Arc<std::sync::Mutex<Vec<Vec<String>>>>,
}
impl CapturingModel {
fn new(responses: Vec<Vec<AgentEvent>>) -> (Self, Arc<std::sync::Mutex<Vec<String>>>) {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let captured_tool_names = Arc::new(std::sync::Mutex::new(Vec::new()));
(
Self {
responses: Arc::new(Mutex::new(responses)),
captured_system_prompts: captured.clone(),
captured_tool_names,
},
captured,
)
}
fn new_with_tool_capture(
responses: Vec<Vec<AgentEvent>>,
) -> (
Self,
Arc<std::sync::Mutex<Vec<String>>>,
Arc<std::sync::Mutex<Vec<Vec<String>>>>,
) {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let captured_tool_names = Arc::new(std::sync::Mutex::new(Vec::new()));
(
Self {
responses: Arc::new(Mutex::new(responses)),
captured_system_prompts: captured.clone(),
captured_tool_names: captured_tool_names.clone(),
},
captured,
captured_tool_names,
)
}
}
#[async_trait]
impl LanguageModel for CapturingModel {
async fn stream(&self, messages: &[Message]) -> ProviderResult<Receiver<AgentEvent>> {
for msg in messages {
if let Message::System { content, .. } = msg {
self.captured_system_prompts
.lock()
.expect("lock poisoned")
.push(content.clone());
}
}
let (tx, rx) = mpsc::channel(64);
let responses = self.responses.clone();
tokio::spawn(async move {
let mut responses = responses.lock().await;
let events = responses.pop_front().unwrap_or_default();
for event in events {
tx.send(event).await.expect("receiver dropped");
}
});
Ok(rx)
}
async fn stream_with_tools(
&self,
messages: &[Message],
tools: &[ToolDefinition],
) -> ProviderResult<Receiver<AgentEvent>> {
self.captured_tool_names
.lock()
.expect("lock poisoned")
.push(tools.iter().map(|tool| tool.name.clone()).collect());
self.stream(messages).await
}
}
struct CapturingMessagesModel {
responses: Arc<Mutex<Vec<Vec<AgentEvent>>>>,
captured_messages: Arc<std::sync::Mutex<Vec<Vec<Message>>>>,
}
impl CapturingMessagesModel {
fn new(responses: Vec<Vec<AgentEvent>>) -> (Self, Arc<std::sync::Mutex<Vec<Vec<Message>>>>) {
let captured_messages = Arc::new(std::sync::Mutex::new(Vec::new()));
(
Self {
responses: Arc::new(Mutex::new(responses)),
captured_messages: captured_messages.clone(),
},
captured_messages,
)
}
}
#[async_trait]
impl LanguageModel for CapturingMessagesModel {
async fn stream(&self, _messages: &[Message]) -> ProviderResult<Receiver<AgentEvent>> {
let (tx, rx) = mpsc::channel(64);
let responses = self.responses.clone();
tokio::spawn(async move {
let mut responses = responses.lock().await;
let events = responses.pop_front().unwrap_or_default();
for event in events {
tx.send(event).await.expect("receiver dropped");
}
});
Ok(rx)
}
async fn stream_with_tools(
&self,
messages: &[Message],
_tools: &[ToolDefinition],
) -> ProviderResult<Receiver<AgentEvent>> {
self.captured_messages
.lock()
.expect("lock poisoned")
.push(messages.to_vec());
self.stream(messages).await
}
}
fn stable_part(prompt: &str) -> &str {
for marker in ["# Runtime Context", "# Context", "# User Preferences"] {
if let Some(pos) = prompt.find(marker) {
return &prompt[..pos];
}
}
prompt
}
#[tokio::test]
async fn test_tool_presentation_policy_syncs_prompt_and_provider_definitions() {
let response_events = vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta { delta: "OK".into() },
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
];
let (model, captured_prompts, captured_tool_names) =
CapturingModel::new_with_tool_capture(vec![response_events]);
let execution_log = Arc::new(Mutex::new(Vec::new()));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(FamilyMockTool {
tool_name: "read".into(),
family: ToolFamily::File,
always_on: true,
execution_log: execution_log.clone(),
}));
registry.register(Arc::new(FamilyMockTool {
tool_name: "git_status".into(),
family: ToolFamily::Git,
always_on: false,
execution_log: execution_log.clone(),
}));
registry.register(Arc::new(FamilyMockTool {
tool_name: "web_search".into(),
family: ToolFamily::Network,
always_on: false,
execution_log,
}));
let mut agent =
Agent::with_security(Arc::new(model), registry, None, None, PathBuf::from("/tmp"));
agent.set_tool_presentation_policy(ToolPresentationPolicy::with_families([ToolFamily::Git]));
let response = agent
.run("status".into())
.await
.expect("operation should succeed");
assert_eq!(response, "OK");
let prompts = captured_prompts.lock().expect("lock poisoned");
assert_eq!(prompts.len(), 1);
assert!(prompts[0].contains("## git_status"));
assert!(prompts[0].contains("## read"));
assert!(!prompts[0].contains("## web_search"));
let tool_names = captured_tool_names.lock().expect("lock poisoned");
assert_eq!(
tool_names.as_slice(),
&[vec![String::from("git_status"), String::from("read")]]
);
}
#[tokio::test]
async fn test_unpresented_registered_tool_returns_recoverable_error_without_execution() {
let call = ToolCall {
id: "call-1".into(),
name: "git_status".into(),
input: serde_json::json!({}),
};
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call,
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
],
];
let execution_log = Arc::new(Mutex::new(Vec::new()));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(FamilyMockTool {
tool_name: "read".into(),
family: ToolFamily::File,
always_on: true,
execution_log: execution_log.clone(),
}));
registry.register(Arc::new(FamilyMockTool {
tool_name: "git_status".into(),
family: ToolFamily::Git,
always_on: false,
execution_log: execution_log.clone(),
}));
let mut agent = Agent::with_security(
Arc::new(MockModel::new(responses)),
registry,
None,
None,
PathBuf::from("/tmp"),
);
agent.set_tool_presentation_policy(ToolPresentationPolicy::always_on());
let response = agent
.run("status".into())
.await
.expect("operation should succeed");
assert_eq!(response, "done");
assert!(
execution_log.lock().await.is_empty(),
"unpresented registered tool must not execute"
);
}
#[tokio::test]
async fn test_disclosed_backend_updates_provider_schema() {
let response_events = vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta { delta: "OK".into() },
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
];
let (model, captured_tools) = ToolDefinitionCapturingModel::new(vec![response_events]);
let execution_log = Arc::new(Mutex::new(Vec::new()));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(BackendMockTool {
execution_log: execution_log.clone(),
}));
let mut agent =
Agent::with_security(Arc::new(model), registry, None, None, PathBuf::from("/tmp"));
agent.set_tool_presentation_policy(
ToolPresentationPolicy::always_on().disclose_backend("fetch_url", "browser_page"),
);
let response = agent
.run("read browser page".into())
.await
.expect("operation should succeed");
assert_eq!(response, "OK");
let captured = captured_tools.lock().expect("lock poisoned");
let fetch_definition = captured[0]
.iter()
.find(|tool| tool.name == "fetch_url")
.expect("fetch_url should be presented by backend disclosure");
let access_enum = fetch_definition
.parameters
.pointer("/properties/access/enum")
.and_then(Value::as_array)
.expect("access enum");
assert!(access_enum.iter().any(|value| value == "browser"));
}
#[tokio::test]
async fn test_disclosed_browser_backend_still_requires_permission_allow() {
let call = ToolCall {
id: "call-1".into(),
name: "fetch_url".into(),
input: serde_json::json!({
"url": "https://example.com/private",
"access": "browser"
}),
};
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call,
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
],
];
let execution_log = Arc::new(Mutex::new(Vec::new()));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(BackendMockTool {
execution_log: execution_log.clone(),
}));
let mut engine = PermissionEngine {
rules: Vec::new(),
workspace_root: None,
trusted_workspace: false,
};
engine.add_rule(talos_permission::PermissionRule::new_nature(
ToolNature::Network,
None,
None,
PermissionDecision::Deny("network blocked".into()),
));
let mut agent = Agent::with_security(
Arc::new(MockModel::new(responses)),
registry,
Some(Arc::new(engine)),
None,
PathBuf::from("/tmp"),
);
agent.set_tool_presentation_policy(
ToolPresentationPolicy::with_families([ToolFamily::Network])
.disclose_backend("fetch_url", "browser_page"),
);
let response = agent
.run("read browser page".into())
.await
.expect("operation should succeed");
assert_eq!(response, "done");
assert!(
execution_log.lock().await.is_empty(),
"backend disclosure must not bypass permission denial"
);
}
#[tokio::test]
async fn test_undisclosed_backend_returns_recoverable_error_without_execution() {
let call = ToolCall {
id: "call-1".into(),
name: "fetch_url".into(),
input: serde_json::json!({
"url": "https://example.com",
"access": "browser"
}),
};
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call,
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
],
];
let execution_log = Arc::new(Mutex::new(Vec::new()));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(BackendMockTool {
execution_log: execution_log.clone(),
}));
let mut agent = Agent::with_security(
Arc::new(MockModel::new(responses)),
registry,
None,
None,
PathBuf::from("/tmp"),
);
agent
.set_tool_presentation_policy(ToolPresentationPolicy::with_families([ToolFamily::Network]));
let response = agent
.run("read browser page".into())
.await
.expect("operation should succeed");
assert_eq!(response, "done");
assert!(
execution_log.lock().await.is_empty(),
"undisclosed backend must not execute"
);
}
#[tokio::test]
async fn test_tool_continuation_discloses_backend_for_next_provider_call() {
let call = ToolCall {
id: "call-1".into(),
name: "fetch_url".into(),
input: serde_json::json!({
"url": "https://example.com/private",
"needs_browser": true
}),
};
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call,
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
],
];
let (model, captured_tools) = ToolDefinitionCapturingModel::new(responses);
let execution_log = Arc::new(Mutex::new(Vec::new()));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(BackendMockTool {
execution_log: execution_log.clone(),
}));
let mut agent =
Agent::with_security(Arc::new(model), registry, None, None, PathBuf::from("/tmp"));
agent
.set_tool_presentation_policy(ToolPresentationPolicy::with_families([ToolFamily::Network]));
let response = agent
.run("read private page".into())
.await
.expect("operation should succeed");
assert_eq!(response, "done");
assert_eq!(execution_log.lock().await.len(), 1);
let captured = captured_tools.lock().expect("lock poisoned");
assert_eq!(captured.len(), 2, "provider should be called twice");
let first_fetch = captured[0]
.iter()
.find(|tool| tool.name == "fetch_url")
.expect("fetch_url first call");
let first_enum = first_fetch
.parameters
.pointer("/properties/access/enum")
.and_then(Value::as_array)
.expect("first access enum");
assert!(!first_enum.iter().any(|value| value == "browser"));
let second_fetch = captured[1]
.iter()
.find(|tool| tool.name == "fetch_url")
.expect("fetch_url second call");
let second_enum = second_fetch
.parameters
.pointer("/properties/access/enum")
.and_then(Value::as_array)
.expect("second access enum");
assert!(second_enum.iter().any(|value| value == "browser"));
}
#[tokio::test]
async fn test_tool_continuation_discloses_tool_for_next_provider_call() {
let call = ToolCall {
id: "call-1".into(),
name: "fetch_url".into(),
input: serde_json::json!({
"url": "https://example.com/api"
}),
};
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call,
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
],
];
let (model, captured_tools) = ToolDefinitionCapturingModel::new(responses);
let mut registry = ToolRegistry::new();
registry.register(Arc::new(AdvancedHttpDisclosureTool));
registry.register(Arc::new(FamilyMockTool {
tool_name: "http_request".to_string(),
family: ToolFamily::AdvancedNetwork,
always_on: false,
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::with_security(Arc::new(model), registry, None, None, PathBuf::from("/tmp"));
let response = agent
.run("fetch with advanced HTTP if needed".into())
.await
.expect("operation should succeed");
assert_eq!(response, "done");
let captured = captured_tools.lock().expect("lock poisoned");
assert_eq!(captured.len(), 2, "provider should be called twice");
assert!(captured[0].iter().any(|tool| tool.name == "fetch_url"));
assert!(
!captured[0].iter().any(|tool| tool.name == "http_request"),
"advanced HTTP should not be visible before disclosure"
);
assert!(
captured[1].iter().any(|tool| tool.name == "http_request"),
"tool continuation should disclose http_request on the next call"
);
}
#[tokio::test]
#[allow(deprecated)]
async fn test_stable_prefix_identical_across_turns() {
let response_events = vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta { delta: "OK".into() },
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
];
let all_responses = vec![
response_events.clone(),
response_events.clone(),
response_events.clone(),
];
let (model, captured) = CapturingModel::new(all_responses);
let agent = Agent::new(Arc::new(model), ToolRegistry::new());
let _ = agent
.run("turn 1".into())
.await
.expect("operation should succeed");
let _ = agent
.run("turn 2".into())
.await
.expect("operation should succeed");
let _ = agent
.run("turn 3".into())
.await
.expect("operation should succeed");
let prompts = captured.lock().expect("lock poisoned");
assert_eq!(prompts.len(), 3, "should have 3 system prompts");
fn stable_part(prompt: &str) -> &str {
for marker in ["# Runtime Context", "# Context", "# User Preferences"] {
if let Some(pos) = prompt.find(marker) {
return &prompt[..pos];
}
}
prompt
}
let stable_0 = stable_part(&prompts[0]);
let stable_1 = stable_part(&prompts[1]);
let stable_2 = stable_part(&prompts[2]);
assert_eq!(
stable_0, stable_1,
"stable prefix should be identical between turn 1 and 2"
);
assert_eq!(
stable_1, stable_2,
"stable prefix should be identical between turn 2 and 3"
);
}
#[tokio::test]
#[allow(deprecated)]
async fn test_bash_compression_does_not_change_stable_prefix() {
let response_events = vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta { delta: "OK".into() },
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
];
let (plain_model, plain_prompts) = CapturingModel::new(vec![response_events.clone()]);
let plain_agent = Agent::new(Arc::new(plain_model), ToolRegistry::new());
let _ = plain_agent
.run("plain".into())
.await
.expect("operation should succeed");
let (compressed_model, compressed_prompts) = CapturingModel::new(vec![response_events]);
let compressed_agent =
Agent::new(Arc::new(compressed_model), ToolRegistry::new()).with_bash_compression(true);
let _ = compressed_agent
.run("compressed".into())
.await
.expect("operation should succeed");
let plain_prompts = plain_prompts.lock().expect("lock poisoned");
let compressed_prompts = compressed_prompts.lock().expect("lock poisoned");
assert_eq!(plain_prompts.len(), 1);
assert_eq!(compressed_prompts.len(), 1);
assert_eq!(
stable_part(&plain_prompts[0]),
stable_part(&compressed_prompts[0]),
"bash compression must not alter stable-prefix bytes"
);
}
#[tokio::test]
#[allow(deprecated)]
async fn test_bash_compression_preserves_ui_result_and_compresses_model_context() {
let long_output = (0..50)
.map(|i| format!("row-{i:03}"))
.collect::<Vec<_>>()
.join("\n");
let responses = vec![
vec![
AgentEvent::TurnStart,
AgentEvent::ToolCall {
call: ToolCall {
id: "call_bash".into(),
name: "bash".into(),
input: serde_json::json!({ "command": "generate output" }),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
},
],
vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta {
delta: "done".into(),
},
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
],
];
let (model, captured_messages) = CapturingMessagesModel::new(responses);
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TimedMockTool {
tool_name: "bash".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success(long_output.clone()),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let agent = Agent::new(Arc::new(model), registry).with_bash_compression(true);
let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
let (_response, _new_messages) = agent
.run_streaming("run bash".into(), vec![], tx)
.await
.expect("operation should succeed");
let captured = captured_messages.lock().expect("lock poisoned");
assert_eq!(captured.len(), 2, "tool turn should call provider twice");
let model_tool_result = captured[1]
.iter()
.find_map(|message| match message {
Message::Tool { result } => Some(result),
_ => None,
})
.expect("second provider call should include model-facing tool result");
assert!(model_tool_result.content.contains("first 20 lines omitted"));
assert!(model_tool_result.content.contains("row-020"));
assert!(model_tool_result.content.contains("row-049"));
assert!(!model_tool_result.content.contains("row-000"));
assert!(!model_tool_result.content.contains("row-019"));
let mut ui_tool_result = None;
while let Ok(event) = rx.try_recv() {
if let AgentEvent::ToolResult { result } = event {
ui_tool_result = Some(result);
}
}
let ui_tool_result = ui_tool_result.expect("UI should receive full tool result event");
assert_eq!(ui_tool_result.content, long_output);
}
#[tokio::test]
#[allow(deprecated)]
async fn test_stable_prefix_changes_after_set_tools() {
let response_events = vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta { delta: "OK".into() },
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
];
let (model, captured) =
CapturingModel::new(vec![response_events.clone(), response_events.clone()]);
let mut agent = Agent::new(Arc::new(model), ToolRegistry::new());
let _ = agent
.run("turn 1".into())
.await
.expect("operation should succeed");
agent.set_tools(vec![ToolDescription {
name: "new_tool".into(),
description: "A new tool".into(),
..Default::default()
}]);
let _ = agent
.run("turn 2".into())
.await
.expect("operation should succeed");
let prompts = captured.lock().expect("lock poisoned");
assert_eq!(prompts.len(), 2, "should have 2 system prompts");
fn stable_part(prompt: &str) -> &str {
for marker in ["# Runtime Context", "# Context", "# User Preferences"] {
if let Some(pos) = prompt.find(marker) {
return &prompt[..pos];
}
}
prompt
}
let stable_0 = stable_part(&prompts[0]);
let stable_1 = stable_part(&prompts[1]);
assert_ne!(
stable_0, stable_1,
"stable prefix should differ after set_tools()"
);
assert!(
stable_1.contains("new_tool"),
"new stable prefix should contain the new tool"
);
}
#[tokio::test]
#[allow(deprecated)]
async fn test_stable_prefix_changes_after_set_skill_index() {
let response_events = vec![
AgentEvent::TurnStart,
AgentEvent::TextDelta { delta: "OK".into() },
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
];
let (model, captured) =
CapturingModel::new(vec![response_events.clone(), response_events.clone()]);
let mut agent = Agent::new(Arc::new(model), ToolRegistry::new());
let _ = agent
.run("turn 1".into())
.await
.expect("operation should succeed");
agent.set_skill_index(vec![SkillIndex {
name: "new-skill".into(),
description: "A new skill".into(),
triggers: vec!["new".into()],
estimated_tokens: 0,
source: talos_skill::SkillSource::Project,
}]);
let _ = agent
.run("turn 2".into())
.await
.expect("operation should succeed");
let prompts = captured.lock().expect("lock poisoned");
assert_eq!(prompts.len(), 2);
fn stable_part(prompt: &str) -> &str {
for marker in ["# Runtime Context", "# Context", "# User Preferences"] {
if let Some(pos) = prompt.find(marker) {
return &prompt[..pos];
}
}
prompt
}
let stable_0 = stable_part(&prompts[0]);
let stable_1 = stable_part(&prompts[1]);
assert_ne!(
stable_0, stable_1,
"stable prefix should differ after set_skill_index()"
);
assert!(
stable_1.contains("new-skill"),
"new stable prefix should contain the new skill"
);
}
#[test]
fn test_build_stable_prefix_and_dynamic_suffix() {
use crate::prompt::SystemPromptBuilder;
let builder = SystemPromptBuilder::new()
.with_tools(vec![ToolDescription {
name: "read".into(),
description: "Read a file".into(),
..Default::default()
}])
.with_skill_index(vec![SkillIndex {
name: "test-skill".into(),
description: "A test skill".into(),
triggers: vec!["test".into()],
estimated_tokens: 0,
source: talos_skill::SkillSource::Project,
}]);
let stable = builder.build_stable_prefix();
let dynamic = builder.build_dynamic_suffix();
assert!(stable.contains("# Identity"));
assert!(stable.contains("# Tools"));
assert!(stable.contains("## read"));
assert!(stable.contains("# Skills"));
assert!(stable.contains("test-skill"));
assert!(!stable.contains("# Runtime Context"));
assert!(dynamic.contains("# Runtime Context"));
assert!(dynamic.contains("unix_seconds="));
let combined = if stable.is_empty() {
dynamic.clone()
} else if dynamic.is_empty() {
stable.clone()
} else {
format!("{stable}\n{dynamic}")
};
let full = builder.build();
assert_eq!(combined, full);
}
struct CapturingMockModel {
responses: Arc<Mutex<Vec<Vec<AgentEvent>>>>,
captured_messages: Arc<Mutex<Vec<Vec<Message>>>>,
}
impl CapturingMockModel {
fn new(responses: Vec<Vec<AgentEvent>>) -> (Self, Arc<Mutex<Vec<Vec<Message>>>>) {
let captured = Arc::new(Mutex::new(Vec::new()));
let model = Self {
responses: Arc::new(Mutex::new(responses)),
captured_messages: captured.clone(),
};
(model, captured)
}
}
#[async_trait]
impl LanguageModel for CapturingMockModel {
async fn stream(&self, messages: &[Message]) -> ProviderResult<Receiver<AgentEvent>> {
self.captured_messages.lock().await.push(messages.to_vec());
let (tx, rx) = mpsc::channel(64);
let responses = self.responses.clone();
tokio::spawn(async move {
let mut responses = responses.lock().await;
let events = responses.pop_front().unwrap_or_default();
for event in events {
tx.send(event).await.expect("receiver dropped");
}
});
Ok(rx)
}
}
struct TestReadImageTool;
#[async_trait]
impl AgentTool for TestReadImageTool {
fn name(&self) -> &str {
"read_image"
}
fn description(&self) -> &str {
"test read_image"
}
fn parameters(&self) -> Value {
serde_json::json!({"type": "object", "properties": {"path": {"type": "string"}}})
}
fn is_read_only(&self) -> bool {
true
}
fn nature(&self) -> ToolNature {
ToolNature::Read
}
fn family(&self) -> ToolFamily {
ToolFamily::File
}
fn permission_profile(&self, input: &Value) -> Vec<ToolPermissionFacet> {
match input.get("path").and_then(Value::as_str) {
Some(path) => vec![ToolPermissionFacet::with_resource(
ToolNature::Read,
path,
ToolResourceKind::Path,
)],
None => vec![ToolPermissionFacet::new(ToolNature::Read)],
}
}
async fn execute(&self, _input: Value) -> ToolExecutionResult {
ToolExecutionResult::success("[Image read: test.png]")
}
async fn execute_with_output(&self, _input: Value) -> ToolExecutionOutput {
ToolExecutionOutput {
result: ToolExecutionResult::success("[Image read: test.png (8 bytes, image/png)]"),
next_provider_parts: vec![ContentPart::Image {
path: PathBuf::from("test.png"),
mime: "image/png".to_string(),
byte_count: 8,
content_digest: ContentDigest::default(),
}],
}
}
}
fn image_continuation_events() -> Vec<AgentEvent> {
vec![
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "read_image".into(),
input: serde_json::json!({"path": "test.png"}),
},
provenance: Default::default(),
summary_fields: vec!["path".into()],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
},
]
}
fn text_done_events(text: &str) -> Vec<AgentEvent> {
vec![
AgentEvent::TextDelta { delta: text.into() },
AgentEvent::TurnEnd {
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
},
]
}
#[tokio::test]
async fn continuation_image_appears_once_in_next_provider_request() {
let (model, captured) = CapturingMockModel::new(vec![
image_continuation_events(),
text_done_events("described the image"),
]);
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TestReadImageTool));
let mut agent = Agent::with_security_and_hooks(
Arc::new(model),
registry,
Some(Arc::new(PermissionEngine::new())),
None,
PathBuf::from("/tmp"),
Arc::new(HookRegistry::new()),
);
agent.set_image_input_supported(true);
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let result = agent
.run_streaming("read the image".into(), vec![], event_tx)
.await;
assert!(result.is_ok());
let calls = captured.lock().await;
assert_eq!(calls.len(), 2, "exactly 2 provider calls");
let first = &calls[0];
assert!(
!first
.iter()
.any(|m| matches!(m, Message::Multimodal { .. })),
"first provider call must not contain Multimodal"
);
let second = &calls[1];
assert!(
second
.iter()
.any(|m| matches!(m, Message::Multimodal { parts } if parts.iter().any(|p| matches!(p, ContentPart::Image { .. })))),
"second provider call must contain Multimodal with Image"
);
}
#[tokio::test]
async fn continuation_image_consumed_after_second_call() {
let read_tool_call = vec![
AgentEvent::ToolCall {
call: ToolCall {
id: "call_read".into(),
name: "read".into(),
input: serde_json::json!({"path": "file.txt"}),
},
provenance: Default::default(),
summary_fields: vec![],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
},
];
let (model, captured) = CapturingMockModel::new(vec![
image_continuation_events(),
read_tool_call,
text_done_events("done"),
]);
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TestReadImageTool));
registry.register(Arc::new(TimedMockTool {
tool_name: "read".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("file content"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let mut agent = Agent::with_security_and_hooks(
Arc::new(model),
registry,
Some(Arc::new(PermissionEngine::new())),
None,
PathBuf::from("/tmp"),
Arc::new(HookRegistry::new()),
);
agent.set_image_input_supported(true);
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let result = agent
.run_streaming("read the image then the file".into(), vec![], event_tx)
.await;
assert!(result.is_ok());
let calls = captured.lock().await;
assert_eq!(calls.len(), 3, "exactly 3 provider calls");
assert!(
!calls[0]
.iter()
.any(|m| matches!(m, Message::Multimodal { .. })),
"first call must not have image"
);
assert!(
calls[1].iter().any(|m| matches!(m, Message::Multimodal { parts } if parts.iter().any(|p| matches!(p, ContentPart::Image { .. })))),
"second call must have image (one-shot)"
);
assert!(
!calls[2].iter().any(|m| matches!(m, Message::Multimodal { parts } if parts.iter().any(|p| matches!(p, ContentPart::Image { .. })))),
"third call must NOT have image (consumed)"
);
}
#[tokio::test]
async fn batch_limit_rejects_second_read_image_before_execution() {
let two_read_image_events = vec![
AgentEvent::ToolCall {
call: ToolCall {
id: "call_1".into(),
name: "read_image".into(),
input: serde_json::json!({"path": "a.png"}),
},
provenance: Default::default(),
summary_fields: vec!["path".into()],
},
AgentEvent::ToolCall {
call: ToolCall {
id: "call_2".into(),
name: "read_image".into(),
input: serde_json::json!({"path": "b.png"}),
},
provenance: Default::default(),
summary_fields: vec!["path".into()],
},
AgentEvent::TurnEnd {
stop_reason: StopReason::ToolUse,
usage: Usage::default(),
},
];
let (model, captured) =
CapturingMockModel::new(vec![two_read_image_events, text_done_events("done")]);
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TestReadImageTool));
let mut agent = Agent::with_security_and_hooks(
Arc::new(model),
registry,
Some(Arc::new(PermissionEngine::new())),
None,
PathBuf::from("/tmp"),
Arc::new(HookRegistry::new()),
);
agent.set_image_input_supported(true);
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let result = agent
.run_streaming("read both images".into(), vec![], event_tx)
.await;
assert!(result.is_ok());
let calls = captured.lock().await;
if calls.len() >= 2 {
let second = &calls[1];
let image_msgs: Vec<_> = second
.iter()
.filter(|m| matches!(m, Message::Multimodal { parts } if parts.iter().any(|p| matches!(p, ContentPart::Image { .. }))))
.collect();
assert_eq!(image_msgs.len(), 1, "only 1 image in second call");
let tool_results: Vec<_> = second
.iter()
.filter_map(|m| match m {
Message::Tool { result } => Some(result.clone()),
_ => None,
})
.collect();
assert_eq!(tool_results.len(), 2, "2 tool results");
assert!(!tool_results[0].is_error, "first read_image succeeds");
assert!(tool_results[1].is_error, "second read_image rejected");
assert!(
tool_results[1].content.contains("Only one image"),
"second result must explain batch limit: {}",
tool_results[1].content
);
}
}
#[tokio::test]
async fn continuation_image_not_in_persisted_messages() {
let (model, _captured) =
CapturingMockModel::new(vec![image_continuation_events(), text_done_events("done")]);
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TestReadImageTool));
let mut agent = Agent::with_security_and_hooks(
Arc::new(model),
registry,
Some(Arc::new(PermissionEngine::new())),
None,
PathBuf::from("/tmp"),
Arc::new(HookRegistry::new()),
);
agent.set_image_input_supported(true);
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let (result, messages) = agent
.run_for_session_turn("read the image".into(), vec![], event_tx)
.await;
assert!(result.is_ok());
assert!(
!messages.iter().any(|m| {
matches!(m, Message::Multimodal { parts } if parts.iter().any(|p| matches!(p, ContentPart::Image { .. })))
}),
"persisted messages must not contain image continuation parts"
);
}
#[tokio::test]
async fn continuation_image_safe_summary_persisted_in_tool_result() {
let (model, _captured) =
CapturingMockModel::new(vec![image_continuation_events(), text_done_events("done")]);
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TestReadImageTool));
let mut agent = Agent::with_security_and_hooks(
Arc::new(model),
registry,
Some(Arc::new(PermissionEngine::new())),
None,
PathBuf::from("/tmp"),
Arc::new(HookRegistry::new()),
);
agent.set_image_input_supported(true);
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let (result, messages) = agent
.run_for_session_turn("read the image".into(), vec![], event_tx)
.await;
assert!(result.is_ok());
let tool_msg = messages.iter().find_map(|m| match m {
Message::Tool { result } if result.tool_use_id == "call_1" => Some(result),
_ => None,
});
assert!(
tool_msg.is_some(),
"persisted messages must contain the read_image tool result"
);
let content = &tool_msg.expect("operation should succeed").content;
assert!(
content.contains("Image read") && content.contains("image/png"),
"tool result must contain safe summary: {content}"
);
assert!(
!content.contains("data:") && !content.contains("base64"),
"tool result must not contain data URL or base64: {content}"
);
}
struct CapturingFailModel {
first_events: Vec<AgentEvent>,
captured_messages: Arc<Mutex<Vec<Vec<Message>>>>,
call_count: Arc<Mutex<usize>>,
}
impl CapturingFailModel {
fn new(first_events: Vec<AgentEvent>) -> (Self, Arc<Mutex<Vec<Vec<Message>>>>) {
let captured = Arc::new(Mutex::new(Vec::new()));
let count = Arc::new(Mutex::new(0usize));
let model = Self {
first_events,
captured_messages: captured.clone(),
call_count: count,
};
(model, captured)
}
}
#[async_trait]
impl LanguageModel for CapturingFailModel {
async fn stream(&self, messages: &[Message]) -> ProviderResult<Receiver<AgentEvent>> {
let mut count = self.call_count.lock().await;
*count += 1;
let call_num = *count;
drop(count);
self.captured_messages.lock().await.push(messages.to_vec());
if call_num >= 2 {
return Err(ProviderError::NetworkError(
"simulated provider failure".into(),
));
}
let (tx, rx) = mpsc::channel(64);
let events = self.first_events.clone();
tokio::spawn(async move {
for event in events {
tx.send(event).await.expect("receiver dropped");
}
});
Ok(rx)
}
}
#[tokio::test]
async fn continuation_image_consumed_on_provider_failure() {
let (model, captured) = CapturingFailModel::new(image_continuation_events());
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TestReadImageTool));
let mut agent = Agent::with_security_and_hooks(
Arc::new(model),
registry,
Some(Arc::new(PermissionEngine::new())),
None,
PathBuf::from("/tmp"),
Arc::new(HookRegistry::new()),
);
agent.set_image_input_supported(true);
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let result = agent
.run_streaming("read the image".into(), vec![], event_tx)
.await;
assert!(result.is_err(), "provider failure should produce an error");
let calls = captured.lock().await;
assert_eq!(calls.len(), 2, "exactly 2 provider calls before failure");
let second = &calls[1];
let has_image = second.iter().any(|m| {
matches!(m, Message::Multimodal { parts } if parts.iter().any(|p| matches!(p, ContentPart::Image { .. })))
});
assert!(
has_image,
"second call must have image (injected before failure)"
);
assert_eq!(
calls.len(),
2,
"no third call — continuation consumed by std::mem::take"
);
}
#[tokio::test]
async fn continuation_not_resent_in_separate_turn_messages() {
let (model, _captured) =
CapturingMockModel::new(vec![image_continuation_events(), text_done_events("done")]);
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TestReadImageTool));
let mut agent = Agent::with_security_and_hooks(
Arc::new(model),
registry,
Some(Arc::new(PermissionEngine::new())),
None,
PathBuf::from("/tmp"),
Arc::new(HookRegistry::new()),
);
agent.set_image_input_supported(true);
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let (result, messages) = agent
.run_for_session_turn("read the image".into(), vec![], event_tx)
.await;
assert!(result.is_ok());
assert!(
!messages.iter().any(|m| {
matches!(m, Message::Multimodal { parts } if parts.iter().any(|p| matches!(p, ContentPart::Image { .. })))
}),
"returned session messages must not contain image — continuation is local to run_inner"
);
}
struct CancellationMockModel {
responses: Vec<Vec<AgentEvent>>,
call: std::sync::Arc<std::sync::atomic::AtomicUsize>,
captured: std::sync::Arc<std::sync::Mutex<Vec<Vec<Message>>>>,
}
impl CancellationMockModel {
fn new(
responses: Vec<Vec<AgentEvent>>,
) -> (Self, std::sync::Arc<std::sync::Mutex<Vec<Vec<Message>>>>) {
let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let model = Self {
responses,
call: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
captured: captured.clone(),
};
(model, captured)
}
}
#[async_trait]
impl LanguageModel for CancellationMockModel {
async fn stream(&self, messages: &[Message]) -> ProviderResult<Receiver<AgentEvent>> {
let n = self.call.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
{
let mut guard = self.captured.lock().expect("operation should succeed");
guard.push(messages.to_vec());
}
let (tx, rx) = mpsc::channel(64);
if n < self.responses.len() {
let events = self.responses[n].clone();
tokio::spawn(async move {
for event in events {
let _ = tx.send(event).await;
}
});
}
Ok(rx)
}
}
#[tokio::test]
async fn continuation_not_resent_after_stream_interruption() {
let empty: Vec<AgentEvent> = vec![];
let (model, captured) = CancellationMockModel::new(vec![
image_continuation_events(),
empty,
text_done_events("follow up answer"),
]);
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TestReadImageTool));
let mut agent = Agent::with_security_and_hooks(
Arc::new(model),
registry,
Some(Arc::new(PermissionEngine::new())),
None,
PathBuf::from("/tmp"),
Arc::new(HookRegistry::new()),
);
agent.set_image_input_supported(true);
let (tx1, _rx1) = mpsc::unbounded_channel();
let _ = agent
.run_streaming("read the image".into(), vec![], tx1)
.await;
let (tx2, _rx2) = mpsc::unbounded_channel();
let result2 = agent.run_streaming("follow up".into(), vec![], tx2).await;
assert!(result2.is_ok(), "second turn should complete");
let calls = captured.lock().expect("operation should succeed");
assert!(calls.len() >= 3, "at least 3 provider calls");
if calls.len() >= 3 {
let third = &calls[2];
assert!(
!third.iter().any(|m| {
matches!(m, Message::Multimodal { parts } if parts.iter().any(|p| matches!(p, ContentPart::Image { .. })))
}),
"third call (after interruption) must NOT have image"
);
}
}
#[tokio::test]
async fn capability_gate_hides_read_image_when_unsupported() {
let mut registry = ToolRegistry::new();
registry.register(Arc::new(TestReadImageTool));
registry.register(Arc::new(TimedMockTool {
tool_name: "read".into(),
read_only: true,
delay_ms: 0,
result: ToolExecutionResult::success("content"),
execution_log: Arc::new(Mutex::new(Vec::new())),
}));
let policy = ToolPresentationPolicy::full();
let (descs, defs, names) = crate::describe_presented_tools(®istry, &policy);
let has_read = descs.iter().any(|d| d.name == "read");
let has_read_image = descs.iter().any(|d| d.name == "read_image");
assert!(has_read, "text read must be presented");
assert!(
has_read_image,
"read_image must be in describe_presented_tools (filtering happens in Agent)"
);
let mut agent = Agent::with_security_and_hooks(
Arc::new(MockModel::new(vec![text_done_events("ok")])),
registry,
Some(Arc::new(PermissionEngine::new())),
None,
PathBuf::from("/tmp"),
Arc::new(HookRegistry::new()),
);
assert!(
!agent.presented_tool_names.contains("read_image"),
"read_image must NOT be in presented_tool_names when !image_input_supported"
);
assert!(
agent.presented_tool_names.contains("read"),
"text read must be in presented_tool_names"
);
assert!(
!agent
.tool_definitions
.iter()
.any(|td| td.name == "read_image"),
"read_image must NOT be in tool_definitions when !image_input_supported"
);
agent.set_image_input_supported(true);
assert!(
agent.presented_tool_names.contains("read_image"),
"after set_image_input_supported(true), read_image must be in presented_tool_names"
);
assert!(
agent
.tool_definitions
.iter()
.any(|td| td.name == "read_image"),
"after set_image_input_supported(true), read_image must be in tool_definitions"
);
assert!(
agent.presented_tool_names.contains("read"),
"text read must remain in presented_tool_names after capability change"
);
let read_def: Vec<_> = agent
.tool_definitions
.iter()
.filter(|td| td.name == "read")
.collect();
assert_eq!(read_def.len(), 1, "exactly one read tool definition");
}