use crate::context::{self, LoadedSkill, SkillMeta};
use crate::providers::InferenceProvider;
use crate::reasoning::{
build_chat_request_messages, initial_prev_resp_id, reasoning_artifact_tokens,
warn_on_missing_reasoning_artifacts,
};
use crate::sessions::{
AssistantResponse, RequestContext, SessionCommand, SessionState, turn_for_client,
};
use crate::tools::context::ToolContext;
use crate::tools::load_tools::{LoadToolsArgs, apply_load_tools};
use crate::tools::set_working_dir::{SetWorkingDirArgs, resolve_working_dir_path};
use crate::tools::unload_tools::{UnloadToolsArgs, apply_unload_tools};
use crate::tools::{
PreparedImage, STREAMING_CHANNEL_CAPACITY, ToolError, ToolOutput, ToolOutputFormat,
ToolRegistry,
};
use choreo_ai_protocols::openai::{ChatRequestMessage, ChatToolDefinition};
use choreo_ai_protocols::{
ChatToolCall, ChatTurnRequest, ChatTurnResult, StreamEvent, ToolResultItem,
model_reasoning_capability,
};
use choreo_keystore::ServiceCredential;
use choreo_proto::{
AssistantToolCallRecord, ContextConfig, DaemonMessage, DisplayedImageRecord, ImageMetadata,
OutputStream, ReasoningProducer, SessionStatus, TokenUsage,
};
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use tracing::{debug, info, trace, warn};
fn broadcast_turn_appended(
cmd_tx: &mpsc::Sender<SessionCommand>,
session: &SessionState,
session_id: u64,
turn_id: u32,
) {
if let Some(turn) = session.turns.get(&turn_id)
&& let Err(e) = cmd_tx.send(SessionCommand::Broadcast(DaemonMessage::TurnAppended {
session_id,
turn_id,
turn: turn_for_client(turn),
}))
{
warn!(%turn_id, error = %e, "failed to broadcast TurnAppended");
}
}
fn emit_image(
cmd_tx: &mpsc::Sender<SessionCommand>,
image: PreparedImage,
tool_call_id: Option<String>,
session: &mut SessionState,
session_id: u64,
turn_id: u32,
) {
let record = DisplayedImageRecord {
metadata: ImageMetadata {
mime_type: image.mime_type,
width: image.width,
height: image.height,
byte_len: image.data.len() as u64,
alt: image.alt,
},
data: image.data,
tool_call_id,
};
session.add_displayed_image(turn_id, record.clone());
broadcast_turn_appended(cmd_tx, session, session_id, turn_id);
}
fn spawn_forwarding_thread(
cmd_tx: mpsc::Sender<SessionCommand>,
session_id: u64,
request_id: u32,
call_id: String,
output_rx: crossbeam_channel::Receiver<Vec<u8>>,
kill_rx: crossbeam_channel::Receiver<()>,
) -> std::thread::JoinHandle<()> {
thread::spawn(move || {
loop {
crossbeam_channel::select_biased! {
recv(output_rx) -> msg => match msg {
Ok(data) => {
if cmd_tx
.send(SessionCommand::Broadcast(DaemonMessage::ToolResultChunk {
session_id,
request_id,
call_id: call_id.clone(),
data,
}))
.is_err()
{
break;
}
if matches!(
kill_rx.try_recv(),
Ok(()) | Err(crossbeam_channel::TryRecvError::Disconnected)
) {
let drain_budget = output_rx.len();
for _ in 0..drain_budget {
let Ok(data) = output_rx.try_recv() else {
break;
};
if cmd_tx
.send(SessionCommand::Broadcast(
DaemonMessage::ToolResultChunk {
session_id,
request_id,
call_id: call_id.clone(),
data,
},
))
.is_err()
{
break;
}
}
break;
}
}
Err(_) => break,
},
recv(kill_rx) -> _ => break,
}
}
})
}
pub(crate) fn is_cancelled_once(rx: &crossbeam_channel::Receiver<()>) -> bool {
rx.try_recv().is_ok()
}
fn accumulate_token_usage(
session: &mut SessionState,
token_usage: &Option<TokenUsage>,
turn: u32,
ctx: &RequestContext,
) {
if let Some(u) = token_usage {
session.config.accumulated_usage.input_tokens += u.input_tokens;
session.config.accumulated_usage.output_tokens += u.output_tokens;
session.config.accumulated_usage.total_tokens += u.total_tokens;
session.config.last_prompt_tokens = Some(u.input_tokens);
debug!(
session_id = ctx.session_id,
turn,
input_tokens = u.input_tokens,
output_tokens = u.output_tokens,
total_tokens = u.total_tokens,
accumulated_input = session.config.accumulated_usage.input_tokens,
accumulated_output = session.config.accumulated_usage.output_tokens,
"accumulated token usage"
);
}
}
fn broadcast_token_usage(session_id: u64, ctx: &RequestContext, session: &SessionState) {
let _ = ctx
.cmd_tx
.send(SessionCommand::Broadcast(DaemonMessage::TokenUsageUpdate {
session_id,
token_usage: session.config.accumulated_usage,
last_prompt_tokens: session.config.last_prompt_tokens,
}));
}
fn determine_tool_timeout(name: &str) -> Option<Duration> {
if name == "spawn_subsession" {
None
} else if matches!(name, "sh" | "nushell" | "fish" | "exec") {
Some(Duration::from_secs(300))
} else {
Some(Duration::from_secs(60))
}
}
struct ToolHandle {
tool_call: ChatToolCall,
output: ToolOutput,
image: Option<PreparedImage>,
started_at: Instant,
}
struct SpawnToolArgs {
tool_call: ChatToolCall,
timeout: Option<Duration>,
request_id: u32,
session_id: u64,
registry: Arc<ToolRegistry>,
cmd_tx: mpsc::Sender<SessionCommand>,
x_credentials: Option<ServiceCredential>,
working_dir: Option<PathBuf>,
ctx: ToolContext,
invocation_description: String,
started_at: Instant,
result_tx: crossbeam_channel::Sender<ToolHandle>,
}
struct CallInfo {
call_id: String,
tool_name: String,
arguments_json: String,
invocation_description: String,
started_at: Instant,
kill_tx: crossbeam_channel::Sender<()>,
}
fn missing_calls<'a>(
call_infos: &'a [CallInfo],
delivered: &HashSet<String>,
) -> impl Iterator<Item = &'a CallInfo> {
call_infos
.iter()
.filter(move |info| !delivered.contains(&info.call_id))
}
fn panic_tool_handle(info: &CallInfo) -> ToolHandle {
ToolHandle {
tool_call: ChatToolCall {
id: info.call_id.clone(),
name: info.tool_name.clone(),
arguments_json: info.arguments_json.clone(),
caller: None,
},
output: ToolOutput {
content: "tool thread panicked".to_string(),
is_error: true,
invocation_description: info.invocation_description.clone(),
..Default::default()
},
image: None,
started_at: info.started_at,
}
}
struct SpawnedToolExecution {
exec_rx: crossbeam_channel::Receiver<Result<ToolOutput, ToolError>>,
kill_tx: crossbeam_channel::Sender<()>,
image_rx: mpsc::Receiver<PreparedImage>,
_forwarder: std::thread::JoinHandle<()>,
}
#[expect(clippy::too_many_arguments)]
fn spawn_tool_execution(
tool_call: &ChatToolCall,
format: ToolOutputFormat,
registry: Arc<ToolRegistry>,
x_credentials: Option<ServiceCredential>,
working_dir: Option<PathBuf>,
tool_ctx: ToolContext,
cmd_tx: mpsc::Sender<SessionCommand>,
session_id: u64,
request_id: u32,
) -> SpawnedToolExecution {
let (exec_tx, exec_rx) = crossbeam_channel::unbounded::<Result<ToolOutput, ToolError>>();
let (output_tx, output_rx) = crossbeam_channel::bounded::<Vec<u8>>(STREAMING_CHANNEL_CAPACITY);
let (kill_tx, kill_rx) = crossbeam_channel::unbounded::<()>();
let (image_tx, image_rx) = mpsc::channel::<PreparedImage>();
let _forwarder = spawn_forwarding_thread(
cmd_tx,
session_id,
request_id,
tool_call.id.clone(),
output_rx,
kill_rx,
);
let tc = tool_call.clone();
thread::spawn(move || {
let result = registry.execute_streaming_json(
&tc,
format,
output_tx,
x_credentials.as_ref(),
working_dir.as_deref(),
Some(&tool_ctx),
Some(image_tx),
);
let _ = exec_tx.send(result);
});
SpawnedToolExecution {
exec_rx,
kill_tx,
image_rx,
_forwarder,
}
}
fn spawn_single_tool(args: SpawnToolArgs) -> crossbeam_channel::Sender<()> {
let SpawnToolArgs {
tool_call,
timeout,
request_id,
session_id,
registry,
cmd_tx,
x_credentials,
working_dir,
ctx,
invocation_description,
started_at,
result_tx,
} = args;
let cancel_flag = Arc::clone(&ctx.cancelled);
let (tool_kill_tx, tool_kill_rx) = crossbeam_channel::unbounded::<()>();
let SpawnedToolExecution {
exec_rx,
kill_tx,
image_rx,
_forwarder,
} = spawn_tool_execution(
&tool_call,
ToolOutputFormat::Text,
registry,
x_credentials,
working_dir,
ctx,
cmd_tx,
session_id,
request_id,
);
let deadline = timeout.map(|d| Instant::now() + d);
thread::spawn(move || {
enum WaitOutcome {
Result(Result<Result<ToolOutput, ToolError>, crossbeam_channel::RecvError>),
Kill,
Deadline,
}
let outcome = match deadline {
None => crossbeam_channel::select_biased! {
recv(exec_rx) -> msg => WaitOutcome::Result(msg),
recv(tool_kill_rx) -> _ => WaitOutcome::Kill,
},
Some(deadline) => {
let remaining = deadline.saturating_duration_since(Instant::now());
crossbeam_channel::select_biased! {
recv(exec_rx) -> msg => WaitOutcome::Result(msg),
recv(tool_kill_rx) -> _ => WaitOutcome::Kill,
recv(crossbeam_channel::after(remaining)) -> _ => WaitOutcome::Deadline,
}
}
};
let output = match outcome {
WaitOutcome::Result(msg) => {
tool_result_from_channel(
&tool_call.name,
started_at,
msg,
&invocation_description,
false,
)
.0
}
WaitOutcome::Deadline => {
cancel_flag.store(true, Ordering::Relaxed);
drain_queued_or_synthesize(
&tool_call.name,
started_at,
&invocation_description,
&exec_rx,
format!(
"tool '{}' timed out after {}s",
tool_call.name,
timeout.unwrap_or(Duration::ZERO).as_secs(),
),
false,
)
.0
}
WaitOutcome::Kill => {
cancel_flag.store(true, Ordering::Relaxed);
let _ = kill_tx.send(());
let image = image_rx.try_recv().ok();
let content = format!("tool '{}' cancelled", tool_call.name);
crate::metrics::record_tool_execution(
&tool_call.name,
started_at.elapsed().as_secs_f64(),
true,
);
let _ = result_tx.send(ToolHandle {
tool_call,
output: ToolOutput {
content,
is_error: true,
invocation_description: invocation_description.clone(),
..Default::default()
},
image,
started_at,
});
return;
}
};
let image = image_rx.try_recv().ok();
let _ = kill_tx.send(());
let _ = result_tx.send(ToolHandle {
tool_call,
output,
image,
started_at,
});
});
tool_kill_tx
}
fn resolve_reasoning_effort(
client: &InferenceProvider,
model: &str,
session_id: u64,
turn_iter: u32,
configured_effort: &str,
) -> String {
if configured_effort == "off" {
return configured_effort.to_string();
}
let slug = client.provider_slug();
let capability = model_reasoning_capability(slug, model);
if capability.available_effort_levels.is_empty() {
warn!(
session_id, turn = turn_iter, model,
effort = %configured_effort,
"model does not support reasoning, disabling",
);
"off".to_string()
} else if !capability
.available_effort_levels
.iter()
.any(|l| l == configured_effort)
{
warn!(
session_id, turn = turn_iter, model,
effort = %configured_effort,
valid = ?capability.available_effort_levels,
"reasoning effort '{}' not in model's capability set, disabling",
configured_effort,
);
"off".to_string()
} else {
configured_effort.to_string()
}
}
fn estimate_prompt_tokens(
model: &str,
messages: &[ChatRequestMessage],
tools: &[ChatToolDefinition],
) -> (Option<&'static tiktoken::CoreBpe>, u32) {
let encoding =
tiktoken::encoding_for_model(model).or_else(|| tiktoken::get_encoding("cl100k_base"));
let estimated = match &encoding {
Some(enc) => {
let content_tokens: u32 = messages
.iter()
.filter_map(|m| m.content.as_deref())
.map(|text| enc.count(text) as u32)
.sum();
let tool_call_tokens: u32 = messages
.iter()
.filter_map(|m| m.tool_calls.as_ref())
.flat_map(|calls| calls.iter())
.map(|tc| {
enc.count(&tc.id) as u32
+ enc.count(&tc.kind) as u32
+ enc.count(&tc.function.name) as u32
+ enc.count(&tc.function.arguments) as u32
})
.sum();
let tool_def_tokens: u32 = tools
.iter()
.filter_map(|def| {
match serde_json::to_string(def) {
Ok(s) => Some(enc.count(&s) as u32),
Err(e) => {
warn!(error = %e, "failed to serialize tool definition for token estimation");
None
}
}
})
.sum();
let artifact_tokens: u32 = messages
.iter()
.filter_map(|m| m.reasoning_artifact.as_ref())
.map(|artifact| reasoning_artifact_tokens(enc, artifact))
.sum();
content_tokens + tool_call_tokens + tool_def_tokens + artifact_tokens
}
None => {
tracing::warn!("no tiktoken encoding available for {model}");
0
}
};
(encoding, estimated)
}
fn extract_json_string(json: &str, key: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(json).ok()?;
v.get(key)?.as_str().map(|s| s.to_string())
}
struct SystemContentParams<'a> {
working_dir: Option<&'a Path>,
context_config: &'a ContextConfig,
skills: &'a [SkillMeta],
loaded_skill_bodies: &'a [LoadedSkill],
tool_registry: &'a ToolRegistry,
pending_hints: &'a [String],
session_title: Option<&'a str>,
}
fn build_system_content(
params: SystemContentParams,
context_cache: &mut Option<(u64, Arc<String>)>,
) -> Option<String> {
let working_dir = match params.working_dir {
Some(wd) => wd,
None => {
warn!("cannot build system content: no working directory on session");
return None;
}
};
let groups = params.tool_registry.groups();
let base_prompt =
context::build_base_prompt(params.skills, &groups, params.loaded_skill_bodies);
let mut content = base_prompt;
if let Ok(bundle) = context::discover_context(working_dir, params.context_config) {
let context_str = match context_cache {
Some((fp, cached)) if *fp == bundle.fingerprint => {
debug!("context cache HIT (fp={})", fp);
cached.as_str().to_string()
}
_ => {
let s = context::assemble_context(&bundle);
debug!(
"context cache MISS — rebuilt context ({} bytes from {} file(s))",
s.len(),
bundle.files.len()
);
*context_cache = Some((bundle.fingerprint, Arc::new(s.clone())));
s
}
};
if !context_str.is_empty() {
content.push_str("\n\n");
content.push_str(&context_str);
}
}
if let Some(title) = params.session_title
&& !title.is_empty()
{
content.push_str("\n\n## Current Session Title\n");
content.push_str(title);
}
if !params.pending_hints.is_empty() {
content.push_str("\n\n## New context from project subdirectories\n");
for hint in params.pending_hints {
content.push('\n');
content.push_str(hint);
}
}
Some(content)
}
fn persist_loaded_skill(session: &mut SessionState, tool_name: &str, arguments_json: &str) {
if tool_name != "load_skill" {
return;
}
let Some(name) = extract_json_string(arguments_json, "name") else {
warn!("load_skill tool call missing 'name' argument");
return;
};
if session.loaded_skill_bodies.iter().any(|ls| ls.name == name) {
debug!("skill '{}' already loaded, skipping", name);
return;
}
let Some(ref working_dir) = session.config.working_dir else {
warn!("cannot load skill '{}': no working directory", name);
return;
};
if let Some(body) = context::load_skill_body(&name, working_dir) {
info!("loaded skill body: '{}' ({} bytes)", name, body.len());
session.loaded_skill_bodies.push(LoadedSkill { name, body });
} else {
warn!("skill '{}' not found or has empty body", name);
}
}
fn check_subdirectory_hints(
working_dir: Option<&Path>,
tool_name: &str,
arguments_json: &str,
known_hint_paths: &mut Vec<PathBuf>,
pending_hints: &mut Vec<String>,
) {
if let Some((hint_text, new_paths)) =
context::subdirectory_hints(tool_name, arguments_json, working_dir, known_hint_paths)
{
debug!(
"subdirectory hints for '{}': {} new path(s)",
tool_name,
new_paths.len()
);
known_hint_paths.extend(new_paths);
pending_hints.push(hint_text);
}
}
struct CollectToolResultParams<'a> {
tool_results: &'a mut Vec<ToolResultItem>,
session: &'a mut SessionState,
tool_call: &'a ChatToolCall,
output: &'a ToolOutput,
known_hint_paths: &'a mut Vec<PathBuf>,
pending_hints: &'a mut Vec<String>,
}
fn collect_tool_result(params: CollectToolResultParams) {
let CollectToolResultParams {
tool_results,
session,
tool_call,
output,
known_hint_paths,
pending_hints,
} = params;
trace!(
"collecting tool result for call {} (tool: '{}')",
tool_call.id, tool_call.name
);
tool_results.push(ToolResultItem {
call_id: tool_call.id.clone(),
output: output.content.clone(),
caller: tool_call.caller.clone(),
});
persist_loaded_skill(session, &tool_call.name, &tool_call.arguments_json);
check_subdirectory_hints(
session.config.working_dir.as_deref(),
&tool_call.name,
&tool_call.arguments_json,
known_hint_paths,
pending_hints,
);
}
fn sort_by_call_order<T>(
tool_calls: &[AssistantToolCallRecord],
items: &mut [T],
call_id_of: impl Fn(&T) -> &str,
) {
let order: HashMap<&str, usize> = tool_calls
.iter()
.enumerate()
.map(|(i, tc)| (tc.call_id.as_str(), i))
.collect();
if order.is_empty() {
return;
}
items.sort_by_key(|item| order.get(call_id_of(item)).copied().unwrap_or(usize::MAX));
}
enum PendingConfigChange {
LoadTools(Vec<String>),
UnloadTools(Vec<String>),
SetWorkingDir(Option<PathBuf>),
}
fn is_session_config_tool(name: &str) -> bool {
matches!(name, "load_tools" | "unload_tools" | "set_working_dir")
}
fn concurrent_tool_status_label(tools: &[ChatToolCall]) -> String {
if tools.len() == 1 {
tools[0].name.clone()
} else {
"(parallel)".into()
}
}
fn pending_config_change(
tool_call: &ChatToolCall,
output: &ToolOutput,
base_working_dir: Option<&Path>,
) -> Option<PendingConfigChange> {
if !is_session_config_tool(&tool_call.name) {
return None;
}
match tool_call.name.as_str() {
"load_tools" => {
let Ok(args) = serde_json::from_str::<LoadToolsArgs>(&tool_call.arguments_json) else {
warn!(
tool_call_id = %tool_call.id,
"load_tools: could not parse args to mirror onto worker config",
);
return None;
};
Some(PendingConfigChange::LoadTools(args.groups))
}
"unload_tools" => {
let Ok(args) = serde_json::from_str::<UnloadToolsArgs>(&tool_call.arguments_json)
else {
warn!(
tool_call_id = %tool_call.id,
"unload_tools: could not parse args to mirror onto worker config",
);
return None;
};
Some(PendingConfigChange::UnloadTools(args.groups))
}
"set_working_dir" => {
if let Some(path) = output
.result_json
.as_ref()
.and_then(|v| v.get("path"))
.and_then(|v| v.as_str())
{
return Some(PendingConfigChange::SetWorkingDir(Some(PathBuf::from(
path,
))));
}
let Ok(args) = serde_json::from_str::<SetWorkingDirArgs>(&tool_call.arguments_json)
else {
warn!(
tool_call_id = %tool_call.id,
"set_working_dir: could not parse args to mirror onto worker config",
);
return Some(PendingConfigChange::SetWorkingDir(None));
};
let path = resolve_working_dir_path(&args.path, base_working_dir).ok();
Some(PendingConfigChange::SetWorkingDir(path))
}
_ => None,
}
}
fn apply_pending_config_change(session: &mut SessionState, change: &PendingConfigChange) {
match change {
PendingConfigChange::LoadTools(groups) => {
apply_load_tools(&mut session.config.active_tool_groups, groups);
debug!(groups = ?groups, "mirrored load_tools onto worker session config");
}
PendingConfigChange::UnloadTools(groups) => {
apply_unload_tools(&mut session.config.active_tool_groups, groups);
debug!(groups = ?groups, "mirrored unload_tools onto worker session config");
}
PendingConfigChange::SetWorkingDir(path) => {
if let Some(path) = path {
session.config.working_dir = Some(path.clone());
}
session.discovered_skills = None;
debug!(path = ?path, "mirrored set_working_dir onto worker session config");
}
}
}
pub(crate) fn run_agent_loop(
client: &InferenceProvider,
session: &mut SessionState,
model: &str,
request_id: u32,
cancel_rx: &crossbeam_channel::Receiver<()>,
ctx: &RequestContext,
user_text: Option<String>,
) -> io::Result<bool> {
let max_turns = ctx.max_turns;
let limited = max_turns > 0;
let provider_slug = client.provider_slug();
let mut prev_resp_id = initial_prev_resp_id(session, provider_slug, model);
let mut tool_results: Vec<ToolResultItem> = Vec::new();
let mut known_hint_paths: Vec<PathBuf> = Vec::new();
let mut pending_hints: Vec<String> = Vec::new();
warn_on_missing_reasoning_artifacts(session, ctx.session_id, provider_slug, model);
if session.discovered_skills.is_none()
&& let Some(ref wd) = session.config.working_dir
{
session.discovered_skills = Some(context::discover_skills(wd));
}
let mut turn_iter: u32 = 0;
loop {
if limited && turn_iter >= max_turns {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("tool loop exceeded {max_turns} iterations"),
));
}
debug!(
session_id = ctx.session_id,
turn = turn_iter,
"agent loop turn"
);
let configured = session.config.reasoning_effort.as_deref().unwrap_or("off");
let thinking_effort =
resolve_reasoning_effort(client, model, ctx.session_id, turn_iter, configured);
crate::metrics::record_turn(model);
let tools = ctx
.tool_registry
.available_definitions(&session.config.active_tool_groups);
if is_cancelled_once(cancel_rx) {
return Ok(true);
}
let turn_user_text = if turn_iter == 0 {
user_text.clone()
} else {
None
};
let (current_turn_id, _) = session.start_turn(turn_user_text);
broadcast_turn_appended(&ctx.cmd_tx, session, ctx.session_id, current_turn_id);
if ctx
.cmd_tx
.send(SessionCommand::StatusChanged(SessionStatus::Inference))
.is_err()
{
return Ok(false);
}
let system_content = {
let skills: &[SkillMeta] = session.discovered_skills.as_deref().unwrap_or_default();
build_system_content(
SystemContentParams {
working_dir: session.config.working_dir.as_deref(),
context_config: &session.config.context_config,
skills,
loaded_skill_bodies: &session.loaded_skill_bodies,
tool_registry: &ctx.tool_registry,
pending_hints: &pending_hints,
session_title: session.config.title.as_deref(),
},
&mut session.context_cache,
)
};
pending_hints.clear();
let messages =
build_chat_request_messages(session, system_content.as_deref(), provider_slug, model);
let (encoding, estimated_prompt_tokens) = estimate_prompt_tokens(model, &messages, &tools);
let _ = ctx
.cmd_tx
.send(SessionCommand::Broadcast(DaemonMessage::Started {
session_id: ctx.session_id,
request_id,
turn_id: current_turn_id,
estimated_prompt_tokens,
}));
let mut retry_cb: Option<choreo_ai_protocols::openai::RetryCallback> = Some(Box::new({
let cmd_tx = ctx.cmd_tx.clone();
move |attempt, max_attempts, delay| {
let _ = cmd_tx.send(SessionCommand::StatusChanged(SessionStatus::Retrying {
attempt,
max_attempts,
delay_ms: delay.as_millis() as u64,
}));
}
}));
let mut output_token_count: u32 = 0;
match client.chat_completion_turn_streaming(
ChatTurnRequest {
model,
messages: &messages,
tools: &tools,
thinking_effort,
on_retry: &mut retry_cb,
cancel_rx: Some(cancel_rx),
previous_response_id: prev_resp_id.as_deref(),
tool_results: &tool_results,
programmatic_tool_calling: client.supports_programmatic_tool_calling(model),
},
&mut |event| {
match event {
StreamEvent::Answer(text) => {
if let Some(enc) = &encoding {
output_token_count += enc.count(&text) as u32;
}
let _ = ctx.cmd_tx.send(SessionCommand::Broadcast(
DaemonMessage::OutputChunk {
session_id: ctx.session_id,
request_id,
stream: OutputStream::Answer,
data: text.into_bytes(),
},
));
let _ = ctx.cmd_tx.send(SessionCommand::Broadcast(
DaemonMessage::LiveOutputTokenCount {
session_id: ctx.session_id,
request_id,
output_tokens: output_token_count,
},
));
}
StreamEvent::Reasoning(text) => {
if let Some(enc) = &encoding {
output_token_count += enc.count(&text) as u32;
}
let _ = ctx.cmd_tx.send(SessionCommand::Broadcast(
DaemonMessage::OutputChunk {
session_id: ctx.session_id,
request_id,
stream: OutputStream::Reasoning,
data: text.into_bytes(),
},
));
let _ = ctx.cmd_tx.send(SessionCommand::Broadcast(
DaemonMessage::LiveOutputTokenCount {
session_id: ctx.session_id,
request_id,
output_tokens: output_token_count,
},
));
}
_ => {}
}
Ok(())
},
) {
Ok(ChatTurnResult::FinalText(final_text)) => {
debug!(
session_id = ctx.session_id,
turn = turn_iter,
response_len = final_text.content.len(),
reasoning = final_text.reasoning.as_deref().unwrap_or_default(),
"model returned final text",
);
let token_usage = final_text.usage;
accumulate_token_usage(session, &token_usage, turn_iter, ctx);
broadcast_token_usage(ctx.session_id, ctx, session);
let producer = ReasoningProducer {
provider_slug: provider_slug.to_string(),
model: model.to_string(),
};
session.set_assistant_response(
current_turn_id,
AssistantResponse {
text: Some(final_text.content),
reasoning: final_text.reasoning,
token_usage,
reasoning_artifact: final_text.reasoning_artifact.clone(),
reasoning_producer: Some(producer.clone()),
..Default::default()
},
);
session.config.last_response_id = final_text.response_id.clone();
session.config.last_response_id_producer = Some(producer);
finalize_and_broadcast_turn(session, ctx, current_turn_id)?;
tool_results.clear();
return Ok(false);
}
Ok(ChatTurnResult::ToolUse(tool_use)) => {
let token_usage = tool_use.usage;
accumulate_token_usage(session, &token_usage, turn_iter, ctx);
broadcast_token_usage(ctx.session_id, ctx, session);
let tool_call_records: Vec<AssistantToolCallRecord> = tool_use
.tool_calls
.iter()
.map(|tc| AssistantToolCallRecord {
call_id: tc.id.clone(),
name: tc.name.clone(),
arguments_json: tc.arguments_json.clone(),
})
.collect();
let producer = ReasoningProducer {
provider_slug: provider_slug.to_string(),
model: model.to_string(),
};
session.set_assistant_response(
current_turn_id,
AssistantResponse {
text: tool_use.content.clone(),
reasoning: tool_use.reasoning.clone(),
tool_calls: tool_call_records.clone(),
token_usage,
reasoning_artifact: tool_use.reasoning_artifact.clone(),
reasoning_producer: Some(producer.clone()),
},
);
session.seed_tool_results(current_turn_id, &tool_call_records);
broadcast_turn_appended(&ctx.cmd_tx, session, ctx.session_id, current_turn_id);
prev_resp_id = tool_use.response_id.clone();
session.config.last_response_id = prev_resp_id.clone();
session.config.last_response_id_producer = Some(producer);
tool_results.clear();
let (mutators, concurrent): (Vec<_>, Vec<_>) = tool_use
.tool_calls
.into_iter()
.partition(|tc| is_session_config_tool(&tc.name));
let turn_base_working_dir = session.config.working_dir.clone();
let mut pending_config_changes: Vec<PendingConfigChange> = Vec::new();
let mut cancelled = false;
let mut executed_tool_calls: HashSet<String> = HashSet::new();
for tool_call in mutators.into_iter() {
if is_cancelled_once(cancel_rx) {
cancelled = true;
break;
}
if let Err(e) =
ctx.cmd_tx
.send(SessionCommand::Broadcast(DaemonMessage::ToolCallStarted {
session_id: ctx.session_id,
request_id,
call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
arguments_json: tool_call.arguments_json.clone(),
}))
{
warn!(%request_id, call_id = %tool_call.id, error = %e, "failed to broadcast ToolCallStarted");
}
let tool_timeout =
determine_tool_timeout(&tool_call.name).unwrap_or(Duration::from_secs(60));
if ctx
.cmd_tx
.send(SessionCommand::StatusChanged(SessionStatus::ToolCall(
tool_call.name.clone(),
)))
.is_err()
{
return Ok(false);
}
debug!(
session_id = ctx.session_id,
turn = turn_iter,
tool_name = %tool_call.name,
tool_call_id = %tool_call.id,
args_preview = %(&tool_call.arguments_json[..tool_call.arguments_json.len().min(200)]),
"executing tool (serial)",
);
let invocation_description = ctx.tool_registry.describe_invocation(&tool_call);
let turn_working_dir = session.config.working_dir.clone();
let (mut output, tool_cancelled, image) = execute_tool_with_timeout(
&tool_call,
None,
turn_working_dir.as_deref(),
tool_timeout,
request_id,
ctx.session_id,
session,
cancel_rx,
ctx,
&invocation_description,
);
if tool_cancelled {
cancelled = true;
}
record_tool_completion(
request_id,
session,
&tool_call,
&mut output,
image,
ctx,
current_turn_id,
&mut tool_results,
&mut known_hint_paths,
&mut pending_hints,
);
executed_tool_calls.insert(tool_call.id.clone());
if !output.is_error
&& let Some(change) = pending_config_change(
&tool_call,
&output,
turn_base_working_dir.as_deref(),
)
{
pending_config_changes.push(change);
}
if cancelled {
break;
}
}
if !cancelled && !concurrent.is_empty() {
for tc in concurrent.iter() {
if let Err(e) = ctx.cmd_tx.send(SessionCommand::Broadcast(
DaemonMessage::ToolCallStarted {
session_id: ctx.session_id,
request_id,
call_id: tc.id.clone(),
tool_name: tc.name.clone(),
arguments_json: tc.arguments_json.clone(),
},
)) {
warn!(%request_id, call_id = %tc.id, error = %e, "failed to broadcast ToolCallStarted");
}
}
if ctx
.cmd_tx
.send(SessionCommand::StatusChanged(SessionStatus::ToolCall(
concurrent_tool_status_label(&concurrent),
)))
.is_err()
{
return Ok(false);
}
debug!(
session_id = ctx.session_id,
turn = turn_iter,
count = concurrent.len(),
"dispatching {} tools concurrently",
concurrent.len(),
);
let cancel_flag = Arc::new(AtomicBool::new(false));
let tool_ctx = ToolContext {
session_id: ctx.session_id,
db: Arc::clone(&ctx.db),
daemon_tx: ctx.daemon_tx.clone(),
active_tool_groups: session.config.active_tool_groups.clone(),
reasoning_effort: session.config.reasoning_effort.clone(),
selected_model: session.config.selected_model.clone(),
working_dir: session.config.working_dir.clone(),
cancelled: Arc::clone(&cancel_flag),
account_name: session.config.account_name.clone(),
};
let cmd_tx = ctx.cmd_tx.clone();
let reg = Arc::clone(&ctx.tool_registry);
let (batch_tx, batch_rx) = crossbeam_channel::unbounded::<ToolHandle>();
let mut call_infos: Vec<CallInfo> = Vec::with_capacity(concurrent.len());
for tool_call in concurrent.into_iter() {
let timeout = determine_tool_timeout(&tool_call.name);
let invocation_description = reg.describe_invocation(&tool_call);
let started_at = Instant::now();
let call_id = tool_call.id.clone();
let tool_name = tool_call.name.clone();
let arguments_json = tool_call.arguments_json.clone();
let kill_tx = spawn_single_tool(SpawnToolArgs {
tool_call,
timeout,
request_id,
session_id: ctx.session_id,
registry: Arc::clone(®),
cmd_tx: cmd_tx.clone(),
x_credentials: None,
working_dir: session.config.working_dir.clone(),
ctx: tool_ctx.clone(),
invocation_description: invocation_description.clone(),
started_at,
result_tx: batch_tx.clone(),
});
call_infos.push(CallInfo {
call_id,
tool_name,
arguments_json,
invocation_description,
started_at,
kill_tx,
});
}
drop(batch_tx);
let batch_size = call_infos.len();
let mut process_tool_handle =
|ToolHandle {
tool_call,
mut output,
image,
started_at,
}: ToolHandle| {
let elapsed = started_at.elapsed();
debug!(
session_id = ctx.session_id,
turn = turn_iter,
tool_name = %tool_call.name,
elapsed_ms = elapsed.as_millis(),
result_len = output.content.len(),
is_error = output.is_error,
"tool finished (concurrent)",
);
record_tool_completion(
request_id,
session,
&tool_call,
&mut output,
image,
ctx,
current_turn_id,
&mut tool_results,
&mut known_hint_paths,
&mut pending_hints,
);
executed_tool_calls.insert(tool_call.id.clone());
};
let mut delivered: HashSet<String> = HashSet::with_capacity(batch_size);
while delivered.len() < batch_size {
let (cancelled_now, handle_msg) = crossbeam_channel::select_biased! {
recv(cancel_rx) -> _ => (true, None),
recv(batch_rx) -> msg => (false, Some(msg)),
};
if cancelled_now {
cancel_flag.store(true, Ordering::Relaxed);
cancelled = true;
for info in &call_infos {
let _ = info.kill_tx.send(());
}
while let Ok(handle) = batch_rx.try_recv() {
delivered.insert(handle.tool_call.id.clone());
process_tool_handle(handle);
}
while delivered.len() < batch_size {
match batch_rx.recv() {
Ok(handle) => {
delivered.insert(handle.tool_call.id.clone());
process_tool_handle(handle);
}
Err(_) => {
warn!(
session_id = ctx.session_id,
request_id,
delivered = delivered.len(),
expected = batch_size,
"concurrent tool batch ended early after cancel; synthesizing missing tool results",
);
for info in missing_calls(&call_infos, &delivered) {
process_tool_handle(panic_tool_handle(info));
}
break;
}
}
}
break;
}
if let Some(msg) = handle_msg {
match msg {
Ok(handle) => {
delivered.insert(handle.tool_call.id.clone());
process_tool_handle(handle);
}
Err(_) => {
warn!(
session_id = ctx.session_id,
request_id,
delivered = delivered.len(),
expected = batch_size,
"concurrent tool batch ended early; synthesizing missing tool results",
);
for info in missing_calls(&call_infos, &delivered) {
process_tool_handle(panic_tool_handle(info));
}
break;
}
}
}
}
sort_by_call_order(&tool_call_records, &mut tool_results, |r| {
r.call_id.as_str()
});
}
for change in &pending_config_changes {
apply_pending_config_change(session, change);
}
if cancelled {
session.mark_unexecuted_tool_results(current_turn_id, &executed_tool_calls);
broadcast_turn_appended(&ctx.cmd_tx, session, ctx.session_id, current_turn_id);
return Ok(true);
}
}
Ok(_) => {
warn!("provider returned an unhandled ChatTurnResult variant");
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"provider returned an unhandled turn result variant",
));
}
Err(choreo_proto::InferenceError::Cancelled) => {
return Ok(true);
}
Err(e) => {
if matches!(&e, choreo_proto::InferenceError::TruncatedToolCall { .. }) {
tracing::warn!(?e, "truncated tool call, finalizing turn gracefully");
session.set_assistant_response(
current_turn_id,
AssistantResponse {
text: Some(format!("[tool call truncated: {e}]")),
..Default::default()
},
);
finalize_and_broadcast_turn(session, ctx, current_turn_id)?;
tool_results.clear();
return Ok(false);
}
return Err(e.into());
}
}
turn_iter += 1;
}
}
fn finalize_and_broadcast_turn(
session: &mut SessionState,
ctx: &RequestContext,
current_turn_id: u32,
) -> io::Result<()> {
session.finalize_turn(&ctx.db, ctx.session_id, current_turn_id)?;
if let Some(turn) = session.turns.get(¤t_turn_id) {
let _ = ctx
.cmd_tx
.send(SessionCommand::Broadcast(DaemonMessage::TurnFinalized {
session_id: ctx.session_id,
turn_id: current_turn_id,
turn: turn_for_client(turn),
}));
}
Ok(())
}
fn finish_tool_call(
request_id: u32,
session: &mut SessionState,
tool_call: &ChatToolCall,
output: &mut ToolOutput,
ctx: &RequestContext,
turn_id: u32,
) {
let is_error = output.is_error;
let content = output.content.clone();
let invocation_description = output.invocation_description.clone();
session.update_tool_result(
turn_id,
&tool_call.id,
tool_call.name.clone(),
content.clone(),
is_error,
invocation_description,
);
broadcast_turn_appended(&ctx.cmd_tx, session, ctx.session_id, turn_id);
let event = if is_error {
DaemonMessage::ToolCallFailed {
session_id: ctx.session_id,
request_id,
call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
error: content,
}
} else {
DaemonMessage::ToolCallFinished {
session_id: ctx.session_id,
request_id,
call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
}
};
if let Err(e) = ctx.cmd_tx.send(SessionCommand::Broadcast(event)) {
warn!(%request_id, error = %e, "failed to broadcast tool call finished/failed event");
}
}
#[expect(clippy::too_many_arguments)]
fn record_tool_completion(
request_id: u32,
session: &mut SessionState,
tool_call: &ChatToolCall,
output: &mut ToolOutput,
image: Option<PreparedImage>,
ctx: &RequestContext,
current_turn_id: u32,
tool_results: &mut Vec<ToolResultItem>,
known_hint_paths: &mut Vec<PathBuf>,
pending_hints: &mut Vec<String>,
) {
if let Some(image) = image {
emit_image(
&ctx.cmd_tx,
image,
Some(tool_call.id.clone()),
session,
ctx.session_id,
current_turn_id,
);
}
finish_tool_call(request_id, session, tool_call, output, ctx, current_turn_id);
collect_tool_result(CollectToolResultParams {
tool_results,
session,
tool_call,
output,
known_hint_paths,
pending_hints,
});
}
fn tool_result_from_channel(
tool_name: &str,
exec_start: std::time::Instant,
msg: Result<Result<ToolOutput, ToolError>, crossbeam_channel::RecvError>,
invocation_description: &str,
cancelled: bool,
) -> (ToolOutput, bool) {
match msg {
Ok(Ok(output)) => {
crate::metrics::record_tool_execution(
tool_name,
exec_start.elapsed().as_secs_f64(),
output.is_error,
);
(output, cancelled)
}
Ok(Err(e)) => {
crate::metrics::record_tool_execution(
tool_name,
exec_start.elapsed().as_secs_f64(),
true,
);
(
ToolOutput {
content: e.to_string(),
is_error: true,
invocation_description: invocation_description.to_string(),
..Default::default()
},
cancelled,
)
}
Err(_) => {
crate::metrics::record_tool_execution(
tool_name,
exec_start.elapsed().as_secs_f64(),
true,
);
(
ToolOutput {
content: "tool execution thread panicked".to_string(),
is_error: true,
invocation_description: invocation_description.to_string(),
..Default::default()
},
cancelled,
)
}
}
}
fn drain_queued_or_synthesize(
tool_name: &str,
exec_start: std::time::Instant,
invocation_description: &str,
exec_rx: &crossbeam_channel::Receiver<Result<ToolOutput, ToolError>>,
stop_message: String,
sticky_cancelled: bool,
) -> (ToolOutput, bool) {
match exec_rx.try_recv() {
Ok(msg) => tool_result_from_channel(
tool_name,
exec_start,
Ok(msg),
invocation_description,
sticky_cancelled,
),
Err(crossbeam_channel::TryRecvError::Empty) => {
crate::metrics::record_tool_execution(
tool_name,
exec_start.elapsed().as_secs_f64(),
true,
);
(
ToolOutput {
content: stop_message,
is_error: true,
invocation_description: invocation_description.to_string(),
..Default::default()
},
sticky_cancelled,
)
}
Err(crossbeam_channel::TryRecvError::Disconnected) => {
crate::metrics::record_tool_execution(
tool_name,
exec_start.elapsed().as_secs_f64(),
true,
);
(
ToolOutput {
content: "tool execution thread panicked".to_string(),
is_error: true,
invocation_description: invocation_description.to_string(),
..Default::default()
},
sticky_cancelled,
)
}
}
}
#[expect(clippy::too_many_arguments)]
fn execute_tool_with_timeout(
tool_call: &ChatToolCall,
x_credentials: Option<&ServiceCredential>,
working_dir: Option<&Path>,
timeout_dur: Duration,
request_id: u32,
session_id: u64,
session: &mut SessionState,
cancel_rx: &crossbeam_channel::Receiver<()>,
ctx: &RequestContext,
invocation_description: &str,
) -> (ToolOutput, bool, Option<PreparedImage>) {
let format = match &tool_call.caller {
Some(caller) if caller.kind == "program" => ToolOutputFormat::Json,
_ => ToolOutputFormat::Text,
};
let exec_start = std::time::Instant::now();
let cancel_flag = Arc::new(AtomicBool::new(false));
let tool_ctx = crate::tools::context::ToolContext {
session_id: ctx.session_id,
db: Arc::clone(&ctx.db),
daemon_tx: ctx.daemon_tx.clone(),
active_tool_groups: session.config.active_tool_groups.clone(),
reasoning_effort: session.config.reasoning_effort.clone(),
selected_model: session.config.selected_model.clone(),
working_dir: working_dir.map(|p| p.to_path_buf()),
cancelled: Arc::clone(&cancel_flag),
account_name: session.config.account_name.clone(),
};
let SpawnedToolExecution {
exec_rx: result_rx,
kill_tx,
image_rx,
_forwarder,
} = spawn_tool_execution(
tool_call,
format,
Arc::clone(&ctx.tool_registry),
x_credentials.cloned(),
working_dir.map(|p| p.to_path_buf()),
tool_ctx,
ctx.cmd_tx.clone(),
session_id,
request_id,
);
struct KillGuard(crossbeam_channel::Sender<()>);
impl Drop for KillGuard {
fn drop(&mut self) {
let _ = self.0.send(());
}
}
let _kill_guard = KillGuard(kill_tx);
let deadline = std::time::Instant::now() + timeout_dur;
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let (output, cancelled) = crossbeam_channel::select_biased! {
recv(cancel_rx) -> _ => {
cancel_flag.store(true, Ordering::Relaxed);
drain_queued_or_synthesize(
&tool_call.name,
exec_start,
invocation_description,
&result_rx,
format!("tool '{}' cancelled", tool_call.name),
true,
)
}
recv(result_rx) -> msg => {
tool_result_from_channel(&tool_call.name, exec_start, msg, invocation_description, false)
}
recv(crossbeam_channel::after(remaining)) -> _ => {
cancel_flag.store(true, Ordering::Relaxed);
drain_queued_or_synthesize(
&tool_call.name,
exec_start,
invocation_description,
&result_rx,
format!(
"tool '{}' timed out after {}s",
tool_call.name,
timeout_dur.as_secs()
),
false,
)
}
};
let image = image_rx.try_recv().ok();
(output, cancelled, image)
}
pub const REQUEST_IMAGE_BYTES: &[u8] = include_bytes!("../assets/dua.jpg");
pub const REQUEST_IMAGE_MIME_TYPE: &str = "image/jpeg";
pub const REQUEST_IMAGE_WIDTH: u32 = 640;
pub const REQUEST_IMAGE_HEIGHT: u32 = 640;
#[cfg(test)]
mod tests {
use super::*;
use crate::daemon::DaemonCommand;
use crate::providers::InferenceProvider;
use crate::providers::test_util::make_test_provider;
use crate::reasoning::{
build_chat_request_messages, initial_prev_resp_id, warn_on_missing_reasoning_artifacts,
};
use crate::tools::context::ToolContext;
use crate::tools::{Tool, ToolExecError, ToolRegistry};
use choreo_ai_protocols::openai::{AssistantToolCall, AssistantToolFunction};
use choreo_proto::{ChatReasoningField, ReasoningArtifact};
use std::sync::mpsc;
fn make_session_with_turns() -> SessionState {
let mut session = SessionState::empty();
let (tid0, _) = session.start_turn(Some("hello".into()));
session.set_assistant_response(
tid0,
AssistantResponse {
text: Some("hi".into()),
..Default::default()
},
);
session
}
const TEST_PROVIDER: &str = "test-stub";
const TEST_MODEL: &str = "test-model";
#[test]
fn build_chat_request_messages_empty() {
let session = SessionState::empty();
let result = build_chat_request_messages(&session, None, TEST_PROVIDER, TEST_MODEL);
assert!(result.is_empty());
}
#[test]
fn build_chat_request_messages_with_system_prompt() {
let session = SessionState::empty();
let result =
build_chat_request_messages(&session, Some("system prompt"), TEST_PROVIDER, TEST_MODEL);
assert_eq!(result.len(), 1);
assert_eq!(result[0].role, "system");
assert_eq!(result[0].content.as_deref(), Some("system prompt"));
}
#[test]
fn build_chat_request_messages_user_and_assistant() {
let session = make_session_with_turns();
let result = build_chat_request_messages(&session, None, TEST_PROVIDER, TEST_MODEL);
assert_eq!(result.len(), 2);
assert_eq!(result[0].role, "user");
assert_eq!(result[0].content.as_deref(), Some("hello"));
assert_eq!(result[1].role, "assistant");
assert_eq!(result[1].content.as_deref(), Some("hi"));
}
#[test]
fn build_chat_request_messages_with_tool_calls() {
let mut session = SessionState::empty();
let (tid, _) = session.start_turn(Some("list files".into()));
let records = vec![AssistantToolCallRecord {
call_id: "call_1".into(),
name: "ls".into(),
arguments_json: r#"{"path": "."}"#.into(),
}];
session.set_assistant_response(
tid,
AssistantResponse {
text: Some("thinking".into()),
tool_calls: records.clone(),
..Default::default()
},
);
session.seed_tool_results(tid, &records);
session.update_tool_result(
tid,
"call_1",
"ls".into(),
"file.txt".into(),
false,
String::new(),
);
let result = build_chat_request_messages(&session, None, TEST_PROVIDER, TEST_MODEL);
assert_eq!(result.len(), 3);
assert_eq!(result[0].role, "user");
assert_eq!(result[1].role, "assistant");
assert!(result[1].tool_calls.is_some());
assert_eq!(result[2].role, "tool");
assert_eq!(result[2].tool_call_id.as_deref(), Some("call_1"));
}
#[test]
fn build_chat_request_messages_skips_undone_turns() {
let mut session = SessionState::empty();
let (tid0, _) = session.start_turn(Some("visible".into()));
session.set_assistant_response(
tid0,
AssistantResponse {
text: Some("ok".into()),
..Default::default()
},
);
let (tid1, _) = session.start_turn(Some("hidden".into()));
session.set_assistant_response(
tid1,
AssistantResponse {
text: Some("nope".into()),
..Default::default()
},
);
if let Some(turn) = session.turns.get_mut(&tid1) {
turn.undone = true;
}
let result = build_chat_request_messages(&session, None, TEST_PROVIDER, TEST_MODEL);
assert_eq!(result.len(), 2);
assert_eq!(result[0].role, "user");
assert_eq!(result[0].content.as_deref(), Some("visible"));
}
fn tool_call_record(call_id: &str, name: &str) -> AssistantToolCallRecord {
AssistantToolCallRecord {
call_id: call_id.into(),
name: name.into(),
arguments_json: "{}".into(),
}
}
fn deepseek_producer() -> ReasoningProducer {
ReasoningProducer {
provider_slug: "deepseek".into(),
model: "deepseek-v4-pro".into(),
}
}
fn anthropic_producer() -> ReasoningProducer {
ReasoningProducer {
provider_slug: "anthropic".into(),
model: "claude-sonnet-5".into(),
}
}
fn artifact(bytes: &[u8]) -> ReasoningArtifact {
ReasoningArtifact::ChatReasoning {
field: ChatReasoningField::ReasoningContent,
bytes: bytes.to_vec(),
}
}
fn add_turn(
session: &mut SessionState,
user_text: &str,
assistant_text: &str,
artifact: Option<ReasoningArtifact>,
producer: Option<ReasoningProducer>,
tool_calls: Vec<AssistantToolCallRecord>,
) -> u32 {
let (tid, _) = session.start_turn(Some(user_text.to_string()));
session.set_assistant_response(
tid,
AssistantResponse {
text: Some(assistant_text.to_string()),
tool_calls,
reasoning_artifact: artifact,
reasoning_producer: producer,
..Default::default()
},
);
tid
}
fn assistant_messages(result: &[ChatRequestMessage]) -> Vec<&ChatRequestMessage> {
result.iter().filter(|m| m.role == "assistant").collect()
}
#[test]
fn builder_tool_loop_attaches_artifact_only_for_tool_involving_turns() {
let mut session = SessionState::empty();
add_turn(
&mut session,
"hello",
"hi",
Some(artifact(b"plain")),
Some(deepseek_producer()),
vec![],
);
add_turn(
&mut session,
"list files",
"thinking...",
Some(artifact(b"tool-thinking")),
Some(deepseek_producer()),
vec![tool_call_record("call_1", "ls")],
);
let result = build_chat_request_messages(&session, None, "deepseek", "deepseek-v4-pro");
let assistants = assistant_messages(&result);
assert_eq!(assistants.len(), 2);
assert_eq!(
assistants[0].reasoning_artifact, None,
"plain text turn must not replay reasoning under ToolLoop",
);
assert_eq!(
assistants[1].reasoning_artifact,
Some(artifact(b"tool-thinking")),
"tool-call turn must replay its artifact under ToolLoop",
);
}
#[test]
fn builder_tool_loop_attaches_artifact_for_tool_result_turns() {
let mut session = SessionState::empty();
let tid = add_turn(
&mut session,
"run it",
"running",
Some(artifact(b"mid-loop")),
Some(deepseek_producer()),
vec![],
);
session
.turns
.get_mut(&tid)
.expect("turn exists")
.tool_results
.push(choreo_proto::ToolResultRecord {
call_id: "call_1".into(),
name: "sh".into(),
content: "ok".into(),
is_error: false,
invocation_description: String::new(),
});
let result = build_chat_request_messages(&session, None, "deepseek", "deepseek-v4-pro");
let assistants = assistant_messages(&result);
assert_eq!(assistants.len(), 1);
assert_eq!(
assistants[0].reasoning_artifact,
Some(artifact(b"mid-loop"))
);
}
#[test]
fn builder_all_turns_attaches_always() {
let mut session = SessionState::empty();
let producer = ReasoningProducer {
provider_slug: "anthropic".into(),
model: "claude-unknown-model".into(),
};
add_turn(
&mut session,
"hello",
"hi",
Some(artifact(b"one")),
Some(producer.clone()),
vec![],
);
add_turn(
&mut session,
"again",
"bye",
Some(artifact(b"two")),
Some(producer),
vec![],
);
let result =
build_chat_request_messages(&session, None, "anthropic", "claude-unknown-model");
let assistants = assistant_messages(&result);
assert_eq!(assistants.len(), 2);
assert_eq!(assistants[0].reasoning_artifact, Some(artifact(b"one")));
assert_eq!(assistants[1].reasoning_artifact, Some(artifact(b"two")));
}
#[test]
fn builder_signature_policy_attaches_always() {
let mut session = SessionState::empty();
add_turn(
&mut session,
"hello",
"hi",
Some(ReasoningArtifact::GoogleSignatures(b"sig".to_vec())),
Some(ReasoningProducer {
provider_slug: "google".into(),
model: "gemini-2.5-pro".into(),
}),
vec![],
);
let result = build_chat_request_messages(&session, None, "google", "gemini-2.5-pro");
let assistants = assistant_messages(&result);
assert_eq!(assistants.len(), 1);
assert_eq!(
assistants[0].reasoning_artifact,
Some(ReasoningArtifact::GoogleSignatures(b"sig".to_vec())),
);
}
#[test]
fn builder_none_never_attaches() {
let mut session = SessionState::empty();
add_turn(
&mut session,
"list",
"thinking",
Some(artifact(b"payload")),
Some(ReasoningProducer {
provider_slug: "unknown-provider".into(),
model: "m".into(),
}),
vec![tool_call_record("call_1", "ls")],
);
let result = build_chat_request_messages(&session, None, "unknown-provider", "m");
let assistants = assistant_messages(&result);
assert_eq!(assistants.len(), 1);
assert_eq!(assistants[0].reasoning_artifact, None);
}
#[test]
fn builder_response_id_policy_never_attaches_via_message() {
let mut session = SessionState::empty();
add_turn(
&mut session,
"list",
"thinking",
Some(artifact(b"payload")),
Some(ReasoningProducer {
provider_slug: "openai".into(),
model: "gpt-4".into(),
}),
vec![tool_call_record("call_1", "ls")],
);
let result = build_chat_request_messages(&session, None, "openai", "gpt-4");
let assistants = assistant_messages(&result);
assert_eq!(assistants.len(), 1);
assert_eq!(assistants[0].reasoning_artifact, None);
}
#[test]
fn builder_same_model_mismatch_drops_artifact() {
let mut session = SessionState::empty();
add_turn(
&mut session,
"list",
"thinking",
Some(artifact(b"kept")),
Some(deepseek_producer()),
vec![tool_call_record("call_1", "ls")],
);
add_turn(
&mut session,
"old model turn",
"old thinking",
Some(artifact(b"dropped")),
Some(ReasoningProducer {
provider_slug: "anthropic".into(),
model: "claude-sonnet-4-5".into(),
}),
vec![tool_call_record("call_2", "grep")],
);
let result = build_chat_request_messages(&session, None, "deepseek", "deepseek-v4-pro");
let assistants = assistant_messages(&result);
assert_eq!(assistants.len(), 2);
assert_eq!(assistants[0].reasoning_artifact, Some(artifact(b"kept")));
assert_eq!(
assistants[1].reasoning_artifact, None,
"artifact from a previous model must be dropped",
);
}
#[test]
fn builder_undone_turn_artifact_is_skipped() {
let mut session = SessionState::empty();
add_turn(
&mut session,
"visible",
"ok",
Some(artifact(b"kept")),
Some(deepseek_producer()),
vec![tool_call_record("call_0", "pwd")],
);
let undone_tid = add_turn(
&mut session,
"hidden",
"nope",
Some(artifact(b"dropped")),
Some(deepseek_producer()),
vec![tool_call_record("call_1", "ls")],
);
session
.turns
.get_mut(&undone_tid)
.expect("turn exists")
.undone = true;
let result = build_chat_request_messages(&session, None, "deepseek", "deepseek-v4-pro");
let assistants = assistant_messages(&result);
assert_eq!(assistants.len(), 1, "undone turn must be skipped entirely");
assert_eq!(assistants[0].reasoning_artifact, Some(artifact(b"kept")));
}
#[test]
fn initial_prev_resp_id_response_policy_restores_persisted_id() {
let mut session = SessionState::empty();
session.config.last_response_id = Some("resp_123".into());
session.config.last_response_id_producer = Some(ReasoningProducer {
provider_slug: "openai".into(),
model: "gpt-4".into(),
});
assert_eq!(
initial_prev_resp_id(&session, "openai", "gpt-4").as_deref(),
Some("resp_123"),
);
}
#[test]
fn initial_prev_resp_id_other_policies_reset_to_none() {
let mut session = SessionState::empty();
session.config.last_response_id = Some("resp_123".into());
session.config.last_response_id_producer = Some(ReasoningProducer {
provider_slug: "deepseek".into(),
model: "deepseek-v4-pro".into(),
});
assert_eq!(
initial_prev_resp_id(&session, "deepseek", "deepseek-v4-pro"),
None,
);
assert_eq!(
initial_prev_resp_id(&session, "unknown-provider", "m"),
None
);
}
#[test]
fn initial_prev_resp_id_drops_stale_id_from_other_producer() {
let mut session = SessionState::empty();
session.config.last_response_id = Some("resp_openai".into());
session.config.last_response_id_producer = Some(ReasoningProducer {
provider_slug: "openai".into(),
model: "gpt-5.4".into(),
});
assert_eq!(
initial_prev_resp_id(&session, "openai", "gpt-4"),
None,
"id from gpt-5.4 must not be restored for gpt-4",
);
assert_eq!(
initial_prev_resp_id(&session, "openai", "gpt-5.4").as_deref(),
Some("resp_openai"),
);
let fresh = SessionState::empty();
assert_eq!(initial_prev_resp_id(&fresh, "openai", "gpt-5.4"), None);
}
#[test]
fn guard_warns_when_tool_involving_turn_lacks_artifact() {
let mut session = SessionState::empty();
let (tid, _) = session.start_turn(Some("list".into()));
let records = vec![tool_call_record("call_1", "ls")];
session.set_assistant_response(
tid,
AssistantResponse {
text: Some("thinking".into()),
tool_calls: records.clone(),
..Default::default()
},
);
session.seed_tool_results(tid, &records);
add_turn(
&mut session,
"again",
"thinking2",
Some(artifact(b"ok")),
Some(deepseek_producer()),
vec![tool_call_record("call_2", "sh")],
);
let missing =
warn_on_missing_reasoning_artifacts(&session, 7, "deepseek", "deepseek-v4-pro");
assert_eq!(
missing, 1,
"only the artifact-less tool turn should be flagged",
);
}
#[test]
fn guard_clean_when_all_tool_turns_have_artifacts() {
let mut session = SessionState::empty();
add_turn(
&mut session,
"list",
"thinking",
Some(artifact(b"ok")),
Some(deepseek_producer()),
vec![tool_call_record("call_1", "ls")],
);
add_turn(
&mut session,
"plain",
"hi",
None,
Some(deepseek_producer()),
vec![],
);
assert_eq!(
warn_on_missing_reasoning_artifacts(&session, 7, "deepseek", "deepseek-v4-pro"),
0,
);
}
#[test]
fn guard_all_turns_policy_flags_non_tool_turn_missing_artifact() {
let mut session = SessionState::empty();
add_turn(
&mut session,
"list",
"thinking",
Some(artifact(b"ok")),
Some(anthropic_producer()),
vec![tool_call_record("call_1", "ls")],
);
add_turn(
&mut session,
"plain",
"hi",
Some(artifact(b"ok")),
Some(anthropic_producer()),
vec![],
);
let (tid, _) = session.start_turn(Some("later".into()));
session.set_assistant_response(
tid,
AssistantResponse {
text: Some("hello".into()),
..Default::default()
},
);
assert_eq!(
warn_on_missing_reasoning_artifacts(&session, 7, "anthropic", "claude-sonnet-5"),
1,
"AllTurns flags the artifact-less non-tool assistant turn",
);
}
#[test]
fn guard_user_only_turn_is_not_flagged() {
let mut session = SessionState::empty();
add_turn(
&mut session,
"list",
"thinking",
Some(artifact(b"ok")),
Some(anthropic_producer()),
vec![tool_call_record("call_1", "ls")],
);
let _ = session.start_turn(Some("pending user text".into()));
assert_eq!(
warn_on_missing_reasoning_artifacts(&session, 7, "anthropic", "claude-sonnet-5"),
0,
);
}
#[test]
fn guard_flags_foreign_producer_artifact() {
let mut session = SessionState::empty();
add_turn(
&mut session,
"list",
"thinking",
Some(artifact(b"ok")),
Some(deepseek_producer()),
vec![tool_call_record("call_1", "ls")],
);
assert_eq!(
warn_on_missing_reasoning_artifacts(&session, 7, "anthropic", "claude-sonnet-5"),
1,
"foreign-producer artifact flagged under AllTurns",
);
assert_eq!(
warn_on_missing_reasoning_artifacts(&session, 7, "deepseek", "deepseek-v4-pro"),
0,
);
}
#[test]
fn guard_skipped_for_non_echo_policies() {
let mut session = SessionState::empty();
let (tid, _) = session.start_turn(Some("list".into()));
let records = vec![tool_call_record("call_1", "ls")];
session.set_assistant_response(
tid,
AssistantResponse {
text: Some("thinking".into()),
tool_calls: records.clone(),
..Default::default()
},
);
session.seed_tool_results(tid, &records);
assert_eq!(
warn_on_missing_reasoning_artifacts(&session, 7, "openai", "gpt-4"),
0,
);
}
#[test]
fn concurrent_status_label_single_tool_uses_real_name() {
let label = concurrent_tool_status_label(&[config_change_call("sh", "{}")]);
assert_eq!(label, "sh");
}
#[test]
fn concurrent_status_label_multi_tool_batch_is_parallel() {
let label = concurrent_tool_status_label(&[
config_change_call("sh", "{}"),
config_change_call("grep", "{}"),
]);
assert_eq!(label, "(parallel)");
}
#[test]
fn concurrent_status_label_empty_batch_is_parallel() {
let label = concurrent_tool_status_label(&[]);
assert_eq!(label, "(parallel)");
}
#[test]
fn is_cancelled_once_no_signal() {
let (_tx, rx) = crossbeam_channel::unbounded::<()>();
assert!(!is_cancelled_once(&rx));
}
#[test]
fn is_cancelled_once_with_signal() {
let (tx, rx) = crossbeam_channel::unbounded::<()>();
tx.send(()).unwrap();
assert!(is_cancelled_once(&rx));
}
fn config_change_call(name: &str, arguments_json: &str) -> ChatToolCall {
ChatToolCall {
id: "call_1".into(),
name: name.into(),
arguments_json: arguments_json.into(),
caller: None,
}
}
fn ok_output(result_json: Option<serde_json::Value>) -> ToolOutput {
ToolOutput {
content: String::new(),
is_error: false,
invocation_description: String::new(),
result_json,
}
}
#[test]
fn pending_load_tools_captures_groups_and_applies() {
let tool_call = config_change_call("load_tools", r#"{"groups": ["shell", "x"]}"#);
let change = pending_config_change(&tool_call, &ok_output(None), None)
.expect("load_tools should produce a change");
assert!(matches!(change, PendingConfigChange::LoadTools(ref g) if g == &["shell", "x"]));
let mut session = SessionState::empty();
session.config.active_tool_groups = ["core".into(), "git".into()].into_iter().collect();
apply_pending_config_change(&mut session, &change);
assert!(session.config.active_tool_groups.contains("shell"));
assert!(session.config.active_tool_groups.contains("x"));
assert!(session.config.active_tool_groups.contains("core"));
}
#[test]
fn pending_unload_tools_captures_groups_and_applies() {
let tool_call = config_change_call("unload_tools", r#"{"groups": ["shell"]}"#);
let change = pending_config_change(&tool_call, &ok_output(None), None)
.expect("unload_tools should produce a change");
assert!(matches!(change, PendingConfigChange::UnloadTools(ref g) if g == &["shell"]));
let mut session = SessionState::empty();
session.config.active_tool_groups = ["core".into(), "shell".into()].into_iter().collect();
apply_pending_config_change(&mut session, &change);
assert!(!session.config.active_tool_groups.contains("shell"));
assert!(session.config.active_tool_groups.contains("core"));
}
#[test]
fn pending_set_working_dir_mirrors_executed_result() {
let tool_call = config_change_call("set_working_dir", r#"{"path": "sub"}"#);
let output = ok_output(Some(serde_json::json!({ "path": "/real/canonical/sub" })));
let change = pending_config_change(&tool_call, &output, None)
.expect("set_working_dir should produce a change");
assert!(matches!(
change,
PendingConfigChange::SetWorkingDir(Some(ref p)) if p == &PathBuf::from("/real/canonical/sub")
));
let mut session = SessionState::empty();
session.discovered_skills = Some(Vec::new());
apply_pending_config_change(&mut session, &change);
assert_eq!(
session.config.working_dir.as_deref(),
Some(PathBuf::from("/real/canonical/sub").as_path())
);
assert!(
session.discovered_skills.is_none(),
"skill cache must be invalidated by the mirror"
);
}
#[test]
fn pending_set_working_dir_falls_back_to_shared_resolution() {
let base = tempfile::tempdir().unwrap();
let sub = base.path().join("sub");
std::fs::create_dir(&sub).unwrap();
let tool_call = config_change_call("set_working_dir", r#"{"path": "sub"}"#);
let change = pending_config_change(&tool_call, &ok_output(None), Some(base.path()))
.expect("set_working_dir should produce a change");
let mut session = SessionState::empty();
apply_pending_config_change(&mut session, &change);
assert_eq!(
session.config.working_dir.as_deref(),
Some(sub.canonicalize().unwrap().as_path())
);
}
#[test]
fn pending_set_working_dir_nonexistent_path_still_invalidates_skills() {
let tool_call = config_change_call("set_working_dir", r#"{"path": "gone"}"#);
let output = ok_output(Some(serde_json::json!({ "path": "/gone/dir" })));
let change = pending_config_change(&tool_call, &output, None)
.expect("set_working_dir should produce a change");
let mut session = SessionState::empty();
session.discovered_skills = Some(Vec::new());
apply_pending_config_change(&mut session, &change);
assert_eq!(
session.config.working_dir.as_deref(),
Some(PathBuf::from("/gone/dir").as_path())
);
assert!(
session.discovered_skills.is_none(),
"skill cache must be invalidated even when the path is gone"
);
}
#[test]
fn pending_set_working_dir_unresolvable_fallback_still_invalidates_skills() {
let base = tempfile::tempdir().unwrap();
let tool_call = config_change_call("set_working_dir", r#"{"path": "does-not-exist"}"#);
let change = pending_config_change(&tool_call, &ok_output(None), Some(base.path()))
.expect("set_working_dir should still produce a change");
assert!(matches!(change, PendingConfigChange::SetWorkingDir(None)));
let mut session = SessionState::empty();
session.discovered_skills = Some(Vec::new());
apply_pending_config_change(&mut session, &change);
assert!(session.config.working_dir.is_none());
assert!(
session.discovered_skills.is_none(),
"skill cache must be invalidated even when no path could be resolved"
);
}
#[test]
fn pending_unknown_tool_is_noop() {
let tool_call = config_change_call("read_file", r#"{"path": "x"}"#);
assert!(pending_config_change(&tool_call, &ok_output(None), None).is_none());
}
#[test]
fn pending_unparseable_args_is_noop() {
let tool_call = config_change_call("load_tools", "not json");
assert!(pending_config_change(&tool_call, &ok_output(None), None).is_none());
}
#[test]
fn broadcast_turn_appended_sends_when_turn_exists() {
let (tx, rx) = mpsc::channel::<SessionCommand>();
let mut session = SessionState::empty();
let (turn_id, _) = session.start_turn(Some("hello".into()));
broadcast_turn_appended(&tx, &session, 0, turn_id);
match rx.try_recv() {
Ok(SessionCommand::Broadcast(DaemonMessage::TurnAppended { turn_id: id, .. })) => {
assert_eq!(id, turn_id);
}
Ok(_) => panic!("expected TurnAppended broadcast, got different command"),
Err(e) => panic!("expected TurnAppended broadcast, got error: {e}"),
}
}
#[test]
fn broadcast_turn_appended_no_turn_no_broadcast() {
let (tx, rx) = mpsc::channel::<SessionCommand>();
let session = SessionState::empty();
broadcast_turn_appended(&tx, &session, 0, 999);
assert!(rx.try_recv().is_err(), "expected no message");
}
#[test]
fn broadcast_turn_appended_disconnected_receiver_no_panic() {
let (tx, rx) = mpsc::channel::<SessionCommand>();
let mut session = SessionState::empty();
let (turn_id, _) = session.start_turn(Some("hello".into()));
drop(rx);
broadcast_turn_appended(&tx, &session, 0, turn_id);
}
#[test]
fn broadcast_turn_appended_strips_reasoning_artifact() {
let (tx, rx) = mpsc::channel::<SessionCommand>();
let mut session = SessionState::empty();
let (turn_id, _) = session.start_turn(Some("hello".into()));
session.set_assistant_response(
turn_id,
AssistantResponse {
text: Some("hi".into()),
reasoning: Some("thinking out loud".into()),
reasoning_artifact: Some(ReasoningArtifact::ChatReasoning {
field: ChatReasoningField::ReasoningContent,
bytes: b"thinking".to_vec(),
}),
reasoning_producer: Some(ReasoningProducer {
provider_slug: "deepseek".into(),
model: "deepseek-v4-pro".into(),
}),
..Default::default()
},
);
broadcast_turn_appended(&tx, &session, 0, turn_id);
match rx.try_recv() {
Ok(SessionCommand::Broadcast(DaemonMessage::TurnAppended {
turn_id: id,
turn,
..
})) => {
assert_eq!(id, turn_id);
assert_eq!(turn.reasoning_artifact, None);
assert_eq!(turn.reasoning_producer, None);
assert_eq!(turn.assistant_text.as_deref(), Some("hi"));
assert_eq!(
turn.assistant_reasoning.as_deref(),
Some("thinking out loud")
);
}
Ok(_) => panic!("expected TurnAppended broadcast, got different command"),
Err(e) => panic!("expected TurnAppended broadcast, got error: {e}"),
}
assert!(session.turns[&turn_id].reasoning_artifact.is_some());
assert!(session.turns[&turn_id].reasoning_producer.is_some());
}
#[test]
fn finalize_and_broadcast_turn_strips_reasoning_artifact() {
let (daemon_tx, _daemon_rx) = mpsc::channel::<DaemonCommand>();
let (cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(redb::Database::create(dir.path().join("test.redb")).unwrap());
let ctx = RequestContext {
cmd_tx,
session_id: 1,
db,
tool_registry: ToolRegistry::new().build(),
daemon_tx,
max_turns: 0,
};
let mut session = SessionState::empty();
let (turn_id, _) = session.start_turn(Some("hello".into()));
session.set_assistant_response(
turn_id,
AssistantResponse {
text: Some("hi".into()),
reasoning: Some("thinking out loud".into()),
reasoning_artifact: Some(ReasoningArtifact::ChatReasoning {
field: ChatReasoningField::ReasoningContent,
bytes: b"thinking".to_vec(),
}),
reasoning_producer: Some(ReasoningProducer {
provider_slug: "deepseek".into(),
model: "deepseek-v4-pro".into(),
}),
..Default::default()
},
);
finalize_and_broadcast_turn(&mut session, &ctx, turn_id).unwrap();
match cmd_rx.try_recv() {
Ok(SessionCommand::Broadcast(DaemonMessage::TurnFinalized { turn, .. })) => {
assert_eq!(turn.reasoning_artifact, None);
assert_eq!(turn.reasoning_producer, None);
assert_eq!(turn.assistant_text.as_deref(), Some("hi"));
assert_eq!(
turn.assistant_reasoning.as_deref(),
Some("thinking out loud")
);
}
Ok(_) => panic!("expected TurnFinalized broadcast, got different command"),
Err(e) => panic!("expected TurnFinalized broadcast, got error: {e}"),
}
assert!(session.turns[&turn_id].reasoning_artifact.is_some());
assert!(session.turns[&turn_id].reasoning_producer.is_some());
}
#[test]
fn resolve_reasoning_effort_off_returns_off() {
let provider = make_test_provider();
let result = resolve_reasoning_effort(&provider, "o3-mini", 1, 0, "off");
assert_eq!(result, "off");
}
#[test]
fn resolve_reasoning_effort_unknown_provider_disables() {
let provider = make_test_provider();
let result = resolve_reasoning_effort(&provider, "o3-mini", 1, 0, "low");
assert_eq!(result, "off");
}
#[test]
fn resolve_reasoning_effort_openai_supported_model_preserves() {
let config = choreo_ai_protocols::openai::ServiceConfig::default();
let client =
choreo_ai_protocols::openai::OpenAiClient::new(config, "test-key".into()).unwrap();
let provider = InferenceProvider::from_openai(client);
let result = resolve_reasoning_effort(&provider, "o3-mini", 1, 0, "high");
assert_eq!(result, "high");
}
#[test]
fn resolve_reasoning_effort_openai_unsupported_model_disables() {
let config = choreo_ai_protocols::openai::ServiceConfig::default();
let client =
choreo_ai_protocols::openai::OpenAiClient::new(config, "test-key".into()).unwrap();
let provider = InferenceProvider::from_openai(client);
let result = resolve_reasoning_effort(&provider, "gpt-4.1", 1, 0, "medium");
assert_eq!(result, "off");
}
#[test]
fn estimate_prompt_tokens_empty() {
let (encoding, estimated) = estimate_prompt_tokens("gpt-4", &[], &[]);
assert!(encoding.is_some());
assert_eq!(estimated, 0);
}
#[test]
fn estimate_prompt_tokens_counts_content() {
let messages = vec![
ChatRequestMessage::simple("user", "hello world".into()),
ChatRequestMessage::simple("assistant", "hi there".into()),
];
let (_, estimated) = estimate_prompt_tokens("gpt-4", &messages, &[]);
assert!(
estimated > 0,
"expected positive token count, got {estimated}"
);
}
#[test]
fn estimate_prompt_tokens_does_not_count_reasoning_content() {
let base_messages = vec![
ChatRequestMessage::simple("user", "hello".into()),
ChatRequestMessage::simple("assistant", "visible".into()),
];
let mut with_reasoning = base_messages.clone();
with_reasoning[1].reasoning_content = Some("thinking deep...".into());
let (_, base_est) = estimate_prompt_tokens("gpt-4", &base_messages, &[]);
let (_, reason_est) = estimate_prompt_tokens("gpt-4", &with_reasoning, &[]);
assert_eq!(
base_est, reason_est,
"legacy reasoning_content string field is never populated by the daemon and must not count"
);
}
#[test]
fn estimate_prompt_tokens_counts_reasoning_artifact() {
let base_messages = vec![
ChatRequestMessage::simple("user", "hello".into()),
ChatRequestMessage::simple("assistant", "visible".into()),
];
let mut with_artifact = base_messages.clone();
with_artifact[1].reasoning_artifact = Some(ReasoningArtifact::ChatReasoning {
field: ChatReasoningField::ReasoningContent,
bytes: "thinking deep...".into(),
});
let (_, base_est) = estimate_prompt_tokens("gpt-4", &base_messages, &[]);
let (_, artifact_est) = estimate_prompt_tokens("gpt-4", &with_artifact, &[]);
assert!(
artifact_est > base_est,
"replayed reasoning artifact should count as input: {artifact_est} <= {base_est}",
);
}
#[test]
fn estimate_prompt_tokens_counts_tool_call_metadata() {
let messages = vec![ChatRequestMessage {
role: "assistant",
content: None,
tool_calls: Some(vec![AssistantToolCall {
id: "call_abc".into(),
kind: "function".into(),
function: AssistantToolFunction {
name: "read_file".into(),
arguments: r#"{"path": "/etc/hosts"}"#.into(),
},
}]),
tool_call_id: None,
reasoning_content: None,
reasoning: None,
reasoning_text: None,
reasoning_artifact: None,
}];
let (_, estimated) = estimate_prompt_tokens("gpt-4", &messages, &[]);
assert!(
estimated > 0,
"expected positive token count, got {estimated}"
);
}
#[test]
fn estimate_prompt_tokens_includes_tool_defs() {
let tools = vec![ChatToolDefinition::function(
"read_file",
"Read a file from disk",
serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string"}
}
}),
)];
let messages = vec![ChatRequestMessage::simple("user", "read file".into())];
let (_, with_tools) = estimate_prompt_tokens("gpt-4", &messages, &tools);
let (_, without_tools) = estimate_prompt_tokens("gpt-4", &messages, &[]);
assert!(
with_tools > without_tools,
"tool defs should increase token count: {with_tools} <= {without_tools}",
);
}
#[test]
fn estimate_prompt_tokens_unknown_model_falls_back() {
let messages = vec![ChatRequestMessage::simple("user", "hello".into())];
let (encoding, estimated) =
estimate_prompt_tokens("nonexistent-model-9000", &messages, &[]);
assert!(encoding.is_some(), "should fall back to cl100k_base");
assert!(estimated > 0);
}
#[test]
fn estimate_prompt_tokens_no_chained_context_addend() {
let messages = vec![
ChatRequestMessage::simple("system", "rebuilt system prompt".into()),
ChatRequestMessage::simple("user", "turn one".into()),
ChatRequestMessage::simple("assistant", "answer".into()),
ChatRequestMessage::simple("user", "turn two".into()),
];
let (_, estimated) = estimate_prompt_tokens("gpt-4", &messages, &[]);
let (_, recounted) = estimate_prompt_tokens("gpt-4", &messages, &[]);
assert_eq!(estimated, recounted, "estimate must be deterministic");
assert!(
estimated > 0,
"full conversation must count: got {estimated}"
);
}
struct FastTestTool;
impl Tool for FastTestTool {
type Args = serde_json::Value;
type Return = String;
type Error = ToolExecError;
fn name(&self) -> &'static str {
"_test_fast"
}
fn group(&self) -> &'static str {
"test"
}
fn description(&self) -> &'static str {
"test tool that completes immediately"
}
fn describe_invocation(&self, _args: &Self::Args) -> String {
format!("{}.", self.description())
}
fn return_string(ret: &Self::Return) -> String {
ret.clone()
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({})
}
fn execute(
&self,
_args: Self::Args,
_xc: Option<&ServiceCredential>,
_working_dir: Option<&Path>,
_ctx: Option<&ToolContext>,
) -> Result<Self::Return, Self::Error> {
Ok("fast result".into())
}
}
struct BlockingTestTool {
proceed: std::sync::Mutex<Option<mpsc::Receiver<()>>>,
}
impl Tool for BlockingTestTool {
type Args = serde_json::Value;
type Return = String;
type Error = ToolExecError;
fn name(&self) -> &'static str {
"_test_blocking"
}
fn group(&self) -> &'static str {
"test"
}
fn description(&self) -> &'static str {
"test tool that blocks until proceed"
}
fn describe_invocation(&self, _args: &Self::Args) -> String {
format!("{}.", self.description())
}
fn return_string(ret: &Self::Return) -> String {
ret.clone()
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({})
}
fn execute(
&self,
_args: Self::Args,
_xc: Option<&ServiceCredential>,
_working_dir: Option<&Path>,
_ctx: Option<&ToolContext>,
) -> Result<Self::Return, Self::Error> {
Ok("ignored".into())
}
fn execute_streaming(
&self,
_args: Self::Args,
_xc: Option<&ServiceCredential>,
_working_dir: Option<&Path>,
_output_tx: crossbeam_channel::Sender<Vec<u8>>,
_ctx: Option<&ToolContext>,
) -> Result<Self::Return, Self::Error> {
if let Some(rx) = self.proceed.lock().unwrap().take() {
let _ = rx.recv();
}
Ok("blocked tool done".into())
}
}
fn run_exec_tool(
tool: impl Tool + 'static,
tool_name: &str,
tool_args: &str,
timeout_dur: Duration,
cancel_rx: crossbeam_channel::Receiver<()>,
) -> (ToolOutput, bool, mpsc::Receiver<SessionCommand>) {
let (daemon_tx, _daemon_rx) = mpsc::channel::<DaemonCommand>();
let (cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let dir = tempfile::tempdir().expect("tempdir");
let db = redb::Database::create(dir.path().join("test.redb")).expect("Database");
let mut session = SessionState::empty();
let mut registry = ToolRegistry::new();
registry.register(tool);
let registry = registry.build();
let tool_call = ChatToolCall {
id: "call_test".into(),
name: tool_name.into(),
arguments_json: tool_args.into(),
caller: None,
};
let ctx = RequestContext {
cmd_tx,
session_id: 1,
db: Arc::new(db),
tool_registry: registry,
daemon_tx,
max_turns: 0,
};
let (result, cancelled, _image) = execute_tool_with_timeout(
&tool_call,
None,
None,
timeout_dur,
1,
1,
&mut session,
&cancel_rx,
&ctx,
"test invocation",
);
(result, cancelled, cmd_rx)
}
#[test]
fn execute_tool_normal_completion() {
let (_cancel_tx, cancel_rx) = crossbeam_channel::unbounded::<()>();
let (result, cancelled, _cmd_rx) = run_exec_tool(
FastTestTool,
"_test_fast",
"{}",
Duration::from_secs(60),
cancel_rx,
);
assert!(!result.is_error, "expected success: {}", result.content);
assert!(result.content.contains("fast result"), "{}", result.content);
assert!(!cancelled, "completion must not report a cancel");
}
#[test]
fn execute_tool_cancelled_before_execution() {
let (cancel_tx, cancel_rx) = crossbeam_channel::unbounded::<()>();
cancel_tx.send(()).expect("send cancel");
drop(cancel_tx);
let (proceed_tx, proceed_rx) = mpsc::channel::<()>();
let (result, cancelled, _cmd_rx) = run_exec_tool(
BlockingTestTool {
proceed: std::sync::Mutex::new(Some(proceed_rx)),
},
"_test_blocking",
"{}",
Duration::from_secs(60),
cancel_rx,
);
assert!(result.is_error, "expected error: {}", result.content);
assert!(result.content.contains("cancelled"), "{}", result.content);
assert!(cancelled, "cancel must be reported to the caller");
assert_eq!(result.invocation_description, "test invocation");
drop(proceed_tx);
}
#[test]
fn execute_tool_timeout() {
let (_cancel_tx, cancel_rx) = crossbeam_channel::unbounded::<()>();
let (proceed_tx, proceed_rx) = mpsc::channel::<()>();
let (result, cancelled, _cmd_rx) = run_exec_tool(
BlockingTestTool {
proceed: std::sync::Mutex::new(Some(proceed_rx)),
},
"_test_blocking",
"{}",
Duration::ZERO,
cancel_rx,
);
assert!(result.is_error, "expected error: {}", result.content);
assert!(result.content.contains("timed out"), "{}", result.content);
assert!(!cancelled, "a timeout is not a cancellation");
drop(proceed_tx);
}
#[test]
fn drain_queued_result_beats_stop_message() {
let (tx, rx) = crossbeam_channel::unbounded::<Result<ToolOutput, ToolError>>();
tx.send(Ok(ToolOutput {
content: "real result".into(),
invocation_description: "real desc".into(),
..Default::default()
}))
.expect("send result");
let (output, cancelled) = drain_queued_or_synthesize(
"_test",
std::time::Instant::now(),
"test invocation",
&rx,
"tool '_test' cancelled".to_string(),
true,
);
assert_eq!(output.content, "real result");
assert!(!output.is_error);
assert_eq!(output.invocation_description, "real desc");
assert!(cancelled, "sticky cancel must survive a drained result");
}
#[test]
fn drain_queued_empty_synthesizes_stop_message() {
let (_tx, rx) = crossbeam_channel::unbounded::<Result<ToolOutput, ToolError>>();
let (output, cancelled) = drain_queued_or_synthesize(
"_test",
std::time::Instant::now(),
"test invocation",
&rx,
"tool '_test' timed out after 60s".to_string(),
false,
);
assert_eq!(output.content, "tool '_test' timed out after 60s");
assert!(output.is_error);
assert_eq!(output.invocation_description, "test invocation");
assert!(!cancelled, "a timeout is not a request cancel");
}
#[test]
fn drain_queued_disconnected_reports_panic_not_stop() {
let (tx, rx) = crossbeam_channel::unbounded::<Result<ToolOutput, ToolError>>();
drop(tx);
let (output, cancelled) = drain_queued_or_synthesize(
"_test",
std::time::Instant::now(),
"test invocation",
&rx,
"tool '_test' cancelled".to_string(),
true,
);
assert_eq!(output.content, "tool execution thread panicked");
assert!(output.is_error);
assert_eq!(output.invocation_description, "test invocation");
assert!(cancelled, "sticky flag still applies on the panic path");
}
struct StreamingTestTool;
impl Tool for StreamingTestTool {
type Args = serde_json::Value;
type Return = String;
type Error = ToolExecError;
fn name(&self) -> &'static str {
"_test_streaming"
}
fn group(&self) -> &'static str {
"test"
}
fn description(&self) -> &'static str {
"test tool that sends streaming output"
}
fn describe_invocation(&self, _args: &Self::Args) -> String {
format!("{}.", self.description())
}
fn return_string(ret: &Self::Return) -> String {
ret.clone()
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({})
}
fn execute(
&self,
_args: Self::Args,
_xc: Option<&ServiceCredential>,
_working_dir: Option<&Path>,
_ctx: Option<&ToolContext>,
) -> Result<Self::Return, Self::Error> {
Ok("exec result".into())
}
fn supports_streaming_output() -> bool {
true
}
fn execute_streaming(
&self,
_args: Self::Args,
_xc: Option<&ServiceCredential>,
_working_dir: Option<&Path>,
output_tx: crossbeam_channel::Sender<Vec<u8>>,
_ctx: Option<&ToolContext>,
) -> Result<Self::Return, Self::Error> {
let _ = output_tx.send(b"streamed payload".to_vec());
Ok("streaming done".into())
}
}
#[test]
fn execute_tool_forwards_streaming_output() {
let (_cancel_tx, cancel_rx) = crossbeam_channel::unbounded::<()>();
let (result, cancelled, cmd_rx) = run_exec_tool(
StreamingTestTool,
"_test_streaming",
"{}",
Duration::from_secs(60),
cancel_rx,
);
assert!(!result.is_error, "expected success: {}", result.content);
assert!(
result.content.contains("streaming done"),
"{}",
result.content
);
assert!(!cancelled, "completion must not report a cancel");
match cmd_rx.recv() {
Ok(SessionCommand::Broadcast(DaemonMessage::ToolResultChunk { data, .. })) => {
assert_eq!(data, b"test tool that sends streaming output.");
}
Ok(_other) => panic!("expected ToolResultChunk, got unexpected SessionCommand"),
Err(e) => panic!("channel disconnected while waiting for streaming output: {e}"),
}
match cmd_rx.recv() {
Ok(SessionCommand::Broadcast(DaemonMessage::ToolResultChunk { data, .. })) => {
assert_eq!(data, b"streamed payload");
}
Ok(_other) => panic!("expected ToolResultChunk, got unexpected SessionCommand"),
Err(e) => panic!("channel disconnected while waiting for streaming output: {e}"),
}
}
#[test]
fn forwarding_thread_drains_queued_output_before_kill() {
let (cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let (output_tx, output_rx) = crossbeam_channel::unbounded::<Vec<u8>>();
let (kill_tx, kill_rx) = crossbeam_channel::unbounded::<()>();
let handle = spawn_forwarding_thread(cmd_tx, 1, 1, "call_1".into(), output_rx, kill_rx);
output_tx
.send(b"queued chunk".to_vec())
.expect("send chunk");
kill_tx.send(()).expect("send kill");
match cmd_rx.recv() {
Ok(SessionCommand::Broadcast(DaemonMessage::ToolResultChunk { data, .. })) => {
assert_eq!(data, b"queued chunk");
}
Ok(_other) => panic!("expected ToolResultChunk, got unexpected SessionCommand"),
Err(e) => panic!("channel disconnected while waiting for chunk: {e}"),
}
handle.join().expect("forwarder should exit after kill");
}
#[test]
fn forwarding_thread_exits_when_output_disconnects() {
let (cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let (output_tx, output_rx) = crossbeam_channel::unbounded::<Vec<u8>>();
let (_kill_tx, kill_rx) = crossbeam_channel::unbounded::<()>();
let handle = spawn_forwarding_thread(cmd_tx, 1, 1, "call_2".into(), output_rx, kill_rx);
output_tx.send(b"last chunk".to_vec()).expect("send chunk");
drop(output_tx);
match cmd_rx.recv() {
Ok(SessionCommand::Broadcast(DaemonMessage::ToolResultChunk { data, .. })) => {
assert_eq!(data, b"last chunk");
}
Ok(_other) => panic!("expected ToolResultChunk, got unexpected SessionCommand"),
Err(e) => panic!("channel disconnected while waiting for chunk: {e}"),
}
handle
.join()
.expect("forwarder should exit on output disconnect");
}
#[test]
fn forwarding_thread_exits_when_kill_sender_dropped() {
let (cmd_tx, _cmd_rx) = mpsc::channel::<SessionCommand>();
let (output_tx, output_rx) = crossbeam_channel::unbounded::<Vec<u8>>();
let (kill_tx, kill_rx) = crossbeam_channel::unbounded::<()>();
let handle = spawn_forwarding_thread(cmd_tx, 1, 1, "call_3".into(), output_rx, kill_rx);
drop(kill_tx);
handle
.join()
.expect("forwarder should exit when kill sender dropped");
drop(output_tx);
}
#[test]
fn forwarding_thread_honors_kill_while_output_is_still_alive() {
let (cmd_tx, cmd_rx) = mpsc::channel::<SessionCommand>();
let (output_tx, output_rx) = crossbeam_channel::unbounded::<Vec<u8>>();
let (kill_tx, kill_rx) = crossbeam_channel::unbounded::<()>();
let handle = spawn_forwarding_thread(cmd_tx, 1, 1, "call_4".into(), output_rx, kill_rx);
for i in 0..5 {
output_tx
.send(format!("chunk {i}").into_bytes())
.expect("send chunk");
}
kill_tx.send(()).expect("send kill");
match cmd_rx.recv() {
Ok(SessionCommand::Broadcast(DaemonMessage::ToolResultChunk { data, .. })) => {
assert_eq!(data, b"chunk 0", "first queued chunk should be forwarded");
}
Ok(_other) => panic!("expected ToolResultChunk, got unexpected SessionCommand"),
Err(e) => panic!("channel disconnected while waiting for chunk: {e}"),
}
handle
.join()
.expect("forwarder should exit on kill while output is still live");
drop(output_tx);
drop(kill_tx);
}
#[test]
fn determine_tool_timeout_subsession_none() {
assert!(determine_tool_timeout("spawn_subsession").is_none());
}
#[test]
fn determine_tool_timeout_shell_300() {
for name in &["sh", "nushell", "fish", "exec"] {
assert_eq!(
determine_tool_timeout(name),
Some(Duration::from_secs(300)),
"tool {name} should have 300s timeout",
);
}
}
#[test]
fn determine_tool_timeout_default_60() {
for name in &[
"read_file",
"write_file",
"run_riscv",
"grep",
"http_request",
] {
assert_eq!(
determine_tool_timeout(name),
Some(Duration::from_secs(60)),
"tool {name} should have 60s timeout",
);
}
}
fn spawn_test_ctx() -> (ToolContext, mpsc::Sender<SessionCommand>) {
let (cmd_tx, _cmd_rx) = mpsc::channel::<SessionCommand>();
let (_daemon_tx, _daemon_rx) = mpsc::channel::<DaemonCommand>();
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(redb::Database::create(dir.path().join("test.redb")).expect("Database"));
let ctx = ToolContext {
session_id: 1,
db,
daemon_tx: _daemon_tx,
active_tool_groups: std::collections::HashSet::new(),
reasoning_effort: None,
selected_model: None,
working_dir: None,
cancelled: Arc::new(AtomicBool::new(false)),
account_name: None,
};
(ctx, cmd_tx)
}
fn run_spawn_single_tool(
tool: impl Tool + 'static,
tool_name: &str,
tool_args: &str,
timeout: Option<Duration>,
) -> ToolHandle {
let (ctx, cmd_tx) = spawn_test_ctx();
let mut registry = ToolRegistry::new();
registry.register(tool);
let registry = registry.build();
let tool_call = ChatToolCall {
id: "call_test".into(),
name: tool_name.into(),
arguments_json: tool_args.into(),
caller: None,
};
let invocation_description = registry
.describe_invocation_for(&tool_call.name, &tool_call.arguments_json)
.unwrap_or_default();
let (result_tx, result_rx) = crossbeam_channel::unbounded::<ToolHandle>();
let _kill_tx = spawn_single_tool(SpawnToolArgs {
tool_call,
timeout,
request_id: 1,
session_id: 1,
registry,
cmd_tx,
x_credentials: None,
working_dir: None,
ctx,
invocation_description,
started_at: Instant::now(),
result_tx,
});
result_rx.recv().expect("tool did not deliver a result")
}
#[test]
fn spawn_single_tool_fast_returns_result() {
let handle = run_spawn_single_tool(
FastTestTool,
"_test_fast",
"{}",
Some(Duration::from_secs(60)),
);
assert!(
!handle.output.is_error,
"expected success: {}",
handle.output.content
);
assert!(
handle.output.content.contains("fast result"),
"{}",
handle.output.content
);
assert!(handle.image.is_none(), "expected no image from fast tool");
}
#[test]
fn spawn_single_tool_no_timeout_still_completes() {
let handle = run_spawn_single_tool(FastTestTool, "_test_fast", "{}", None);
assert!(
!handle.output.is_error,
"expected success: {}",
handle.output.content
);
assert!(
handle.output.content.contains("fast result"),
"{}",
handle.output.content
);
}
#[test]
fn concurrent_tools_deliver_in_completion_order() {
let (proceed_tx, proceed_rx) = mpsc::channel::<()>();
let (ctx, cmd_tx) = spawn_test_ctx();
let mut registry = ToolRegistry::new();
registry.register(BlockingTestTool {
proceed: std::sync::Mutex::new(Some(proceed_rx)),
});
registry.register(FastTestTool);
let registry = registry.build();
let slow_call = ChatToolCall {
id: "call_slow".into(),
name: "_test_blocking".into(),
arguments_json: "{}".into(),
caller: None,
};
let fast_call = ChatToolCall {
id: "call_fast".into(),
name: "_test_fast".into(),
arguments_json: "{}".into(),
caller: None,
};
let (batch_tx, batch_rx) = crossbeam_channel::unbounded::<ToolHandle>();
let _slow_kill = spawn_single_tool(SpawnToolArgs {
tool_call: slow_call,
timeout: Some(Duration::from_secs(5)),
request_id: 1,
session_id: 1,
registry: Arc::clone(®istry),
cmd_tx: cmd_tx.clone(),
x_credentials: None,
working_dir: None,
ctx: ctx.clone(),
invocation_description: String::new(),
started_at: Instant::now(),
result_tx: batch_tx.clone(),
});
let _fast_kill = spawn_single_tool(SpawnToolArgs {
tool_call: fast_call,
timeout: Some(Duration::from_secs(60)),
request_id: 1,
session_id: 1,
registry,
cmd_tx,
x_credentials: None,
working_dir: None,
ctx,
invocation_description: String::new(),
started_at: Instant::now(),
result_tx: batch_tx,
});
let first = batch_rx.recv().expect("expected a first tool result");
assert_eq!(first.tool_call.name, "_test_fast");
assert!(
first.output.content.contains("fast result"),
"{}",
first.output.content
);
drop(proceed_tx);
let second = batch_rx.recv().expect("expected the slow tool result");
assert_eq!(second.tool_call.name, "_test_blocking");
assert!(
second.output.content.contains("blocked tool done"),
"{}",
second.output.content
);
}
#[test]
fn wait_loop_honors_kill_while_tool_is_still_running() {
let (proceed_tx, proceed_rx) = mpsc::channel::<()>();
let (ctx, cmd_tx) = spawn_test_ctx();
let cancel_flag = Arc::clone(&ctx.cancelled);
let mut registry = ToolRegistry::new();
registry.register(BlockingTestTool {
proceed: std::sync::Mutex::new(Some(proceed_rx)),
});
let registry = registry.build();
let tool_call = ChatToolCall {
id: "call_kill".into(),
name: "_test_blocking".into(),
arguments_json: "{}".into(),
caller: None,
};
let (result_tx, result_rx) = crossbeam_channel::unbounded::<ToolHandle>();
let kill_tx = spawn_single_tool(SpawnToolArgs {
tool_call,
timeout: None, request_id: 1,
session_id: 1,
registry,
cmd_tx,
x_credentials: None,
working_dir: None,
ctx,
invocation_description: String::new(),
started_at: Instant::now(),
result_tx,
});
kill_tx.send(()).expect("send kill");
let handle = result_rx
.recv()
.expect("cancelled result must be delivered");
assert!(handle.output.is_error, "{}", handle.output.content);
assert!(
handle.output.content.contains("cancelled"),
"{}",
handle.output.content
);
assert!(
cancel_flag.load(Ordering::Relaxed),
"cooperative cancel flag must be set"
);
drop(proceed_tx);
}
#[test]
fn missing_calls_skips_delivered_by_id_not_index() {
let call_infos = vec![
CallInfo {
call_id: "a".into(),
tool_name: "slow_1".into(),
arguments_json: "{}".into(),
invocation_description: "a".into(),
started_at: Instant::now(),
kill_tx: crossbeam_channel::unbounded().0,
},
CallInfo {
call_id: "b".into(),
tool_name: "fast".into(),
arguments_json: "{}".into(),
invocation_description: "b".into(),
started_at: Instant::now(),
kill_tx: crossbeam_channel::unbounded().0,
},
CallInfo {
call_id: "c".into(),
tool_name: "slow_2".into(),
arguments_json: "{}".into(),
invocation_description: "c".into(),
started_at: Instant::now(),
kill_tx: crossbeam_channel::unbounded().0,
},
];
let delivered = HashSet::from(["b".to_string()]);
let missing: Vec<&str> = missing_calls(&call_infos, &delivered)
.map(|info| info.call_id.as_str())
.collect();
assert_eq!(missing, vec!["a", "c"]);
}
#[test]
fn missing_calls_empty_when_all_delivered() {
let call_infos = vec![CallInfo {
call_id: "a".into(),
tool_name: "read_file".into(),
arguments_json: "{}".into(),
invocation_description: "a".into(),
started_at: Instant::now(),
kill_tx: crossbeam_channel::unbounded().0,
}];
let delivered = HashSet::from(["a".to_string()]);
assert_eq!(missing_calls(&call_infos, &delivered).count(), 0);
}
#[test]
fn sort_by_call_order_restores_model_order() {
let tool_calls = vec![
AssistantToolCallRecord {
call_id: "a".into(),
name: "read_file".into(),
arguments_json: "{}".into(),
},
AssistantToolCallRecord {
call_id: "b".into(),
name: "grep".into(),
arguments_json: "{}".into(),
},
AssistantToolCallRecord {
call_id: "c".into(),
name: "sh".into(),
arguments_json: "{}".into(),
},
];
let mut items = vec![
ToolResultItem {
call_id: "c".into(),
output: "c-out".into(),
caller: None,
},
ToolResultItem {
call_id: "a".into(),
output: "a-out".into(),
caller: None,
},
ToolResultItem {
call_id: "b".into(),
output: "b-out".into(),
caller: None,
},
];
sort_by_call_order(&tool_calls, &mut items, |r| r.call_id.as_str());
let order: Vec<_> = items.iter().map(|r| r.call_id.as_str()).collect();
assert_eq!(order, vec!["a", "b", "c"]);
}
#[test]
fn sort_by_call_order_sinks_unknown_call_ids() {
let tool_calls = vec![AssistantToolCallRecord {
call_id: "a".into(),
name: "read_file".into(),
arguments_json: "{}".into(),
}];
let mut items = vec![
ToolResultItem {
call_id: "ghost".into(),
output: "g-out".into(),
caller: None,
},
ToolResultItem {
call_id: "a".into(),
output: "a-out".into(),
caller: None,
},
];
sort_by_call_order(&tool_calls, &mut items, |r| r.call_id.as_str());
let order: Vec<_> = items.iter().map(|r| r.call_id.as_str()).collect();
assert_eq!(order, vec!["a", "ghost"]);
}
#[test]
fn extract_json_string_gets_value() {
let json = r#"{"name": "test-skill", "path": "src/main.rs"}"#;
assert_eq!(
extract_json_string(json, "name").as_deref(),
Some("test-skill")
);
assert_eq!(
extract_json_string(json, "path").as_deref(),
Some("src/main.rs")
);
}
#[test]
fn extract_json_string_missing_key() {
assert_eq!(extract_json_string(r#"{"other": "val"}"#, "name"), None);
}
#[test]
fn extract_json_string_invalid_json() {
assert_eq!(extract_json_string("not json", "name"), None);
}
#[test]
fn persist_loaded_skill_adds_to_session() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join(".agents/skills/test-skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"\
---\n\
name: test-skill\n\
description: A test skill\n\
---\n\
Hello, this is the skill body.\n\
---\n",
)
.unwrap();
let mut session = SessionState::empty();
session.config.working_dir = Some(dir.path().to_path_buf());
assert!(session.loaded_skill_bodies.is_empty());
persist_loaded_skill(&mut session, "load_skill", r#"{"name": "test-skill"}"#);
assert_eq!(session.loaded_skill_bodies.len(), 1);
assert_eq!(session.loaded_skill_bodies[0].name, "test-skill");
assert!(session.loaded_skill_bodies[0].body.contains("skill body"));
}
#[test]
fn persist_loaded_skill_skips_non_load_skill() {
let mut session = SessionState::empty();
persist_loaded_skill(&mut session, "read_file", r#"{"path": "Cargo.toml"}"#);
assert!(session.loaded_skill_bodies.is_empty());
}
#[test]
fn persist_loaded_skill_skips_missing_name() {
let mut session = SessionState::empty();
session.config.working_dir = Some(PathBuf::from("/tmp"));
persist_loaded_skill(&mut session, "load_skill", r#"{}"#);
assert!(session.loaded_skill_bodies.is_empty());
}
#[test]
fn persist_loaded_skill_skips_without_working_dir() {
let mut session = SessionState::empty();
persist_loaded_skill(&mut session, "load_skill", r#"{"name": "test-skill"}"#);
assert!(session.loaded_skill_bodies.is_empty());
}
fn setup_build_system_content_session() -> (SessionState, Arc<ToolRegistry>, tempfile::TempDir)
{
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("AGENTS.md"), "Project rules").unwrap();
let mut registry = ToolRegistry::new();
registry.register(FastTestTool);
let registry = registry.build();
let mut session = SessionState::empty();
session.config.working_dir = Some(dir.path().to_path_buf());
(session, registry, dir)
}
fn test_build_content(
session: &mut SessionState,
registry: &ToolRegistry,
pending_hints: &[String],
) -> Option<String> {
build_system_content(
SystemContentParams {
working_dir: session.config.working_dir.as_deref(),
context_config: &session.config.context_config,
skills: &[],
loaded_skill_bodies: &session.loaded_skill_bodies,
tool_registry: registry,
pending_hints,
session_title: session.config.title.as_deref(),
},
&mut session.context_cache,
)
}
#[test]
fn build_system_content_with_working_dir() {
let (mut session, registry, _dir) = setup_build_system_content_session();
let content = test_build_content(&mut session, ®istry, &[]);
assert!(content.is_some());
let content = content.unwrap();
assert!(content.contains("Tool groups"));
assert!(content.contains("core"));
assert!(content.contains("Project rules"));
}
#[test]
fn build_system_content_without_working_dir() {
let mut session = SessionState::empty();
let registry = ToolRegistry::new().build();
let content = test_build_content(&mut session, ®istry, &[]);
assert!(content.is_none());
}
#[test]
fn build_system_content_includes_loaded_skills() {
let (mut session, registry, _dir) = setup_build_system_content_session();
session.loaded_skill_bodies.push(LoadedSkill {
name: "loaded-test".to_string(),
body: "Loaded body text.".to_string(),
});
let content = test_build_content(&mut session, ®istry, &[]);
assert!(content.is_some());
let content = content.unwrap();
assert!(content.contains("Loaded skills"));
assert!(content.contains("loaded-test"));
assert!(content.contains("Loaded body text."));
}
#[test]
fn build_system_content_populates_context_cache() {
let (mut session, registry, _dir) = setup_build_system_content_session();
assert!(session.context_cache.is_none());
let _ = test_build_content(&mut session, ®istry, &[]);
assert!(
session.context_cache.is_some(),
"context_cache should be populated after first call"
);
let (fp, _) = session.context_cache.as_ref().unwrap();
assert!(*fp > 0, "fingerprint should be non-zero");
}
#[test]
fn build_system_content_includes_pending_hints() {
let (mut session, registry, _dir) = setup_build_system_content_session();
let pending_hints = vec!["Hint about subdirectory config.".to_string()];
let content = test_build_content(&mut session, ®istry, &pending_hints);
assert!(content.is_some());
let content = content.unwrap();
assert!(content.contains("New context from project subdirectories"));
assert!(content.contains("Hint about subdirectory config."));
}
#[test]
fn build_system_content_includes_session_title() {
let (mut session, registry, _dir) = setup_build_system_content_session();
session.config.title = Some("Refactoring the database layer".into());
let content = test_build_content(&mut session, ®istry, &[]);
assert!(content.is_some());
let content = content.unwrap();
assert!(content.contains("## Current Session Title"));
assert!(content.contains("Refactoring the database layer"));
}
#[test]
fn build_system_content_omits_empty_title() {
let (mut session, registry, _dir) = setup_build_system_content_session();
let content = test_build_content(&mut session, ®istry, &[]);
assert!(content.is_some());
let content = content.unwrap();
assert!(!content.contains("## Current Session Title"));
session.config.title = Some("".into());
let content2 = test_build_content(&mut session, ®istry, &[]);
assert!(content2.is_some());
let content2 = content2.unwrap();
assert!(!content2.contains("## Current Session Title"));
}
}