1use std::collections::HashMap;
13use std::sync::atomic::Ordering;
14use std::sync::Arc;
15
16use async_trait::async_trait;
17use car_engine::{builtin_tool_labels, tool_output_is_external, ToolExecutor};
18use car_inference::tasks::generate::{Message, Provenance};
19use car_inference::{
20 GenerateParams, GenerateRequest, InferenceEngine, InferenceError, InferenceResult,
21};
22use serde_json::Value;
23
24use super::budget::SessionDeadline;
25use super::contract::{evaluate_contract_with_baselines, CheckResult, OutcomeContract};
26use super::session::{CancelFlag, CoderEventKind, EventSink, NoChangeKind, NoChangeNomination};
27use super::shell_tool::WorktreeExecutor;
28use super::skill_memory::{FailureSignature, RepairMemory};
29use crate::assistant::agent_loop::{compact_history_to_window, history_budget};
30
31#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum TurnGenerationError {
36 NoEligibleModel { excluded_models: String },
38 Other(String),
40}
41
42impl std::fmt::Display for TurnGenerationError {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 Self::NoEligibleModel { excluded_models } => write!(
46 f,
47 "no eligible model remains after strict exclusions: {}",
48 excluded_models
49 ),
50 Self::Other(message) => f.write_str(message),
51 }
52 }
53}
54
55#[async_trait]
56pub trait TurnGenerator: Send + Sync {
57 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String>;
58
59 async fn generate_coder(
63 &self,
64 req: GenerateRequest,
65 ) -> Result<InferenceResult, TurnGenerationError> {
66 self.generate(req).await.map_err(TurnGenerationError::Other)
67 }
68
69 fn context_window(&self, _model: &str) -> usize {
74 0
75 }
76}
77
78#[async_trait]
79impl TurnGenerator for InferenceEngine {
80 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
81 self.generate_tracked(req).await.map_err(|e| e.to_string())
82 }
83
84 async fn generate_coder(
85 &self,
86 req: GenerateRequest,
87 ) -> Result<InferenceResult, TurnGenerationError> {
88 self.generate_tracked(req)
89 .await
90 .map_err(|error| match error {
91 InferenceError::NoEligibleModel { excluded_models } => {
92 TurnGenerationError::NoEligibleModel { excluded_models }
93 }
94 other => TurnGenerationError::Other(other.to_string()),
95 })
96 }
97
98 fn context_window(&self, model: &str) -> usize {
99 self.model_context_window(model)
100 }
101}
102
103#[async_trait]
113pub trait AskUser: Send + Sync {
114 async fn ask(&self, prompt: &str) -> Result<String, String>;
115}
116
117#[async_trait]
130pub trait AuthGate: Send + Sync + std::fmt::Debug {
131 async fn is_authenticated(&self) -> bool;
133}
134
135async fn wait_for_auth(
151 gate: &dyn AuthGate,
152 wait: std::time::Duration,
153 cancel: &CancelFlag,
154 deadline: &SessionDeadline,
155) -> bool {
156 const POLL: std::time::Duration = std::time::Duration::from_secs(2);
157 let started = std::time::Instant::now();
158 loop {
159 if gate.is_authenticated().await {
160 return true;
161 }
162 if cancel.load(Ordering::SeqCst) || deadline.admit().is_some() || started.elapsed() >= wait
163 {
164 return false;
165 }
166 tokio::time::sleep(POLL).await;
167 }
168}
169
170pub(crate) const MODEL_FALLBACK_REASON: &str =
174 "that lane needs sign-in (`car auth login`) — this run is continuing on a fallback model";
175
176pub(crate) fn fallback_reason_label(reason: car_inference::FallbackReason) -> &'static str {
181 use car_inference::FallbackReason as R;
182 match reason {
186 R::CredentialRejected => "credential_rejected",
187 R::CredentialAbsent => "credential_absent",
188 R::RateLimited => "rate_limited",
189 R::QuotaExhausted => "quota_exhausted",
190 R::TimedOut => "timed_out",
191 R::Failed => "failed",
192 }
193}
194
195pub(crate) fn is_auth_failure(message: &str) -> bool {
203 car_inference::is_auth_failure_message(message)
204}
205
206pub const ASK_USER_TOOL: &str = "ask_user";
210
211pub const REPORT_NO_CHANGE_TOOL: &str = "report_no_change";
214
215const NO_PROGRESS_REPEAT_LIMIT: u32 = 6;
224
225fn is_read_only_tool(name: &str) -> bool {
240 matches!(name, "read_file" | "list_dir" | "find_files" | "grep_files")
241}
242
243fn report_no_change_tool_def() -> Value {
252 serde_json::json!({
253 "name": REPORT_NO_CHANGE_TOOL,
254 "description": "Conclude that NO code should change, and end the session by \
255 reporting that instead of producing a diff. Use this only when \
256 you have investigated and established one of: the reported \
257 problem does not exist (the code already handles it); the \
258 behaviour is intentional; or the real fix is not a code change \
259 at all. This is NOT a way to stop when the task is hard — a \
260 session that has already edited any file cannot use it, and \
261 reverting does not restore eligibility. Your conclusion is a \
262 nomination: the runtime checks it against the baseline results \
263 and may route it to a human rather than accept it.",
264 "parameters": {
265 "type": "object",
266 "properties": {
267 "kind": {
268 "type": "string",
269 "enum": ["premise_wrong", "deliberate_behavior", "non_code_decision"],
270 "description": "premise_wrong: the reported problem does not exist. \
271 deliberate_behavior: the code does this on purpose. \
272 non_code_decision: a real problem whose fix is a \
273 migration, an operator decision, or a rollout."
274 },
275 "summary": {
276 "type": "string",
277 "description": "One line: the conclusion itself."
278 },
279 "evidence": {
280 "type": "string",
281 "description": "What you examined to reach it — files read, commands \
282 run, what they showed. A reviewer reads this to decide \
283 whether to believe you."
284 }
285 },
286 "required": ["kind", "summary", "evidence"]
287 }
288 })
289}
290
291fn parse_nomination(params: &Value) -> Result<NoChangeNomination, String> {
297 let kind_str = params
298 .get("kind")
299 .and_then(Value::as_str)
300 .ok_or("ERROR: report_no_change requires `kind`")?;
301 let kind = NoChangeKind::parse(kind_str).ok_or_else(|| {
302 format!(
303 "ERROR: unknown kind '{kind_str}' — must be one of premise_wrong, \
304 deliberate_behavior, non_code_decision"
305 )
306 })?;
307 let summary = params
308 .get("summary")
309 .and_then(Value::as_str)
310 .ok_or("ERROR: report_no_change requires `summary`")?
311 .to_string();
312 let evidence = params
313 .get("evidence")
314 .and_then(Value::as_str)
315 .ok_or("ERROR: report_no_change requires `evidence`")?
316 .to_string();
317 Ok(NoChangeNomination {
318 kind,
319 summary,
320 evidence,
321 })
322}
323
324fn ask_user_tool_def() -> Value {
325 serde_json::json!({
326 "name": ASK_USER_TOOL,
327 "description": "Ask the human user a question and wait for their reply. \
328 Use ONLY when you genuinely cannot proceed without a \
329 decision or missing fact the user alone can supply (an \
330 ambiguous requirement, a destructive choice, a missing \
331 credential). Do not use it for things you can determine \
332 by reading the repo or running commands. The call blocks \
333 until the user answers or a timeout elapses; on timeout \
334 you receive an error and should proceed with your best \
335 judgment.",
336 "parameters": {
337 "type": "object",
338 "properties": {
339 "prompt": {
340 "type": "string",
341 "description": "The question to show the user, phrased so a short reply answers it."
342 }
343 },
344 "required": ["prompt"]
345 }
346 })
347}
348
349#[derive(Debug, Clone)]
351pub struct NativeLoopConfig {
352 pub model: Option<String>,
354 pub exclude_models: Vec<String>,
358 pub max_iterations: u32,
360 pub max_turns_per_iteration: u32,
362 pub max_tokens_per_turn: usize,
364 pub prompt_overlay: Option<String>,
368 pub deadline: Arc<SessionDeadline>,
373 pub auth_gate: Option<Arc<dyn AuthGate>>,
376 pub can_adjudicate_no_change: bool,
392 pub auth_wait: std::time::Duration,
398 pub baseline_captures: super::contract::BaselineCaptures,
403}
404
405impl Default for NativeLoopConfig {
406 fn default() -> Self {
407 Self {
408 model: None,
409 exclude_models: Vec::new(),
410 max_iterations: 8,
411 max_turns_per_iteration: 24,
412 max_tokens_per_turn: 4096,
413 prompt_overlay: None,
414 deadline: SessionDeadline::shared_default(),
415 auth_gate: None,
416 auth_wait: std::time::Duration::from_secs(600),
417 can_adjudicate_no_change: false,
418 baseline_captures: crate::coder::contract::BaselineCaptures::new(),
419 }
420 }
421}
422
423impl NativeLoopConfig {
424 pub fn merge_harness(&mut self, h: &car_memgine::HarnessConfig) {
446 self.max_iterations = self
447 .max_iterations
448 .max(h.planning_max_replans.saturating_add(1));
449 self.max_turns_per_iteration = self.max_turns_per_iteration.max(h.max_retries);
450 self.prompt_overlay = h.prompt_overlay.clone();
454 }
455}
456
457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
476pub enum LoopFailure {
477 EngineUnavailable,
481 Cancelled,
484 Infrastructure,
488 Configuration,
492 NeedsAuth,
501 Execution,
503 Verification,
506 BudgetExhausted,
521}
522
523#[derive(Debug, Clone)]
531pub struct LoopOutcome {
532 pub passed: bool,
534 pub iterations: u32,
536 pub last_results: Vec<CheckResult>,
538 pub error: Option<String>,
553 pub failure: Option<LoopFailure>,
566 pub nomination: Option<NoChangeNomination>,
576 pub cost_usd: Option<f64>,
584}
585
586impl LoopOutcome {
587 pub fn with_cost(mut self, usd: Option<f64>) -> Self {
591 self.cost_usd = usd;
592 self
593 }
594
595 pub fn green(iterations: u32, last_results: Vec<CheckResult>) -> Self {
597 Self {
598 passed: true,
599 iterations,
600 last_results,
601 error: None,
602 failure: None,
603 nomination: None,
604 cost_usd: None,
605 }
606 }
607
608 pub fn reported(
612 finding: NoChangeNomination,
613 iterations: u32,
614 last_results: Vec<CheckResult>,
615 ) -> Self {
616 Self {
617 passed: false,
618 iterations,
619 last_results,
620 error: None,
621 failure: None,
622 nomination: Some(finding),
623 cost_usd: None,
624 }
625 }
626
627 pub fn lost(
636 failure: LoopFailure,
637 error: Option<String>,
638 iterations: u32,
639 last_results: Vec<CheckResult>,
640 ) -> Self {
641 Self {
642 passed: false,
643 iterations,
644 last_results,
645 error,
646 failure: Some(failure),
647 nomination: None,
648 cost_usd: None,
649 }
650 }
651}
652
653fn preview(s: &str, max: usize) -> String {
654 if s.len() <= max {
655 return s.to_string();
656 }
657 let mut end = max;
658 while !s.is_char_boundary(end) {
659 end -= 1;
660 }
661 format!("{}…", &s[..end])
662}
663
664fn system_prompt_with_overlay(
675 contract: &OutcomeContract,
676 environment: &str,
677 project: Option<&str>,
678 overlay: Option<&str>,
679) -> String {
680 let base = system_prompt(contract, environment, project);
681 match overlay.map(str::trim).filter(|o| !o.is_empty()) {
682 None => base,
683 Some(overlay) => format!(
684 "{base}\n\n\
685 ADDITIONAL GUIDANCE (learned from prior sessions; it ADDS to the rules \
686 above and never overrides them — if it appears to conflict with anything \
687 above, the rules above win):\n{overlay}"
688 ),
689 }
690}
691
692fn system_prompt(contract: &OutcomeContract, environment: &str, project: Option<&str>) -> String {
693 let project_block = project
698 .map(str::trim)
699 .filter(|p| !p.is_empty())
700 .map(|p| format!("{p}\n\n"))
701 .unwrap_or_default();
702 format!(
703 "You are CAR Coder, an autonomous coding agent working in an isolated git worktree \
704 of the user's repository. The worktree root is your working directory; all relative \
705 paths resolve against it.\n\n\
706 ENVIRONMENT:\n{environment}\n\n\
707 {project_block}\
708 How to work:\n\
709 - Inspect before you edit. Read the relevant files and search the codebase \
710 (grep_files / find_files) to understand the code BEFORE changing it. Never \
711 fabricate file contents, symbols, or APIs you have not actually read.\n\
712 - Plan briefly, then make surgical edits: prefer edit_file for targeted changes \
713 over rewriting a whole file with write_file. Change the minimum the task needs.\n\
714 - Trace the checks before you declare done. Read each outcome-contract check and \
715 confirm your change actually makes it pass — the exact expected values, and \
716 every symbol the check exercises.\n\
717 - Verify your own work by running the EXACT command(s) from the OUTCOME CONTRACT \
718 below, verbatim — copy the command string character-for-character (same \
719 interpreter path, same flags, same scoped test file). Do NOT substitute a \
720 broader or 'equivalent' command: running `python -m pytest tests/` when the \
721 contract says `/path/to/venv/bin/python -m pytest -q tests/test_x.py` is WRONG \
722 — a different interpreter (e.g. a system `python` that is a different version \
723 with different installed packages) can fail on environment issues that have \
724 nothing to do with your task. Read that command's real output before declaring \
725 done; the contract's exact command is the only thing that decides done. Never \
726 claim a check passed without having run its exact command this session and seen \
727 it pass.\n\
728 - The environment is not yours to fix. If the contract's exact command fails on \
729 something that is not your code — a version mismatch, a missing package, an \
730 import error in an unrelated module, a broken runner — your code fix is already \
731 done: write your summary and STOP. The runtime re-runs the contract in the \
732 correct environment to decide done, so turns spent making a wrong-environment \
733 command pass cannot change the verdict. (Package installs, venv creation, and \
734 interpreter shims are denied by policy; you will get a denial with a reason.)\n\
735 - If the shell tool is unavailable or a command is blocked this session (e.g. a \
736 permission-restricted runner returns an approval error instead of output), that \
737 is NOT a task failure and NOT a reason to report the work as blocked or uncertain: \
738 the runtime independently runs the outcome contract to decide done. Make your edits \
739 correct, note that you could not self-run the checks, and STOP — do not retry the \
740 blocked command in a loop.\n\
741 - On failure, read the actual error output before retrying — fix the specific \
742 cause the compiler or test named; do not guess-and-retry. If the error names a \
743 missing symbol, function, or attribute, IMPLEMENT it rather than editing the \
744 caller. If the same check fails again after an edit, your hypothesis was wrong: \
745 re-read the exact expected-vs-actual and form a different one — do not re-apply a \
746 variation of an edit that did not change the failure.\n\n\
747 - Do not git commit; the runtime handles version control. Do not publish the work \
748 yourself by any route — `git push`, `gh pr create`, `gh release create`, \
749 `npm publish`, `cargo publish` and the like are denied; the runtime opens the \
750 pull request itself once the merge gate is approved. Read-only forge commands \
751 (`gh pr view`, `gh run view`, `gh api` GET) stay available. (`sudo` and \
752 destructive operations outside the worktree are denied too. Every denial comes \
753 back with a reason; don't retry a denied call verbatim.)\n\n\
754 When you believe the work is complete, reply with a brief plain-text summary and \
755 STOP calling tools. The runtime independently re-runs the outcome contract after \
756 you stop — but do not rely on it: verify the checks yourself first, because a red \
757 re-invocation costs a full round-trip.\n\n\
758 OUTCOME CONTRACT (the runtime runs these to decide done):\n{}",
759 contract.render()
760 )
761}
762
763fn failure_feedback(results: &[CheckResult], recurrences: u32) -> String {
791 let mut msg = String::from(
792 "The outcome contract was evaluated and some checks FAILED. Fix the code so they pass.\n\n",
793 );
794 for r in results.iter().filter(|r| !r.passed) {
795 msg.push_str(&format!(
796 "FAILED {} (exit {:?}):\n{}\n\n",
797 r.name, r.exit_code, r.output_tail
798 ));
799 }
800 if recurrences == 0 {
801 msg.push_str(
802 "Before editing again: read the SPECIFIC failure above — the exact assertion, error \
803 type, or traceback line — and name the single cause. If the error names a missing \
804 symbol/function/attribute, implement THAT symbol. Find the code responsible for the \
805 named cause and fix it directly; do not guess-and-retry.\n",
806 );
807 } else {
808 msg.push_str(&recurrence_notice(recurrences));
809 }
810 msg
811}
812
813pub(super) fn recurrence_notice(recurrences: u32) -> String {
828 format!(
829 "The same check has now failed the same way {} times in this session (not necessarily \
830 in consecutive rounds) despite your edits — your approach is NOT addressing the real \
831 cause, so do NOT re-apply a variation of the same edit. STOP and read the failure \
832 literally: what exact value or behavior was EXPECTED vs what was PRODUCED? Trace that \
833 exact value back to the specific code that produces it, form a DIFFERENT hypothesis \
834 about the named cause, and make one targeted change to it. If the error names a missing \
835 symbol/function/attribute, the fix is to IMPLEMENT it, not to adjust the caller.\n",
836 recurrences + 1
837 )
838}
839
840pub(super) fn record_recurrence(
850 seen: &mut HashMap<String, u32>,
851 sig: Option<&FailureSignature>,
852) -> u32 {
853 let Some(sig) = sig else { return 0 };
854 let entry = seen.entry(sig.key()).or_insert(0);
855 let prior = *entry;
856 *entry += 1;
857 prior
858}
859
860pub(super) fn primary_failure(results: &[CheckResult]) -> Option<FailureSignature> {
863 results
864 .iter()
865 .find(|r| !r.passed)
866 .map(FailureSignature::from_check)
867}
868
869fn append_recall_hint(prompt: &mut String, hint: &str) {
873 prompt.push_str(
874 "\nHINT — a prior session resolved this same failure signature with this approach; \
875 use it as a lead, verify it still applies:\n",
876 );
877 prompt.push_str(hint);
878 prompt.push('\n');
879}
880
881fn message_memory_text(message: &Message) -> Option<String> {
882 match message {
883 Message::System { content }
884 | Message::User { content }
885 | Message::Assistant { content, .. }
886 | Message::ToolResult { content, .. } => {
887 let trimmed = content.trim();
888 (!trimmed.is_empty()).then(|| trimmed.to_string())
889 }
890 Message::UserMultimodal { content } => {
891 let text = content
892 .iter()
893 .filter_map(|block| match block {
894 car_inference::ContentBlock::Text { text } => Some(text.trim()),
895 _ => None,
896 })
897 .filter(|s| !s.is_empty())
898 .collect::<Vec<_>>()
899 .join("\n");
900 (!text.is_empty()).then_some(text)
901 }
902 _ => None,
903 }
904}
905
906fn append_context_block(req: &mut GenerateRequest, title: &str, body: &str) {
907 let block = format!("## {title}\n{body}");
908 req.context = Some(match req.context.take() {
909 Some(existing) if !existing.trim().is_empty() => format!("{existing}\n\n{block}"),
910 _ => block,
911 });
912}
913
914async fn maybe_apply_coder_proactive_memory(
915 req: &mut GenerateRequest,
916 intent: &str,
917 messages: &[Message],
918 sink: &EventSink,
919 memory: &RepairMemory,
920) {
921 let mut recent = messages
922 .iter()
923 .rev()
924 .filter_map(message_memory_text)
925 .take(6)
926 .collect::<Vec<_>>();
927 recent.reverse();
928 let events = sink.events();
929 let Some((maintenance, decision)) = memory.proactive_for_task(intent, recent, &events).await
930 else {
931 return;
932 };
933 sink.record_proactive_memory(&maintenance, &decision);
934 if let car_memgine::ProactiveMemoryDecision::Inject { reminder, .. } = decision {
935 append_context_block(req, "Proactive Memory", &reminder);
936 }
937}
938
939fn winning_approach(sig: &FailureSignature, plan_text: &str) -> String {
944 let plan = plan_text.trim();
945 if plan.is_empty() {
946 format!(
947 "Re-attempted the edit; the '{}' failure of check '{}' cleared after repair.",
948 sig.error_class, sig.check
949 )
950 } else {
951 preview(plan, 1024)
952 }
953}
954
955pub(crate) fn native_loop_tool_defs(with_ask: bool) -> Vec<Value> {
970 let mut tools = WorktreeExecutor::tool_defs();
971 if with_ask {
972 tools.push(ask_user_tool_def());
973 }
974 tools
975}
976
977pub fn model_tool_catalog() -> Vec<Value> {
981 let mut tools = native_loop_tool_defs(true);
982 tools.push(report_no_change_tool_def());
983 tools.extend(
984 crate::assistant::memory::MemoryTools::tool_defs()
985 .into_iter()
986 .filter(|tool| tool["name"] == "recall"),
987 );
988 tools.extend(
991 crate::assistant::browser_tools::BrowserTools::new(std::env::temp_dir()).tool_defs(),
992 );
993 tools.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str()));
994 tools.dedup_by(|left, right| left["name"] == right["name"]);
995 tools
996}
997
998pub(crate) fn network_tool_defs(executor: &WorktreeExecutor, granted: bool) -> Vec<Value> {
1009 if !granted {
1010 return Vec::new();
1011 }
1012 ["http_request", "web_search"]
1013 .iter()
1014 .flat_map(|name| executor.delegate_defs_named(name))
1015 .collect()
1016}
1017
1018pub(crate) fn coder_session_tool_defs(
1049 executor: &WorktreeExecutor,
1050 with_ask: bool,
1051 can_adjudicate_no_change: bool,
1052) -> Vec<Value> {
1053 let mut tools = native_loop_tool_defs(with_ask);
1054 if can_adjudicate_no_change {
1055 tools.push(report_no_change_tool_def());
1056 }
1057 let memory_defs = executor.delegate_defs_named("recall");
1058 if !memory_defs.is_empty() {
1059 executor.advertise_delegates();
1060 tools.extend(memory_defs);
1061 }
1062 let net_defs = network_tool_defs(executor, executor.permits_full_access());
1063 if !net_defs.is_empty() {
1064 executor.advertise_delegates();
1065 tools.extend(net_defs);
1066 }
1067 let mut browser_defs = executor.delegate_defs_with_prefix("browse_");
1068 browser_defs.extend(executor.delegate_defs_with_prefix("browser_"));
1069 if !browser_defs.is_empty() {
1070 executor.advertise_delegates();
1071 tools.extend(browser_defs);
1072 }
1073 let denied = executor.denied_tools();
1084 if !denied.is_empty() {
1085 tools.retain(|def| !denied.contains(def["name"].as_str().unwrap_or_default()));
1086 tracing::info!(
1097 withdrawn = ?denied,
1098 "operator policy denies these tools outright; withheld from the coding loop's \
1099 advertised list (still refused at dispatch by the inspector chain)"
1100 );
1101 }
1102 tools
1103}
1104
1105#[allow(clippy::too_many_arguments)]
1112pub async fn run_native_loop(
1113 inference: &dyn TurnGenerator,
1114 executor: &WorktreeExecutor,
1115 intent: &str,
1116 contract: &OutcomeContract,
1117 sink: &EventSink,
1118 cancel: &CancelFlag,
1119 cfg: &NativeLoopConfig,
1120 memory: &RepairMemory,
1121 ask: Option<&dyn AskUser>,
1122) -> LoopOutcome {
1123 let tools = coder_session_tool_defs(executor, ask.is_some(), cfg.can_adjudicate_no_change);
1124 let environment = super::rpc::summarize_repo(executor.worktree());
1128 let tool_labels = builtin_tool_labels();
1132 let project = super::project_context::project_context(executor.worktree());
1137 let system = system_prompt_with_overlay(
1138 contract,
1139 &environment,
1140 project.as_deref(),
1141 cfg.prompt_overlay.as_deref(),
1142 );
1143 let mut feedback: Option<String> = None;
1144 let mut last_results: Vec<CheckResult> = Vec::new();
1145 let mut consecutive_inference_failures = 0u32;
1146 let mut announced_model_fallback = false;
1151 let mut last_journaled_hops: Vec<(String, String, &'static str)> = Vec::new();
1154 let mut announced_ungated_auth = false;
1157 let mut no_progress_iterations = 0u32;
1161 let mut seen_sigs: HashMap<String, u32> = HashMap::new();
1168 let mut prior_sig: Option<FailureSignature> = None;
1174
1175 let mut initial_user = format!("Task:\n{intent}\n");
1189 if let Some(block) = memory.recall_for_task(intent).await {
1190 initial_user.push_str(
1191 "\nRecall from prior sessions (heuristic — verify against the repo \
1192 before acting on it):\n",
1193 );
1194 initial_user.push_str(&block);
1195 }
1196 let mut messages = vec![
1197 Message::System {
1198 content: system.clone(),
1199 },
1200 Message::User {
1201 content: initial_user,
1202 },
1203 ];
1204
1205 let context_window = cfg
1209 .model
1210 .as_deref()
1211 .map(|m| inference.context_window(m))
1212 .unwrap_or(0);
1213 let adaptive_exclusions = if cfg.model.is_none() {
1214 cfg.exclude_models.clone()
1215 } else {
1216 Vec::new()
1217 };
1218 let strict_exclusions = !adaptive_exclusions.is_empty();
1219 tracing::debug!(
1224 context_window,
1225 budget = history_budget(context_window),
1226 "coder history compaction budget resolved for this run"
1227 );
1228
1229 for iteration in 1..=cfg.max_iterations {
1230 if cancel.load(Ordering::SeqCst) {
1231 return LoopOutcome::lost(
1232 LoopFailure::Cancelled,
1233 Some("cancelled".into()),
1234 iteration - 1,
1235 last_results,
1236 );
1237 }
1238 if let Some(reason) = cfg.deadline.admit() {
1242 sink.emit(CoderEventKind::BudgetExhausted {
1243 reason: reason.clone(),
1244 elapsed_secs: cfg.deadline.elapsed_secs(),
1245 iterations: iteration - 1,
1246 });
1247 return LoopOutcome::lost(
1248 LoopFailure::BudgetExhausted,
1249 Some(reason),
1250 iteration - 1,
1251 last_results,
1252 );
1253 }
1254 sink.emit(CoderEventKind::IterationStarted {
1255 n: iteration,
1256 max: cfg.max_iterations,
1257 });
1258
1259 if let Some(fb) = &feedback {
1264 let mut user = fb.clone();
1265 if let Some(sig) = &prior_sig {
1269 if let Some(hint) = memory.recall(sig).await {
1270 append_recall_hint(&mut user, &hint);
1271 }
1272 }
1273 messages.push(Message::User { content: user });
1274 }
1275
1276 let mut closing_plan = String::new();
1279 let mut turn = 0;
1281 let mut identical_read_calls: std::collections::HashMap<(String, String), u32> =
1287 std::collections::HashMap::new();
1288 let mut no_progress_this_iteration = false;
1289 let mut last_model = String::new();
1294 let mut models_this_iteration: Vec<String> = Vec::new();
1308 let mut model_declared_done = false;
1309 while turn < cfg.max_turns_per_iteration {
1310 turn += 1;
1311 if cancel.load(Ordering::SeqCst) {
1312 return LoopOutcome::lost(
1313 LoopFailure::Cancelled,
1314 Some("cancelled".into()),
1315 iteration,
1316 last_results,
1317 );
1318 }
1319
1320 compact_history_to_window(&mut messages, context_window);
1327
1328 let mut req = GenerateRequest {
1329 prompt: intent.to_string(), model: cfg.model.clone(),
1331 params: GenerateParams {
1332 temperature: 0.0,
1333 max_tokens: cfg.max_tokens_per_turn,
1334 strict_model: cfg.model.is_some(),
1340 ..Default::default()
1341 },
1342 tools: Some(tools.clone()),
1343 messages: Some(messages.clone()),
1344 intent: Some(car_inference::IntentHint {
1345 task: Some(car_inference::TaskHint::Code),
1346 high_stakes: true,
1355 exclude_models: adaptive_exclusions.clone(),
1356 strict_exclusions,
1357 ..Default::default()
1358 }),
1359 ..Default::default()
1360 };
1361 maybe_apply_coder_proactive_memory(&mut req, intent, &messages, sink, memory).await;
1362
1363 let result = match inference.generate_coder(req).await {
1364 Ok(r) => {
1365 consecutive_inference_failures = 0;
1366 let hops: Vec<(String, String, &'static str)> = r
1394 .fallback_from
1395 .iter()
1396 .enumerate()
1397 .map(|(i, fb)| {
1398 let to = r
1399 .fallback_from
1400 .get(i + 1)
1401 .map(|next| next.candidate.clone())
1402 .unwrap_or_else(|| r.model_used.clone());
1403 (fb.candidate.clone(), to, fallback_reason_label(fb.reason))
1404 })
1405 .collect();
1406 if hops != last_journaled_hops {
1407 for (from, to, reason) in &hops {
1408 sink.record_model_fallback(from, to, reason);
1409 }
1410 last_journaled_hops = hops;
1411 }
1412 if let Some(lane) = r.auth_fallback_from.clone() {
1413 if !announced_model_fallback {
1414 announced_model_fallback = true;
1415 sink.emit(CoderEventKind::ModelFallback {
1416 from: lane,
1417 to: r.model_used.clone(),
1418 reason: MODEL_FALLBACK_REASON.into(),
1419 });
1420 }
1421 }
1422 r
1423 }
1424 Err(e) => {
1425 if let TurnGenerationError::NoEligibleModel { excluded_models } = &e {
1426 let message = format!(
1434 "no independent coder model is available: review models {} are excluded; \
1435 configure an independent coder_model or review_models in heal.toml",
1436 excluded_models
1437 );
1438 sink.emit(CoderEventKind::Error {
1439 message: message.clone(),
1440 });
1441 sink.record_turn_completed(
1442 "no_eligible_model",
1443 None,
1444 false,
1445 turn,
1446 &last_model,
1447 &models_this_iteration,
1448 );
1449 let results = evaluate_contract_with_baselines(
1450 contract,
1451 executor,
1452 sink,
1453 &cfg.baseline_captures,
1454 )
1455 .await;
1456 let passed = results.iter().all(|result| result.passed);
1457 return if passed {
1458 LoopOutcome::green(iteration, results)
1459 } else {
1460 LoopOutcome::lost(
1461 LoopFailure::Configuration,
1462 Some(message),
1463 iteration,
1464 results,
1465 )
1466 };
1467 }
1468 let message = e.to_string();
1473 if is_auth_failure(&message) {
1474 let gate = cfg.auth_gate.clone();
1475 let announce = if gate.is_some() {
1496 true
1497 } else if announced_ungated_auth {
1498 false
1499 } else {
1500 announced_ungated_auth = true;
1501 true
1502 };
1503 if announce {
1504 sink.emit(CoderEventKind::AuthRequired {
1505 message: message.clone(),
1506 wait_secs: gate.as_ref().map_or(0, |_| cfg.auth_wait.as_secs()),
1509 });
1510 }
1511 if let Some(gate) = gate {
1519 if wait_for_auth(gate.as_ref(), cfg.auth_wait, cancel, &cfg.deadline)
1520 .await
1521 {
1522 consecutive_inference_failures = 0;
1526 continue;
1527 }
1528 sink.record_turn_completed(
1541 "auth_lapsed",
1542 None,
1543 false,
1544 turn,
1545 &last_model,
1546 &models_this_iteration,
1547 );
1548 let results = evaluate_contract_with_baselines(
1549 contract,
1550 executor,
1551 sink,
1552 &cfg.baseline_captures,
1553 )
1554 .await;
1555 let passed = results.iter().all(|r| r.passed);
1556 return if passed {
1557 LoopOutcome::green(iteration, results)
1558 } else {
1559 LoopOutcome::lost(
1560 LoopFailure::NeedsAuth,
1561 Some(format!(
1562 "not signed in, and no credential appeared within {}s: {message}",
1563 cfg.auth_wait.as_secs()
1564 )),
1565 iteration,
1566 results,
1567 )
1568 };
1569 }
1570 }
1571 consecutive_inference_failures += 1;
1572 sink.emit(CoderEventKind::Error {
1573 message: format!("inference failed (turn {turn}): {e}"),
1574 });
1575 if consecutive_inference_failures >= 3 {
1576 sink.record_turn_completed(
1583 "inference_failed",
1584 None,
1585 false,
1586 turn,
1587 &last_model,
1588 &models_this_iteration,
1589 );
1590 let results = evaluate_contract_with_baselines(
1598 contract,
1599 executor,
1600 sink,
1601 &cfg.baseline_captures,
1602 )
1603 .await;
1604 let passed = results.iter().all(|r| r.passed);
1605 if passed {
1611 if let Some(sig) = &prior_sig {
1612 memory
1613 .record_success(sig, &winning_approach(sig, &closing_plan))
1614 .await;
1615 }
1616 }
1617 return if passed {
1621 LoopOutcome::green(iteration, results)
1622 } else {
1623 let failure = if is_auth_failure(&message) {
1632 LoopFailure::NeedsAuth
1633 } else {
1634 LoopFailure::Infrastructure
1635 };
1636 LoopOutcome::lost(
1637 failure,
1638 Some(format!("inference failed repeatedly: {e}")),
1639 iteration,
1640 results,
1641 )
1642 };
1643 }
1644 continue; }
1646 };
1647 last_model = result.model_used.clone();
1648 let served = result.model_used.trim();
1649 if !served.is_empty() && !models_this_iteration.iter().any(|m| m == served) {
1650 models_this_iteration.push(served.to_string());
1651 }
1652
1653 if result.tool_calls.is_empty() {
1654 if result.was_truncated() {
1664 sink.emit(CoderEventKind::Error {
1665 message: format!(
1666 "model turn truncated (stop_reason={:?}) — continuing so it can finish",
1667 result.stop_reason
1668 ),
1669 });
1670 result.append_assistant_history(&mut messages, vec![]);
1671 messages.push(Message::User {
1672 content: "Your previous response was cut off at the token limit. \
1673 Continue exactly where you left off; if you were in the \
1674 middle of a tool call, re-issue that call in full."
1675 .to_string(),
1676 });
1677 continue;
1678 }
1679 sink.record_turn_completed(
1684 "empty_tool_calls",
1685 result.stop_reason.as_deref(),
1686 result.was_truncated(),
1687 turn,
1688 &result.model_used,
1689 &models_this_iteration,
1690 );
1691 model_declared_done = true;
1692 if !result.text.trim().is_empty() {
1693 closing_plan = result.text.clone();
1694 sink.emit(CoderEventKind::PlanText {
1695 text: result.text.clone(),
1696 });
1697 }
1698 break;
1699 }
1700
1701 let mut calls = result.tool_calls.clone();
1704 for (i, call) in calls.iter_mut().enumerate() {
1705 if call.id.is_none() {
1706 call.id = Some(format!("call_{iteration}_{turn}_{i}"));
1707 }
1708 }
1709 result.append_assistant_history(&mut messages, calls.clone());
1710
1711 for call in &calls {
1712 let params = Value::Object(call.arguments.clone().into_iter().collect());
1713 sink.emit(CoderEventKind::ToolCall {
1714 tool: call.name.clone(),
1715 params_preview: preview(¶ms.to_string(), 400),
1716 });
1717 if call.name == REPORT_NO_CHANGE_TOOL && cfg.can_adjudicate_no_change {
1724 match parse_nomination(¶ms) {
1725 Ok(nomination) => {
1726 return LoopOutcome::reported(
1727 nomination,
1728 iteration,
1729 last_results.clone(),
1730 );
1731 }
1732 Err(message) => {
1733 sink.emit(CoderEventKind::ToolResult {
1734 tool: call.name.clone(),
1735 ok: false,
1736 preview: message.clone(),
1737 });
1738 messages.push(Message::ToolResult {
1739 tool_use_id: call.id.clone().expect("assigned above"),
1740 content: message,
1741 provenance: Provenance::Internal,
1742 });
1743 continue;
1744 }
1745 }
1746 }
1747 if is_read_only_tool(&call.name) {
1759 let c = identical_read_calls
1760 .entry((call.name.clone(), params.to_string()))
1761 .or_insert(0);
1762 *c += 1;
1763 if *c >= NO_PROGRESS_REPEAT_LIMIT && !no_progress_this_iteration {
1764 no_progress_this_iteration = true;
1765 sink.emit(CoderEventKind::Error {
1766 message: format!(
1767 "no-progress loop: `{}` called {c} times with identical arguments \
1768 and no intervening edit — ending this attempt",
1769 call.name
1770 ),
1771 });
1772 }
1773 } else {
1774 identical_read_calls.clear();
1776 }
1777 let (ok, content) = if call.name == ASK_USER_TOOL {
1778 match ask {
1783 Some(asker) => {
1784 let prompt = params
1785 .get("prompt")
1786 .and_then(Value::as_str)
1787 .unwrap_or("")
1788 .to_string();
1789 match asker.ask(&prompt).await {
1790 Ok(answer) => (true, answer),
1791 Err(e) => (false, format!("ERROR: {e}")),
1792 }
1793 }
1794 None => (
1795 false,
1796 "ERROR: ask_user is not available in this session".to_string(),
1797 ),
1798 }
1799 } else {
1800 match executor.execute(&call.name, ¶ms).await {
1801 Ok(v) => (true, v.to_string()),
1802 Err(e) => (false, format!("ERROR: {e}")),
1803 }
1804 };
1805 sink.emit(CoderEventKind::ToolResult {
1806 tool: call.name.clone(),
1807 ok,
1808 preview: preview(&content, 400),
1809 });
1810 messages.push(Message::ToolResult {
1811 tool_use_id: call.id.clone().expect("assigned above"),
1812 content: preview(&content, 16 * 1024),
1813 provenance: if tool_output_is_external(&call.name, &tool_labels) {
1832 Provenance::External
1833 } else {
1834 Provenance::Internal
1835 },
1836 });
1837 }
1838 if no_progress_this_iteration {
1841 break;
1842 }
1843 }
1844 if no_progress_this_iteration {
1848 sink.record_turn_completed(
1849 "no_progress_loop",
1850 None,
1851 false,
1852 turn,
1853 &last_model,
1854 &models_this_iteration,
1855 );
1856 } else if !model_declared_done {
1857 sink.record_turn_completed(
1858 "max_turns",
1859 None,
1860 false,
1861 turn,
1862 &last_model,
1863 &models_this_iteration,
1864 );
1865 }
1866
1867 last_results =
1869 evaluate_contract_with_baselines(contract, executor, sink, &cfg.baseline_captures)
1870 .await;
1871 if last_results.iter().all(|r| r.passed) {
1872 if let Some(sig) = &prior_sig {
1876 memory
1877 .record_success(sig, &winning_approach(sig, &closing_plan))
1878 .await;
1879 }
1880 return LoopOutcome::green(iteration, last_results);
1881 }
1882 if no_progress_this_iteration {
1888 no_progress_iterations += 1;
1889 if no_progress_iterations >= 2 {
1890 return LoopOutcome::lost(
1898 LoopFailure::Verification,
1899 Some(
1900 "no-progress loop: the model repeatedly re-read the same files without \
1901 making edits across two attempts — the backbone is likely not returning \
1902 tool results. Aborted before exhausting the iteration budget."
1903 .to_string(),
1904 ),
1905 iteration,
1906 last_results,
1907 );
1908 }
1909 } else {
1910 no_progress_iterations = 0;
1911 }
1912 let cur_sig = primary_failure(&last_results);
1927 let recurrences = if no_progress_this_iteration {
1928 0
1929 } else {
1930 record_recurrence(&mut seen_sigs, cur_sig.as_ref())
1931 };
1932
1933 feedback = Some(failure_feedback(&last_results, recurrences));
1937 if let Some(sig) = cur_sig {
1938 memory.record_failure(&sig).await;
1939 prior_sig = Some(sig);
1940 } else {
1941 prior_sig = None;
1942 }
1943 }
1944
1945 LoopOutcome::lost(
1948 LoopFailure::Verification,
1949 None,
1950 cfg.max_iterations,
1951 last_results,
1952 )
1953}
1954
1955#[cfg(test)]
1956mod tests {
1957
1958 #[test]
1963 fn the_journal_labels_match_the_serde_spelling() {
1964 use car_inference::FallbackReason as R;
1965 for r in [
1966 R::CredentialRejected,
1967 R::CredentialAbsent,
1968 R::RateLimited,
1969 R::TimedOut,
1970 R::Failed,
1971 ] {
1972 let serde_spelling = serde_json::to_value(r).unwrap();
1973 assert_eq!(
1974 serde_spelling.as_str(),
1975 Some(fallback_reason_label(r)),
1976 "{r:?}"
1977 );
1978 }
1979 }
1980
1981 #[test]
1989 fn recall_is_advertised_and_reachable_together() {
1990 let dir = tempfile::tempdir().unwrap();
1991 let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
1992
1993 assert!(
1995 !exec.delegates_reachable(),
1996 "delegates start closed — attachment is not reachability"
1997 );
1998 let memory_defs = exec.delegate_defs_named("recall");
1999 assert_eq!(memory_defs.len(), 1, "recall must be attached");
2000
2001 exec.advertise_delegates();
2003 assert!(exec.delegates_reachable(), "advertising must open dispatch");
2004
2005 let mut tools = native_loop_tool_defs(false);
2006 tools.extend(memory_defs);
2007 let names: Vec<String> = tools
2008 .iter()
2009 .filter_map(|d| d["name"].as_str().map(String::from))
2010 .collect();
2011 assert!(names.iter().any(|n| n == "recall"));
2012 assert!(
2013 names.iter().any(|n| n == "shell"),
2014 "built-ins still present"
2015 );
2016 assert!(
2019 !names.iter().any(|n| n.starts_with("parslee_")),
2020 "advertising recall must not offer the rest of the delegate surface"
2021 );
2022 }
2023
2024 #[test]
2034 fn tool_results_are_classified_from_labels_not_from_a_constant() {
2035 let labels = car_engine::builtin_tool_labels();
2036
2037 for local in [
2039 "shell",
2040 "read_file",
2041 "write_file",
2042 "edit_file",
2043 "grep_files",
2044 ] {
2045 assert!(
2046 !car_engine::tool_output_is_external(local, &labels),
2047 "{local} is local and must not be marked External"
2048 );
2049 }
2050
2051 for networked in [
2056 "http_request",
2057 "web_search",
2058 "browse_navigate",
2059 "browse_observe",
2060 "browser_await_answer",
2061 ] {
2062 assert!(
2063 car_engine::tool_output_is_external(networked, &labels),
2064 "{networked} reaches the network and must be marked External"
2065 );
2066 }
2067
2068 assert!(!car_engine::tool_output_is_external(
2072 "some_unlabeled_tool",
2073 &labels
2074 ));
2075 }
2076
2077 #[test]
2081 fn report_no_change_is_offered_only_to_a_caller_that_can_adjudicate() {
2082 let names = |cfg: &NativeLoopConfig| -> Vec<String> {
2085 let mut tools = native_loop_tool_defs(false);
2086 if cfg.can_adjudicate_no_change {
2087 tools.push(report_no_change_tool_def());
2088 }
2089 tools
2090 .iter()
2091 .filter_map(|t| t["name"].as_str().map(String::from))
2092 .collect()
2093 };
2094
2095 let cannot = NativeLoopConfig::default();
2096 assert!(
2097 !cannot.can_adjudicate_no_change,
2098 "false is the only safe default"
2099 );
2100 assert!(!names(&cannot).contains(&REPORT_NO_CHANGE_TOOL.to_string()));
2101
2102 let can = NativeLoopConfig {
2103 can_adjudicate_no_change: true,
2104 ..Default::default()
2105 };
2106 assert!(names(&can).contains(&REPORT_NO_CHANGE_TOOL.to_string()));
2107 }
2108
2109 #[test]
2112 fn an_unknown_nomination_kind_is_rejected_not_defaulted() {
2113 let bad = serde_json::json!({
2114 "kind": "premise_wrongg",
2115 "summary": "s",
2116 "evidence": "e"
2117 });
2118 let err = parse_nomination(&bad).unwrap_err();
2119 assert!(err.contains("unknown kind"), "{err}");
2120
2121 for missing in ["kind", "summary", "evidence"] {
2122 let mut obj = serde_json::json!({
2123 "kind": "premise_wrong", "summary": "s", "evidence": "e"
2124 });
2125 obj.as_object_mut().unwrap().remove(missing);
2126 assert!(
2127 parse_nomination(&obj).is_err(),
2128 "`{missing}` must be required"
2129 );
2130 }
2131
2132 let ok = parse_nomination(&serde_json::json!({
2133 "kind": "non_code_decision", "summary": "s", "evidence": "e"
2134 }))
2135 .unwrap();
2136 assert_eq!(ok.kind, NoChangeKind::NonCodeDecision);
2137 }
2138
2139 use super::*;
2140 use crate::coder::contract::ContractCheck;
2141 use crate::coder::CoderEvent;
2142 use std::sync::atomic::AtomicUsize;
2143 use std::sync::Arc;
2144
2145 fn contract_for_prompt_test() -> OutcomeContract {
2146 OutcomeContract {
2147 description: "d".into(),
2148 checks: vec![],
2149 }
2150 }
2151
2152 #[test]
2164 fn the_coding_loop_withholds_tools_operator_policy_denies() {
2165 fn names(defs: &[Value]) -> std::collections::BTreeSet<String> {
2166 defs.iter()
2167 .filter_map(|d| d["name"].as_str().map(String::from))
2168 .collect()
2169 }
2170 let dir = tempfile::tempdir().unwrap();
2171
2172 let open = WorktreeExecutor::new(dir.path());
2173 let before = names(&coder_session_tool_defs(&open, false, false));
2174 assert!(
2175 before.contains("shell"),
2176 "control must offer the tool the next one denies: {before:?}"
2177 );
2178
2179 let denied = WorktreeExecutor::new(dir.path())
2180 .with_denied_tools(["shell".to_string()].into_iter().collect());
2181 let after = names(&coder_session_tool_defs(&denied, false, false));
2182
2183 assert_eq!(
2184 before.difference(&after).cloned().collect::<Vec<_>>(),
2185 vec!["shell".to_string()],
2186 "exactly the denied tool is withheld"
2187 );
2188 assert!(
2189 after.difference(&before).next().is_none(),
2190 "withholding must not ADD anything"
2191 );
2192 }
2193
2194 #[test]
2196 fn the_coding_loop_advertises_the_built_ins_not_the_delegate() {
2197 fn names(defs: &[Value]) -> Vec<String> {
2198 defs.iter()
2199 .filter_map(|d| d["name"].as_str().map(String::from))
2200 .collect()
2201 }
2202
2203 let advertised = names(&native_loop_tool_defs(false));
2204 assert_eq!(advertised, names(&WorktreeExecutor::tool_defs()));
2205 assert!(
2206 !advertised.iter().any(|n| n.starts_with("parslee_")),
2207 "delegate tools leaked into the coding loop: {advertised:?}"
2208 );
2209 assert!(advertised.iter().any(|n| n == "shell"));
2210
2211 let dir = tempfile::tempdir().unwrap();
2214 let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
2215 assert!(names(&exec.all_tool_defs())
2216 .iter()
2217 .any(|n| n.starts_with("parslee_")));
2218
2219 let with_ask = names(&native_loop_tool_defs(true));
2221 assert_eq!(with_ask.len(), advertised.len() + 1);
2222 assert_eq!(with_ask.last().unwrap(), ASK_USER_TOOL);
2223 }
2224
2225 #[test]
2229 fn the_network_pair_is_offered_only_once_the_operator_grants_it() {
2230 let dir = tempfile::tempdir().unwrap();
2231 let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
2232
2233 assert!(
2234 network_tool_defs(&exec, false).is_empty(),
2235 "an ungranted session must not be shown a tool the gate will refuse"
2236 );
2237
2238 let granted: Vec<String> = network_tool_defs(&exec, true)
2239 .iter()
2240 .filter_map(|d| d["name"].as_str().map(String::from))
2241 .collect();
2242 assert_eq!(granted, vec!["http_request", "web_search"]);
2243 }
2244
2245 #[test]
2249 fn final_policy_filter_withholds_browser_tools_after_opt_in_assembly() {
2250 let dir = tempfile::tempdir().unwrap();
2251 let policy_dir = dir.path().join(".car/policies");
2252 std::fs::create_dir_all(&policy_dir).unwrap();
2253 std::fs::write(
2254 policy_dir.join("browser.toml"),
2255 "deny_tool = [\"browse_navigate\"]\n",
2256 )
2257 .unwrap();
2258 let executor = WorktreeExecutor::for_coder_session(dir.path())
2259 .unwrap()
2260 .with_browser_tools();
2261 let tools = coder_session_tool_defs(&executor, false, false);
2262 assert!(!tools.iter().any(|tool| tool["name"] == "browse_navigate"));
2263 assert!(tools.iter().any(|tool| tool["name"] == "browse_observe"));
2264 }
2265
2266 #[test]
2267 fn the_coding_loop_offers_browser_tools_only_after_opt_in() {
2268 let dir = tempfile::tempdir().unwrap();
2269 let plain = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
2270 let plain_names: Vec<String> = coder_session_tool_defs(&plain, true, true)
2271 .iter()
2272 .filter_map(|d| d["name"].as_str().map(String::from))
2273 .collect();
2274 assert!(!plain_names.iter().any(|n| n.starts_with("browse_")));
2275 assert!(!plain_names.iter().any(|n| n.starts_with("browser_")));
2276
2277 let enabled = WorktreeExecutor::for_coder_session(dir.path())
2278 .unwrap()
2279 .with_browser_tools();
2280 assert!(!enabled.delegates_reachable());
2281 let enabled_names: Vec<String> = coder_session_tool_defs(&enabled, true, true)
2282 .iter()
2283 .filter_map(|d| d["name"].as_str().map(String::from))
2284 .collect();
2285 for required in [
2286 "browse_navigate",
2287 "browse_click",
2288 "browse_type",
2289 "browse_scroll",
2290 "browse_keypress",
2291 "browse_wait",
2292 "browse_observe",
2293 "browser_await_answer",
2294 "browser_await_signin",
2295 "browser_record_start",
2296 "browser_record_stop",
2297 ] {
2298 assert!(
2299 enabled_names.contains(&required.to_string()),
2300 "missing {required}"
2301 );
2302 }
2303 assert!(enabled.delegates_reachable());
2304 }
2305
2306 #[test]
2309 fn an_overlay_is_appended_to_the_prompt() {
2310 let contract = contract_for_prompt_test();
2311 let base = system_prompt_with_overlay(&contract, "env", None, None);
2312 let with =
2313 system_prompt_with_overlay(&contract, "env", None, Some("Prefer smaller diffs."));
2314
2315 assert!(with.contains("Prefer smaller diffs."));
2316 assert!(
2317 with.starts_with(&base),
2318 "the overlay must be strictly additive — the base prompt has to survive verbatim"
2319 );
2320 assert!(with.len() > base.len());
2321 }
2322
2323 #[test]
2328 fn a_repo_with_no_project_context_gets_an_unchanged_prompt() {
2329 let contract = contract_for_prompt_test();
2330 let bare = system_prompt(&contract, "env", None);
2331 assert_eq!(system_prompt(&contract, "env", Some("")), bare);
2332 assert_eq!(system_prompt(&contract, "env", Some(" \n ")), bare);
2333 }
2334
2335 #[test]
2338 fn project_instructions_reach_the_system_prompt() {
2339 let contract = contract_for_prompt_test();
2340 let dir = tempfile::tempdir().unwrap();
2343 std::fs::write(
2344 dir.path().join("CLAUDE.md"),
2345 "No cargo feature flags. Ever.",
2346 )
2347 .unwrap();
2348 let block = crate::coder::project_context::project_context(dir.path())
2349 .expect("a repo with CLAUDE.md yields a block");
2350
2351 let p = system_prompt(&contract, "env", Some(&block));
2352 assert!(p.contains("No cargo feature flags. Ever."));
2353 assert!(p.contains("does NOT check them"));
2356 assert!(p.contains("never weaken or edit a contract check"));
2357 let env_at = p.find("ENVIRONMENT:").expect("environment section");
2359 let rules_at = p.find("No cargo feature flags").expect("instructions");
2360 let how_at = p.find("How to work:").expect("how-to-work section");
2361 assert!(env_at < rules_at && rules_at < how_at);
2362 }
2363
2364 #[test]
2366 fn no_overlay_changes_nothing() {
2367 let contract = contract_for_prompt_test();
2368 let base = system_prompt(&contract, "env", None);
2369 assert_eq!(
2370 system_prompt_with_overlay(&contract, "env", None, None),
2371 base
2372 );
2373 assert_eq!(
2374 system_prompt_with_overlay(&contract, "env", None, Some("")),
2375 base
2376 );
2377 assert_eq!(
2378 system_prompt_with_overlay(&contract, "env", None, Some(" \n ")),
2379 base,
2380 "whitespace is not an overlay"
2381 );
2382 }
2383
2384 #[test]
2389 fn the_overlay_is_marked_subordinate_to_the_base_rules() {
2390 let contract = contract_for_prompt_test();
2391 let with = system_prompt_with_overlay(&contract, "env", None, Some("Commit when done."));
2392 let marker = with
2393 .find("ADDITIONAL GUIDANCE")
2394 .expect("the overlay must be delimited, not silently concatenated");
2395 assert!(
2396 with[marker..].contains("the rules above win"),
2397 "a conflicting overlay instruction must not read as authoritative"
2398 );
2399 assert!(
2400 with.find("Commit when done.").unwrap() > marker,
2401 "the overlay must come after its own header"
2402 );
2403 }
2404
2405 #[test]
2408 fn merge_harness_adopts_and_clears_the_overlay() {
2409 let mut cfg = NativeLoopConfig::default();
2410 cfg.merge_harness(&car_memgine::HarnessConfig {
2411 prompt_overlay: Some("evolved guidance".into()),
2412 ..Default::default()
2413 });
2414 assert_eq!(cfg.prompt_overlay.as_deref(), Some("evolved guidance"));
2415
2416 cfg.merge_harness(&car_memgine::HarnessConfig {
2417 prompt_overlay: None,
2418 ..Default::default()
2419 });
2420 assert_eq!(
2421 cfg.prompt_overlay, None,
2422 "a rollback must actually remove the overlay, not leave it latched"
2423 );
2424 }
2425
2426 #[test]
2427 fn merge_harness_raises_coder_budgets_only_upward() {
2428 let mut cfg = NativeLoopConfig {
2431 max_iterations: 8,
2432 max_turns_per_iteration: 24,
2433 ..Default::default()
2434 };
2435 cfg.merge_harness(&car_memgine::HarnessConfig {
2436 prompt_overlay: None,
2437 max_retries: 30,
2438 retry_backoff_ms: 0,
2439 planning_max_replans: 12, });
2441 assert_eq!(
2442 cfg.max_iterations, 13,
2443 "planning_max_replans+1 reaches the coder"
2444 );
2445 assert_eq!(
2446 cfg.max_turns_per_iteration, 30,
2447 "max_retries raises the turn floor"
2448 );
2449
2450 let mut base = NativeLoopConfig {
2452 max_iterations: 8,
2453 max_turns_per_iteration: 24,
2454 ..Default::default()
2455 };
2456 base.merge_harness(&car_memgine::HarnessConfig::default()); assert_eq!(base.max_iterations, 8, "never lowered below base");
2458 assert_eq!(base.max_turns_per_iteration, 24);
2459 }
2460
2461 struct Script {
2463 turns: Vec<InferenceResult>,
2464 cursor: AtomicUsize,
2465 seen: std::sync::Mutex<Vec<GenerateRequest>>,
2470 }
2471
2472 impl Script {
2473 fn new(turns: Vec<InferenceResult>) -> Self {
2474 Self {
2475 turns,
2476 cursor: AtomicUsize::new(0),
2477 seen: std::sync::Mutex::new(Vec::new()),
2478 }
2479 }
2480 fn prompt(&self, n: usize) -> String {
2482 let reqs = self.seen.lock().expect("seen poisoned");
2483 serde_json::to_string(&reqs[n].messages).unwrap_or_default()
2484 }
2485 fn prompts(&self) -> usize {
2486 self.seen.lock().expect("seen poisoned").len()
2487 }
2488 }
2489
2490 fn turn(text: &str, tool_calls: serde_json::Value) -> InferenceResult {
2491 serde_json::from_value(serde_json::json!({
2492 "text": text,
2493 "tool_calls": tool_calls,
2494 "trace_id": "t",
2495 "model_used": "scripted",
2496 "latency_ms": 0,
2497 }))
2498 .expect("scripted InferenceResult shape")
2499 }
2500
2501 fn turn_with_stop(
2502 text: &str,
2503 tool_calls: serde_json::Value,
2504 stop_reason: Option<&str>,
2505 ) -> InferenceResult {
2506 serde_json::from_value(serde_json::json!({
2507 "text": text,
2508 "tool_calls": tool_calls,
2509 "trace_id": "t",
2510 "model_used": "scripted",
2511 "latency_ms": 0,
2512 "stop_reason": stop_reason,
2513 }))
2514 .expect("scripted InferenceResult shape")
2515 }
2516
2517 #[async_trait]
2518 impl TurnGenerator for Script {
2519 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2520 self.seen.lock().expect("seen poisoned").push(req);
2521 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
2522 self.turns
2523 .get(i)
2524 .cloned()
2525 .ok_or_else(|| "script exhausted".to_string())
2526 }
2527 }
2528
2529 #[test]
2535 fn the_constructors_cannot_produce_a_passed_run_that_also_failed() {
2536 let green = LoopOutcome::green(3, Vec::new());
2537 assert!(green.passed);
2538 assert_eq!(green.failure, None);
2539 assert_eq!(green.error, None);
2540
2541 let lost = LoopOutcome::lost(LoopFailure::Verification, None, 3, Vec::new());
2542 assert!(!lost.passed);
2543 assert_eq!(lost.failure, Some(LoopFailure::Verification));
2544
2545 let scraped = LoopOutcome::lost(
2548 LoopFailure::EngineUnavailable,
2549 Some("external agent 'codex' failed: no binary".into()),
2550 0,
2551 Vec::new(),
2552 );
2553 assert!(scraped.error.unwrap().starts_with("external agent '"));
2554 }
2555
2556 fn failed(name: &str, exit: i64, tail: &str) -> CheckResult {
2559 CheckResult {
2560 name: name.into(),
2561 passed: false,
2562 exit_code: Some(exit),
2563 output_tail: tail.into(),
2564 duration_ms: 1,
2565 timed_out: false,
2566 deadline_clamped: false,
2567 }
2568 }
2569
2570 #[test]
2576 fn a_changed_error_class_under_one_check_name_is_not_a_recurrence() {
2577 let mut seen = HashMap::new();
2578 let compile = primary_failure(&[failed("tests", 101, "error[E0433]: failed to resolve")]);
2579 let assertion = primary_failure(&[failed("tests", 1, "assertion `left == right` failed")]);
2580 assert_ne!(
2581 compile.as_ref().map(|s| s.key()),
2582 assertion.as_ref().map(|s| s.key()),
2583 "same check, different error class must be different signatures"
2584 );
2585 assert_eq!(record_recurrence(&mut seen, compile.as_ref()), 0);
2586 assert_eq!(
2587 record_recurrence(&mut seen, assertion.as_ref()),
2588 0,
2589 "progress must not read as a recurrence"
2590 );
2591 }
2592
2593 #[test]
2596 fn the_identical_failure_recurs_and_counts_up() {
2597 let mut seen = HashMap::new();
2598 let sig = primary_failure(&[failed("tests", 1, "assertion `left == right` failed")]);
2599 assert_eq!(record_recurrence(&mut seen, sig.as_ref()), 0);
2600 assert_eq!(record_recurrence(&mut seen, sig.as_ref()), 1);
2601 assert_eq!(record_recurrence(&mut seen, sig.as_ref()), 2);
2602 }
2603
2604 #[test]
2608 fn an_oscillating_failure_still_recurs() {
2609 let mut seen = HashMap::new();
2610 let a = primary_failure(&[failed("tests", 1, "assertion `left == right` failed")]);
2611 let b = primary_failure(&[failed("build", 101, "error[E0433]: failed to resolve")]);
2612 assert_eq!(record_recurrence(&mut seen, a.as_ref()), 0);
2613 assert_eq!(record_recurrence(&mut seen, b.as_ref()), 0);
2614 assert_eq!(
2615 record_recurrence(&mut seen, a.as_ref()),
2616 1,
2617 "A -> B -> A is going in circles, not progress"
2618 );
2619 }
2620
2621 #[test]
2623 fn a_green_evaluation_is_not_a_recurrence() {
2624 let mut seen = HashMap::new();
2625 assert_eq!(record_recurrence(&mut seen, None), 0);
2626 assert!(seen.is_empty());
2627 }
2628
2629 #[tokio::test]
2633 async fn an_exhausted_budget_denies_admission_before_any_turn() {
2634 let script = Script::new(vec![turn("should never run", serde_json::json!([]))]);
2635 let dir = tempfile::tempdir().unwrap();
2636 let executor = WorktreeExecutor::new(dir.path());
2637 let sink = Arc::new(EventSink::test_sink());
2638 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
2639 let cfg = NativeLoopConfig {
2640 deadline: std::sync::Arc::new(SessionDeadline::new(Some(0))),
2642 ..Default::default()
2643 };
2644 let outcome = run_native_loop(
2645 &script,
2646 &executor,
2647 "x",
2648 &OutcomeContract {
2649 description: "x".into(),
2650 checks: vec![ContractCheck {
2651 name: "gate".into(),
2652 command: "exit 1".into(),
2653 expect_exit_zero: true,
2654 output_contains: None,
2655 timeout_secs: 10,
2656 baseline: false,
2657 differential: None,
2658 }],
2659 },
2660 &sink,
2661 &cancel,
2662 &cfg,
2663 &RepairMemory::disabled(),
2664 None,
2665 )
2666 .await;
2667 assert_eq!(
2668 script.prompts(),
2669 0,
2670 "the budget gates before any model turn"
2671 );
2672 assert_eq!(outcome.failure, Some(LoopFailure::BudgetExhausted));
2673 assert_eq!(outcome.iterations, 0);
2674 assert!(outcome
2675 .error
2676 .expect("the reason must surface")
2677 .contains("budget exhausted"));
2678 }
2679
2680 #[tokio::test]
2687 async fn the_escalation_is_delivered_to_the_model_only_after_a_repeat() {
2688 let dir = tempfile::tempdir().unwrap();
2689 let executor = WorktreeExecutor::new(dir.path());
2690 let sink = Arc::new(EventSink::test_sink());
2691 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
2692
2693 let edit = |n: u32| {
2696 turn(
2697 "editing",
2698 serde_json::json!([{
2699 "id": format!("c{n}"),
2700 "name": "write_file",
2701 "arguments": {"path": format!("f{n}.txt"), "content": "x"}
2702 }]),
2703 )
2704 };
2705 let script = Script::new(vec![
2706 edit(1),
2707 turn("done", serde_json::json!([])),
2708 edit(2),
2709 turn("done", serde_json::json!([])),
2710 edit(3),
2711 turn("done", serde_json::json!([])),
2712 ]);
2713 let contract = OutcomeContract {
2714 description: "never green".into(),
2715 checks: vec![ContractCheck {
2716 name: "gate".into(),
2717 command: "exit 1".into(),
2718 expect_exit_zero: true,
2719 output_contains: None,
2720 timeout_secs: 10,
2721 baseline: false,
2722 differential: None,
2723 }],
2724 };
2725 let cfg = NativeLoopConfig {
2726 max_iterations: 3,
2727 ..Default::default()
2728 };
2729 let outcome = run_native_loop(
2730 &script,
2731 &executor,
2732 "x",
2733 &contract,
2734 &sink,
2735 &cancel,
2736 &cfg,
2737 &RepairMemory::disabled(),
2738 None,
2739 )
2740 .await;
2741 assert!(!outcome.passed);
2742
2743 assert!(
2745 !script.prompt(0).contains("failed the same way"),
2746 "escalated before anything repeated"
2747 );
2748 let last = script.prompt(script.prompts() - 1);
2751 assert!(
2752 last.contains("failed the same way"),
2753 "the escalation never reached the model: {last}"
2754 );
2755 }
2756
2757 fn dead_backbone() -> Script {
2760 Script {
2761 turns: vec![],
2762 cursor: AtomicUsize::new(0),
2763 seen: std::sync::Mutex::new(Vec::new()),
2764 }
2765 }
2766
2767 async fn run_against(script: &Script, check: &str) -> LoopOutcome {
2768 let dir = tempfile::tempdir().unwrap();
2769 let executor = WorktreeExecutor::new(dir.path());
2770 let sink = Arc::new(EventSink::test_sink());
2771 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
2772 let contract = OutcomeContract {
2773 description: "x".into(),
2774 checks: vec![ContractCheck {
2775 name: "gate".into(),
2776 command: check.into(),
2777 expect_exit_zero: true,
2778 output_contains: None,
2779 timeout_secs: 10,
2780 baseline: false,
2781 differential: None,
2782 }],
2783 };
2784 run_native_loop(
2785 script,
2786 &executor,
2787 "x",
2788 &contract,
2789 &sink,
2790 &cancel,
2791 &NativeLoopConfig::default(),
2792 &RepairMemory::disabled(),
2793 None,
2794 )
2795 .await
2796 }
2797
2798 struct AuthFlaky {
2801 remaining: AtomicUsize,
2802 inner: Script,
2803 }
2804
2805 #[async_trait]
2806 impl TurnGenerator for AuthFlaky {
2807 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2808 if self.remaining.load(Ordering::SeqCst) > 0 {
2809 self.remaining.fetch_sub(1, Ordering::SeqCst);
2810 return Err("no credential for proprietary provider 'parslee': \
2811 set $PARSLEE_ACCESS_TOKEN or run `car auth login parslee`"
2812 .to_string());
2813 }
2814 self.inner.generate(req).await
2815 }
2816 }
2817
2818 #[derive(Debug)]
2820 struct SignsIn;
2821 #[async_trait]
2822 impl AuthGate for SignsIn {
2823 async fn is_authenticated(&self) -> bool {
2824 true
2825 }
2826 }
2827
2828 #[derive(Debug)]
2830 struct NeverSignsIn;
2831 #[async_trait]
2832 impl AuthGate for NeverSignsIn {
2833 async fn is_authenticated(&self) -> bool {
2834 false
2835 }
2836 }
2837
2838 async fn run_with_auth(
2839 gen: &dyn TurnGenerator,
2840 check: &str,
2841 gate: Arc<dyn AuthGate>,
2842 auth_wait: std::time::Duration,
2843 ) -> LoopOutcome {
2844 let dir = tempfile::tempdir().unwrap();
2845 let executor = WorktreeExecutor::new(dir.path());
2846 let sink = Arc::new(EventSink::test_sink());
2847 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
2848 let contract = OutcomeContract {
2849 description: "x".into(),
2850 checks: vec![ContractCheck {
2851 name: "gate".into(),
2852 command: check.into(),
2853 expect_exit_zero: true,
2854 output_contains: None,
2855 timeout_secs: 10,
2856 baseline: false,
2857 differential: None,
2858 }],
2859 };
2860 let cfg = NativeLoopConfig {
2861 auth_gate: Some(gate),
2862 auth_wait,
2863 ..Default::default()
2864 };
2865 run_native_loop(
2866 gen,
2867 &executor,
2868 "x",
2869 &contract,
2870 &sink,
2871 &cancel,
2872 &cfg,
2873 &RepairMemory::disabled(),
2874 None,
2875 )
2876 .await
2877 }
2878
2879 #[tokio::test]
2885 async fn a_lapsed_credential_waits_for_sign_in_and_then_resumes() {
2886 let gen = AuthFlaky {
2887 remaining: AtomicUsize::new(5),
2890 inner: Script::new(vec![turn("done", serde_json::json!([]))]),
2891 };
2892 let outcome = run_with_auth(
2893 &gen,
2894 "exit 0",
2895 Arc::new(SignsIn),
2896 std::time::Duration::from_secs(5),
2897 )
2898 .await;
2899
2900 assert!(
2901 outcome.passed,
2902 "the session must resume after sign-in, not die: {:?}",
2903 outcome.error
2904 );
2905 assert_eq!(outcome.failure, None);
2906 }
2907
2908 #[tokio::test]
2912 async fn nobody_signs_in_reports_needs_auth_not_infrastructure() {
2913 let gen = AuthFlaky {
2914 remaining: AtomicUsize::new(99),
2915 inner: Script::new(vec![turn("done", serde_json::json!([]))]),
2916 };
2917 let outcome = run_with_auth(
2918 &gen,
2919 "exit 1",
2920 Arc::new(NeverSignsIn),
2921 std::time::Duration::ZERO,
2922 )
2923 .await;
2924
2925 assert!(!outcome.passed);
2926 assert_eq!(
2927 outcome.failure,
2928 Some(LoopFailure::NeedsAuth),
2929 "an unanswered sign-in must not masquerade as an outage"
2930 );
2931 }
2932
2933 #[test]
2940 fn enriched_credential_errors_still_classify_as_auth_failures() {
2941 for msg in [
2942 "no credential for proprietary provider 'parslee' (model parslee/reasoning): the \
2943 Parslee token expired at unix 1234 and could not be refreshed. Re-authenticate \
2944 with `car auth login`",
2945 "no credential for proprietary provider 'parslee' (model parslee/reasoning): the \
2946 credential store could not be read (code=152). This is not a sign-out",
2947 "no credential for proprietary provider 'parslee' (model parslee/reasoning): no \
2948 account is signed in. Run `car auth login`",
2949 ] {
2950 assert!(
2951 is_auth_failure(msg),
2952 "enriched credential error must still read as an auth failure: {msg}"
2953 );
2954 }
2955 }
2956
2957 #[test]
2960 fn auth_failures_are_distinguished_from_outages() {
2961 assert!(is_auth_failure(
2962 "no credential for proprietary provider 'parslee': run `car auth login parslee`"
2963 ));
2964 assert!(is_auth_failure(
2965 "your Parslee session has expired or was rejected"
2966 ));
2967 assert!(is_auth_failure(
2968 "car-auth: cannot read Parslee credentials (secret store error)"
2969 ));
2970 assert!(is_auth_failure(
2971 "Parslee credential store unreadable for `parslee/reasoning`"
2972 ));
2973 assert!(is_auth_failure(
2974 "openai credential environment variable missing: `OPENAI_API_KEY` for explicitly requested `openai/gpt-5.6`"
2975 ));
2976 assert!(is_auth_failure(
2980 "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: \
2981 Authentication required"
2982 ));
2983
2984 assert!(!is_auth_failure("connection reset by peer"));
2985 assert!(!is_auth_failure("503 Service Unavailable"));
2986 assert!(!is_auth_failure("script exhausted"));
2987 assert!(!is_auth_failure("model failed, trying next fallback"));
2988 }
2989
2990 struct AlwaysFails {
2993 message: String,
2994 }
2995
2996 #[async_trait]
2997 impl TurnGenerator for AlwaysFails {
2998 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
2999 Err(self.message.clone())
3000 }
3001 }
3002
3003 async fn run_collecting(
3006 gen: &dyn TurnGenerator,
3007 check: &str,
3008 cfg: NativeLoopConfig,
3009 ) -> (LoopOutcome, Vec<CoderEvent>) {
3010 let dir = tempfile::tempdir().unwrap();
3011 let executor = WorktreeExecutor::new(dir.path());
3012 let (sink, collected) = EventSink::collecting("coder-auth");
3013 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3014 let contract = OutcomeContract {
3015 description: "x".into(),
3016 checks: vec![ContractCheck {
3017 name: "gate".into(),
3018 command: check.into(),
3019 expect_exit_zero: true,
3020 output_contains: None,
3021 timeout_secs: 10,
3022 baseline: false,
3023 differential: None,
3024 }],
3025 };
3026 let outcome = run_native_loop(
3027 gen,
3028 &executor,
3029 "x",
3030 &contract,
3031 &sink,
3032 &cancel,
3033 &cfg,
3034 &RepairMemory::disabled(),
3035 None,
3036 )
3037 .await;
3038 let events = collected.lock().expect("collector poisoned").clone();
3039 (outcome, events)
3040 }
3041
3042 #[tokio::test]
3055 async fn an_ungated_auth_failure_still_asks_for_sign_in_without_waiting() {
3056 let gen = AlwaysFails {
3057 message: "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: \
3058 Authentication required"
3059 .to_string(),
3060 };
3061 let (outcome, events) = run_collecting(&gen, "exit 1", NativeLoopConfig::default()).await;
3062
3063 let prompts: Vec<(u64, String)> = events
3064 .iter()
3065 .filter_map(|e| match &e.kind {
3066 CoderEventKind::AuthRequired { wait_secs, message } => {
3067 Some((*wait_secs, message.clone()))
3068 }
3069 _ => None,
3070 })
3071 .collect();
3072 let strikes = events
3076 .iter()
3077 .filter(|e| {
3078 matches!(&e.kind, CoderEventKind::Error { message }
3079 if message.contains("inference failed (turn"))
3080 })
3081 .count();
3082 assert_eq!(strikes, 3, "the run must have burned all three strikes");
3083 assert_eq!(
3084 prompts.len(),
3085 1,
3086 "an expired credential must ask for a sign-in exactly ONCE with no auth \
3087 gate — one prompt across all {strikes} strikes, not one per strike: {:?}",
3088 events.iter().map(|e| &e.kind).collect::<Vec<_>>()
3089 );
3090 for (wait_secs, message) in &prompts {
3091 assert_eq!(
3092 *wait_secs, 0,
3093 "an ungated loop is not waiting; saying it is would be a lie on the wire"
3094 );
3095 assert!(message.contains("401"), "the cause must survive: {message}");
3096 }
3097 assert_eq!(
3103 outcome.failure,
3104 Some(LoopFailure::NeedsAuth),
3105 "no gate means no wait, not that an expired login becomes infrastructure"
3106 );
3107 }
3108
3109 #[tokio::test]
3110 async fn mixed_auth_and_local_oom_is_classified_as_auth() {
3111 let gen = AlwaysFails {
3112 message: "inference failed: Parslee login expired for `parslee/reasoning` — \
3113 run `car auth login`; fallback then failed: This model needs about \
3114 9059 MB, beyond the configured 6553 MB local-model allocation"
3115 .to_string(),
3116 };
3117 let (outcome, events) = run_collecting(&gen, "exit 1", NativeLoopConfig::default()).await;
3118
3119 assert_eq!(outcome.failure, Some(LoopFailure::NeedsAuth));
3120 let auth_message = events
3121 .iter()
3122 .find_map(|event| match &event.kind {
3123 CoderEventKind::AuthRequired { message, .. } => Some(message.as_str()),
3124 _ => None,
3125 })
3126 .expect("mixed failure must emit auth_required");
3127 let auth_pos = auth_message
3128 .find("Parslee login expired")
3129 .expect("credential cause must be named");
3130 let oom_pos = auth_message
3131 .find("9059 MB")
3132 .expect("fallback OOM must remain as secondary detail");
3133 assert!(auth_pos < oom_pos, "credential cause must be named first");
3134 }
3135
3136 #[tokio::test]
3137 async fn genuine_local_oom_remains_inference_infrastructure() {
3138 let gen = AlwaysFails {
3139 message: "inference failed: This model needs about 9059 MB, beyond the configured \
3140 6553 MB local-model allocation"
3141 .to_string(),
3142 };
3143 let (outcome, events) = run_collecting(&gen, "exit 1", NativeLoopConfig::default()).await;
3144
3145 assert_eq!(outcome.failure, Some(LoopFailure::Infrastructure));
3146 assert!(events
3147 .iter()
3148 .all(|event| !matches!(&event.kind, CoderEventKind::AuthRequired { .. })));
3149 }
3150
3151 struct RejectedThenServes {
3157 remaining: AtomicUsize,
3158 message: String,
3159 inner: Script,
3160 }
3161
3162 #[async_trait]
3163 impl TurnGenerator for RejectedThenServes {
3164 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
3165 if self.remaining.load(Ordering::SeqCst) > 0 {
3166 self.remaining.fetch_sub(1, Ordering::SeqCst);
3167 return Err(self.message.clone());
3168 }
3169 self.inner.generate(req).await
3170 }
3171 }
3172
3173 #[derive(Debug, Default)]
3178 struct SignsInWhenAsked {
3179 polls: AtomicUsize,
3180 }
3181 #[async_trait]
3182 impl AuthGate for SignsInWhenAsked {
3183 async fn is_authenticated(&self) -> bool {
3184 self.polls.fetch_add(1, Ordering::SeqCst);
3185 true
3186 }
3187 }
3188
3189 #[tokio::test]
3197 async fn a_rejected_credential_with_a_gate_waits_and_then_resumes() {
3198 let gate = Arc::new(SignsInWhenAsked::default());
3199 let cfg = NativeLoopConfig {
3200 auth_gate: Some(gate.clone()),
3201 auth_wait: std::time::Duration::from_secs(5),
3204 ..Default::default()
3205 };
3206 let gen = RejectedThenServes {
3207 remaining: AtomicUsize::new(1),
3209 message: "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: \
3210 Authentication required"
3211 .to_string(),
3212 inner: Script::new(vec![turn("done", serde_json::json!([]))]),
3213 };
3214 let (outcome, events) = run_collecting(&gen, "exit 0", cfg.clone()).await;
3215
3216 let prompts: Vec<u64> = events
3217 .iter()
3218 .filter_map(|e| match &e.kind {
3219 CoderEventKind::AuthRequired { wait_secs, .. } => Some(*wait_secs),
3220 _ => None,
3221 })
3222 .collect();
3223 assert_eq!(
3224 prompts,
3225 vec![cfg.auth_wait.as_secs()],
3226 "a gated lapse must advertise the REAL wait window, not 0: {:?}",
3227 events.iter().map(|e| &e.kind).collect::<Vec<_>>()
3228 );
3229 assert!(
3230 cfg.auth_wait.as_secs() > 0,
3231 "the window must be non-zero or the assertion above proves nothing"
3232 );
3233 assert!(
3234 gate.polls.load(Ordering::SeqCst) >= 1,
3235 "the loop must have actually waited on the gate"
3236 );
3237
3238 assert!(
3241 outcome.passed,
3242 "the session must resume after sign-in, not die: {:?}",
3243 outcome.error
3244 );
3245 assert_eq!(
3246 outcome.failure, None,
3247 "a recovered lapse is neither NeedsAuth nor Infrastructure"
3248 );
3249 }
3250
3251 #[tokio::test]
3256 async fn a_degraded_lane_is_announced_once_per_run() {
3257 let degraded = |text: &str, tool_calls: serde_json::Value| {
3258 let mut t = turn(text, tool_calls);
3259 t.auth_fallback_from = Some("parslee/reasoning".to_string());
3260 t.model_used = "local/qwen3".to_string();
3261 t
3262 };
3263 let script = Script::new(vec![
3266 degraded(
3267 "editing",
3268 serde_json::json!([{
3269 "id": "c1",
3270 "name": "write_file",
3271 "arguments": {"path": "hello.txt", "content": "hello coder"}
3272 }]),
3273 ),
3274 degraded("done", serde_json::json!([])),
3275 ]);
3276 let (outcome, events) =
3277 run_collecting(&script, "exit 0", NativeLoopConfig::default()).await;
3278 assert!(outcome.passed, "outcome: {outcome:?}");
3279 assert_eq!(
3280 script.prompts(),
3281 2,
3282 "both degraded turns must actually have run"
3283 );
3284
3285 let announcements: Vec<(&str, &str)> = events
3286 .iter()
3287 .filter_map(|e| match &e.kind {
3288 CoderEventKind::ModelFallback { from, to, reason } => {
3289 assert!(
3290 reason.contains("car auth login"),
3291 "the reason must name the remedy: {reason}"
3292 );
3293 Some((from.as_str(), to.as_str()))
3294 }
3295 _ => None,
3296 })
3297 .collect();
3298 assert_eq!(
3299 announcements,
3300 vec![("parslee/reasoning", "local/qwen3")],
3301 "exactly one announcement, naming the dead lane and the model that answered"
3302 );
3303 }
3304
3305 #[tokio::test]
3311 async fn a_dead_backbone_over_green_checks_still_passes() {
3312 let outcome = run_against(&dead_backbone(), &crate::coder::test_cmds::touch("m.txt")).await;
3313 assert!(outcome.passed, "the contract decides: {outcome:?}");
3314 assert_eq!(outcome.failure, None);
3315 assert!(outcome.error.is_none());
3316 }
3317
3318 #[tokio::test]
3321 async fn a_dead_backbone_over_red_checks_is_infrastructure() {
3322 let outcome = run_against(&dead_backbone(), "exit 1").await;
3323 assert!(!outcome.passed);
3324 assert_eq!(outcome.failure, Some(LoopFailure::Infrastructure));
3325 assert!(outcome
3326 .error
3327 .expect("a dead backbone must surface")
3328 .contains("inference failed repeatedly"));
3329 }
3330
3331 #[tokio::test]
3332 async fn scripted_loop_edits_verifies_and_passes() {
3333 let dir = tempfile::tempdir().unwrap();
3334 let executor = WorktreeExecutor::new(dir.path());
3335 let (sink, collected) = EventSink::collecting("coder-native");
3336 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3337
3338 let script = Script {
3340 turns: vec![
3341 turn(
3342 "creating the file",
3343 serde_json::json!([{
3344 "id": "c1",
3345 "name": "write_file",
3346 "arguments": {"path": "hello.txt", "content": "hello coder"}
3347 }]),
3348 ),
3349 turn("done — file created", serde_json::json!([])),
3350 ],
3351 cursor: AtomicUsize::new(0),
3352 seen: std::sync::Mutex::new(Vec::new()),
3353 };
3354 let contract = OutcomeContract {
3355 description: "hello.txt exists with content".into(),
3356 checks: vec![ContractCheck {
3357 name: "exists".into(),
3358 command: crate::coder::test_cmds::contains("coder", "hello.txt"),
3359 expect_exit_zero: true,
3360 output_contains: None,
3361 timeout_secs: 10,
3362 baseline: false,
3363 differential: None,
3364 }],
3365 };
3366
3367 let outcome = run_native_loop(
3368 &script,
3369 &executor,
3370 "create hello.txt containing 'hello coder'",
3371 &contract,
3372 &sink,
3373 &cancel,
3374 &NativeLoopConfig::default(),
3375 &RepairMemory::disabled(),
3376 None,
3377 )
3378 .await;
3379
3380 assert!(outcome.passed, "outcome: {outcome:?}");
3381 assert_eq!(outcome.iterations, 1);
3382 assert!(dir.path().join("hello.txt").exists());
3383
3384 let events = collected.lock().unwrap();
3386 let types: Vec<&str> = events
3387 .iter()
3388 .map(|e| match &e.kind {
3389 CoderEventKind::IterationStarted { .. } => "iteration",
3390 CoderEventKind::ToolCall { .. } => "tool_call",
3391 CoderEventKind::ToolResult { .. } => "tool_result",
3392 CoderEventKind::PlanText { .. } => "plan",
3393 CoderEventKind::CheckStarted { .. } => "check_started",
3394 CoderEventKind::CheckCompleted { .. } => "check_completed",
3395 _ => "other",
3396 })
3397 .collect();
3398 assert_eq!(
3399 types,
3400 vec![
3401 "iteration",
3402 "tool_call",
3403 "tool_result",
3404 "plan",
3405 "check_started",
3406 "check_completed"
3407 ]
3408 );
3409 }
3410
3411 #[tokio::test]
3415 async fn browser_policy_denial_is_recorded_as_tool_receipts() {
3416 let dir = tempfile::tempdir().unwrap();
3417 let policies = dir.path().join(".car").join("policies");
3418 std::fs::create_dir_all(&policies).unwrap();
3419 std::fs::write(
3420 policies.join("browser.toml"),
3421 "deny_tool = [\"browse_navigate\"]\n",
3422 )
3423 .unwrap();
3424 let executor = WorktreeExecutor::for_coder_session(dir.path())
3425 .unwrap()
3426 .with_browser_tools();
3427 let collected: Arc<std::sync::Mutex<Vec<crate::coder::CoderEvent>>> =
3428 Arc::new(std::sync::Mutex::new(Vec::new()));
3429 let collector = Arc::clone(&collected);
3430 let emitter: crate::coder::EventEmitter = Arc::new(move |event| {
3431 collector.lock().unwrap().push(event);
3432 });
3433 let journal = dir.path().join("browser-receipts.events.jsonl");
3434 let sink = EventSink::new("coder-browser", Some(emitter), Some(journal.clone()));
3435 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3436 let script = Script {
3437 turns: vec![
3438 turn(
3439 "checking the page",
3440 serde_json::json!([{
3441 "id": "browser-call",
3442 "name": "browse_navigate",
3443 "arguments": {"url": "https://example.com"}
3444 }]),
3445 ),
3446 turn("done", serde_json::json!([])),
3447 ],
3448 cursor: AtomicUsize::new(0),
3449 seen: std::sync::Mutex::new(Vec::new()),
3450 };
3451 let contract = OutcomeContract {
3452 description: "receipt probe".into(),
3453 checks: vec![],
3454 };
3455
3456 let outcome = run_native_loop(
3457 &script,
3458 &executor,
3459 "inspect a page",
3460 &contract,
3461 &sink,
3462 &cancel,
3463 &NativeLoopConfig::default(),
3464 &RepairMemory::disabled(),
3465 None,
3466 )
3467 .await;
3468 assert!(outcome.passed, "outcome: {outcome:?}");
3469
3470 let events = collected.lock().unwrap();
3471 assert!(events.iter().any(|event| matches!(
3472 &event.kind,
3473 CoderEventKind::ToolCall { tool, .. } if tool == "browse_navigate"
3474 )));
3475 assert!(events.iter().any(|event| matches!(
3476 &event.kind,
3477 CoderEventKind::ToolResult { tool, ok: false, preview }
3478 if tool == "browse_navigate" && preview.contains("operator policy")
3479 )));
3480 drop(events);
3481 drop(sink);
3482
3483 let durable = car_eventlog::EventLog::load_read_only(&journal).unwrap();
3484 assert!(durable.events().iter().any(|event| {
3485 event.kind == car_eventlog::EventKind::ActionExecuting
3486 && event.action_id.as_deref() == Some("browse_navigate")
3487 }));
3488 assert!(durable.events().iter().any(|event| {
3489 event.kind == car_eventlog::EventKind::ActionFailed
3490 && event.action_id.as_deref() == Some("browse_navigate")
3491 }));
3492 }
3493
3494 fn identical_read_turn() -> InferenceResult {
3497 turn(
3498 "reading again",
3499 serde_json::json!([{
3500 "id": "c",
3501 "name": "read_file",
3502 "arguments": {"path": "src.py"}
3503 }]),
3504 )
3505 }
3506
3507 #[tokio::test]
3508 async fn native_loop_no_progress_bails_but_green_contract_still_passes() {
3509 let dir = tempfile::tempdir().unwrap();
3513 let executor = WorktreeExecutor::new(dir.path());
3514 let (sink, _collected) = EventSink::collecting("coder-native");
3515 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3516
3517 let script = Script {
3518 turns: (0..NO_PROGRESS_REPEAT_LIMIT + 2)
3519 .map(|_| identical_read_turn())
3520 .collect(),
3521 cursor: AtomicUsize::new(0),
3522 seen: std::sync::Mutex::new(Vec::new()),
3523 };
3524 let contract = OutcomeContract {
3526 description: "already satisfied".into(),
3527 checks: vec![ContractCheck {
3528 name: "ok".into(),
3529 command: crate::coder::test_cmds::PASS.into(),
3533 expect_exit_zero: true,
3534 output_contains: None,
3535 timeout_secs: 10,
3536 baseline: false,
3537 differential: None,
3538 }],
3539 };
3540
3541 let outcome = run_native_loop(
3542 &script,
3543 &executor,
3544 "fix the bug",
3545 &contract,
3546 &sink,
3547 &cancel,
3548 &NativeLoopConfig::default(),
3549 &RepairMemory::disabled(),
3550 None,
3551 )
3552 .await;
3553
3554 assert!(
3555 outcome.passed,
3556 "green contract must pass despite the thrash: {outcome:?}"
3557 );
3558 assert_eq!(outcome.iterations, 1);
3559 }
3560
3561 #[tokio::test]
3562 async fn native_loop_aborts_after_two_no_progress_iterations() {
3563 let dir = tempfile::tempdir().unwrap();
3567 let executor = WorktreeExecutor::new(dir.path());
3568 let (sink, _collected) = EventSink::collecting("coder-native");
3569 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3570
3571 let script = Script {
3573 turns: (0..(NO_PROGRESS_REPEAT_LIMIT * 2 + 4))
3574 .map(|_| identical_read_turn())
3575 .collect(),
3576 cursor: AtomicUsize::new(0),
3577 seen: std::sync::Mutex::new(Vec::new()),
3578 };
3579 let contract = OutcomeContract {
3581 description: "never satisfied".into(),
3582 checks: vec![ContractCheck {
3583 name: "never".into(),
3584 command: crate::coder::test_cmds::FAIL.to_string(),
3585 expect_exit_zero: true,
3586 output_contains: None,
3587 timeout_secs: 10,
3588 baseline: false,
3589 differential: None,
3590 }],
3591 };
3592
3593 let outcome = run_native_loop(
3594 &script,
3595 &executor,
3596 "fix the bug",
3597 &contract,
3598 &sink,
3599 &cancel,
3600 &NativeLoopConfig::default(),
3601 &RepairMemory::disabled(),
3602 None,
3603 )
3604 .await;
3605
3606 assert!(!outcome.passed, "outcome: {outcome:?}");
3607 let err = outcome.error.unwrap_or_default();
3608 assert!(err.contains("no-progress loop"), "error was: {err}");
3609 assert_eq!(outcome.iterations, 2);
3611 }
3612
3613 #[tokio::test]
3624 async fn a_dead_backbone_after_the_edit_still_records_the_author() {
3625 let dir = tempfile::tempdir().unwrap();
3626 let workspace = dir.path().join("workspace");
3627 std::fs::create_dir(&workspace).unwrap();
3628 let executor = WorktreeExecutor::new(&workspace);
3629 let journal = dir.path().join("journal").join("events.jsonl");
3630 let sink = EventSink::new("coder-dead-backbone", None, Some(journal.clone()));
3631 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3632
3633 let mut editing = turn(
3636 "creating the file",
3637 serde_json::json!([{
3638 "id": "c1",
3639 "name": "write_file",
3640 "arguments": {"path": "hello.txt", "content": "hello coder"}
3641 }]),
3642 );
3643 editing.model_used = "writer".to_string();
3644 let script = Script::new(vec![editing]);
3645
3646 let contract = OutcomeContract {
3647 description: "hello.txt exists with content".into(),
3648 checks: vec![ContractCheck {
3649 name: "exists".into(),
3650 command: crate::coder::test_cmds::contains("coder", "hello.txt"),
3651 expect_exit_zero: true,
3652 output_contains: None,
3653 timeout_secs: 10,
3654 baseline: false,
3655 differential: None,
3656 }],
3657 };
3658
3659 let outcome = run_native_loop(
3660 &script,
3661 &executor,
3662 "create hello.txt containing 'hello coder'",
3663 &contract,
3664 &sink,
3665 &cancel,
3666 &NativeLoopConfig::default(),
3667 &RepairMemory::disabled(),
3668 None,
3669 )
3670 .await;
3671 assert!(outcome.passed, "outcome: {outcome:?}");
3673
3674 drop(sink);
3675 let log = car_eventlog::EventLog::load(&journal).unwrap();
3676 let ev = log
3677 .events()
3678 .iter()
3679 .find(|e| e.kind == car_eventlog::EventKind::TurnCompleted)
3680 .expect("the backbone-death exit must journal a terminal too");
3681 assert_eq!(
3682 ev.data.get("decision"),
3683 Some(&serde_json::json!("inference_failed"))
3684 );
3685 assert_eq!(
3686 ev.data.get("models_served"),
3687 Some(&serde_json::json!(["writer"])),
3688 "the model that landed the edit must be named: {:?}",
3689 ev.data
3690 );
3691 }
3692
3693 #[tokio::test]
3701 async fn native_loop_journals_every_model_that_served_an_iteration() {
3702 let dir = tempfile::tempdir().unwrap();
3703 let workspace = dir.path().join("workspace");
3704 std::fs::create_dir(&workspace).unwrap();
3705 let executor = WorktreeExecutor::new(&workspace);
3706 let journal = dir.path().join("journal").join("events.jsonl");
3707 let sink = EventSink::new("coder-models", None, Some(journal.clone()));
3708 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3709
3710 let mut editing = turn(
3713 "creating the file",
3714 serde_json::json!([{
3715 "id": "c1",
3716 "name": "write_file",
3717 "arguments": {"path": "hello.txt", "content": "hello coder"}
3718 }]),
3719 );
3720 editing.model_used = "writer".to_string();
3721 let mut closing = turn("done — file created", serde_json::json!([]));
3722 closing.model_used = "finisher".to_string();
3723
3724 let script = Script::new(vec![editing, closing]);
3725 let contract = OutcomeContract {
3726 description: "hello.txt exists with content".into(),
3727 checks: vec![ContractCheck {
3728 name: "exists".into(),
3729 command: crate::coder::test_cmds::contains("coder", "hello.txt"),
3730 expect_exit_zero: true,
3731 output_contains: None,
3732 timeout_secs: 10,
3733 baseline: false,
3734 differential: None,
3735 }],
3736 };
3737
3738 let outcome = run_native_loop(
3739 &script,
3740 &executor,
3741 "create hello.txt containing 'hello coder'",
3742 &contract,
3743 &sink,
3744 &cancel,
3745 &NativeLoopConfig::default(),
3746 &RepairMemory::disabled(),
3747 None,
3748 )
3749 .await;
3750 assert!(outcome.passed, "outcome: {outcome:?}");
3751
3752 drop(sink);
3753 let log = car_eventlog::EventLog::load(&journal).unwrap();
3754 let ev = log
3755 .events()
3756 .iter()
3757 .find(|e| e.kind == car_eventlog::EventKind::TurnCompleted)
3758 .expect("a terminal was journaled");
3759
3760 assert_eq!(
3762 ev.data.get("model_id"),
3763 Some(&serde_json::json!("finisher"))
3764 );
3765 assert_eq!(
3767 ev.data.get("models_served"),
3768 Some(&serde_json::json!(["writer", "finisher"])),
3769 "the model that wrote the edit must be recorded: {:?}",
3770 ev.data
3771 );
3772 }
3773
3774 #[tokio::test]
3775 async fn native_loop_empty_tool_calls_journals_turn_completed() {
3776 let dir = tempfile::tempdir().unwrap();
3780 let workspace = dir.path().join("workspace");
3781 std::fs::create_dir(&workspace).unwrap();
3782 let executor = WorktreeExecutor::new(&workspace);
3783 let journal = dir.path().join("journal").join("events.jsonl");
3784 let sink = EventSink::new("coder-native", None, Some(journal.clone()));
3788 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3789
3790 let script = Script {
3791 turns: vec![
3792 turn(
3793 "creating the file",
3794 serde_json::json!([{
3795 "id": "c1",
3796 "name": "write_file",
3797 "arguments": {"path": "hello.txt", "content": "hello coder"}
3798 }]),
3799 ),
3800 turn("done — file created", serde_json::json!([])),
3801 ],
3802 cursor: AtomicUsize::new(0),
3803 seen: std::sync::Mutex::new(Vec::new()),
3804 };
3805 let contract = OutcomeContract {
3806 description: "hello.txt exists with content".into(),
3807 checks: vec![ContractCheck {
3808 name: "exists".into(),
3809 command: crate::coder::test_cmds::contains("coder", "hello.txt"),
3810 expect_exit_zero: true,
3811 output_contains: None,
3812 timeout_secs: 10,
3813 baseline: false,
3814 differential: None,
3815 }],
3816 };
3817
3818 let outcome = run_native_loop(
3819 &script,
3820 &executor,
3821 "create hello.txt containing 'hello coder'",
3822 &contract,
3823 &sink,
3824 &cancel,
3825 &NativeLoopConfig::default(),
3826 &RepairMemory::disabled(),
3827 None,
3828 )
3829 .await;
3830 assert!(outcome.passed, "outcome: {outcome:?}");
3831
3832 drop(sink);
3834 let log = car_eventlog::EventLog::load(&journal).unwrap();
3835 let terminals: Vec<_> = log
3836 .events()
3837 .iter()
3838 .filter(|e| e.kind == car_eventlog::EventKind::TurnCompleted)
3839 .collect();
3840 assert_eq!(
3841 terminals.len(),
3842 1,
3843 "exactly one empty-tool-calls terminal recorded"
3844 );
3845 let ev = terminals[0];
3846 assert_eq!(
3847 ev.data.get("decision"),
3848 Some(&serde_json::json!("empty_tool_calls"))
3849 );
3850 assert_eq!(
3851 ev.data.get("model_id"),
3852 Some(&serde_json::json!("scripted"))
3853 );
3854 assert_eq!(
3857 ev.data.get("model_tier"),
3858 Some(&serde_json::json!("unknown"))
3859 );
3860 }
3861
3862 #[tokio::test]
3863 async fn native_loop_injects_proactive_memory_from_journaled_failures() {
3864 use car_memgine::MemgineEngine;
3865 use std::sync::Mutex as StdMutex;
3866 use tokio::sync::Mutex as AsyncMutex;
3867
3868 struct CaptureContext {
3869 seen: Arc<StdMutex<Vec<Option<String>>>>,
3870 }
3871
3872 #[async_trait]
3873 impl TurnGenerator for CaptureContext {
3874 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
3875 self.seen.lock().unwrap().push(req.context.clone());
3876 Ok(turn("done", serde_json::json!([])))
3877 }
3878 }
3879
3880 let dir = tempfile::tempdir().unwrap();
3881 let executor = WorktreeExecutor::new(dir.path());
3882 let journal = dir.path().join("events.jsonl");
3883 let sink = EventSink::new("coder-native", None, Some(journal.clone()));
3884 sink.emit(CoderEventKind::ToolResult {
3885 tool: "shell".into(),
3886 ok: false,
3887 preview: "pytest failed because fixture data is missing".into(),
3888 });
3889 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3890 let memory = RepairMemory::new(Some(Arc::new(AsyncMutex::new(MemgineEngine::new(None)))));
3891 let seen = Arc::new(StdMutex::new(Vec::new()));
3892 let capture = CaptureContext { seen: seen.clone() };
3893 let contract = OutcomeContract {
3894 description: "noop".into(),
3895 checks: vec![],
3896 };
3897
3898 let outcome = run_native_loop(
3899 &capture,
3900 &executor,
3901 "fix the pytest failure",
3902 &contract,
3903 &sink,
3904 &cancel,
3905 &NativeLoopConfig::default(),
3906 &memory,
3907 None,
3908 )
3909 .await;
3910
3911 assert!(outcome.passed, "outcome: {outcome:?}");
3912 let contexts = seen.lock().unwrap();
3913 let context = contexts[0].as_deref().unwrap_or("");
3914 assert!(
3915 context.contains("## Proactive Memory"),
3916 "request context should carry proactive memory: {context}"
3917 );
3918 assert!(
3919 context.contains("Action shell in proposal session failed"),
3920 "journaled failure should be injected: {context}"
3921 );
3922 drop(sink);
3923 let log = car_eventlog::EventLog::load(&journal).unwrap();
3924 assert!(log
3925 .events()
3926 .iter()
3927 .any(|e| e.kind == car_eventlog::EventKind::ProactiveMemoryMaintained));
3928 assert!(log.events().iter().any(|e| {
3929 e.kind == car_eventlog::EventKind::ProactiveMemoryIntervention
3930 && e.data.get("decision") == Some(&serde_json::json!("inject"))
3931 }));
3932 }
3933
3934 #[tokio::test]
3935 async fn native_loop_turn_budget_exhaustion_journals_max_turns() {
3936 let dir = tempfile::tempdir().unwrap();
3941 let executor = WorktreeExecutor::new(dir.path());
3942 let journal = dir.path().join("events.jsonl");
3943 let sink = EventSink::new("coder-native", None, Some(journal.clone()));
3944 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3945
3946 let tool_turn = || {
3947 turn(
3948 "still working",
3949 serde_json::json!([{
3950 "id": "c",
3951 "name": "write_file",
3952 "arguments": {"path": "scratch.txt", "content": "x"}
3953 }]),
3954 )
3955 };
3956 let script = Script {
3957 turns: vec![tool_turn(), tool_turn()],
3958 cursor: AtomicUsize::new(0),
3959 seen: std::sync::Mutex::new(Vec::new()),
3960 };
3961 let contract = OutcomeContract {
3962 description: "never satisfied".into(),
3963 checks: vec![ContractCheck {
3964 name: "exists".into(),
3965 command: crate::coder::test_cmds::contains("coder", "hello.txt"),
3966 expect_exit_zero: true,
3967 output_contains: None,
3968 timeout_secs: 10,
3969 baseline: false,
3970 differential: None,
3971 }],
3972 };
3973 let cfg = NativeLoopConfig {
3974 prompt_overlay: None,
3975 model: None,
3976 exclude_models: Vec::new(),
3977 max_iterations: 1,
3978 max_turns_per_iteration: 2,
3979 max_tokens_per_turn: 4096,
3980 deadline: SessionDeadline::shared_default(),
3981 auth_gate: None,
3982 auth_wait: std::time::Duration::ZERO,
3983 can_adjudicate_no_change: false,
3984 baseline_captures: crate::coder::contract::BaselineCaptures::new(),
3985 };
3986
3987 let outcome = run_native_loop(
3988 &script,
3989 &executor,
3990 "keep writing forever",
3991 &contract,
3992 &sink,
3993 &cancel,
3994 &cfg,
3995 &RepairMemory::disabled(),
3996 None,
3997 )
3998 .await;
3999 assert!(!outcome.passed, "outcome: {outcome:?}");
4000
4001 drop(sink);
4002 let log = car_eventlog::EventLog::load(&journal).unwrap();
4003 let max_turns: Vec<_> = log
4004 .events()
4005 .iter()
4006 .filter(|e| {
4007 e.kind == car_eventlog::EventKind::TurnCompleted
4008 && e.data.get("decision") == Some(&serde_json::json!("max_turns"))
4009 })
4010 .collect();
4011 assert_eq!(
4012 max_turns.len(),
4013 1,
4014 "turn-budget exhaustion recorded once as max_turns"
4015 );
4016 assert_eq!(max_turns[0].data.get("turns"), Some(&serde_json::json!(2)));
4017 }
4018
4019 #[tokio::test]
4020 async fn native_loop_compacts_persistent_history_to_context_window() {
4021 use std::sync::Mutex;
4029
4030 struct RecordingGen {
4031 seen: Arc<Mutex<Vec<(usize, bool)>>>,
4033 turn_no: AtomicUsize,
4036 }
4037 #[async_trait]
4038 impl TurnGenerator for RecordingGen {
4039 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
4040 let msgs = req.messages.as_ref().expect("coder always sets messages");
4041 let starts_with_system = matches!(msgs.first(), Some(Message::System { .. }));
4042 self.seen
4043 .lock()
4044 .unwrap()
4045 .push((msgs.len(), starts_with_system));
4046 let n = self.turn_no.fetch_add(1, Ordering::SeqCst);
4050 Ok(turn(
4051 &"x".repeat(8000),
4052 serde_json::json!([{
4053 "id": format!("c{n}"),
4054 "name": "write_file",
4055 "arguments": {"path": format!("big{n}.txt"), "content": "y"}
4056 }]),
4057 ))
4058 }
4059 fn context_window(&self, _model: &str) -> usize {
4060 200 }
4062 }
4063
4064 let dir = tempfile::tempdir().unwrap();
4065 let executor = WorktreeExecutor::new(dir.path());
4066 let (sink, _collected) = EventSink::collecting("compact-test");
4067 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4068 let seen: Arc<Mutex<Vec<(usize, bool)>>> = Arc::new(Mutex::new(Vec::new()));
4069 let gen = RecordingGen {
4070 seen: seen.clone(),
4071 turn_no: AtomicUsize::new(0),
4072 };
4073
4074 let cfg = NativeLoopConfig {
4075 prompt_overlay: None,
4076 model: Some("scripted".into()),
4077 exclude_models: Vec::new(),
4078 max_iterations: 1,
4079 max_turns_per_iteration: 12,
4080 max_tokens_per_turn: 4096,
4081 deadline: SessionDeadline::shared_default(),
4082 auth_gate: None,
4083 auth_wait: std::time::Duration::ZERO,
4084 can_adjudicate_no_change: false,
4085 baseline_captures: crate::coder::contract::BaselineCaptures::new(),
4086 };
4087 let contract = OutcomeContract {
4088 description: "never satisfied".into(),
4089 checks: vec![ContractCheck {
4090 name: "never".into(),
4091 command: crate::coder::test_cmds::FAIL.to_string(),
4092 expect_exit_zero: true,
4093 output_contains: None,
4094 timeout_secs: 10,
4095 baseline: false,
4096 differential: None,
4097 }],
4098 };
4099
4100 let _ = run_native_loop(
4101 &gen,
4102 &executor,
4103 "grow the thread",
4104 &contract,
4105 &sink,
4106 &cancel,
4107 &cfg,
4108 &RepairMemory::disabled(),
4109 None,
4110 )
4111 .await;
4112
4113 let seen = seen.lock().unwrap();
4114 assert_eq!(seen.len(), 12, "all 12 turns generated");
4115 assert!(
4118 seen.iter().all(|(_, sys)| *sys),
4119 "System prompt must stay pinned every turn"
4120 );
4121 let max_len = seen.iter().map(|(n, _)| *n).max().unwrap();
4124 assert!(
4125 max_len < 14,
4126 "persistent history not bounded — max messages/turn = {max_len}"
4127 );
4128 }
4129
4130 #[tokio::test]
4131 async fn native_loop_routes_high_stakes_and_excludes_review_models() {
4132 use std::sync::Mutex;
4138 struct CapturingGen {
4139 intents: Arc<Mutex<Vec<Option<car_inference::IntentHint>>>>,
4140 cursor: AtomicUsize,
4141 }
4142 #[async_trait]
4143 impl TurnGenerator for CapturingGen {
4144 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
4145 self.intents.lock().unwrap().push(req.intent.clone());
4146 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
4150 if i == 0 {
4151 Ok(turn(
4152 "",
4153 serde_json::json!([{
4154 "id": "c1", "name": "write_file",
4155 "arguments": {"path": "f.txt", "content": "x"}
4156 }]),
4157 ))
4158 } else {
4159 Ok(turn("done", serde_json::json!([])))
4160 }
4161 }
4162 }
4163
4164 let dir = tempfile::tempdir().unwrap();
4165 let executor = WorktreeExecutor::new(dir.path());
4166 let sink = EventSink::test_sink();
4167 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4168 let captured = Arc::new(Mutex::new(Vec::new()));
4169 let gen = CapturingGen {
4170 intents: captured.clone(),
4171 cursor: AtomicUsize::new(0),
4172 };
4173 let contract = OutcomeContract {
4174 description: "noop".into(),
4175 checks: vec![],
4176 };
4177
4178 let _ = run_native_loop(
4179 &gen,
4180 &executor,
4181 "make a change",
4182 &contract,
4183 &sink,
4184 &cancel,
4185 &NativeLoopConfig {
4186 exclude_models: vec!["reviewer-a".into(), "reviewer-b".into()],
4187 ..Default::default()
4188 },
4189 &RepairMemory::disabled(),
4190 None,
4191 )
4192 .await;
4193
4194 {
4195 let intents = captured.lock().unwrap();
4196 assert!(
4197 intents.len() >= 2,
4198 "expected the loop to issue multiple inferences, got {}",
4199 intents.len()
4200 );
4201 for (n, intent) in intents.iter().enumerate() {
4202 let intent = intent
4203 .as_ref()
4204 .unwrap_or_else(|| panic!("turn {n} issued an inference with no IntentHint"));
4205 assert!(intent.high_stakes, "turn {n} must route high_stakes");
4206 assert_eq!(
4207 intent.task,
4208 Some(car_inference::TaskHint::Code),
4209 "turn {n} must keep the Code task hint"
4210 );
4211 assert_eq!(
4212 intent.exclude_models,
4213 ["reviewer-a", "reviewer-b"],
4214 "turn {n} must exclude every review-panel seat"
4215 );
4216 assert!(
4217 intent.strict_exclusions,
4218 "turn {n} must refuse instead of falling back to a review-panel seat"
4219 );
4220 }
4221 }
4222
4223 let pinned_captured = Arc::new(Mutex::new(Vec::new()));
4227 let pinned = CapturingGen {
4228 intents: pinned_captured.clone(),
4229 cursor: AtomicUsize::new(0),
4230 };
4231 let _ = run_native_loop(
4232 &pinned,
4233 &executor,
4234 "make another change",
4235 &contract,
4236 &EventSink::test_sink(),
4237 &Arc::new(std::sync::atomic::AtomicBool::new(false)),
4238 &NativeLoopConfig {
4239 model: Some("operator-pinned".into()),
4240 exclude_models: vec!["reviewer-a".into()],
4241 ..Default::default()
4242 },
4243 &RepairMemory::disabled(),
4244 None,
4245 )
4246 .await;
4247 assert!(pinned_captured.lock().unwrap().iter().all(|intent| {
4248 intent
4249 .as_ref()
4250 .is_some_and(|hint| hint.exclude_models.is_empty() && !hint.strict_exclusions)
4251 }));
4252 }
4253
4254 #[tokio::test]
4255 async fn no_independent_coder_stops_after_one_route_attempt_as_configuration() {
4256 struct NoEligible {
4257 calls: AtomicUsize,
4258 }
4259 #[async_trait]
4260 impl TurnGenerator for NoEligible {
4261 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
4262 panic!("native coder must use the typed generation seam")
4263 }
4264
4265 async fn generate_coder(
4266 &self,
4267 _req: GenerateRequest,
4268 ) -> Result<InferenceResult, TurnGenerationError> {
4269 self.calls.fetch_add(1, Ordering::SeqCst);
4270 Err(TurnGenerationError::NoEligibleModel {
4271 excluded_models: "reviewer-a, reviewer-b".into(),
4272 })
4273 }
4274 }
4275
4276 let dir = tempfile::tempdir().unwrap();
4277 let generator = NoEligible {
4278 calls: AtomicUsize::new(0),
4279 };
4280 let outcome = run_native_loop(
4281 &generator,
4282 &WorktreeExecutor::new(dir.path()),
4283 "make a change",
4284 &OutcomeContract {
4285 description: "must change".into(),
4286 checks: vec![ContractCheck {
4287 baseline: false,
4288 differential: None,
4289 name: "red baseline".into(),
4290 command: crate::coder::test_cmds::FAIL.into(),
4291 expect_exit_zero: true,
4292 output_contains: None,
4293 timeout_secs: 10,
4294 }],
4295 },
4296 &EventSink::test_sink(),
4297 &Arc::new(std::sync::atomic::AtomicBool::new(false)),
4298 &NativeLoopConfig {
4299 exclude_models: vec!["reviewer-a".into(), "reviewer-b".into()],
4300 ..Default::default()
4301 },
4302 &RepairMemory::disabled(),
4303 None,
4304 )
4305 .await;
4306
4307 assert_eq!(generator.calls.load(Ordering::SeqCst), 1);
4308 assert_eq!(outcome.failure, Some(LoopFailure::Configuration));
4309 let error = outcome.error.expect("configuration detail");
4310 assert!(error.contains("reviewer-a"), "{error}");
4311 assert!(error.contains("reviewer-b"), "{error}");
4312 assert!(error.contains("heal.toml"), "{error}");
4313 }
4314
4315 #[tokio::test]
4316 async fn scripted_loop_repairs_after_red_checks() {
4317 let dir = tempfile::tempdir().unwrap();
4318 let executor = WorktreeExecutor::new(dir.path());
4319 let sink = EventSink::test_sink();
4320 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4321
4322 let script = Script {
4325 turns: vec![
4326 turn(
4327 "",
4328 serde_json::json!([{
4329 "id": "c1", "name": "write_file",
4330 "arguments": {"path": "x.txt", "content": "wrong"}
4331 }]),
4332 ),
4333 turn("done", serde_json::json!([])),
4334 turn(
4335 "",
4336 serde_json::json!([{
4337 "id": "c2", "name": "write_file",
4338 "arguments": {"path": "x.txt", "content": "right"}
4339 }]),
4340 ),
4341 turn("fixed", serde_json::json!([])),
4342 ],
4343 cursor: AtomicUsize::new(0),
4344 seen: std::sync::Mutex::new(Vec::new()),
4345 };
4346 let contract = OutcomeContract {
4347 description: "x.txt says right".into(),
4348 checks: vec![ContractCheck {
4349 name: "content".into(),
4350 command: crate::coder::test_cmds::contains_or_report("right", "x.txt"),
4351 expect_exit_zero: true,
4352 output_contains: None,
4353 timeout_secs: 10,
4354 baseline: false,
4355 differential: None,
4356 }],
4357 };
4358
4359 let outcome = run_native_loop(
4360 &script,
4361 &executor,
4362 "write right into x.txt",
4363 &contract,
4364 &sink,
4365 &cancel,
4366 &NativeLoopConfig::default(),
4367 &RepairMemory::disabled(),
4368 None,
4369 )
4370 .await;
4371 assert!(outcome.passed);
4372 assert_eq!(outcome.iterations, 2, "one repair round expected");
4373 }
4374
4375 #[tokio::test]
4381 async fn f2_iteration_two_carries_iteration_one_conversation() {
4382 use std::sync::Mutex as StdMutex;
4383
4384 struct MsgCapture {
4385 seen: Arc<StdMutex<Vec<String>>>,
4386 cursor: AtomicUsize,
4387 }
4388 #[async_trait]
4389 impl TurnGenerator for MsgCapture {
4390 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
4391 self.seen
4392 .lock()
4393 .unwrap()
4394 .push(serde_json::to_string(&req.messages).unwrap_or_default());
4395 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
4396 match i {
4397 0 => Ok(turn(
4398 "",
4399 serde_json::json!([{
4400 "id": "iter1call", "name": "write_file",
4401 "arguments": {"path": "x.txt", "content": "ITER1_WRONG"}
4402 }]),
4403 )),
4404 1 => Ok(turn("done", serde_json::json!([]))),
4405 2 => Ok(turn(
4406 "",
4407 serde_json::json!([{
4408 "id": "iter2call", "name": "write_file",
4409 "arguments": {"path": "x.txt", "content": "ITER2_right"}
4410 }]),
4411 )),
4412 _ => Ok(turn("fixed", serde_json::json!([]))),
4413 }
4414 }
4415 }
4416
4417 let dir = tempfile::tempdir().unwrap();
4418 let executor = WorktreeExecutor::new(dir.path());
4419 let sink = EventSink::test_sink();
4420 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4421 let seen = Arc::new(StdMutex::new(Vec::new()));
4422 let gen = MsgCapture {
4423 seen: seen.clone(),
4424 cursor: AtomicUsize::new(0),
4425 };
4426 let contract = OutcomeContract {
4427 description: "x.txt says ITER2_right".into(),
4428 checks: vec![ContractCheck {
4429 name: "content".into(),
4430 command: crate::coder::test_cmds::contains("ITER2_right", "x.txt"),
4431 expect_exit_zero: true,
4432 output_contains: None,
4433 timeout_secs: 10,
4434 baseline: false,
4435 differential: None,
4436 }],
4437 };
4438
4439 let outcome = run_native_loop(
4440 &gen,
4441 &executor,
4442 "write ITER2_right into x.txt",
4443 &contract,
4444 &sink,
4445 &cancel,
4446 &NativeLoopConfig::default(),
4447 &RepairMemory::disabled(),
4448 None,
4449 )
4450 .await;
4451
4452 assert!(outcome.passed);
4453 assert_eq!(outcome.iterations, 2, "expected a repair round");
4454 let seen = seen.lock().unwrap();
4455 assert!(
4456 seen.len() >= 4,
4457 "expected >=4 inferences, got {}",
4458 seen.len()
4459 );
4460 assert!(
4463 seen[2].contains("ITER1_WRONG") || seen[2].contains("iter1call"),
4464 "F2: iteration 2 lost iteration 1's conversation:\n{}",
4465 seen[2]
4466 );
4467 }
4468
4469 #[tokio::test]
4477 async fn a_nomination_exits_the_loop_unjudged() {
4478 let dir = tempfile::tempdir().unwrap();
4479 let executor = WorktreeExecutor::new(dir.path());
4480 let sink = EventSink::test_sink();
4481 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4482
4483 let script = Script {
4484 turns: vec![
4485 turn(
4486 "this is already handled",
4487 serde_json::json!([{
4488 "id": "n1",
4489 "name": REPORT_NO_CHANGE_TOOL,
4490 "arguments": {
4491 "kind": "premise_wrong",
4492 "summary": "the handler already covers the empty case",
4493 "evidence": "read handler.rs:88 and ran the suite green"
4494 }
4495 }]),
4496 ),
4497 turn("should not be reached", serde_json::json!([])),
4499 ],
4500 cursor: AtomicUsize::new(0),
4501 seen: std::sync::Mutex::new(Vec::new()),
4502 };
4503 let contract = OutcomeContract {
4504 description: "noop".into(),
4505 checks: vec![],
4506 };
4507 let cfg = NativeLoopConfig {
4508 can_adjudicate_no_change: true,
4509 ..Default::default()
4510 };
4511
4512 let outcome = run_native_loop(
4513 &script,
4514 &executor,
4515 "fix the empty case",
4516 &contract,
4517 &sink,
4518 &cancel,
4519 &cfg,
4520 &RepairMemory::disabled(),
4521 None,
4522 )
4523 .await;
4524
4525 let nomination = outcome
4526 .nomination
4527 .expect("the finding must survive out of the loop");
4528 assert_eq!(nomination.kind, NoChangeKind::PremiseWrong);
4529 assert!(nomination.summary.contains("already covers"));
4530 assert!(nomination.evidence.contains("handler.rs:88"));
4531 assert!(!outcome.passed, "no diff, so not green");
4532 assert!(
4533 outcome.failure.is_none(),
4534 "a nomination is not a loss — booking it as one is the whole defect"
4535 );
4536 assert_eq!(
4537 script.cursor.load(Ordering::SeqCst),
4538 1,
4539 "the loop kept going after the nomination"
4540 );
4541 }
4542
4543 #[tokio::test]
4547 async fn a_nomination_from_an_unprepared_caller_is_not_honoured() {
4548 let dir = tempfile::tempdir().unwrap();
4549 let executor = WorktreeExecutor::new(dir.path());
4550 let sink = EventSink::test_sink();
4551 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4552
4553 let script = Script {
4554 turns: vec![
4555 turn(
4556 "declaring no change",
4557 serde_json::json!([{
4558 "id": "n1",
4559 "name": REPORT_NO_CHANGE_TOOL,
4560 "arguments": {
4561 "kind": "premise_wrong",
4562 "summary": "s",
4563 "evidence": "e"
4564 }
4565 }]),
4566 ),
4567 turn("giving up", serde_json::json!([])),
4568 ],
4569 cursor: AtomicUsize::new(0),
4570 seen: std::sync::Mutex::new(Vec::new()),
4571 };
4572 let contract = OutcomeContract {
4573 description: "noop".into(),
4574 checks: vec![],
4575 };
4576
4577 let outcome = run_native_loop(
4578 &script,
4579 &executor,
4580 "fix it",
4581 &contract,
4582 &sink,
4583 &cancel,
4584 &NativeLoopConfig::default(),
4586 &RepairMemory::disabled(),
4587 None,
4588 )
4589 .await;
4590
4591 assert!(
4592 outcome.nomination.is_none(),
4593 "a caller that cannot judge a nomination must never receive one"
4594 );
4595 }
4596
4597 #[tokio::test]
4602 async fn f3_truncated_turn_is_not_treated_as_done() {
4603 let dir = tempfile::tempdir().unwrap();
4604 let executor = WorktreeExecutor::new(dir.path());
4605 let sink = EventSink::test_sink();
4606 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4607
4608 let script = Script {
4609 turns: vec![
4610 turn_with_stop(
4612 "partial output that got cut o",
4613 serde_json::json!([]),
4614 Some("length"),
4615 ),
4616 turn_with_stop("done for real", serde_json::json!([]), Some("stop")),
4618 ],
4619 cursor: AtomicUsize::new(0),
4620 seen: std::sync::Mutex::new(Vec::new()),
4621 };
4622 let contract = OutcomeContract {
4623 description: "noop".into(),
4624 checks: vec![],
4625 };
4626
4627 let _ = run_native_loop(
4628 &script,
4629 &executor,
4630 "do the thing",
4631 &contract,
4632 &sink,
4633 &cancel,
4634 &NativeLoopConfig::default(),
4635 &RepairMemory::disabled(),
4636 None,
4637 )
4638 .await;
4639
4640 assert_eq!(
4643 script.cursor.load(Ordering::SeqCst),
4644 2,
4645 "truncated turn was mistaken for completion — loop stopped early instead of continuing"
4646 );
4647 }
4648
4649 #[tokio::test]
4653 async fn repair_round_learns_and_recalls_across_sessions() {
4654 use crate::coder::skill_memory::FailureSignature;
4655 use car_memgine::MemgineEngine;
4656 use tokio::sync::Mutex as AsyncMutex;
4657
4658 let memory = RepairMemory::new(Some(Arc::new(AsyncMutex::new(MemgineEngine::new(None)))));
4660
4661 let contract = OutcomeContract {
4664 description: "x.txt says right".into(),
4665 checks: vec![ContractCheck {
4666 name: "content".into(),
4667 command: crate::coder::test_cmds::contains_or_report("right", "x.txt"),
4668 expect_exit_zero: true,
4669 output_contains: None,
4670 timeout_secs: 10,
4671 baseline: false,
4672 differential: None,
4673 }],
4674 };
4675 let sig = FailureSignature {
4676 check: "content".into(),
4677 error_class: "test_failure".into(),
4678 };
4679
4680 let dir1 = tempfile::tempdir().unwrap();
4682 let exec1 = WorktreeExecutor::new(dir1.path());
4683 let sink = EventSink::test_sink();
4684 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4685 let script1 = Script {
4686 turns: vec![
4687 turn(
4688 "",
4689 serde_json::json!([{
4690 "id": "c1", "name": "write_file",
4691 "arguments": {"path": "x.txt", "content": "wrong"}
4692 }]),
4693 ),
4694 turn("nothing useful yet", serde_json::json!([])),
4695 turn(
4696 "",
4697 serde_json::json!([{
4698 "id": "c2", "name": "write_file",
4699 "arguments": {"path": "x.txt", "content": "right"}
4700 }]),
4701 ),
4702 turn(
4703 "wrote 'right' into x.txt to satisfy the grep",
4704 serde_json::json!([]),
4705 ),
4706 ],
4707 cursor: AtomicUsize::new(0),
4708 seen: std::sync::Mutex::new(Vec::new()),
4709 };
4710 let outcome1 = run_native_loop(
4711 &script1,
4712 &exec1,
4713 "write right into x.txt",
4714 &contract,
4715 &sink,
4716 &cancel,
4717 &NativeLoopConfig::default(),
4718 &memory,
4719 None,
4720 )
4721 .await;
4722 assert!(outcome1.passed);
4723 let recalled = memory
4725 .recall(&sig)
4726 .await
4727 .expect("session 1 should have learned");
4728 assert!(recalled.contains("right"), "approach captured: {recalled}");
4729
4730 let dir2 = tempfile::tempdir().unwrap();
4733 let exec2 = WorktreeExecutor::new(dir2.path());
4734 let (sink2, collected) = EventSink::collecting("coder-learn");
4735 let seen_hint = Arc::new(std::sync::atomic::AtomicBool::new(false));
4736
4737 struct HintWatcher {
4740 seen: Arc<std::sync::atomic::AtomicBool>,
4741 cursor: AtomicUsize,
4742 }
4743 #[async_trait]
4744 impl TurnGenerator for HintWatcher {
4745 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
4746 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
4747 let saw_hint = req
4748 .messages
4749 .as_ref()
4750 .map(|ms| {
4751 ms.iter().any(
4752 |m| matches!(m, Message::User { content } if content.contains("HINT")),
4753 )
4754 })
4755 .unwrap_or(false);
4756 if saw_hint {
4757 self.seen.store(true, Ordering::SeqCst);
4758 }
4759 Ok(match i {
4760 0 => turn("did nothing", serde_json::json!([])),
4762 1 => turn(
4764 "",
4765 serde_json::json!([{
4766 "id": "c1", "name": "write_file",
4767 "arguments": {"path": "x.txt", "content": "right"}
4768 }]),
4769 ),
4770 _ => turn("applied the recalled fix", serde_json::json!([])),
4771 })
4772 }
4773 }
4774
4775 let script2 = HintWatcher {
4776 seen: seen_hint.clone(),
4777 cursor: AtomicUsize::new(0),
4778 };
4779 let outcome2 = run_native_loop(
4780 &script2,
4781 &exec2,
4782 "write right into x.txt",
4783 &contract,
4784 &sink2,
4785 &cancel,
4786 &NativeLoopConfig::default(),
4787 &memory,
4788 None,
4789 )
4790 .await;
4791 assert!(outcome2.passed, "session 2 should pass: {outcome2:?}");
4792 assert!(
4793 seen_hint.load(Ordering::SeqCst),
4794 "the recalled hint must have been injected into the repair prompt"
4795 );
4796 drop(collected);
4797 }
4798
4799 #[tokio::test]
4803 async fn ask_user_tool_routes_to_handler_and_answer_reaches_model() {
4804 use std::sync::Mutex as StdMutex;
4805
4806 let dir = tempfile::tempdir().unwrap();
4807 let executor = WorktreeExecutor::new(dir.path());
4808 let (sink, collected) = EventSink::collecting("coder-ask");
4809 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4810
4811 struct CannedAsker {
4814 seen_prompt: Arc<StdMutex<Option<String>>>,
4815 answer: String,
4816 }
4817 #[async_trait]
4818 impl AskUser for CannedAsker {
4819 async fn ask(&self, prompt: &str) -> Result<String, String> {
4820 *self.seen_prompt.lock().unwrap() = Some(prompt.to_string());
4821 Ok(self.answer.clone())
4822 }
4823 }
4824 let seen_prompt = Arc::new(StdMutex::new(None));
4825 let asker = CannedAsker {
4826 seen_prompt: seen_prompt.clone(),
4827 answer: "use port 8080".to_string(),
4828 };
4829
4830 struct AskThenWrite {
4834 cursor: AtomicUsize,
4835 }
4836 #[async_trait]
4837 impl TurnGenerator for AskThenWrite {
4838 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
4839 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
4840 match i {
4841 0 => Ok(turn(
4842 "",
4843 serde_json::json!([{
4844 "id": "a1", "name": "ask_user",
4845 "arguments": {"prompt": "which port?"}
4846 }]),
4847 )),
4848 1 => {
4849 let answer = req
4851 .messages
4852 .as_ref()
4853 .and_then(|ms| {
4854 ms.iter().rev().find_map(|m| match m {
4855 Message::ToolResult { content, .. } => Some(content.clone()),
4856 _ => None,
4857 })
4858 })
4859 .unwrap_or_default();
4860 Ok(turn(
4861 "",
4862 serde_json::json!([{
4863 "id": "w1", "name": "write_file",
4864 "arguments": {"path": "answer.txt", "content": answer}
4865 }]),
4866 ))
4867 }
4868 _ => Ok(turn("done", serde_json::json!([]))),
4869 }
4870 }
4871 }
4872
4873 let contract = OutcomeContract {
4874 description: "answer.txt records the chosen port".into(),
4875 checks: vec![ContractCheck {
4876 name: "has_port".into(),
4877 command: crate::coder::test_cmds::contains("8080", "answer.txt"),
4878 expect_exit_zero: true,
4879 output_contains: None,
4880 timeout_secs: 10,
4881 baseline: false,
4882 differential: None,
4883 }],
4884 };
4885
4886 let outcome = run_native_loop(
4887 &AskThenWrite {
4888 cursor: AtomicUsize::new(0),
4889 },
4890 &executor,
4891 "pick a port and record it",
4892 &contract,
4893 &sink,
4894 &cancel,
4895 &NativeLoopConfig::default(),
4896 &RepairMemory::disabled(),
4897 Some(&asker),
4898 )
4899 .await;
4900
4901 assert!(outcome.passed, "outcome: {outcome:?}");
4902 assert_eq!(seen_prompt.lock().unwrap().as_deref(), Some("which port?"));
4904 assert_eq!(
4906 std::fs::read_to_string(dir.path().join("answer.txt")).unwrap(),
4907 "use port 8080"
4908 );
4909 let events = collected.lock().unwrap();
4913 assert!(events.iter().any(|e| matches!(
4914 &e.kind,
4915 CoderEventKind::ToolCall { tool, .. } if tool == ASK_USER_TOOL
4916 )));
4917 }
4918
4919 #[tokio::test]
4922 async fn ask_user_without_handler_is_a_recoverable_error() {
4923 let dir = tempfile::tempdir().unwrap();
4924 let executor = WorktreeExecutor::new(dir.path());
4925 let (sink, _collected) = EventSink::collecting("coder-noask");
4926 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4927
4928 struct ToolPeek {
4930 offered: Arc<std::sync::atomic::AtomicBool>,
4931 }
4932 #[async_trait]
4933 impl TurnGenerator for ToolPeek {
4934 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
4935 let has_ask = req
4936 .tools
4937 .as_ref()
4938 .map(|ts| ts.iter().any(|t| t["name"] == ASK_USER_TOOL))
4939 .unwrap_or(false);
4940 self.offered.store(has_ask, Ordering::SeqCst);
4941 Ok(turn("done", serde_json::json!([])))
4942 }
4943 }
4944 let offered_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4945 let contract = OutcomeContract {
4946 description: "noop".into(),
4947 checks: vec![ContractCheck {
4948 name: "ok".into(),
4949 command: crate::coder::test_cmds::PASS.to_string(),
4950 expect_exit_zero: true,
4951 output_contains: None,
4952 timeout_secs: 10,
4953 baseline: false,
4954 differential: None,
4955 }],
4956 };
4957 let _ = run_native_loop(
4958 &ToolPeek {
4959 offered: offered_flag.clone(),
4960 },
4961 &executor,
4962 "x",
4963 &contract,
4964 &sink,
4965 &cancel,
4966 &NativeLoopConfig::default(),
4967 &RepairMemory::disabled(),
4968 None,
4969 )
4970 .await;
4971 assert!(
4972 !offered_flag.load(Ordering::SeqCst),
4973 "ask_user must not be offered when no handler is wired"
4974 );
4975 }
4976
4977 #[tokio::test]
4978 async fn cancellation_stops_the_loop() {
4979 let dir = tempfile::tempdir().unwrap();
4980 let executor = WorktreeExecutor::new(dir.path());
4981 let sink = EventSink::test_sink();
4982 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(true));
4983 let script = Script {
4984 turns: vec![],
4985 cursor: AtomicUsize::new(0),
4986 seen: std::sync::Mutex::new(Vec::new()),
4987 };
4988 let contract = OutcomeContract {
4989 description: "d".into(),
4990 checks: vec![ContractCheck {
4991 name: "never".into(),
4992 command: crate::coder::test_cmds::PASS.to_string(),
4993 expect_exit_zero: true,
4994 output_contains: None,
4995 timeout_secs: 10,
4996 baseline: false,
4997 differential: None,
4998 }],
4999 };
5000 let outcome = run_native_loop(
5001 &script,
5002 &executor,
5003 "x",
5004 &contract,
5005 &sink,
5006 &cancel,
5007 &NativeLoopConfig::default(),
5008 &RepairMemory::disabled(),
5009 None,
5010 )
5011 .await;
5012 assert_eq!(outcome.error.as_deref(), Some("cancelled"));
5013 assert_eq!(outcome.iterations, 0);
5014 }
5015
5016 #[test]
5017 fn failure_feedback_lists_only_failures() {
5018 let results = vec![
5019 CheckResult {
5020 name: "good".into(),
5021 passed: true,
5022 exit_code: Some(0),
5023 output_tail: "ok".into(),
5024 duration_ms: 1,
5025 timed_out: false,
5026 deadline_clamped: false,
5027 },
5028 CheckResult {
5029 name: "bad".into(),
5030 passed: false,
5031 exit_code: Some(1),
5032 output_tail: "assertion failed".into(),
5033 duration_ms: 1,
5034 timed_out: false,
5035 deadline_clamped: false,
5036 },
5037 ];
5038 let fb = failure_feedback(&results, 0);
5039 assert!(fb.contains("FAILED bad"));
5040 assert!(fb.contains("assertion failed"));
5041 assert!(!fb.contains("FAILED good"));
5042 assert!(fb.contains("name the single cause"));
5044 assert!(!fb.contains("failed 2 times in a row"));
5045 }
5046
5047 #[test]
5048 fn failure_feedback_escalates_on_a_recurring_failure() {
5049 let results = vec![CheckResult {
5050 name: "run_tests".into(),
5051 passed: false,
5052 exit_code: Some(1),
5053 output_tail: "AttributeError: no attribute '_remove_slot_root'".into(),
5054 duration_ms: 1,
5055 timed_out: false,
5056 deadline_clamped: false,
5057 }];
5058 let fb = failure_feedback(&results, 1);
5061 assert!(fb.contains("failed the same way 2 times"), "{fb}");
5062 assert!(fb.contains("do NOT re-apply a variation"));
5063 assert!(fb.contains("IMPLEMENT it"));
5064 }
5065
5066 #[test]
5067 fn system_prompt_carries_the_contract() {
5068 let contract = OutcomeContract {
5069 description: "make the tests pass".into(),
5070 checks: vec![super::super::contract::ContractCheck {
5071 name: "tests".into(),
5072 command: "cargo test -p demo".into(),
5073 expect_exit_zero: true,
5074 output_contains: None,
5075 timeout_secs: 300,
5076 baseline: false,
5077 differential: None,
5078 }],
5079 };
5080 let p = system_prompt(&contract, "Top-level entries: Cargo.toml, src", None);
5081 assert!(p.contains("cargo test -p demo"));
5082 assert!(p.contains("STOP calling tools"));
5083 assert!(p.contains("EXACT command(s) from the OUTCOME CONTRACT"));
5086 }
5087
5088 #[test]
5089 fn coder_prompt_contains_discipline_and_keeps_stop_contract() {
5090 let contract = OutcomeContract {
5091 description: "make the tests pass".into(),
5092 checks: vec![ContractCheck {
5093 name: "tests".into(),
5094 command: "cargo test -p demo".into(),
5095 expect_exit_zero: true,
5096 output_contains: None,
5097 timeout_secs: 300,
5098 baseline: false,
5099 differential: None,
5100 }],
5101 };
5102 let env = "Top-level entries: Cargo.toml, src\nBuild systems detected: Rust (cargo)";
5103 let p = system_prompt(&contract, env, None);
5104
5105 assert!(p.contains("Inspect before you edit"), "inspect-first");
5107 assert!(
5108 p.contains("grep_files") && p.contains("find_files"),
5109 "search-before-read discipline"
5110 );
5111 assert!(p.contains("prefer edit_file"), "surgical-edit discipline");
5112 assert!(
5113 p.contains("Never fabricate file contents"),
5114 "anti-fabrication (files)"
5115 );
5116 assert!(
5117 p.contains("Never claim a check passed"),
5118 "anti-fabrication (results)"
5119 );
5120 assert!(
5121 p.contains("read the actual error output before retrying"),
5122 "read-the-error discipline"
5123 );
5124 assert!(
5126 p.contains("is NOT a task failure") && p.contains("blocked"),
5127 "blocked-verification-is-not-failure guidance"
5128 );
5129 assert!(
5135 p.contains("Trace the checks before you declare done"),
5136 "check-tracing guidance"
5137 );
5138 assert!(
5139 !p.contains("set()"),
5140 "no eval-specific correctness hints in the global prompt"
5141 );
5142 assert!(
5150 p.contains("copy the command string character-for-character"),
5151 "exact-command self-verify (no broad substitute)"
5152 );
5153 assert!(
5154 p.contains("The environment is not yours to fix") && p.contains("denied by policy"),
5155 "environment repair: judgment in the prompt, enforcement in policy"
5156 );
5157 assert!(
5158 !p.contains("STRICTLY FORBIDDEN"),
5159 "the enumerated prose blacklist moved to the inspector chain"
5160 );
5161
5162 assert!(p.contains("do not rely on it: verify the checks yourself first"));
5165
5166 assert!(
5168 p.contains("reply with a brief plain-text summary and STOP calling tools"),
5169 "the STOP-calling-tools loop-termination contract must survive verbatim"
5170 );
5171 assert!(p.contains("Do not git commit"), "policy: no git commit");
5175 assert!(
5180 p.contains("gh pr create") && p.contains("the runtime opens the"),
5181 "publication is denied by any route, and the runtime does the publishing"
5182 );
5183 assert!(
5184 p.contains("Read-only forge commands"),
5185 "the allowed half of the forge guard must be stated, not just the denied half"
5186 );
5187 assert!(p.contains("ENVIRONMENT:"));
5189 assert!(p.contains("Rust (cargo)"));
5190 assert!(p.contains("cargo test -p demo"));
5192 }
5193
5194 #[test]
5195 fn preview_truncates_on_char_boundary() {
5196 assert_eq!(preview("short", 10), "short");
5197 let long = "é".repeat(300);
5198 let p = preview(&long, 5);
5199 assert!(p.ends_with('…') && p.chars().count() <= 4);
5200 }
5201
5202 struct FirstUserCapture {
5206 captured: Arc<std::sync::Mutex<String>>,
5207 }
5208 #[async_trait]
5209 impl TurnGenerator for FirstUserCapture {
5210 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5211 let first_user = req
5212 .messages
5213 .as_ref()
5214 .and_then(|ms| {
5215 ms.iter().find_map(|m| match m {
5216 Message::User { content } => Some(content.clone()),
5217 _ => None,
5218 })
5219 })
5220 .unwrap_or_default();
5221 *self.captured.lock().unwrap() = first_user;
5222 Ok(turn("done", serde_json::json!([])))
5223 }
5224 }
5225
5226 fn trivial_contract() -> OutcomeContract {
5227 OutcomeContract {
5228 description: "trivial".into(),
5229 checks: vec![ContractCheck {
5230 name: "ok".into(),
5231 command: crate::coder::test_cmds::PASS.to_string(),
5232 expect_exit_zero: true,
5233 output_contains: None,
5234 timeout_secs: 10,
5235 baseline: false,
5236 differential: None,
5237 }],
5238 }
5239 }
5240
5241 #[tokio::test]
5242 async fn coder_first_message_carries_recall_when_facts_exist() {
5243 use crate::coder::skill_memory::FailureSignature;
5244 use car_memgine::MemgineEngine;
5245 use tokio::sync::Mutex as AsyncMutex;
5246
5247 let memory = RepairMemory::new(Some(Arc::new(AsyncMutex::new(MemgineEngine::new(None)))));
5250 let sig = FailureSignature {
5251 check: "tests".into(),
5252 error_class: "test_failure".into(),
5253 };
5254 memory
5255 .record_success(&sig, "add the missing import and re-run cargo test")
5256 .await;
5257
5258 let dir = tempfile::tempdir().unwrap();
5259 let executor = WorktreeExecutor::new(dir.path());
5260 let sink = EventSink::test_sink();
5261 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5262 let captured = Arc::new(std::sync::Mutex::new(String::new()));
5263
5264 let outcome = run_native_loop(
5265 &FirstUserCapture {
5266 captured: captured.clone(),
5267 },
5268 &executor,
5269 "the tests are failing, please fix them",
5270 &trivial_contract(),
5271 &sink,
5272 &cancel,
5273 &NativeLoopConfig::default(),
5274 &memory,
5275 None,
5276 )
5277 .await;
5278 assert!(outcome.passed, "outcome: {outcome:?}");
5279
5280 let first_user = captured.lock().unwrap().clone();
5281 assert!(
5282 first_user.contains("Recall from prior sessions"),
5283 "the labelled session-start recall must be in the first user turn: {first_user}"
5284 );
5285 assert!(
5286 first_user.contains("missing import"),
5287 "the recalled approach content rides along: {first_user}"
5288 );
5289 }
5290
5291 #[tokio::test]
5292 async fn coder_first_message_recall_absent_when_empty() {
5293 use car_memgine::MemgineEngine;
5294 use tokio::sync::Mutex as AsyncMutex;
5295
5296 let memory = RepairMemory::new(Some(Arc::new(AsyncMutex::new(MemgineEngine::new(None)))));
5299
5300 let dir = tempfile::tempdir().unwrap();
5301 let executor = WorktreeExecutor::new(dir.path());
5302 let sink = EventSink::test_sink();
5303 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5304 let captured = Arc::new(std::sync::Mutex::new(String::new()));
5305
5306 let outcome = run_native_loop(
5307 &FirstUserCapture {
5308 captured: captured.clone(),
5309 },
5310 &executor,
5311 "the tests are failing, please fix them",
5312 &trivial_contract(),
5313 &sink,
5314 &cancel,
5315 &NativeLoopConfig::default(),
5316 &memory,
5317 None,
5318 )
5319 .await;
5320 assert!(outcome.passed, "outcome: {outcome:?}");
5321
5322 let first_user = captured.lock().unwrap().clone();
5323 assert!(
5324 !first_user.contains("Recall from prior sessions"),
5325 "no recall section when the engine has nothing relevant: {first_user}"
5326 );
5327 }
5328}