use car_engine::{format_tool_result, Runtime};
use car_inference::tasks::generate::{ContentBlock, Message, ToolCall};
use car_inference::{GenerateParams, GenerateRequest};
use car_ir::{ActionProposal, ActionStatus};
use serde_json::{json, Value};
use crate::coder::native_loop::TurnGenerator;
const OBSERVATION_CAP: usize = 16 * 1024;
pub enum AssistantEvent {
Text(String),
ToolCall { name: String, params: Value },
ToolResult {
name: String,
ok: bool,
content: String,
},
Done { text: String },
Error(String),
GoalEvaluated {
iteration: u32,
met: bool,
grounded: bool,
reason: String,
},
}
pub struct AssistantConfig {
pub model: Option<String>,
pub max_turns: u32,
pub tools: Vec<Value>,
pub gated_tools: Vec<String>,
pub approval_policy: Option<ApprovalPolicyFn>,
}
pub type ApprovalPolicyFn =
std::sync::Arc<dyn Fn(&str, &Value) -> ToolApprovalDecision + Send + Sync>;
pub enum ToolApprovalDecision {
Allow,
RequireApproval,
Deny(String),
}
pub enum ApprovalDecision {
Approved,
Denied(String),
}
#[async_trait::async_trait]
pub trait ApprovalGate: Send + Sync {
async fn request(&self, tool: &str, params: &Value) -> ApprovalDecision;
}
pub struct AssistantOutcome {
pub status: &'static str,
pub summary: String,
pub turns: u32,
pub tools_called: Vec<String>,
pub tool_receipts: Vec<AssistantToolReceipt>,
}
#[derive(Clone, Debug)]
pub struct AssistantToolReceipt {
pub tool: String,
pub call_id: Option<String>,
pub ok: bool,
pub params: Value,
}
fn cap(mut s: String) -> String {
if s.len() <= OBSERVATION_CAP {
return s;
}
let mut end = OBSERVATION_CAP;
while !s.is_char_boundary(end) {
end -= 1;
}
s.truncate(end);
s.push_str("…[truncated]…");
s
}
const HISTORY_MIN_TAIL: usize = 6;
fn approx_message_tokens(m: &Message) -> usize {
car_inference::media_tokens::messages_history_tokens(std::slice::from_ref(m))
}
fn compact_history_to_window(messages: &mut Vec<Message>, context_window: usize) {
if context_window == 0 {
return;
}
let budget = context_window / 4 * 3;
let total: usize = messages.iter().map(approx_message_tokens).sum();
if total <= budget {
return;
}
let mut head_end = 0;
while head_end < messages.len() && matches!(messages[head_end], Message::System { .. }) {
head_end += 1;
}
if head_end < messages.len()
&& matches!(
messages[head_end],
Message::User { .. } | Message::UserMultimodal { .. }
)
{
head_end += 1;
}
if messages.len().saturating_sub(head_end) <= HISTORY_MIN_TAIL {
return;
}
let max_drop = messages.len() - HISTORY_MIN_TAIL;
let mut drop_end = head_end;
let mut running = total;
while running > budget && drop_end < max_drop {
running -= approx_message_tokens(&messages[drop_end]);
drop_end += 1;
}
while drop_end < messages.len()
&& matches!(
messages[drop_end],
Message::ToolResult { .. } | Message::ProviderOutputItems { .. }
)
{
drop_end += 1;
}
if drop_end <= head_end {
return;
}
let dropped = drop_end - head_end;
messages.drain(head_end..drop_end);
tracing::debug!(
dropped_messages = dropped,
kept = messages.len(),
context_window,
budget,
"compacted assistant history to fit the model context window"
);
}
const STALL_NUDGE: u32 = 3;
const STALL_BREAK: u32 = 6;
const EXPLORE_NUDGE: u32 = 8;
fn mutating_tool_names(tool_defs: &[Value]) -> std::collections::HashSet<String> {
let mut set: std::collections::HashSet<String> = ["write_file", "edit_file"]
.iter()
.map(|s| s.to_string())
.collect();
for def in tool_defs {
if def
.get("mutating")
.and_then(Value::as_bool)
.unwrap_or(false)
{
if let Some(name) = def.get("name").and_then(Value::as_str) {
set.insert(name.to_string());
}
}
}
set
}
fn tool_calls_signature(calls: &[ToolCall]) -> String {
let mut parts: Vec<String> = calls
.iter()
.map(|c| {
format!(
"{}({})",
c.name,
serde_json::to_string(&c.arguments).unwrap_or_default()
)
})
.collect();
parts.sort();
parts.join("|")
}
fn build_proposal(source: &str, call: &ToolCall) -> Result<ActionProposal, String> {
serde_json::from_value(json!({
"source": source,
"actions": [{
"id": call.id,
"type": "tool_call",
"tool": call.name,
"parameters": call.arguments,
}],
}))
.map_err(|e| format!("malformed proposal: {e}"))
}
pub async fn run_assistant_loop(
generator: &dyn TurnGenerator,
runtime: &Runtime,
cfg: &AssistantConfig,
messages: &mut Vec<Message>,
emit: impl FnMut(AssistantEvent),
) -> AssistantOutcome {
let never = std::sync::atomic::AtomicBool::new(false);
run_assistant_loop_cancellable(generator, runtime, cfg, messages, &never, None, None, emit)
.await
}
pub async fn run_assistant_loop_cancellable(
generator: &dyn TurnGenerator,
runtime: &Runtime,
cfg: &AssistantConfig,
messages: &mut Vec<Message>,
cancel: &std::sync::atomic::AtomicBool,
approval: Option<&dyn ApprovalGate>,
images: Option<&[ContentBlock]>,
mut emit: impl FnMut(AssistantEvent),
) -> AssistantOutcome {
use std::sync::atomic::Ordering;
let tools = if cfg.tools.is_empty() {
None
} else {
Some(cfg.tools.clone())
};
let mut tools_called: Vec<String> = Vec::new();
let mut tool_receipts: Vec<AssistantToolReceipt> = Vec::new();
let mut last_text = String::new();
let mut turns = 0u32;
let context_window = cfg
.model
.as_deref()
.map(|m| generator.context_window(m))
.unwrap_or(0);
let mutating_tools = mutating_tool_names(&cfg.tools);
let mut seen_sigs: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut stall_repeats = 0u32;
let mut turns_since_mutation = 0u32;
let mut stall_nudged = false;
while turns < cfg.max_turns {
if cancel.load(Ordering::Relaxed) {
return AssistantOutcome {
status: "cancelled",
summary: "cancelled".to_string(),
turns,
tools_called,
tool_receipts,
};
}
turns += 1;
compact_history_to_window(messages, context_window);
let req = GenerateRequest {
prompt: String::new(),
model: cfg.model.clone(),
params: GenerateParams {
temperature: 0.0,
..Default::default()
},
context: None,
context_stable_prefix: None,
tools: tools.clone(),
images: if turns == 1 {
images.map(|imgs| imgs.to_vec())
} else {
None
},
messages: Some(messages.clone()),
cache_control: false,
response_format: None,
intent: None,
};
let mut result = match generator.generate(req).await {
Ok(r) => r,
Err(e) => {
let msg = format!("inference failed: {e}");
emit(AssistantEvent::Error(msg.clone()));
return AssistantOutcome {
status: "error",
summary: msg,
turns,
tools_called,
tool_receipts,
};
}
};
result.text = car_inference::tasks::generate::strip_leaked_reasoning(&result.text);
if result.tool_calls.is_empty() {
last_text = result.text.clone();
emit(AssistantEvent::Done {
text: last_text.clone(),
});
return AssistantOutcome {
status: "success",
summary: last_text,
turns,
tools_called,
tool_receipts,
};
}
if !result.text.trim().is_empty() {
last_text = result.text.clone();
emit(AssistantEvent::Text(result.text.clone()));
}
let mut calls = result.tool_calls.clone();
for (i, call) in calls.iter_mut().enumerate() {
if call.id.is_none() {
call.id = Some(format!("call_{turns}_{i}"));
}
}
messages.push(Message::Assistant {
content: result.text.clone(),
tool_calls: calls.clone(),
});
let mut mutated_ok = false;
for call in &calls {
let id = call.id.clone().expect("ids assigned above");
emit(AssistantEvent::ToolCall {
name: call.name.clone(),
params: serde_json::to_value(&call.arguments).unwrap_or_default(),
});
let params_val = serde_json::to_value(&call.arguments).unwrap_or_default();
let posture = match &cfg.approval_policy {
Some(policy) => policy(&call.name, ¶ms_val),
None => {
if cfg.gated_tools.iter().any(|t| t == &call.name) {
ToolApprovalDecision::RequireApproval
} else {
ToolApprovalDecision::Allow
}
}
};
let refusal: Option<String> = match posture {
ToolApprovalDecision::Allow => None,
ToolApprovalDecision::Deny(reason) => Some(reason),
ToolApprovalDecision::RequireApproval => {
let decision = match approval {
Some(gate) => gate.request(&call.name, ¶ms_val).await,
None => ApprovalDecision::Denied(format!(
"'{}' needs approval: re-run with --full-access to allow it on this host, \
or use the default sandbox where edits are isolated",
call.name
)),
};
match decision {
ApprovalDecision::Approved => None,
ApprovalDecision::Denied(reason) => Some(reason),
}
}
};
if let Some(reason) = refusal {
let content = cap(json!({ "error": reason }).to_string());
emit(AssistantEvent::ToolResult {
name: call.name.clone(),
ok: false,
content: content.clone(),
});
messages.push(Message::ToolResult {
tool_use_id: id,
content,
});
continue;
}
let proposal = match build_proposal(&result.model_used, call) {
Ok(p) => p,
Err(e) => {
let content = cap(json!({ "error": e }).to_string());
emit(AssistantEvent::ToolResult {
name: call.name.clone(),
ok: false,
content: content.clone(),
});
messages.push(Message::ToolResult {
tool_use_id: id,
content,
});
continue;
}
};
let exec = runtime.execute(&proposal).await;
let action = exec.results.first();
let ok = action
.map(|r| matches!(r.status, ActionStatus::Succeeded))
.unwrap_or(false);
let content = cap(action
.map(format_tool_result)
.unwrap_or_else(|| format!("tool '{}' produced no result", call.name)));
if ok {
tools_called.push(call.name.clone());
if mutating_tools.contains(&call.name) {
mutated_ok = true;
}
}
tool_receipts.push(AssistantToolReceipt {
tool: call.name.clone(),
call_id: action.map(|r| r.action_id.clone()),
ok,
params: params_val.clone(),
});
emit(AssistantEvent::ToolResult {
name: call.name.clone(),
ok,
content: content.clone(),
});
messages.push(Message::ToolResult {
tool_use_id: id,
content,
});
}
let mut inject_nudge = false;
if mutated_ok {
seen_sigs.clear();
stall_repeats = 0;
turns_since_mutation = 0;
stall_nudged = false; } else {
turns_since_mutation += 1;
if !seen_sigs.insert(tool_calls_signature(&calls)) {
stall_repeats += 1;
if stall_repeats >= STALL_BREAK {
let summary = format!(
"Stopped: repeated the same action {stall_repeats} times without \
changing anything — no progress was being made."
);
emit(AssistantEvent::Done {
text: summary.clone(),
});
return AssistantOutcome {
status: "stalled",
summary,
turns,
tools_called,
tool_receipts,
};
}
if stall_repeats >= STALL_NUDGE && !stall_nudged {
stall_nudged = true;
inject_nudge = true;
}
}
if turns_since_mutation >= EXPLORE_NUDGE && !stall_nudged {
stall_nudged = true;
inject_nudge = true;
}
}
if inject_nudge {
messages.push(Message::User {
content: "You have repeated the same action several times without \
changing anything or making progress. Stop re-reading and \
either take a concrete action (write or edit a file, run a \
command) or, if the task is genuinely complete, finish now \
with your summary."
.into(),
});
}
}
AssistantOutcome {
status: "max_turns",
summary: if last_text.is_empty() {
format!("stopped after {} turns without finishing", cfg.max_turns)
} else {
last_text
},
turns,
tools_called,
tool_receipts,
}
}
#[derive(Debug, Clone)]
struct SummaryClaimRequirement {
label: &'static str,
tools: &'static [&'static str],
require_ok: bool,
shell_terms: &'static [&'static str],
paths: Vec<String>,
}
const TEST_TERMS: &[&str] = &[
"test",
"pytest",
"cargo test",
"cargo nextest",
"npm test",
"npm run test",
"pnpm test",
"pnpm run test",
"yarn test",
"bun test",
"go test",
"swift test",
"dotnet test",
"ctest",
"cmake --build",
"make test",
];
const BUILD_TERMS: &[&str] = &[
"build",
"cargo check",
"cargo build",
"npm run build",
"pnpm build",
"yarn build",
"bun run build",
"cmake --build",
"go build",
"swift build",
"dotnet build",
"mvn package",
"gradle build",
"./gradlew build",
];
const CHECK_TERMS: &[&str] = &[
"cargo check",
"git diff --check",
"npm run lint",
"npm run check",
"pnpm check",
"pnpm lint",
"yarn check",
"yarn lint",
"bun run check",
"eslint",
"clippy",
"swiftlint",
"ruff",
"mypy",
"biome check",
];
const READ_TERMS: &[&str] = &["cat ", "sed ", "rg ", "grep ", "ls ", "find "];
const WRITE_TERMS: &[&str] = &["touch ", "cat >", "tee ", "python ", "node ", "perl "];
const SUMMARY_PATH_EXTENSIONS: &[&str] = &[
".rs", ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".swift", ".java", ".kt", ".kts", ".c",
".h", ".cc", ".hh", ".cpp", ".hpp", ".cxx", ".hxx", ".cs", ".fs", ".vb", ".php", ".rb", ".ex",
".exs", ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".html", ".css", ".xml", ".sh",
".sql",
];
fn normalize_summary_path_token(raw: &str) -> Option<String> {
let token = raw.trim_matches(|c: char| {
matches!(
c,
'"' | '\'' | '`' | ',' | ';' | ':' | ')' | '(' | '[' | ']' | '{' | '}' | '.'
)
});
if token.is_empty() || token.starts_with('-') || token.contains("://") || token.contains("..") {
return None;
}
let looks_like_path = token.contains('/')
|| SUMMARY_PATH_EXTENSIONS
.iter()
.any(|ext| token.to_ascii_lowercase().ends_with(ext));
if !looks_like_path {
return None;
}
Some(
token
.trim_start_matches("./")
.replace('\\', "/")
.to_ascii_lowercase(),
)
}
fn summary_path_hints(summary: &str) -> Vec<String> {
let mut paths = Vec::new();
for raw in summary.split_whitespace() {
if let Some(path) = normalize_summary_path_token(raw) {
if !paths.contains(&path) {
paths.push(path);
}
}
}
paths
}
fn summary_claim_requirements(summary: &str) -> Vec<SummaryClaimRequirement> {
let s = summary.to_ascii_lowercase();
let path_hints = summary_path_hints(summary);
let mut claims = Vec::new();
if s.contains("test")
&& (s.contains("passed")
|| s.contains("pass")
|| s.contains("green")
|| s.contains("verified")
|| s.contains("validated")
|| s.contains("succeeded")
|| s.contains("successful")
|| s.contains("ran the test")
|| s.contains("ran tests"))
{
claims.push(SummaryClaimRequirement {
label: "tests were run/passed",
tools: &["shell"],
require_ok: true,
shell_terms: TEST_TERMS,
paths: Vec::new(),
});
}
if (s.contains("build") || s.contains("cargo check"))
&& (s.contains("passed")
|| s.contains("succeeded")
|| s.contains("successful")
|| s.contains("built")
|| s.contains("green")
|| s.contains("verified")
|| s.contains("validated")
|| s.contains("ran the build")
|| s.contains("ran cargo check"))
{
claims.push(SummaryClaimRequirement {
label: "build succeeded",
tools: &["shell"],
require_ok: true,
shell_terms: BUILD_TERMS,
paths: Vec::new(),
});
}
if (s.contains("check")
|| s.contains("checks")
|| s.contains("lint")
|| s.contains("verified")
|| s.contains("validated"))
&& (s.contains("passed")
|| s.contains("pass")
|| s.contains("green")
|| s.contains("succeeded")
|| s.contains("successful")
|| s.contains("clean"))
{
claims.push(SummaryClaimRequirement {
label: "checks were run/passed",
tools: &["shell"],
require_ok: true,
shell_terms: CHECK_TERMS,
paths: Vec::new(),
});
}
if (s.contains("read ") || s.contains("inspected ") || s.contains("looked at "))
&& (s.contains("file") || s.contains("files") || s.contains("source"))
{
claims.push(SummaryClaimRequirement {
label: "files were read/inspected",
tools: &["read_file", "list_dir", "find_files", "grep_files", "shell"],
require_ok: true,
shell_terms: READ_TERMS,
paths: path_hints.clone(),
});
}
if (s.contains("created")
|| s.contains("wrote")
|| s.contains("updated")
|| s.contains("edited"))
&& (s.contains("file") || s.contains("files"))
{
claims.push(SummaryClaimRequirement {
label: "files were created/updated",
tools: &["write_file", "edit_file", "shell"],
require_ok: true,
shell_terms: WRITE_TERMS,
paths: path_hints.clone(),
});
}
claims
}
fn shell_command(params: &Value) -> Option<String> {
params
.get("command")
.and_then(Value::as_str)
.map(|s| s.to_ascii_lowercase())
}
fn normalized_receipt_path(params: &Value) -> Option<String> {
params.get("path").and_then(Value::as_str).map(|path| {
path.trim_start_matches("./")
.replace('\\', "/")
.to_ascii_lowercase()
})
}
fn text_mentions_summary_path(text: &str, path: &str) -> bool {
let text = text.replace('\\', "/").to_ascii_lowercase();
text.contains(path) || text.contains(&format!("./{path}"))
}
fn receipt_mentions_summary_path(receipt: &AssistantToolReceipt, path: &str) -> bool {
if receipt.tool == "shell" {
return shell_command(&receipt.params)
.map(|cmd| text_mentions_summary_path(&cmd, path))
.unwrap_or(false);
}
normalized_receipt_path(&receipt.params)
.map(|receipt_path| text_mentions_summary_path(&receipt_path, path))
.unwrap_or(false)
}
fn receipt_satisfies_claim(
receipt: &AssistantToolReceipt,
claim: &SummaryClaimRequirement,
) -> bool {
if claim.require_ok && !receipt.ok {
return false;
}
if !claim.tools.iter().any(|t| *t == receipt.tool) {
return false;
}
if !claim.paths.is_empty()
&& !claim
.paths
.iter()
.any(|path| receipt_mentions_summary_path(receipt, path))
{
return false;
}
if receipt.tool != "shell" || claim.shell_terms.is_empty() {
return true;
}
let Some(cmd) = shell_command(&receipt.params) else {
return false;
};
claim.shell_terms.iter().any(|term| cmd.contains(term))
}
fn ungrounded_summary_claims(
summary: &str,
receipts: &[AssistantToolReceipt],
) -> Vec<&'static str> {
summary_claim_requirements(summary)
.into_iter()
.filter(|claim| {
!receipts
.iter()
.any(|receipt| receipt_satisfies_claim(receipt, claim))
})
.map(|claim| claim.label)
.collect()
}
fn apply_summary_claim_grounding(
mut verdict: car_verify::goal::GoalVerdict,
outcome: &AssistantOutcome,
) -> car_verify::goal::GoalVerdict {
if !verdict.met {
return verdict;
}
let ungrounded = ungrounded_summary_claims(&outcome.summary, &outcome.tool_receipts);
if ungrounded.is_empty() {
return verdict;
}
verdict.grounded = false;
verdict.reason = format!(
"{}; ungrounded assistant summary claim(s): {}",
verdict.reason,
ungrounded.join(", ")
);
verdict
}
pub struct GoalLoopResult {
pub outcome: AssistantOutcome,
pub run: car_verify::goal::GoalRun,
}
pub async fn run_assistant_goal_loop<G, GF>(
generator: &dyn TurnGenerator,
runtime: &Runtime,
cfg: &AssistantConfig,
messages: &mut Vec<Message>,
cancel: &std::sync::atomic::AtomicBool,
approval: Option<&dyn ApprovalGate>,
spec: &car_verify::goal::GoalSpec,
mut gather: G,
mut emit: impl FnMut(AssistantEvent),
) -> GoalLoopResult
where
G: FnMut(&AssistantOutcome) -> GF,
GF: std::future::Future<Output = car_engine::GoalGather>,
{
use car_verify::goal::{
anchor_directive, evaluate_goal, governor_check, GoalHalt, GoalRun, GoalRunState,
GoalStatus, GoalVerdict,
};
use std::sync::atomic::Ordering;
let start = std::time::Instant::now();
let mut run_state = GoalRunState::default();
let mut evidence: Vec<GoalVerdict> = Vec::new();
let mut last_reason = String::new();
let mut last_outcome = AssistantOutcome {
status: "goal_pending",
summary: String::new(),
turns: 0,
tools_called: Vec::new(),
tool_receipts: Vec::new(),
};
let finish = |status: GoalStatus,
grounded: bool,
reason: String,
iterations: u32,
evidence: Vec<GoalVerdict>,
outcome: AssistantOutcome|
-> GoalLoopResult {
GoalLoopResult {
run: GoalRun {
status,
iterations,
grounded,
cost_usd: 0.0,
last_reason: reason,
evidence,
},
outcome,
}
};
loop {
run_state.elapsed_secs = start.elapsed().as_secs();
if cancel.load(Ordering::Relaxed) {
return finish(
GoalStatus::Halted {
halt: GoalHalt::Cancelled,
},
evidence.last().map(|v| v.grounded).unwrap_or(true),
"cancelled".into(),
run_state.turns,
evidence,
last_outcome,
);
}
if let Some(halt) = governor_check(&spec.governor, &run_state) {
return finish(
GoalStatus::Halted { halt },
evidence.last().map(|v| v.grounded).unwrap_or(true),
if last_reason.is_empty() {
halt.as_str().to_string()
} else {
format!("{} ({})", halt.as_str(), last_reason)
},
run_state.turns,
evidence,
last_outcome,
);
}
let directive = anchor_directive(&spec.goal, &last_reason);
messages.push(Message::User { content: directive });
let outcome = run_assistant_loop_cancellable(
generator, runtime, cfg, messages, cancel, approval, None, &mut emit,
)
.await;
run_state.turns += 1;
if outcome.tools_called.is_empty() {
run_state.turns_since_progress += 1;
} else {
run_state.turns_since_progress = 0;
}
if outcome.status == "cancelled" {
return finish(
GoalStatus::Halted {
halt: GoalHalt::Cancelled,
},
evidence.last().map(|v| v.grounded).unwrap_or(true),
"cancelled".into(),
run_state.turns,
evidence,
outcome,
);
}
let g = gather(&outcome).await;
let inputs = runtime.gather_goal_inputs(&g).await;
let verdict =
apply_summary_claim_grounding(evaluate_goal(&spec.condition, &inputs), &outcome);
evidence.push(verdict.clone());
runtime
.record_goal_evaluated(
&spec.goal,
&spec.condition,
run_state.turns,
verdict.met,
verdict.grounded,
&verdict.reason,
)
.await;
tracing::info!(
target: "car::goal",
iteration = run_state.turns,
met = verdict.met,
grounded = verdict.grounded,
reason = %verdict.reason,
"goal evaluated"
);
emit(AssistantEvent::GoalEvaluated {
iteration: run_state.turns,
met: verdict.met,
grounded: verdict.grounded,
reason: verdict.reason.clone(),
});
if verdict.met && verdict.grounded {
return finish(
GoalStatus::Achieved,
verdict.grounded,
verdict.reason,
run_state.turns,
evidence,
outcome,
);
}
last_reason = verdict.reason;
last_outcome = outcome;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assistant::executor::GeneralExecutor;
use async_trait::async_trait;
use car_engine::{LocalSubstrate, Runtime, Substrate, ToolExecutor};
use car_inference::{InferenceEngine, InferenceResult};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
fn sys(t: &str) -> Message {
Message::System { content: t.into() }
}
fn usr(t: &str) -> Message {
Message::User { content: t.into() }
}
fn asst_call(id: &str) -> Message {
Message::Assistant {
content: String::new(),
tool_calls: vec![serde_json::from_value(json!({
"name": "write_file",
"arguments": {"path": "a.js"},
"id": id
}))
.unwrap()],
}
}
fn tool_res(id: &str, body: &str) -> Message {
Message::ToolResult {
tool_use_id: id.into(),
content: body.into(),
}
}
fn no_orphan_tool_results(msgs: &[Message]) -> bool {
let mut seen_call_ids: std::collections::HashSet<String> = Default::default();
for m in msgs {
match m {
Message::Assistant { tool_calls, .. } => {
for c in tool_calls {
if let Some(id) = &c.id {
seen_call_ids.insert(id.clone());
}
}
}
Message::ToolResult { tool_use_id, .. } if !seen_call_ids.contains(tool_use_id) => {
return false;
}
_ => {}
}
}
true
}
#[test]
fn mutating_tools_are_derived_from_metadata_plus_builtin_file_writers() {
let tools = vec![
json!({"name": "remember", "mutating": true}),
json!({"name": "recall"}),
json!({"name": "generate_image", "mutating": true}),
];
let names = mutating_tool_names(&tools);
assert!(names.contains("write_file"));
assert!(names.contains("edit_file"));
assert!(names.contains("remember"));
assert!(names.contains("generate_image"));
assert!(!names.contains("recall"));
}
#[test]
fn compaction_is_noop_under_budget_and_when_window_unknown() {
let mut m = vec![
sys("s"),
usr("task"),
asst_call("c1"),
tool_res("c1", "small"),
];
let before = m.clone();
compact_history_to_window(&mut m, 128_000); assert_eq!(m, before, "under-budget history must be untouched");
compact_history_to_window(&mut m, 0); assert_eq!(m, before, "unknown window must be a no-op");
}
#[test]
fn compaction_pins_system_and_task_keeps_tail_no_orphans() {
let big = "x".repeat(20_000); let mut m = vec![sys("system"), usr("THE ORIGINAL TASK")];
for i in 0..12 {
m.push(asst_call(&format!("c{i}")));
m.push(tool_res(&format!("c{i}"), &big));
}
let window = 20_000; compact_history_to_window(&mut m, window);
assert!(matches!(&m[0], Message::System { .. }), "system pinned");
assert!(
matches!(&m[1], Message::User { content } if content == "THE ORIGINAL TASK"),
"original task pinned"
);
assert!(
matches!(m.last(), Some(Message::ToolResult { tool_use_id, .. }) if tool_use_id == "c11"),
"most-recent tool result kept"
);
assert!(
no_orphan_tool_results(&m),
"no orphaned tool results after trim"
);
assert!(m.len() < 26, "history was compacted (was 26 msgs)");
}
#[tokio::test]
async fn loop_compacts_history_to_window() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
struct WindowedBig {
cursor: AtomicUsize,
}
#[async_trait]
impl TurnGenerator for WindowedBig {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
let i = self.cursor.fetch_add(1, Ordering::SeqCst);
if i < 6 {
Ok(turn(
&"x".repeat(8000),
json!([{ "id": format!("c{i}"), "name": "calculate",
"arguments": { "expression": format!("1+{i}") } }]),
))
} else {
Ok(turn("done", json!([])))
}
}
fn context_window(&self, _model: &str) -> usize {
4000
}
}
let generator = WindowedBig {
cursor: AtomicUsize::new(0),
};
let mut messages = vec![
Message::System {
content: "system".into(),
},
Message::User {
content: "THE TASK".into(),
},
];
let mut c = cfg();
c.max_turns = 8;
let out = run_assistant_loop(&generator, &rt, &c, &mut messages, |_e| {}).await;
assert_eq!(out.status, "success");
assert!(
messages.len() <= 11,
"history bounded by compaction, got {} messages",
messages.len()
);
assert!(
matches!(&messages[0], Message::System { .. }),
"system stays pinned"
);
assert!(
matches!(&messages[1], Message::User { content } if content == "THE TASK"),
"original task stays pinned"
);
assert!(
no_orphan_tool_results(&messages),
"no orphaned tool results in the live loop"
);
}
#[tokio::test]
async fn loop_halts_a_no_progress_repeat_loop() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
struct Stuck;
#[async_trait]
impl TurnGenerator for Stuck {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
Ok(turn(
"re-reading",
json!([{ "name": "read_file", "arguments": { "path": "app.js" } }]),
))
}
}
let mut messages = vec![
Message::System {
content: "sys".into(),
},
Message::User {
content: "task".into(),
},
];
let mut c = cfg();
c.max_turns = 40;
let out = run_assistant_loop(&Stuck, &rt, &c, &mut messages, |_e| {}).await;
assert_eq!(
out.status, "stalled",
"a no-progress loop must halt as `stalled`, not run to max_turns"
);
assert!(
out.turns < 40,
"must stop well before the turn cap, got {} turns",
out.turns
);
}
#[tokio::test]
async fn loop_halts_a_read_plus_readonly_shell_cycle() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
struct Cycle {
cursor: AtomicUsize,
}
#[async_trait]
impl TurnGenerator for Cycle {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
let i = self.cursor.fetch_add(1, Ordering::SeqCst);
if i.is_multiple_of(2) {
Ok(turn(
"read",
json!([{ "name": "read_file", "arguments": { "path": "app.js" } }]),
))
} else {
Ok(turn(
"probe",
json!([{ "name": "shell", "arguments": { "command": "wc -l app.js" } }]),
))
}
}
}
let mut messages = vec![
Message::System {
content: "sys".into(),
},
Message::User {
content: "task".into(),
},
];
let mut c = cfg();
c.max_turns = 40;
let out = run_assistant_loop(
&Cycle {
cursor: AtomicUsize::new(0),
},
&rt,
&c,
&mut messages,
|_e| {},
)
.await;
assert_eq!(
out.status, "stalled",
"a read/read-only-shell cycle with no file change must halt"
);
assert!(out.turns < 40, "stopped before the cap, got {}", out.turns);
}
#[tokio::test]
async fn loop_halts_a_repeatedly_failing_mutation() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
struct FailWrite;
#[async_trait]
impl TurnGenerator for FailWrite {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
Ok(turn(
"writing",
json!([{ "name": "write_file",
"arguments": { "path": "../../etc/evil", "content": "x" } }]),
))
}
}
let mut messages = vec![
Message::System {
content: "sys".into(),
},
Message::User {
content: "task".into(),
},
];
let mut c = cfg();
c.max_turns = 40;
let out = run_assistant_loop(&FailWrite, &rt, &c, &mut messages, |_e| {}).await;
assert_eq!(
out.status, "stalled",
"a repeatedly-failing mutation makes no progress and must halt (not reset the guard)"
);
assert!(out.turns < 40, "stopped before the cap, got {}", out.turns);
}
fn turn(text: &str, tool_calls: Value) -> InferenceResult {
serde_json::from_value(json!({
"text": text,
"tool_calls": tool_calls,
"trace_id": "t",
"model_used": "scripted",
"latency_ms": 0,
}))
.expect("scripted InferenceResult shape")
}
struct Script {
turns: Vec<InferenceResult>,
cursor: AtomicUsize,
}
#[async_trait]
impl TurnGenerator for Script {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
let i = self.cursor.fetch_add(1, Ordering::SeqCst);
self.turns.get(i).cloned().ok_or("script exhausted".into())
}
}
async fn runtime_for(dir: &std::path::Path) -> Runtime {
let substrate: Arc<dyn Substrate> = Arc::new(LocalSubstrate::new());
let exec: Arc<dyn ToolExecutor> =
Arc::new(GeneralExecutor::new(substrate.clone(), dir, true));
let engine = Arc::new(InferenceEngine::new(Default::default()));
let rt = Runtime::new()
.with_inference(engine)
.with_executor(exec)
.with_substrate(substrate);
rt.register_agent_basics().await;
rt.register_tool_entry(
car_engine::ToolEntry::new(car_ir::builtins::shell()).with_side_effects(true),
)
.await;
rt
}
fn cfg() -> AssistantConfig {
AssistantConfig {
model: Some("scripted".into()),
max_turns: 6,
tools: GeneralExecutor::tool_defs(),
gated_tools: Vec::new(),
approval_policy: None,
}
}
#[tokio::test]
async fn loop_runs_a_tool_then_finishes() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
let script = Script {
turns: vec![
turn(
"computing",
json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
),
turn("The answer is 42.", json!([])),
],
cursor: AtomicUsize::new(0),
};
let mut messages = vec![
Message::System {
content: "sys".into(),
},
Message::User {
content: "what is 6*7?".into(),
},
];
let mut events = Vec::new();
let outcome =
run_assistant_loop(&script, &rt, &cfg(), &mut messages, |e| events.push(e)).await;
assert_eq!(outcome.status, "success");
assert_eq!(outcome.summary, "The answer is 42.");
assert!(outcome.tools_called.contains(&"calculate".to_string()));
assert!(events
.iter()
.any(|e| matches!(e, AssistantEvent::ToolResult { name, ok: true, .. } if name == "calculate")));
}
#[test]
fn summary_claim_grounding_requires_matching_receipts() {
let ungrounded = ungrounded_summary_claims("I ran the tests and they passed.", &[]);
assert_eq!(ungrounded, vec!["tests were run/passed"]);
let grounded = ungrounded_summary_claims(
"I ran the tests and they passed.",
&[AssistantToolReceipt {
tool: "shell".into(),
call_id: Some("s1".into()),
ok: true,
params: json!({ "command": "cargo test -q" }),
}],
);
assert!(grounded.is_empty(), "{grounded:?}");
let failed = ungrounded_summary_claims(
"I ran the tests and they passed.",
&[AssistantToolReceipt {
tool: "shell".into(),
call_id: Some("s1".into()),
ok: false,
params: json!({ "command": "cargo test -q" }),
}],
);
assert_eq!(failed, vec!["tests were run/passed"]);
}
#[test]
fn summary_claim_grounding_catches_verification_and_check_claims() {
assert_eq!(
ungrounded_summary_claims("Verified with cargo test.", &[]),
vec!["tests were run/passed"]
);
assert_eq!(
ungrounded_summary_claims("cargo check passed.", &[]),
vec!["build succeeded", "checks were run/passed"]
);
assert_eq!(
ungrounded_summary_claims("All checks are green.", &[]),
vec!["checks were run/passed"]
);
let cargo_check = [AssistantToolReceipt {
tool: "shell".into(),
call_id: Some("s1".into()),
ok: true,
params: json!({ "command": "cargo check -p car-server-core" }),
}];
assert!(
ungrounded_summary_claims("cargo check passed.", &cargo_check).is_empty(),
"cargo check receipt should ground both build and check claims"
);
let diff_check = [AssistantToolReceipt {
tool: "shell".into(),
call_id: Some("s2".into()),
ok: true,
params: json!({ "command": "git diff --check" }),
}];
assert!(
ungrounded_summary_claims("All checks are green.", &diff_check).is_empty(),
"diff-check receipt should ground generic check claims"
);
let tests = [AssistantToolReceipt {
tool: "shell".into(),
call_id: Some("s3".into()),
ok: true,
params: json!({ "command": "npm run test -- --watch=false" }),
}];
assert!(
ungrounded_summary_claims("Verified with npm run test.", &tests).is_empty(),
"npm run test receipt should ground verification test claims"
);
assert_eq!(
ungrounded_summary_claims("ctest passed.", &[]),
vec!["tests were run/passed"]
);
let ctest = [AssistantToolReceipt {
tool: "shell".into(),
call_id: Some("s4".into()),
ok: true,
params: json!({ "command": "ctest --test-dir build --output-on-failure" }),
}];
assert!(
ungrounded_summary_claims("ctest passed.", &ctest).is_empty(),
"ctest receipt should ground CMake test claims"
);
let cmake_build = [AssistantToolReceipt {
tool: "shell".into(),
call_id: Some("s5".into()),
ok: true,
params: json!({ "command": "cmake -S . -B build && cmake --build build" }),
}];
assert!(
ungrounded_summary_claims("CMake build succeeded.", &cmake_build).is_empty(),
"cmake --build receipt should ground CMake build claims"
);
let pnpm_check = [AssistantToolReceipt {
tool: "shell".into(),
call_id: Some("s6".into()),
ok: true,
params: json!({ "command": "pnpm check" }),
}];
assert!(
ungrounded_summary_claims("Checks passed.", &pnpm_check).is_empty(),
"package check receipts should ground generic check claims"
);
}
#[test]
fn summary_file_claim_grounding_requires_matching_named_path() {
let other_edit = [AssistantToolReceipt {
tool: "edit_file".into(),
call_id: Some("e1".into()),
ok: true,
params: json!({ "path": "src/other.rs" }),
}];
assert_eq!(
ungrounded_summary_claims("Updated file src/lib.rs.", &other_edit),
vec!["files were created/updated"]
);
let matching_edit = [AssistantToolReceipt {
tool: "edit_file".into(),
call_id: Some("e2".into()),
ok: true,
params: json!({ "path": "./src/lib.rs" }),
}];
assert!(
ungrounded_summary_claims("Updated file src/lib.rs.", &matching_edit).is_empty(),
"matching edit_file path should ground the specific update claim"
);
let shell_touch = [AssistantToolReceipt {
tool: "shell".into(),
call_id: Some("s1".into()),
ok: true,
params: json!({ "command": "touch src/lib.rs" }),
}];
assert!(
ungrounded_summary_claims("Created file src/lib.rs.", &shell_touch).is_empty(),
"matching shell command path should ground the specific creation claim"
);
}
#[test]
fn summary_read_claim_grounding_requires_matching_named_path() {
let other_read = [AssistantToolReceipt {
tool: "read_file".into(),
call_id: Some("r1".into()),
ok: true,
params: json!({ "path": "src/other.rs" }),
}];
assert_eq!(
ungrounded_summary_claims("Inspected file src/lib.rs.", &other_read),
vec!["files were read/inspected"]
);
let matching_read = [AssistantToolReceipt {
tool: "read_file".into(),
call_id: Some("r2".into()),
ok: true,
params: json!({ "path": "src/lib.rs" }),
}];
assert!(
ungrounded_summary_claims("Inspected file src/lib.rs.", &matching_read).is_empty(),
"matching read_file path should ground the specific inspection claim"
);
let generic_update = [AssistantToolReceipt {
tool: "edit_file".into(),
call_id: Some("e1".into()),
ok: true,
params: json!({ "path": "src/lib.rs" }),
}];
assert!(
ungrounded_summary_claims("Updated files.", &generic_update).is_empty(),
"generic file claims should keep the existing tool-class grounding"
);
}
#[tokio::test]
async fn goal_loop_converges_when_the_command_check_passes() {
use car_verify::goal::{GoalCondition, GoalGovernor, GoalSpec, GoalStatus};
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
let script = Script {
turns: vec![
turn("Let me start.", json!([])),
turn(
"creating it",
json!([{ "id": "s1", "name": "shell", "arguments": { "command": "touch donefile" } }]),
),
turn("Done — created donefile.", json!([])),
],
cursor: AtomicUsize::new(0),
};
let spec = GoalSpec {
goal: "create a file named donefile".into(),
condition: GoalCondition::Command {
id: "donefile".into(),
expect_exit: 0,
},
governor: GoalGovernor {
max_turns: Some(5),
..Default::default()
},
};
let mut messages = vec![Message::System {
content: "sys".into(),
}];
let never = std::sync::atomic::AtomicBool::new(false);
let donefile = dir.path().join("donefile");
let mut events = Vec::new();
let result = run_assistant_goal_loop(
&script,
&rt,
&cfg(),
&mut messages,
&never,
None,
&spec,
|_outcome| {
let exists = donefile.exists();
async move {
let mut g = car_engine::GoalGather::default();
g.command_exits
.insert("donefile".into(), if exists { 0 } else { 1 });
g
}
},
|e| events.push(e),
)
.await;
assert_eq!(
result.run.status,
GoalStatus::Achieved,
"{:?}",
result.run.last_reason
);
assert_eq!(
result.run.iterations, 2,
"should converge on the 2nd iteration"
);
assert!(
result.run.grounded,
"a Command-check completion is grounded"
);
assert!(donefile.exists(), "the real file must have been created");
let checks: Vec<_> = events
.iter()
.filter_map(|e| match e {
AssistantEvent::GoalEvaluated {
iteration,
met,
grounded,
reason,
} => Some((*iteration, *met, *grounded, reason.as_str())),
_ => None,
})
.collect();
assert_eq!(checks.len(), 2, "one verifier event per goal iteration");
assert_eq!(checks[0].0, 1);
assert!(
!checks[0].1,
"first iteration should not meet the command condition"
);
assert_eq!(checks[1].0, 2);
assert!(
checks[1].1,
"second iteration should meet the command condition"
);
assert!(checks[1].2, "command-backed completion is grounded");
let log = rt.log.lock().await;
let goal_events: Vec<_> = log
.events()
.iter()
.filter(|e| e.kind == car_eventlog::EventKind::GoalEvaluated)
.collect();
assert_eq!(
goal_events.len(),
2,
"event log should audit each verifier pass"
);
assert_eq!(goal_events[0].data.get("iteration"), Some(&json!(1)));
assert_eq!(goal_events[0].data.get("met"), Some(&json!(false)));
assert_eq!(
goal_events[1].data.get("goal"),
Some(&json!("create a file named donefile"))
);
assert_eq!(
goal_events[1].data.get("condition"),
Some(&json!({"kind": "command", "id": "donefile", "expect_exit": 0}))
);
assert_eq!(goal_events[1].data.get("iteration"), Some(&json!(2)));
assert_eq!(goal_events[1].data.get("met"), Some(&json!(true)));
assert_eq!(goal_events[1].data.get("grounded"), Some(&json!(true)));
}
#[tokio::test]
async fn goal_loop_halts_when_summary_claim_is_ungrounded() {
use car_verify::goal::{GoalCondition, GoalGovernor, GoalHalt, GoalSpec, GoalStatus};
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
let script = Script {
turns: vec![turn("I ran the tests and they passed.", json!([]))],
cursor: AtomicUsize::new(0),
};
let spec = GoalSpec {
goal: "make tests pass".into(),
condition: GoalCondition::Command {
id: "tests".into(),
expect_exit: 0,
},
governor: GoalGovernor {
max_turns: Some(1),
..Default::default()
},
};
let mut messages = vec![Message::System {
content: "sys".into(),
}];
let never = std::sync::atomic::AtomicBool::new(false);
let result = run_assistant_goal_loop(
&script,
&rt,
&cfg(),
&mut messages,
&never,
None,
&spec,
|_outcome| async move {
let mut g = car_engine::GoalGather::default();
g.command_exits.insert("tests".into(), 0);
g
},
|_| {},
)
.await;
assert_eq!(
result.run.status,
GoalStatus::Halted {
halt: GoalHalt::TurnBudget
}
);
assert_eq!(result.run.evidence.len(), 1);
assert!(result.run.evidence[0].met);
assert!(!result.run.evidence[0].grounded);
assert!(result
.run
.last_reason
.contains("ungrounded assistant summary claim"));
}
#[tokio::test]
async fn goal_loop_halts_on_turn_budget() {
use car_verify::goal::{GoalCondition, GoalGovernor, GoalHalt, GoalSpec, GoalStatus};
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
struct Idle;
#[async_trait]
impl TurnGenerator for Idle {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
Ok(turn("thinking...", json!([])))
}
}
let spec = GoalSpec {
goal: "impossible".into(),
condition: GoalCondition::Command {
id: "never".into(),
expect_exit: 0,
},
governor: GoalGovernor {
max_turns: Some(3),
..Default::default()
},
};
let mut messages = vec![Message::System {
content: "sys".into(),
}];
let never = std::sync::atomic::AtomicBool::new(false);
let result = run_assistant_goal_loop(
&Idle,
&rt,
&cfg(),
&mut messages,
&never,
None,
&spec,
|_o| async {
let mut g = car_engine::GoalGather::default();
g.command_exits.insert("never".into(), 1);
g
},
|_e| {},
)
.await;
assert_eq!(
result.run.status,
GoalStatus::Halted {
halt: GoalHalt::TurnBudget
}
);
assert_eq!(result.run.iterations, 3);
}
struct FixedGate(bool);
#[async_trait]
impl ApprovalGate for FixedGate {
async fn request(&self, _tool: &str, _params: &Value) -> ApprovalDecision {
if self.0 {
ApprovalDecision::Approved
} else {
ApprovalDecision::Denied("user declined".into())
}
}
}
struct CapturingGen {
images_seen: std::sync::Arc<std::sync::Mutex<Option<usize>>>,
}
#[async_trait]
impl TurnGenerator for CapturingGen {
async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
*self.images_seen.lock().unwrap() = req.images.as_ref().map(|v| v.len());
Ok(turn("done", json!([]))) }
}
#[tokio::test]
async fn images_are_attached_to_the_first_request() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
let generator = CapturingGen {
images_seen: seen.clone(),
};
let img = ContentBlock::ImageUrl {
url: "https://example.com/x.png".into(),
detail: "auto".into(),
};
let mut messages = vec![
Message::System {
content: "s".into(),
},
Message::User {
content: "describe".into(),
},
];
let never = std::sync::atomic::AtomicBool::new(false);
let imgs = [img];
run_assistant_loop_cancellable(
&generator,
&rt,
&cfg(),
&mut messages,
&never,
None,
Some(&imgs),
|_| {},
)
.await;
assert_eq!(
*seen.lock().unwrap(),
Some(1),
"the image should reach the first request"
);
}
#[tokio::test]
async fn gated_tool_is_denied_without_a_gate() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
let script = Script {
turns: vec![
turn(
"",
json!([{ "id": "w1", "name": "write_file", "arguments": { "path": "x.txt", "content": "no" } }]),
),
turn("could not write", json!([])),
],
cursor: AtomicUsize::new(0),
};
let mut cfg = cfg();
cfg.gated_tools = vec!["write_file".into()];
let mut messages = vec![
Message::System {
content: "s".into(),
},
Message::User {
content: "write x".into(),
},
];
let never = std::sync::atomic::AtomicBool::new(false);
let outcome = run_assistant_loop_cancellable(
&script,
&rt,
&cfg,
&mut messages,
&never,
None,
None,
|_| {},
)
.await;
assert_eq!(outcome.status, "success");
assert!(
!dir.path().join("x.txt").exists(),
"gated write must not run"
);
assert!(!outcome.tools_called.contains(&"write_file".to_string()));
}
#[tokio::test]
async fn gated_tool_runs_when_approved() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
let script = Script {
turns: vec![
turn(
"",
json!([{ "id": "w1", "name": "write_file", "arguments": { "path": "ok.txt", "content": "yes" } }]),
),
turn("wrote it", json!([])),
],
cursor: AtomicUsize::new(0),
};
let mut cfg = cfg();
cfg.gated_tools = vec!["write_file".into()];
let gate = FixedGate(true);
let mut messages = vec![
Message::System {
content: "s".into(),
},
Message::User {
content: "write ok".into(),
},
];
let never = std::sync::atomic::AtomicBool::new(false);
let outcome = run_assistant_loop_cancellable(
&script,
&rt,
&cfg,
&mut messages,
&never,
Some(&gate),
None,
|_| {},
)
.await;
assert_eq!(outcome.status, "success");
assert_eq!(
std::fs::read_to_string(dir.path().join("ok.txt")).unwrap(),
"yes"
);
}
#[tokio::test]
async fn loop_writes_a_file_through_the_runtime() {
let dir = tempfile::tempdir().unwrap();
let rt = runtime_for(dir.path()).await;
let script = Script {
turns: vec![
turn(
"",
json!([{ "id": "w1", "name": "write_file", "arguments": { "path": "hi.txt", "content": "hello" } }]),
),
turn("Wrote hi.txt.", json!([])),
],
cursor: AtomicUsize::new(0),
};
let mut messages = vec![
Message::System {
content: "sys".into(),
},
Message::User {
content: "write hi.txt".into(),
},
];
let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
assert_eq!(outcome.status, "success");
assert_eq!(
std::fs::read_to_string(dir.path().join("hi.txt")).unwrap(),
"hello"
);
}
}