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 InferenceRetryProgress,
22};
23use serde_json::Value;
24
25use super::budget::SessionDeadline;
26use super::contract::{evaluate_contract_with_baselines, CheckResult, OutcomeContract};
27use super::session::{CancelFlag, CoderEventKind, EventSink, NoChangeKind, NoChangeNomination};
28use super::shell_tool::WorktreeExecutor;
29use super::skill_memory::{FailureSignature, RepairMemory};
30use crate::assistant::agent_loop::{compact_history_to_window, history_budget};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum InferenceFailureKind {
37 LocalResourceBlocked,
38 CredentialUnavailable,
39 ProviderAccount,
40 ProviderKeyMissing,
41 GatewayUnconfigured,
42 NoBackend,
43 NoEligibleModel,
44 WorkspaceRequired,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum TurnGenerationError {
53 NoEligibleModel { excluded_models: String },
55 NonRetryableInference {
58 kind: InferenceFailureKind,
59 recovery: String,
60 },
61 Other(String),
63}
64
65impl TurnGenerationError {
66 pub fn terminal_inference(&self) -> Option<(InferenceFailureKind, String)> {
68 match self {
69 Self::NoEligibleModel { excluded_models } => Some((
75 InferenceFailureKind::NoEligibleModel,
76 InferenceError::NoEligibleModel {
77 excluded_models: excluded_models.clone(),
78 }
79 .to_string(),
80 )),
81 Self::NonRetryableInference { kind, recovery } => Some((*kind, recovery.clone())),
82 Self::Other(_) => None,
83 }
84 }
85}
86
87impl std::fmt::Display for TurnGenerationError {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 match self {
90 Self::NoEligibleModel { excluded_models } => write!(
91 f,
92 "no eligible model remains after strict exclusions: {}",
93 excluded_models
94 ),
95 Self::NonRetryableInference { recovery, .. } | Self::Other(recovery) => {
96 f.write_str(recovery)
97 }
98 }
99 }
100}
101
102impl From<InferenceError> for TurnGenerationError {
103 fn from(error: InferenceError) -> Self {
104 match error {
105 InferenceError::NoEligibleModel { excluded_models } => {
106 Self::NoEligibleModel { excluded_models }
107 }
108 InferenceError::LocalResourceBlocked { recovery, .. } => Self::NonRetryableInference {
109 kind: InferenceFailureKind::LocalResourceBlocked,
110 recovery,
111 },
112 error @ InferenceError::CredentialUnavailable {
113 reason: car_inference::CredentialFailure::RaceRetryable,
114 ..
115 } => Self::Other(error.to_string()),
116 error @ InferenceError::CredentialUnavailable { .. } => Self::NonRetryableInference {
117 kind: InferenceFailureKind::CredentialUnavailable,
118 recovery: error.to_string(),
119 },
120 error @ InferenceError::ProviderAccount { .. } => Self::NonRetryableInference {
121 kind: InferenceFailureKind::ProviderAccount,
122 recovery: error.to_string(),
123 },
124 error @ InferenceError::ProviderKeyMissing { .. } => Self::NonRetryableInference {
134 kind: InferenceFailureKind::ProviderKeyMissing,
135 recovery: error.to_string(),
136 },
137 error @ InferenceError::GatewayUnconfigured { .. } => Self::NonRetryableInference {
138 kind: InferenceFailureKind::GatewayUnconfigured,
139 recovery: error.to_string(),
140 },
141 error @ InferenceError::WorkspaceRequired { .. } => Self::NonRetryableInference {
148 kind: InferenceFailureKind::WorkspaceRequired,
149 recovery: error.to_string(),
150 },
151 error @ InferenceError::ModelNotFound(_) => Self::NonRetryableInference {
152 kind: InferenceFailureKind::NoBackend,
153 recovery: error.to_string(),
154 },
155 InferenceError::InferenceFailed(message)
156 if is_no_backend_inference_failure(&message) =>
157 {
158 Self::NonRetryableInference {
159 kind: InferenceFailureKind::NoBackend,
160 recovery: format!("inference failed: {message}"),
161 }
162 }
163 error @ InferenceError::DownloadFailed(_) => Self::Other(error.to_string()),
171 error @ InferenceError::InferenceFailed(_) => Self::Other(error.to_string()),
174 error @ InferenceError::CatalogPreconditionMismatch { .. } => {
177 Self::Other(error.to_string())
178 }
179 error @ InferenceError::ControlledTermination => Self::Other(error.to_string()),
183 error @ InferenceError::ModelManagement(_) => Self::Other(error.to_string()),
186 error @ InferenceError::Transient { .. } => Self::Other(error.to_string()),
188 error @ InferenceError::DeadlineExceeded { .. } => Self::Other(error.to_string()),
191 error @ InferenceError::UnsupportedMode { .. } => Self::Other(error.to_string()),
195 error @ InferenceError::ContentRefused { .. } => Self::Other(error.to_string()),
198 error @ InferenceError::TokenizationError(_) => Self::Other(error.to_string()),
200 error @ InferenceError::DeviceError(_) => Self::Other(error.to_string()),
202 error @ InferenceError::Io(_) => Self::Other(error.to_string()),
204 }
205 }
206}
207
208fn is_no_backend_inference_failure(message: &str) -> bool {
216 message.contains(car_inference::NO_BACKEND_RECOVERY_MARKER)
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum AssistantGenerateError {
234 CredentialUnavailable {
237 provider: String,
238 reason: car_inference::CredentialFailure,
239 message: String,
240 },
241 WorkspaceRequired { provider: String, message: String },
243 Transient {
247 status: Option<u16>,
248 message: String,
249 },
250 Other(String),
252}
253
254impl std::fmt::Display for AssistantGenerateError {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 match self {
257 Self::CredentialUnavailable { message, .. }
258 | Self::WorkspaceRequired { message, .. }
259 | Self::Transient { message, .. }
260 | Self::Other(message) => f.write_str(message),
261 }
262 }
263}
264
265impl From<InferenceError> for AssistantGenerateError {
266 fn from(error: InferenceError) -> Self {
267 let message = error.to_string();
268 match error {
269 InferenceError::CredentialUnavailable {
270 provider, reason, ..
271 } => Self::CredentialUnavailable {
272 provider,
273 reason,
274 message,
275 },
276 InferenceError::WorkspaceRequired { provider, .. } => {
277 Self::WorkspaceRequired { provider, message }
278 }
279 InferenceError::Transient { status, .. } => Self::Transient { status, message },
280 _ => Self::Other(message),
285 }
286 }
287}
288
289#[async_trait]
290pub trait TurnGenerator: Send + Sync {
291 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String>;
292
293 async fn generate_coder(
297 &self,
298 req: GenerateRequest,
299 ) -> Result<InferenceResult, TurnGenerationError> {
300 self.generate(req).await.map_err(TurnGenerationError::Other)
301 }
302
303 async fn generate_coder_observed(
304 &self,
305 req: GenerateRequest,
306 _retry_observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
307 ) -> Result<InferenceResult, TurnGenerationError> {
308 self.generate_coder(req).await
309 }
310
311 async fn generate_assistant(
320 &self,
321 req: GenerateRequest,
322 ) -> Result<InferenceResult, AssistantGenerateError> {
323 self.generate(req)
324 .await
325 .map_err(AssistantGenerateError::Other)
326 }
327
328 async fn generate_assistant_observed(
331 &self,
332 req: GenerateRequest,
333 _retry_observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
334 ) -> Result<InferenceResult, AssistantGenerateError> {
335 self.generate_assistant(req).await
336 }
337
338 fn context_window(&self, _model: &str) -> usize {
343 0
344 }
345}
346
347#[async_trait]
348impl TurnGenerator for InferenceEngine {
349 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
350 self.generate_tracked(req).await.map_err(|e| e.to_string())
351 }
352
353 async fn generate_coder(
354 &self,
355 req: GenerateRequest,
356 ) -> Result<InferenceResult, TurnGenerationError> {
357 self.generate_tracked(req)
358 .await
359 .map_err(TurnGenerationError::from)
360 }
361
362 async fn generate_assistant(
363 &self,
364 req: GenerateRequest,
365 ) -> Result<InferenceResult, AssistantGenerateError> {
366 self.generate_tracked(req)
367 .await
368 .map_err(AssistantGenerateError::from)
369 }
370
371 async fn generate_coder_observed(
372 &self,
373 req: GenerateRequest,
374 retry_observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
375 ) -> Result<InferenceResult, TurnGenerationError> {
376 self.generate_tracked_with_retry_observer(req, retry_observer)
377 .await
378 .map_err(TurnGenerationError::from)
379 }
380
381 async fn generate_assistant_observed(
382 &self,
383 req: GenerateRequest,
384 retry_observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
385 ) -> Result<InferenceResult, AssistantGenerateError> {
386 self.generate_tracked_with_retry_observer(req, retry_observer)
387 .await
388 .map_err(AssistantGenerateError::from)
389 }
390
391 fn context_window(&self, model: &str) -> usize {
392 self.model_context_window(model)
393 }
394}
395
396#[derive(Debug)]
397enum NativeTurnError {
398 TimedOut,
399 Generation(TurnGenerationError),
400}
401
402async fn generate_native_turn(
403 inference: &dyn TurnGenerator,
404 req: GenerateRequest,
405 deadline: &SessionDeadline,
406 sink: &EventSink,
407) -> Result<InferenceResult, NativeTurnError> {
408 let started = tokio::time::Instant::now();
409 let mut on_retry = |retry: InferenceRetryProgress| {
410 sink.emit(CoderEventKind::InferenceRetry {
411 model: retry.model,
412 attempt: retry.attempt,
413 reason: retry.reason.into(),
414 backoff_ms: retry.backoff_ms,
415 });
416 };
417 let generation = inference.generate_coder_observed(req, &mut on_retry);
418 let bounded = async {
419 match deadline.remaining_secs() {
420 Some(left) => tokio::time::timeout(std::time::Duration::from_secs(left), generation)
421 .await
422 .map_err(|_| NativeTurnError::TimedOut)?
423 .map_err(NativeTurnError::Generation),
424 None => generation.await.map_err(NativeTurnError::Generation),
425 }
426 };
427 tokio::pin!(bounded);
428 let mut progress = tokio::time::interval_at(
429 started + std::time::Duration::from_secs(15),
430 std::time::Duration::from_secs(15),
431 );
432 progress.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
433 loop {
434 tokio::select! {
435 biased;
436 result = &mut bounded => return result,
437 _ = progress.tick() => {
438 sink.emit(CoderEventKind::InferenceWaiting { elapsed_secs: started.elapsed().as_secs() });
439 }
440 }
441 }
442}
443
444#[async_trait]
454pub trait AskUser: Send + Sync {
455 async fn ask(&self, prompt: &str) -> Result<String, String>;
456}
457
458#[async_trait]
471pub trait AuthGate: Send + Sync + std::fmt::Debug {
472 async fn is_authenticated(&self) -> bool;
474}
475
476async fn wait_for_auth(
492 gate: &dyn AuthGate,
493 wait: std::time::Duration,
494 cancel: &CancelFlag,
495 deadline: &SessionDeadline,
496) -> bool {
497 const POLL: std::time::Duration = std::time::Duration::from_secs(2);
498 let started = std::time::Instant::now();
499 loop {
500 if gate.is_authenticated().await {
501 return true;
502 }
503 if cancel.load(Ordering::SeqCst) || deadline.admit().is_some() || started.elapsed() >= wait
504 {
505 return false;
506 }
507 tokio::time::sleep(POLL).await;
508 }
509}
510
511pub(crate) const MODEL_FALLBACK_REASON: &str =
515 "that lane needs sign-in (`car auth login`) — this run is continuing on a fallback model";
516
517pub(crate) fn fallback_reason_label(reason: car_inference::FallbackReason) -> &'static str {
522 use car_inference::FallbackReason as R;
523 match reason {
527 R::CredentialRejected => "credential_rejected",
528 R::CredentialAbsent => "credential_absent",
529 R::RateLimited => "rate_limited",
530 R::QuotaExhausted => "quota_exhausted",
531 R::TimedOut => "timed_out",
532 R::Failed => "failed",
533 }
534}
535
536pub(crate) fn is_auth_failure(message: &str) -> bool {
544 car_inference::is_auth_failure_message(message)
545}
546
547pub const ASK_USER_TOOL: &str = "ask_user";
551
552pub const REPORT_NO_CHANGE_TOOL: &str = "report_no_change";
555
556const NO_PROGRESS_REPEAT_LIMIT: u32 = 6;
565
566fn is_read_only_tool(name: &str) -> bool {
581 matches!(name, "read_file" | "list_dir" | "find_files" | "grep_files")
582}
583
584fn report_no_change_tool_def() -> Value {
593 serde_json::json!({
594 "name": REPORT_NO_CHANGE_TOOL,
595 "description": "Conclude that NO code should change, and end the session by \
596 reporting that instead of producing a diff. Use this only when \
597 you have investigated and established one of: the reported \
598 problem does not exist (the code already handles it); the \
599 behaviour is intentional; or the real fix is not a code change \
600 at all. This is NOT a way to stop when the task is hard — a \
601 session that has already edited any file cannot use it, and \
602 reverting does not restore eligibility. Your conclusion is a \
603 nomination: the runtime checks it against the baseline results \
604 and may route it to a human rather than accept it.",
605 "parameters": {
606 "type": "object",
607 "properties": {
608 "kind": {
609 "type": "string",
610 "enum": ["premise_wrong", "deliberate_behavior", "non_code_decision"],
611 "description": "premise_wrong: the reported problem does not exist. \
612 deliberate_behavior: the code does this on purpose. \
613 non_code_decision: a real problem whose fix is a \
614 migration, an operator decision, or a rollout."
615 },
616 "summary": {
617 "type": "string",
618 "description": "One line: the conclusion itself."
619 },
620 "evidence": {
621 "type": "string",
622 "description": "What you examined to reach it — files read, commands \
623 run, what they showed. A reviewer reads this to decide \
624 whether to believe you."
625 }
626 },
627 "required": ["kind", "summary", "evidence"]
628 }
629 })
630}
631
632fn parse_nomination(params: &Value) -> Result<NoChangeNomination, String> {
638 let kind_str = params
639 .get("kind")
640 .and_then(Value::as_str)
641 .ok_or("ERROR: report_no_change requires `kind`")?;
642 let kind = NoChangeKind::parse(kind_str).ok_or_else(|| {
643 format!(
644 "ERROR: unknown kind '{kind_str}' — must be one of premise_wrong, \
645 deliberate_behavior, non_code_decision"
646 )
647 })?;
648 let summary = params
649 .get("summary")
650 .and_then(Value::as_str)
651 .ok_or("ERROR: report_no_change requires `summary`")?
652 .to_string();
653 let evidence = params
654 .get("evidence")
655 .and_then(Value::as_str)
656 .ok_or("ERROR: report_no_change requires `evidence`")?
657 .to_string();
658 Ok(NoChangeNomination {
659 kind,
660 summary,
661 evidence,
662 })
663}
664
665fn ask_user_tool_def() -> Value {
666 serde_json::json!({
667 "name": ASK_USER_TOOL,
668 "description": "Ask the human user a question and wait for their reply. \
669 Use ONLY when you genuinely cannot proceed without a \
670 decision or missing fact the user alone can supply (an \
671 ambiguous requirement, a destructive choice, a missing \
672 credential). Do not use it for things you can determine \
673 by reading the repo or running commands. The call blocks \
674 until the user answers or a timeout elapses; on timeout \
675 you receive an error and should proceed with your best \
676 judgment.",
677 "parameters": {
678 "type": "object",
679 "properties": {
680 "prompt": {
681 "type": "string",
682 "description": "The question to show the user, phrased so a short reply answers it."
683 }
684 },
685 "required": ["prompt"]
686 }
687 })
688}
689
690fn apply_operator_guidance(
691 cfg: &NativeLoopConfig,
692 messages: &mut Vec<Message>,
693 sink: &EventSink,
694) -> bool {
695 let queued = cfg
696 .steering
697 .as_ref()
698 .map(|inbox| inbox.take())
699 .unwrap_or_default();
700 let applied = !queued.is_empty();
701 for text in queued {
702 messages.push(Message::User {
703 content: format!("Operator guidance for the current task (the approved checks and execution permissions remain unchanged):\n{text}"),
704 });
705 sink.emit(CoderEventKind::OperatorGuidance {
706 text,
707 status: "applied".into(),
708 });
709 }
710 applied
711}
712
713#[derive(Debug, Clone)]
715pub struct NativeLoopConfig {
716 pub steering: Option<Arc<super::steering::SteeringInbox>>,
717 pub model: Option<String>,
719 pub exclude_models: Vec<String>,
723 pub max_iterations: u32,
725 pub max_turns_per_iteration: u32,
727 pub max_tokens_per_turn: usize,
729 pub prompt_overlay: Option<String>,
733 pub deadline: Arc<SessionDeadline>,
738 pub auth_gate: Option<Arc<dyn AuthGate>>,
741 pub can_adjudicate_no_change: bool,
757 pub auth_wait: std::time::Duration,
763 pub baseline_captures: super::contract::BaselineCaptures,
768}
769
770impl Default for NativeLoopConfig {
771 fn default() -> Self {
772 Self {
773 model: None,
774 steering: None,
775 exclude_models: Vec::new(),
776 max_iterations: 8,
777 max_turns_per_iteration: 24,
778 max_tokens_per_turn: 4096,
779 prompt_overlay: None,
780 deadline: SessionDeadline::shared_default(),
781 auth_gate: None,
782 auth_wait: std::time::Duration::from_secs(600),
783 can_adjudicate_no_change: false,
784 baseline_captures: crate::coder::contract::BaselineCaptures::new(),
785 }
786 }
787}
788
789impl NativeLoopConfig {
790 pub fn merge_harness(&mut self, h: &car_memgine::HarnessConfig) {
812 self.max_iterations = self
813 .max_iterations
814 .max(h.planning_max_replans.saturating_add(1));
815 self.max_turns_per_iteration = self.max_turns_per_iteration.max(h.max_retries);
816 self.prompt_overlay = h.prompt_overlay.clone();
820 }
821}
822
823#[derive(Debug, Clone, Copy, PartialEq, Eq)]
842pub enum LoopFailure {
843 EngineUnavailable,
847 Cancelled,
850 Infrastructure,
854 Configuration,
858 NeedsAuth,
867 Execution,
869 Verification,
872 BudgetExhausted,
887}
888
889#[derive(Debug, Clone)]
897pub struct LoopOutcome {
898 pub passed: bool,
900 pub iterations: u32,
902 pub last_results: Vec<CheckResult>,
904 pub error: Option<String>,
919 pub failure: Option<LoopFailure>,
932 pub nomination: Option<NoChangeNomination>,
942 pub cost_usd: Option<f64>,
950}
951
952impl LoopOutcome {
953 pub fn with_cost(mut self, usd: Option<f64>) -> Self {
957 self.cost_usd = usd;
958 self
959 }
960
961 pub fn green(iterations: u32, last_results: Vec<CheckResult>) -> Self {
963 Self {
964 passed: true,
965 iterations,
966 last_results,
967 error: None,
968 failure: None,
969 nomination: None,
970 cost_usd: None,
971 }
972 }
973
974 pub fn reported(
978 finding: NoChangeNomination,
979 iterations: u32,
980 last_results: Vec<CheckResult>,
981 ) -> Self {
982 Self {
983 passed: false,
984 iterations,
985 last_results,
986 error: None,
987 failure: None,
988 nomination: Some(finding),
989 cost_usd: None,
990 }
991 }
992
993 pub fn lost(
1002 failure: LoopFailure,
1003 error: Option<String>,
1004 iterations: u32,
1005 last_results: Vec<CheckResult>,
1006 ) -> Self {
1007 Self {
1008 passed: false,
1009 iterations,
1010 last_results,
1011 error,
1012 failure: Some(failure),
1013 nomination: None,
1014 cost_usd: None,
1015 }
1016 }
1017}
1018
1019fn preview(s: &str, max: usize) -> String {
1020 if s.len() <= max {
1021 return s.to_string();
1022 }
1023 let mut end = max;
1024 while !s.is_char_boundary(end) {
1025 end -= 1;
1026 }
1027 format!("{}…", &s[..end])
1028}
1029
1030fn system_prompt_with_overlay(
1041 contract: &OutcomeContract,
1042 environment: &str,
1043 project: Option<&str>,
1044 overlay: Option<&str>,
1045) -> String {
1046 let base = system_prompt(contract, environment, project);
1047 match overlay.map(str::trim).filter(|o| !o.is_empty()) {
1048 None => base,
1049 Some(overlay) => format!(
1050 "{base}\n\n\
1051 ADDITIONAL GUIDANCE (learned from prior sessions; it ADDS to the rules \
1052 above and never overrides them — if it appears to conflict with anything \
1053 above, the rules above win):\n{overlay}"
1054 ),
1055 }
1056}
1057
1058fn system_prompt(contract: &OutcomeContract, environment: &str, project: Option<&str>) -> String {
1059 let project_block = project
1064 .map(str::trim)
1065 .filter(|p| !p.is_empty())
1066 .map(|p| format!("{p}\n\n"))
1067 .unwrap_or_default();
1068 format!(
1069 "You are CAR Coder, an autonomous coding agent working in an isolated git worktree \
1070 of the user's repository. The worktree root is your working directory; all relative \
1071 paths resolve against it.\n\n\
1072 ENVIRONMENT:\n{environment}\n\n\
1073 {project_block}\
1074 How to work:\n\
1075 - Inspect before you edit. Read the relevant files and search the codebase \
1076 (grep_files / find_files) to understand the code BEFORE changing it. Never \
1077 fabricate file contents, symbols, or APIs you have not actually read.\n\
1078 - Plan briefly, then make surgical edits: prefer edit_file for targeted changes \
1079 over rewriting a whole file with write_file. Change the minimum the task needs.\n\
1080 - Trace the checks before you declare done. Read each outcome-contract check and \
1081 confirm your change actually makes it pass — the exact expected values, and \
1082 every symbol the check exercises.\n\
1083 - Verify your own work by running the EXACT command(s) from the OUTCOME CONTRACT \
1084 below, verbatim — copy the command string character-for-character (same \
1085 interpreter path, same flags, same scoped test file). Do NOT substitute a \
1086 broader or 'equivalent' command: running `python -m pytest tests/` when the \
1087 contract says `/path/to/venv/bin/python -m pytest -q tests/test_x.py` is WRONG \
1088 — a different interpreter (e.g. a system `python` that is a different version \
1089 with different installed packages) can fail on environment issues that have \
1090 nothing to do with your task. Read that command's real output before declaring \
1091 done; the contract's exact command is the only thing that decides done. Never \
1092 claim a check passed without having run its exact command this session and seen \
1093 it pass.\n\
1094 - The environment is not yours to fix. If the contract's exact command fails on \
1095 something that is not your code — a version mismatch, a missing package, an \
1096 import error in an unrelated module, a broken runner — your code fix is already \
1097 done: write your summary and STOP. The runtime re-runs the contract in the \
1098 correct environment to decide done, so turns spent making a wrong-environment \
1099 command pass cannot change the verdict. (Package installs, venv creation, and \
1100 interpreter shims are denied by policy; you will get a denial with a reason.)\n\
1101 - If the shell tool is unavailable or a command is blocked this session (e.g. a \
1102 permission-restricted runner returns an approval error instead of output), that \
1103 is NOT a task failure and NOT a reason to report the work as blocked or uncertain: \
1104 the runtime independently runs the outcome contract to decide done. Make your edits \
1105 correct, note that you could not self-run the checks, and STOP — do not retry the \
1106 blocked command in a loop.\n\
1107 - On failure, read the actual error output before retrying — fix the specific \
1108 cause the compiler or test named; do not guess-and-retry. If the error names a \
1109 missing symbol, function, or attribute, IMPLEMENT it rather than editing the \
1110 caller. If the same check fails again after an edit, your hypothesis was wrong: \
1111 re-read the exact expected-vs-actual and form a different one — do not re-apply a \
1112 variation of an edit that did not change the failure.\n\n\
1113 - Do not git commit; the runtime handles version control. Do not publish the work \
1114 yourself by any route — `git push`, `gh pr create`, `gh release create`, \
1115 `npm publish`, `cargo publish` and the like are denied; the runtime opens the \
1116 pull request itself once the merge gate is approved. Read-only forge commands \
1117 (`gh pr view`, `gh run view`, `gh api` GET) stay available. (`sudo` and \
1118 destructive operations outside the worktree are denied too. Every denial comes \
1119 back with a reason; don't retry a denied call verbatim.)\n\n\
1120 When you believe the work is complete, reply with a brief plain-text summary and \
1121 STOP calling tools. The runtime independently re-runs the outcome contract after \
1122 you stop — but do not rely on it: verify the checks yourself first, because a red \
1123 re-invocation costs a full round-trip.\n\n\
1124 OUTCOME CONTRACT (the runtime runs these to decide done):\n{}",
1125 contract.render()
1126 )
1127}
1128
1129fn failure_feedback(results: &[CheckResult], recurrences: u32) -> String {
1157 let mut msg = String::from(
1158 "The outcome contract was evaluated and some checks FAILED. Fix the code so they pass.\n\n",
1159 );
1160 for r in results.iter().filter(|r| !r.passed) {
1161 msg.push_str(&format!(
1162 "FAILED {} (exit {:?}):\n{}\n\n",
1163 r.name, r.exit_code, r.output_tail
1164 ));
1165 }
1166 if recurrences == 0 {
1167 msg.push_str(
1168 "Before editing again: read the SPECIFIC failure above — the exact assertion, error \
1169 type, or traceback line — and name the single cause. If the error names a missing \
1170 symbol/function/attribute, implement THAT symbol. Find the code responsible for the \
1171 named cause and fix it directly; do not guess-and-retry.\n",
1172 );
1173 } else {
1174 msg.push_str(&recurrence_notice(recurrences));
1175 }
1176 msg
1177}
1178
1179pub(super) fn recurrence_notice(recurrences: u32) -> String {
1194 format!(
1195 "The same check has now failed the same way {} times in this session (not necessarily \
1196 in consecutive rounds) despite your edits — your approach is NOT addressing the real \
1197 cause, so do NOT re-apply a variation of the same edit. STOP and read the failure \
1198 literally: what exact value or behavior was EXPECTED vs what was PRODUCED? Trace that \
1199 exact value back to the specific code that produces it, form a DIFFERENT hypothesis \
1200 about the named cause, and make one targeted change to it. If the error names a missing \
1201 symbol/function/attribute, the fix is to IMPLEMENT it, not to adjust the caller.\n",
1202 recurrences + 1
1203 )
1204}
1205
1206pub(super) fn record_recurrence(
1216 seen: &mut HashMap<String, u32>,
1217 sig: Option<&FailureSignature>,
1218) -> u32 {
1219 let Some(sig) = sig else { return 0 };
1220 let entry = seen.entry(sig.key()).or_insert(0);
1221 let prior = *entry;
1222 *entry += 1;
1223 prior
1224}
1225
1226pub(super) fn primary_failure(results: &[CheckResult]) -> Option<FailureSignature> {
1229 results
1230 .iter()
1231 .find(|r| !r.passed)
1232 .map(FailureSignature::from_check)
1233}
1234
1235fn append_recall_hint(prompt: &mut String, hint: &str) {
1239 prompt.push_str(
1240 "\nHINT — a prior session resolved this same failure signature with this approach; \
1241 use it as a lead, verify it still applies:\n",
1242 );
1243 prompt.push_str(hint);
1244 prompt.push('\n');
1245}
1246
1247fn message_memory_text(message: &Message) -> Option<String> {
1248 match message {
1249 Message::System { content }
1250 | Message::User { content }
1251 | Message::Assistant { content, .. }
1252 | Message::ToolResult { content, .. } => {
1253 let trimmed = content.trim();
1254 (!trimmed.is_empty()).then(|| trimmed.to_string())
1255 }
1256 Message::UserMultimodal { content } => {
1257 let text = content
1258 .iter()
1259 .filter_map(|block| match block {
1260 car_inference::ContentBlock::Text { text } => Some(text.trim()),
1261 _ => None,
1262 })
1263 .filter(|s| !s.is_empty())
1264 .collect::<Vec<_>>()
1265 .join("\n");
1266 (!text.is_empty()).then_some(text)
1267 }
1268 _ => None,
1269 }
1270}
1271
1272fn append_context_block(req: &mut GenerateRequest, title: &str, body: &str) {
1273 let block = format!("## {title}\n{body}");
1274 req.context = Some(match req.context.take() {
1275 Some(existing) if !existing.trim().is_empty() => format!("{existing}\n\n{block}"),
1276 _ => block,
1277 });
1278}
1279
1280async fn maybe_apply_coder_proactive_memory(
1281 req: &mut GenerateRequest,
1282 intent: &str,
1283 messages: &[Message],
1284 sink: &EventSink,
1285 memory: &RepairMemory,
1286) {
1287 let mut recent = messages
1288 .iter()
1289 .rev()
1290 .filter_map(message_memory_text)
1291 .take(6)
1292 .collect::<Vec<_>>();
1293 recent.reverse();
1294 let events = sink.events();
1295 let Some((maintenance, decision)) = memory.proactive_for_task(intent, recent, &events).await
1296 else {
1297 return;
1298 };
1299 sink.record_proactive_memory(&maintenance, &decision);
1300 if let car_memgine::ProactiveMemoryDecision::Inject { reminder, .. } = decision {
1301 append_context_block(req, "Proactive Memory", &reminder);
1302 }
1303}
1304
1305fn winning_approach(sig: &FailureSignature, plan_text: &str) -> String {
1310 let plan = plan_text.trim();
1311 if plan.is_empty() {
1312 format!(
1313 "Re-attempted the edit; the '{}' failure of check '{}' cleared after repair.",
1314 sig.error_class, sig.check
1315 )
1316 } else {
1317 preview(plan, 1024)
1318 }
1319}
1320
1321pub(crate) fn native_loop_tool_defs(with_ask: bool) -> Vec<Value> {
1336 let mut tools = WorktreeExecutor::tool_defs();
1337 if with_ask {
1338 tools.push(ask_user_tool_def());
1339 }
1340 tools
1341}
1342
1343pub fn model_tool_catalog() -> Vec<Value> {
1347 let mut tools = native_loop_tool_defs(true);
1348 tools.push(report_no_change_tool_def());
1349 tools.extend(
1350 crate::assistant::memory::MemoryTools::tool_defs()
1351 .into_iter()
1352 .filter(|tool| tool["name"] == "recall"),
1353 );
1354 tools.extend(
1357 crate::assistant::browser_tools::BrowserTools::new(std::env::temp_dir()).tool_defs(),
1358 );
1359 tools.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str()));
1360 tools.dedup_by(|left, right| left["name"] == right["name"]);
1361 tools
1362}
1363
1364pub(crate) fn network_tool_defs(executor: &WorktreeExecutor, granted: bool) -> Vec<Value> {
1375 if !granted {
1376 return Vec::new();
1377 }
1378 ["http_request", "web_search"]
1379 .iter()
1380 .flat_map(|name| executor.delegate_defs_named(name))
1381 .collect()
1382}
1383
1384pub(crate) fn coder_session_tool_defs(
1415 executor: &WorktreeExecutor,
1416 with_ask: bool,
1417 can_adjudicate_no_change: bool,
1418) -> Vec<Value> {
1419 let mut tools = native_loop_tool_defs(with_ask);
1420 if can_adjudicate_no_change {
1421 tools.push(report_no_change_tool_def());
1422 }
1423 let memory_defs = executor.delegate_defs_named("recall");
1424 if !memory_defs.is_empty() {
1425 executor.advertise_delegates();
1426 tools.extend(memory_defs);
1427 }
1428 let net_defs = network_tool_defs(executor, executor.permits_full_access());
1429 if !net_defs.is_empty() {
1430 executor.advertise_delegates();
1431 tools.extend(net_defs);
1432 }
1433 let mut browser_defs = executor.delegate_defs_with_prefix("browse_");
1434 browser_defs.extend(executor.delegate_defs_with_prefix("browser_"));
1435 if !browser_defs.is_empty() {
1436 executor.advertise_delegates();
1437 tools.extend(browser_defs);
1438 }
1439 let denied = executor.denied_tools();
1450 if !denied.is_empty() {
1451 tools.retain(|def| !denied.contains(def["name"].as_str().unwrap_or_default()));
1452 tracing::info!(
1463 withdrawn = ?denied,
1464 "operator policy denies these tools outright; withheld from the coding loop's \
1465 advertised list (still refused at dispatch by the inspector chain)"
1466 );
1467 }
1468 tools
1469}
1470
1471#[allow(clippy::too_many_arguments)]
1478pub async fn run_native_loop(
1479 inference: &dyn TurnGenerator,
1480 executor: &WorktreeExecutor,
1481 intent: &str,
1482 contract: &OutcomeContract,
1483 sink: &EventSink,
1484 cancel: &CancelFlag,
1485 cfg: &NativeLoopConfig,
1486 memory: &RepairMemory,
1487 ask: Option<&dyn AskUser>,
1488) -> LoopOutcome {
1489 let _steering_scope = cfg.steering.as_ref().map(|inbox| inbox.enter(sink));
1490 let tools = coder_session_tool_defs(executor, ask.is_some(), cfg.can_adjudicate_no_change);
1491 let environment = super::rpc::summarize_repo(executor.worktree());
1495 let tool_labels = builtin_tool_labels();
1499 let project = super::project_context::project_context(executor.worktree());
1504 let system = system_prompt_with_overlay(
1505 contract,
1506 &environment,
1507 project.as_deref(),
1508 cfg.prompt_overlay.as_deref(),
1509 );
1510 let mut feedback: Option<String> = None;
1511 let mut last_results: Vec<CheckResult> = Vec::new();
1512 let mut consecutive_inference_failures = 0u32;
1513 let mut announced_model_fallback = false;
1518 let mut last_journaled_hops: Vec<(String, String, &'static str)> = Vec::new();
1521 let mut announced_ungated_auth = false;
1524 let mut no_progress_iterations = 0u32;
1528 let mut seen_sigs: HashMap<String, u32> = HashMap::new();
1535 let mut prior_sig: Option<FailureSignature> = None;
1541
1542 let mut initial_user = format!("Task:\n{intent}\n");
1556 if let Some(block) = memory.recall_for_task(intent).await {
1557 initial_user.push_str(
1558 "\nRecall from prior sessions (heuristic — verify against the repo \
1559 before acting on it):\n",
1560 );
1561 initial_user.push_str(&block);
1562 }
1563 let mut messages = vec![
1564 Message::System {
1565 content: system.clone(),
1566 },
1567 Message::User {
1568 content: initial_user,
1569 },
1570 ];
1571
1572 let context_window = cfg
1576 .model
1577 .as_deref()
1578 .map(|m| inference.context_window(m))
1579 .unwrap_or(0);
1580 let adaptive_exclusions = if cfg.model.is_none() {
1581 cfg.exclude_models.clone()
1582 } else {
1583 Vec::new()
1584 };
1585 let strict_exclusions = !adaptive_exclusions.is_empty();
1586 tracing::debug!(
1591 context_window,
1592 budget = history_budget(context_window),
1593 "coder history compaction budget resolved for this run"
1594 );
1595
1596 for iteration in 1..=cfg.max_iterations {
1597 if cancel.load(Ordering::SeqCst) {
1598 return LoopOutcome::lost(
1599 LoopFailure::Cancelled,
1600 Some("cancelled".into()),
1601 iteration - 1,
1602 last_results,
1603 );
1604 }
1605 if let Some(reason) = cfg.deadline.admit() {
1609 sink.emit(CoderEventKind::BudgetExhausted {
1610 reason: reason.clone(),
1611 elapsed_secs: cfg.deadline.elapsed_secs(),
1612 iterations: iteration - 1,
1613 });
1614 return LoopOutcome::lost(
1615 LoopFailure::BudgetExhausted,
1616 Some(reason),
1617 iteration - 1,
1618 last_results,
1619 );
1620 }
1621 sink.emit(CoderEventKind::IterationStarted {
1622 n: iteration,
1623 max: cfg.max_iterations,
1624 });
1625
1626 if let Some(fb) = &feedback {
1631 let mut user = fb.clone();
1632 if let Some(sig) = &prior_sig {
1636 if let Some(hint) = memory.recall(sig).await {
1637 append_recall_hint(&mut user, &hint);
1638 }
1639 }
1640 messages.push(Message::User { content: user });
1641 }
1642
1643 let mut closing_plan = String::new();
1646 let mut turn = 0;
1648 let mut identical_read_calls: std::collections::HashMap<(String, String), u32> =
1654 std::collections::HashMap::new();
1655 let mut identical_failed_edits: std::collections::HashMap<(String, String, String), u32> =
1656 std::collections::HashMap::new();
1657 let mut no_progress_this_iteration = false;
1658 let mut last_model = String::new();
1663 let mut models_this_iteration: Vec<String> = Vec::new();
1677 let mut model_declared_done = false;
1678 while turn < cfg.max_turns_per_iteration {
1679 turn += 1;
1680 if cancel.load(Ordering::SeqCst) {
1681 return LoopOutcome::lost(
1682 LoopFailure::Cancelled,
1683 Some("cancelled".into()),
1684 iteration,
1685 last_results,
1686 );
1687 }
1688
1689 apply_operator_guidance(cfg, &mut messages, sink);
1690 compact_history_to_window(&mut messages, context_window);
1697
1698 let mut req = GenerateRequest {
1699 prompt: intent.to_string(), model: cfg.model.clone(),
1701 params: GenerateParams {
1702 temperature: 0.0,
1703 max_tokens: cfg.max_tokens_per_turn,
1704 strict_model: cfg.model.is_some(),
1710 ..Default::default()
1711 },
1712 tools: Some(tools.clone()),
1713 messages: Some(messages.clone()),
1714 intent: Some(car_inference::IntentHint {
1715 task: Some(car_inference::TaskHint::Code),
1716 high_stakes: true,
1725 exclude_models: adaptive_exclusions.clone(),
1726 strict_exclusions,
1727 ..Default::default()
1728 }),
1729 ..Default::default()
1730 };
1731 maybe_apply_coder_proactive_memory(&mut req, intent, &messages, sink, memory).await;
1732
1733 let result = match generate_native_turn(inference, req, &cfg.deadline, sink).await {
1739 Err(NativeTurnError::TimedOut) => {
1740 let elapsed_secs = cfg.deadline.elapsed_secs();
1741 let ceiling_secs = cfg.deadline.max_wall_secs().unwrap_or(elapsed_secs);
1742 let reason = format!(
1743 "native inference turn {turn} timed out after {elapsed_secs}s at the \
1744 session's {ceiling_secs}s deadline"
1745 );
1746 sink.emit(CoderEventKind::Error {
1750 message: reason.clone(),
1751 });
1752 sink.emit(CoderEventKind::BudgetExhausted {
1753 reason: reason.clone(),
1754 elapsed_secs,
1755 iterations: iteration,
1756 });
1757 sink.record_turn_completed(
1758 "inference_timed_out",
1759 Some("session_deadline"),
1760 false,
1761 turn,
1762 &last_model,
1763 &models_this_iteration,
1764 );
1765 let results = evaluate_contract_with_baselines(
1771 contract,
1772 executor,
1773 sink,
1774 &cfg.baseline_captures,
1775 )
1776 .await;
1777 return if results.iter().all(|result| result.passed) {
1778 LoopOutcome::green(iteration, results)
1779 } else {
1780 LoopOutcome::lost(
1781 LoopFailure::BudgetExhausted,
1782 Some(reason),
1783 iteration,
1784 results,
1785 )
1786 };
1787 }
1788 Ok(r) => {
1789 consecutive_inference_failures = 0;
1790 let hops: Vec<(String, String, &'static str)> = r
1818 .fallback_from
1819 .iter()
1820 .enumerate()
1821 .map(|(i, fb)| {
1822 let to = r
1823 .fallback_from
1824 .get(i + 1)
1825 .map(|next| next.candidate.clone())
1826 .unwrap_or_else(|| r.model_used.clone());
1827 (fb.candidate.clone(), to, fallback_reason_label(fb.reason))
1828 })
1829 .collect();
1830 if hops != last_journaled_hops {
1831 for (from, to, reason) in &hops {
1832 sink.record_model_fallback(from, to, reason);
1833 }
1834 last_journaled_hops = hops;
1835 }
1836 if let Some(lane) = r.auth_fallback_from.clone() {
1837 if !announced_model_fallback {
1838 announced_model_fallback = true;
1839 sink.emit(CoderEventKind::ModelFallback {
1840 from: lane,
1841 to: r.model_used.clone(),
1842 reason: MODEL_FALLBACK_REASON.into(),
1843 });
1844 }
1845 }
1846 r
1847 }
1848 Err(NativeTurnError::Generation(e)) => {
1849 if let TurnGenerationError::NoEligibleModel { excluded_models } = &e {
1850 let message = format!(
1858 "no independent coder model is available: review models {} are excluded; \
1859 configure an independent coder_model or review_models in heal.toml",
1860 excluded_models
1861 );
1862 sink.emit(CoderEventKind::Error {
1863 message: message.clone(),
1864 });
1865 sink.record_turn_completed(
1866 "no_eligible_model",
1867 None,
1868 false,
1869 turn,
1870 &last_model,
1871 &models_this_iteration,
1872 );
1873 let results = evaluate_contract_with_baselines(
1874 contract,
1875 executor,
1876 sink,
1877 &cfg.baseline_captures,
1878 )
1879 .await;
1880 let passed = results.iter().all(|result| result.passed);
1881 return if passed {
1882 LoopOutcome::green(iteration, results)
1883 } else {
1884 LoopOutcome::lost(
1885 LoopFailure::Configuration,
1886 Some(message),
1887 iteration,
1888 results,
1889 )
1890 };
1891 }
1892 let message = e.to_string();
1897 if is_auth_failure(&message) {
1898 let gate = cfg.auth_gate.clone();
1899 let announce = if gate.is_some() {
1920 true
1921 } else if announced_ungated_auth {
1922 false
1923 } else {
1924 announced_ungated_auth = true;
1925 true
1926 };
1927 if announce {
1928 sink.emit(CoderEventKind::AuthRequired {
1929 message: message.clone(),
1930 wait_secs: gate.as_ref().map_or(0, |_| cfg.auth_wait.as_secs()),
1933 });
1934 }
1935 if let Some(gate) = gate {
1943 if wait_for_auth(gate.as_ref(), cfg.auth_wait, cancel, &cfg.deadline)
1944 .await
1945 {
1946 consecutive_inference_failures = 0;
1950 continue;
1951 }
1952 sink.record_turn_completed(
1965 "auth_lapsed",
1966 None,
1967 false,
1968 turn,
1969 &last_model,
1970 &models_this_iteration,
1971 );
1972 let results = evaluate_contract_with_baselines(
1973 contract,
1974 executor,
1975 sink,
1976 &cfg.baseline_captures,
1977 )
1978 .await;
1979 let passed = results.iter().all(|r| r.passed);
1980 return if passed {
1981 LoopOutcome::green(iteration, results)
1982 } else {
1983 LoopOutcome::lost(
1984 LoopFailure::NeedsAuth,
1985 Some(format!(
1986 "not signed in, and no credential appeared within {}s: {message}",
1987 cfg.auth_wait.as_secs()
1988 )),
1989 iteration,
1990 results,
1991 )
1992 };
1993 }
1994 }
1995 consecutive_inference_failures += 1;
1996 sink.emit(CoderEventKind::Error {
1997 message: format!("inference failed (turn {turn}): {e}"),
1998 });
1999 if consecutive_inference_failures >= 3 {
2000 sink.record_turn_completed(
2007 "inference_failed",
2008 None,
2009 false,
2010 turn,
2011 &last_model,
2012 &models_this_iteration,
2013 );
2014 let results = evaluate_contract_with_baselines(
2022 contract,
2023 executor,
2024 sink,
2025 &cfg.baseline_captures,
2026 )
2027 .await;
2028 let passed = results.iter().all(|r| r.passed);
2029 if passed {
2035 if let Some(sig) = &prior_sig {
2036 memory
2037 .record_success(sig, &winning_approach(sig, &closing_plan))
2038 .await;
2039 }
2040 }
2041 return if passed {
2045 LoopOutcome::green(iteration, results)
2046 } else {
2047 let failure = if is_auth_failure(&message) {
2056 LoopFailure::NeedsAuth
2057 } else if matches!(
2058 e.terminal_inference(),
2059 Some((InferenceFailureKind::WorkspaceRequired, _))
2060 ) {
2061 LoopFailure::Configuration
2069 } else {
2070 LoopFailure::Infrastructure
2071 };
2072 LoopOutcome::lost(
2073 failure,
2074 Some(format!("inference failed repeatedly: {e}")),
2075 iteration,
2076 results,
2077 )
2078 };
2079 }
2080 continue; }
2082 };
2083 if turn < cfg.max_turns_per_iteration
2086 && cfg
2087 .steering
2088 .as_ref()
2089 .is_some_and(|inbox| inbox.has_pending())
2090 {
2091 continue;
2092 }
2093 last_model = result.model_used.clone();
2094 let served = result.model_used.trim();
2095 if !served.is_empty() && !models_this_iteration.iter().any(|m| m == served) {
2096 models_this_iteration.push(served.to_string());
2097 }
2098
2099 if result.tool_calls.is_empty() {
2100 if result.was_truncated() {
2110 sink.emit(CoderEventKind::Error {
2111 message: format!(
2112 "model turn truncated (stop_reason={:?}) — continuing so it can finish",
2113 result.stop_reason
2114 ),
2115 });
2116 result.append_assistant_history(&mut messages, vec![]);
2117 messages.push(Message::User {
2118 content: "Your previous response was cut off at the token limit. \
2119 Continue exactly where you left off; if you were in the \
2120 middle of a tool call, re-issue that call in full."
2121 .to_string(),
2122 });
2123 continue;
2124 }
2125 sink.record_turn_completed(
2130 "empty_tool_calls",
2131 result.stop_reason.as_deref(),
2132 result.was_truncated(),
2133 turn,
2134 &result.model_used,
2135 &models_this_iteration,
2136 );
2137 model_declared_done = true;
2138 if !result.text.trim().is_empty() {
2139 closing_plan = result.text.clone();
2140 sink.emit(CoderEventKind::PlanText {
2141 text: result.text.clone(),
2142 });
2143 }
2144 break;
2145 }
2146
2147 let mut calls = result.tool_calls.clone();
2150 for (i, call) in calls.iter_mut().enumerate() {
2151 if call.id.is_none() {
2152 call.id = Some(format!("call_{iteration}_{turn}_{i}"));
2153 }
2154 }
2155 result.append_assistant_history(&mut messages, calls.clone());
2156
2157 for call in &calls {
2158 let params = Value::Object(call.arguments.clone().into_iter().collect());
2159 sink.emit(CoderEventKind::ToolCall {
2160 tool: call.name.clone(),
2161 params_preview: preview(¶ms.to_string(), 400),
2162 });
2163 if call.name == REPORT_NO_CHANGE_TOOL && cfg.can_adjudicate_no_change {
2170 match parse_nomination(¶ms) {
2171 Ok(nomination) => {
2172 sink.record_turn_completed(
2177 "no_change_nominated",
2178 result.stop_reason.as_deref(),
2179 result.was_truncated(),
2180 turn,
2181 &result.model_used,
2182 &models_this_iteration,
2183 );
2184 return LoopOutcome::reported(
2185 nomination,
2186 iteration,
2187 last_results.clone(),
2188 );
2189 }
2190 Err(message) => {
2191 sink.emit(CoderEventKind::ToolResult {
2192 tool: call.name.clone(),
2193 ok: false,
2194 preview: message.clone(),
2195 });
2196 messages.push(Message::ToolResult {
2197 tool_use_id: call.id.clone().expect("assigned above"),
2198 content: message,
2199 provenance: Provenance::Internal,
2200 });
2201 continue;
2202 }
2203 }
2204 }
2205 if is_read_only_tool(&call.name) {
2217 let c = identical_read_calls
2218 .entry((call.name.clone(), params.to_string()))
2219 .or_insert(0);
2220 *c += 1;
2221 if *c >= NO_PROGRESS_REPEAT_LIMIT && !no_progress_this_iteration {
2222 no_progress_this_iteration = true;
2223 sink.emit(CoderEventKind::Error {
2224 message: format!(
2225 "no-progress loop: `{}` called {c} times with identical arguments \
2226 and no intervening edit — ending this attempt",
2227 call.name
2228 ),
2229 });
2230 }
2231 }
2232 let (ok, content) = if call.name == ASK_USER_TOOL {
2233 match ask {
2238 Some(asker) => {
2239 let prompt = params
2240 .get("prompt")
2241 .and_then(Value::as_str)
2242 .unwrap_or("")
2243 .to_string();
2244 match asker.ask(&prompt).await {
2245 Ok(answer) => (true, answer),
2246 Err(e) => (false, format!("ERROR: {e}")),
2247 }
2248 }
2249 None => (
2250 false,
2251 "ERROR: ask_user is not available in this session".to_string(),
2252 ),
2253 }
2254 } else {
2255 match executor.execute(&call.name, ¶ms).await {
2256 Ok(v) => (true, v.to_string()),
2257 Err(e) => (false, format!("ERROR: {e}")),
2258 }
2259 };
2260 if ok && !is_read_only_tool(&call.name) {
2261 identical_read_calls.clear();
2262 identical_failed_edits.clear();
2263 } else if !ok && matches!(call.name.as_str(), "edit_file" | "write_file") {
2264 let count = identical_failed_edits
2265 .entry((call.name.clone(), params.to_string(), content.clone()))
2266 .or_insert(0);
2267 *count += 1;
2268 if *count >= NO_PROGRESS_REPEAT_LIMIT && !no_progress_this_iteration {
2269 no_progress_this_iteration = true;
2270 sink.emit(CoderEventKind::Error {
2271 message: format!(
2272 "no-progress loop: `{}` failed {count} times with identical arguments \
2273 and the same error, without a successful intervening action — \
2274 checking the current work before retrying",
2275 call.name
2276 ),
2277 });
2278 }
2279 }
2280 sink.emit(CoderEventKind::ToolResult {
2281 tool: call.name.clone(),
2282 ok,
2283 preview: preview(&content, 400),
2284 });
2285 messages.push(Message::ToolResult {
2286 tool_use_id: call.id.clone().expect("assigned above"),
2287 content: preview(&content, 16 * 1024),
2288 provenance: if tool_output_is_external(&call.name, &tool_labels) {
2307 Provenance::External
2308 } else {
2309 Provenance::Internal
2310 },
2311 });
2312 }
2313 if no_progress_this_iteration {
2316 break;
2317 }
2318 }
2319 if no_progress_this_iteration {
2323 sink.record_turn_completed(
2324 "no_progress_loop",
2325 None,
2326 false,
2327 turn,
2328 &last_model,
2329 &models_this_iteration,
2330 );
2331 } else if !model_declared_done {
2332 sink.record_turn_completed(
2333 "max_turns",
2334 None,
2335 false,
2336 turn,
2337 &last_model,
2338 &models_this_iteration,
2339 );
2340 }
2341
2342 last_results =
2344 evaluate_contract_with_baselines(contract, executor, sink, &cfg.baseline_captures)
2345 .await;
2346 if last_results.iter().all(|r| r.passed) {
2347 if let Some(sig) = &prior_sig {
2351 memory
2352 .record_success(sig, &winning_approach(sig, &closing_plan))
2353 .await;
2354 }
2355 return LoopOutcome::green(iteration, last_results);
2356 }
2357 if no_progress_this_iteration {
2363 no_progress_iterations += 1;
2364 if no_progress_iterations >= 2 {
2365 return LoopOutcome::lost(
2373 LoopFailure::Verification,
2374 Some(
2375 "no-progress loop: repeated reads or identical failed edits made no progress \
2376 across two attempts. Verification still fails; stopped before exhausting \
2377 the iteration budget."
2378 .to_string(),
2379 ),
2380 iteration,
2381 last_results,
2382 );
2383 }
2384 } else {
2385 no_progress_iterations = 0;
2386 }
2387 let cur_sig = primary_failure(&last_results);
2402 let recurrences = if no_progress_this_iteration {
2403 0
2404 } else {
2405 record_recurrence(&mut seen_sigs, cur_sig.as_ref())
2406 };
2407
2408 feedback = Some(failure_feedback(&last_results, recurrences));
2412 if let Some(sig) = cur_sig {
2413 memory.record_failure(&sig).await;
2414 prior_sig = Some(sig);
2415 } else {
2416 prior_sig = None;
2417 }
2418 }
2419
2420 LoopOutcome::lost(
2423 LoopFailure::Verification,
2424 None,
2425 cfg.max_iterations,
2426 last_results,
2427 )
2428}
2429
2430#[cfg(test)]
2431mod tests {
2432 #[tokio::test]
2433 async fn steering_during_inference_discards_the_unused_tool_proposal() {
2434 struct Guided {
2435 inbox: Arc<super::super::steering::SteeringInbox>,
2436 turns: AtomicUsize,
2437 }
2438 #[async_trait]
2439 impl TurnGenerator for Guided {
2440 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2441 match self.turns.fetch_add(1, Ordering::SeqCst) {
2442 0 => {
2443 self.inbox
2444 .enqueue("Write right to x.txt instead.".into(), || Ok(()))
2445 .unwrap();
2446 Ok(turn(
2447 "",
2448 serde_json::json!([{"id":"stale", "name":"write_file", "arguments":{"path":"stale.txt", "content":"wrong"}}]),
2449 ))
2450 }
2451 1 => {
2452 assert!(req.messages.unwrap().iter().any(|m| matches!(m, Message::User { content } if content.contains("Write right to x.txt instead."))));
2453 Ok(turn(
2454 "",
2455 serde_json::json!([{"id":"guided", "name":"write_file", "arguments":{"path":"x.txt", "content":"right"}}]),
2456 ))
2457 }
2458 _ => Ok(turn("Done", serde_json::json!([]))),
2459 }
2460 }
2461 }
2462 let dir = tempfile::tempdir().unwrap();
2463 let executor = WorktreeExecutor::new(dir.path());
2464 let inbox = Arc::new(super::super::steering::SteeringInbox::default());
2465 let script = Guided {
2466 inbox: inbox.clone(),
2467 turns: AtomicUsize::new(0),
2468 };
2469 let (sink, events) = EventSink::collecting("guided");
2470 let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
2471 let contract = OutcomeContract {
2472 allow_credentials: false,
2473 description: "x.txt says right".into(),
2474 checks: vec![ContractCheck {
2475 name: "content".into(),
2476 command: crate::coder::test_cmds::contains_or_report("right", "x.txt"),
2477 expect_exit_zero: true,
2478 output_contains: None,
2479 timeout_secs: 10,
2480 baseline: false,
2481 differential: None,
2482 }],
2483 };
2484 let outcome = run_native_loop(
2485 &script,
2486 &executor,
2487 "Write a file",
2488 &contract,
2489 &sink,
2490 &cancel,
2491 &NativeLoopConfig {
2492 steering: Some(inbox.clone()),
2493 ..Default::default()
2494 },
2495 &RepairMemory::disabled(),
2496 None,
2497 )
2498 .await;
2499 assert!(outcome.passed, "{outcome:?}");
2500 assert!(!dir.path().join("stale.txt").exists());
2501 assert_eq!(
2502 std::fs::read_to_string(dir.path().join("x.txt")).unwrap(),
2503 "right"
2504 );
2505 assert!(!inbox.is_open());
2506 assert!(events.lock().unwrap().iter().any(|event| matches!(&event.kind, CoderEventKind::OperatorGuidance { status, .. } if status == "applied")));
2507 }
2508
2509 #[tokio::test(start_paused = true)]
2510 async fn native_wait_reports_retry_and_elapsed_without_changing_failure_kind() {
2511 struct Retrying;
2512 #[async_trait]
2513 impl TurnGenerator for Retrying {
2514 async fn generate(&self, _: GenerateRequest) -> Result<InferenceResult, String> {
2515 panic!("native generation must use the typed observed seam");
2516 }
2517 async fn generate_coder_observed(
2518 &self,
2519 _: GenerateRequest,
2520 observer: &mut (dyn FnMut(InferenceRetryProgress) + Send),
2521 ) -> Result<InferenceResult, TurnGenerationError> {
2522 observer(InferenceRetryProgress {
2523 model: "fixture/model".into(),
2524 attempt: 2,
2525 reason: "transport",
2526 backoff_ms: 500,
2527 });
2528 tokio::time::sleep(std::time::Duration::from_secs(16)).await;
2529 Err(TurnGenerationError::NonRetryableInference {
2530 kind: InferenceFailureKind::ProviderAccount,
2531 recovery: "account action needed".into(),
2532 })
2533 }
2534 }
2535 let (sink, events) = EventSink::collecting("progress-test");
2536 let deadline = SessionDeadline::shared_default();
2537 let result =
2538 generate_native_turn(&Retrying, GenerateRequest::default(), &deadline, &sink).await;
2539 assert!(matches!(
2540 result,
2541 Err(NativeTurnError::Generation(
2542 TurnGenerationError::NonRetryableInference {
2543 kind: InferenceFailureKind::ProviderAccount,
2544 ..
2545 }
2546 ))
2547 ));
2548 let events = events.lock().unwrap();
2549 assert!(matches!(
2550 &events[0].kind,
2551 CoderEventKind::InferenceRetry {
2552 attempt: 2,
2553 backoff_ms: 500,
2554 ..
2555 }
2556 ));
2557 assert!(matches!(
2558 &events[1].kind,
2559 CoderEventKind::InferenceWaiting { elapsed_secs: 15 }
2560 ));
2561 assert_eq!(
2562 events.len(),
2563 2,
2564 "progress must not imply a tool call or completion"
2565 );
2566 }
2567
2568 #[test]
2573 fn the_journal_labels_match_the_serde_spelling() {
2574 use car_inference::FallbackReason as R;
2575 for r in [
2576 R::CredentialRejected,
2577 R::CredentialAbsent,
2578 R::RateLimited,
2579 R::TimedOut,
2580 R::Failed,
2581 ] {
2582 let serde_spelling = serde_json::to_value(r).unwrap();
2583 assert_eq!(
2584 serde_spelling.as_str(),
2585 Some(fallback_reason_label(r)),
2586 "{r:?}"
2587 );
2588 }
2589 }
2590
2591 #[test]
2599 fn recall_is_advertised_and_reachable_together() {
2600 let dir = tempfile::tempdir().unwrap();
2601 let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
2602
2603 assert!(
2605 !exec.delegates_reachable(),
2606 "delegates start closed — attachment is not reachability"
2607 );
2608 let memory_defs = exec.delegate_defs_named("recall");
2609 assert_eq!(memory_defs.len(), 1, "recall must be attached");
2610
2611 exec.advertise_delegates();
2613 assert!(exec.delegates_reachable(), "advertising must open dispatch");
2614
2615 let mut tools = native_loop_tool_defs(false);
2616 tools.extend(memory_defs);
2617 let names: Vec<String> = tools
2618 .iter()
2619 .filter_map(|d| d["name"].as_str().map(String::from))
2620 .collect();
2621 assert!(names.iter().any(|n| n == "recall"));
2622 assert!(
2623 names.iter().any(|n| n == "shell"),
2624 "built-ins still present"
2625 );
2626 assert!(
2629 !names.iter().any(|n| n.starts_with("parslee_")),
2630 "advertising recall must not offer the rest of the delegate surface"
2631 );
2632 }
2633
2634 #[test]
2644 fn tool_results_are_classified_from_labels_not_from_a_constant() {
2645 let labels = car_engine::builtin_tool_labels();
2646
2647 for local in [
2649 "shell",
2650 "read_file",
2651 "write_file",
2652 "edit_file",
2653 "grep_files",
2654 ] {
2655 assert!(
2656 !car_engine::tool_output_is_external(local, &labels),
2657 "{local} is local and must not be marked External"
2658 );
2659 }
2660
2661 for networked in [
2666 "http_request",
2667 "web_search",
2668 "browse_navigate",
2669 "browse_observe",
2670 "browser_await_answer",
2671 ] {
2672 assert!(
2673 car_engine::tool_output_is_external(networked, &labels),
2674 "{networked} reaches the network and must be marked External"
2675 );
2676 }
2677
2678 assert!(!car_engine::tool_output_is_external(
2682 "some_unlabeled_tool",
2683 &labels
2684 ));
2685 }
2686
2687 #[test]
2691 fn report_no_change_is_offered_only_to_a_caller_that_can_adjudicate() {
2692 let names = |cfg: &NativeLoopConfig| -> Vec<String> {
2695 let mut tools = native_loop_tool_defs(false);
2696 if cfg.can_adjudicate_no_change {
2697 tools.push(report_no_change_tool_def());
2698 }
2699 tools
2700 .iter()
2701 .filter_map(|t| t["name"].as_str().map(String::from))
2702 .collect()
2703 };
2704
2705 let cannot = NativeLoopConfig::default();
2706 assert!(
2707 !cannot.can_adjudicate_no_change,
2708 "false is the only safe default"
2709 );
2710 assert!(!names(&cannot).contains(&REPORT_NO_CHANGE_TOOL.to_string()));
2711
2712 let can = NativeLoopConfig {
2713 can_adjudicate_no_change: true,
2714 ..Default::default()
2715 };
2716 assert!(names(&can).contains(&REPORT_NO_CHANGE_TOOL.to_string()));
2717 }
2718
2719 #[test]
2722 fn an_unknown_nomination_kind_is_rejected_not_defaulted() {
2723 let bad = serde_json::json!({
2724 "kind": "premise_wrongg",
2725 "summary": "s",
2726 "evidence": "e"
2727 });
2728 let err = parse_nomination(&bad).unwrap_err();
2729 assert!(err.contains("unknown kind"), "{err}");
2730
2731 for missing in ["kind", "summary", "evidence"] {
2732 let mut obj = serde_json::json!({
2733 "kind": "premise_wrong", "summary": "s", "evidence": "e"
2734 });
2735 obj.as_object_mut().unwrap().remove(missing);
2736 assert!(
2737 parse_nomination(&obj).is_err(),
2738 "`{missing}` must be required"
2739 );
2740 }
2741
2742 let ok = parse_nomination(&serde_json::json!({
2743 "kind": "non_code_decision", "summary": "s", "evidence": "e"
2744 }))
2745 .unwrap();
2746 assert_eq!(ok.kind, NoChangeKind::NonCodeDecision);
2747 }
2748
2749 use super::*;
2750 use crate::coder::contract::ContractCheck;
2751 use crate::coder::CoderEvent;
2752 use std::sync::atomic::AtomicUsize;
2753 use std::sync::Arc;
2754
2755 const CREDENTIAL_SUMMARY: &str =
2758 "Parslee login is absent — run `car auth login` before retrying";
2759
2760 const ALL_INFERENCE_ERROR_VARIANTS: [&str; 20] = [
2764 "CatalogPreconditionMismatch",
2765 "ContentRefused",
2766 "ControlledTermination",
2767 "CredentialUnavailable",
2768 "DeadlineExceeded",
2769 "DeviceError",
2770 "DownloadFailed",
2771 "GatewayUnconfigured",
2772 "InferenceFailed",
2773 "Io",
2774 "LocalResourceBlocked",
2775 "ModelManagement",
2776 "ModelNotFound",
2777 "NoEligibleModel",
2778 "ProviderAccount",
2779 "ProviderKeyMissing",
2780 "TokenizationError",
2781 "Transient",
2782 "UnsupportedMode",
2783 "WorkspaceRequired",
2784 ];
2785
2786 fn inference_error_variant(error: &InferenceError) -> &'static str {
2790 match error {
2791 InferenceError::ModelNotFound(_) => "ModelNotFound",
2792 InferenceError::NoEligibleModel { .. } => "NoEligibleModel",
2793 InferenceError::DownloadFailed(_) => "DownloadFailed",
2794 InferenceError::InferenceFailed(_) => "InferenceFailed",
2795 InferenceError::CatalogPreconditionMismatch { .. } => "CatalogPreconditionMismatch",
2796 InferenceError::ControlledTermination => "ControlledTermination",
2797 InferenceError::ModelManagement(_) => "ModelManagement",
2798 InferenceError::LocalResourceBlocked { .. } => "LocalResourceBlocked",
2799 InferenceError::Transient { .. } => "Transient",
2800 InferenceError::DeadlineExceeded { .. } => "DeadlineExceeded",
2801 InferenceError::UnsupportedMode { .. } => "UnsupportedMode",
2802 InferenceError::ProviderAccount { .. } => "ProviderAccount",
2803 InferenceError::ProviderKeyMissing { .. } => "ProviderKeyMissing",
2804 InferenceError::CredentialUnavailable { .. } => "CredentialUnavailable",
2805 InferenceError::ContentRefused { .. } => "ContentRefused",
2806 InferenceError::GatewayUnconfigured { .. } => "GatewayUnconfigured",
2807 InferenceError::TokenizationError(_) => "TokenizationError",
2808 InferenceError::DeviceError(_) => "DeviceError",
2809 InferenceError::Io(_) => "Io",
2810 InferenceError::WorkspaceRequired { .. } => "WorkspaceRequired",
2811 }
2812 }
2813
2814 fn real_no_backend_hint() -> String {
2818 car_inference::no_backend_recovery_hint(
2819 "model declares ModelSource::Delegated but no inference runner is registered",
2820 )
2821 .expect("car-inference emits a no-backend recovery hint for a missing runner")
2822 }
2823
2824 #[test]
2831 fn coder_generation_renders_no_eligible_model_exactly_as_chat_does() {
2832 let excluded_models = "reviewer-a, reviewer-b";
2833 let (kind, recovery) = TurnGenerationError::NoEligibleModel {
2834 excluded_models: excluded_models.to_string(),
2835 }
2836 .terminal_inference()
2837 .expect("strict-exclusion exhaustion is terminal");
2838
2839 assert_eq!(kind, InferenceFailureKind::NoEligibleModel);
2840 assert_eq!(
2841 recovery,
2842 InferenceError::NoEligibleModel {
2843 excluded_models: excluded_models.to_string(),
2844 }
2845 .to_string()
2846 );
2847 }
2848
2849 fn missing_provider_key() -> InferenceError {
2854 InferenceError::ProviderKeyMissing {
2855 provider: "openrouter".into(),
2856 model: "openrouter/auto".into(),
2857 env_vars: vec!["OPENROUTER_API_KEY".into()],
2858 message: "OpenRouter requires a key — run `car keys set openrouter` or connect \
2859 your OpenRouter account in CarHost"
2860 .into(),
2861 }
2862 }
2863
2864 #[test]
2870 fn coder_generation_renders_a_missing_provider_key_exactly_as_chat_does() {
2871 let error = missing_provider_key();
2872 let chat_text = error.to_string();
2873
2874 let (kind, recovery) = TurnGenerationError::from(error)
2875 .terminal_inference()
2876 .expect("a missing provider key cannot be repaired inside one build");
2877
2878 assert_eq!(kind, InferenceFailureKind::ProviderKeyMissing);
2879 assert_eq!(recovery, chat_text);
2880 }
2881
2882 #[test]
2883 fn coder_generation_classifies_only_non_retryable_inference_failures() {
2884 use car_inference::resource_policy::{
2885 LocalLoadPreflight, LocalLoadVerdict, ModelMemoryEstimate, ModelResourceEvidence,
2886 };
2887 use car_inference::CredentialFailure;
2888
2889 let preflight = LocalLoadPreflight {
2890 model_id: "local/test".into(),
2891 estimate: ModelMemoryEstimate {
2892 weights_mb: 1,
2893 runtime_overhead_mb: 0,
2894 context_overhead_mb: 0,
2895 transient_margin_mb: 0,
2896 estimated_peak_mb: 1,
2897 evidence: ModelResourceEvidence::CatalogExact,
2898 },
2899 configured_ceiling_mb: 1,
2900 resident_model_mb: 0,
2901 active_reservations_mb: 0,
2902 estimated_incremental_mb: 1,
2903 accelerator_total_mb: None,
2904 accelerator_resident_mb: None,
2905 accelerator_incremental_mb: None,
2906 live_available_mb: Some(0),
2907 emergency_reserve_mb: 1,
2908 verdict: LocalLoadVerdict::InsufficientLiveMemory,
2909 };
2910 let cases = [
2911 (
2912 InferenceError::LocalResourceBlocked {
2913 preflight,
2914 recovery: "choose a smaller model".into(),
2915 },
2916 InferenceFailureKind::LocalResourceBlocked,
2917 "choose a smaller model",
2918 ),
2919 (
2920 InferenceError::CredentialUnavailable {
2921 provider: "parslee".into(),
2922 model: "hosted/test".into(),
2923 reason: CredentialFailure::SignedOut,
2924 detail: "sign in".into(),
2925 },
2926 InferenceFailureKind::CredentialUnavailable,
2927 "sign in",
2928 ),
2929 (
2930 InferenceError::ProviderAccount {
2931 provider: "provider".into(),
2932 status: 401,
2933 message: "rejected key".into(),
2934 },
2935 InferenceFailureKind::ProviderAccount,
2936 "rejected key",
2937 ),
2938 (
2939 missing_provider_key(),
2940 InferenceFailureKind::ProviderKeyMissing,
2941 "OpenRouter requires a key",
2942 ),
2943 (
2944 InferenceError::GatewayUnconfigured {
2945 provider: "gateway".into(),
2946 namespace: "managed".into(),
2947 status: 503,
2948 message: "configure an upstream".into(),
2949 },
2950 InferenceFailureKind::GatewayUnconfigured,
2951 "configure an upstream",
2952 ),
2953 (
2954 InferenceError::ModelNotFound("missing/model".into()),
2955 InferenceFailureKind::NoBackend,
2956 "missing/model",
2957 ),
2958 (
2961 InferenceError::InferenceFailed(real_no_backend_hint()),
2962 InferenceFailureKind::NoBackend,
2963 car_inference::NO_BACKEND_RECOVERY_MARKER,
2964 ),
2965 (
2970 InferenceError::InferenceFailed(format!(
2971 "{CREDENTIAL_SUMMARY}; {}",
2972 real_no_backend_hint()
2973 )),
2974 InferenceFailureKind::NoBackend,
2975 car_inference::NO_BACKEND_RECOVERY_MARKER,
2976 ),
2977 (
2978 InferenceError::NoEligibleModel {
2979 excluded_models: "only/model".into(),
2980 },
2981 InferenceFailureKind::NoEligibleModel,
2982 "only/model",
2983 ),
2984 (
2988 InferenceError::WorkspaceRequired {
2989 provider: "Parslee".into(),
2990 detail: "finish setting up at https://parslee.ai, then try again".into(),
2991 },
2992 InferenceFailureKind::WorkspaceRequired,
2993 "https://parslee.ai",
2994 ),
2995 ];
2996
2997 let mut covered: Vec<&'static str> = Vec::new();
2998 for (error, expected_kind, expected_text) in cases {
2999 covered.push(inference_error_variant(&error));
3000 let rendered = format!("{error}");
3001 let error = TurnGenerationError::from(error);
3002 let (kind, recovery) = error
3003 .terminal_inference()
3004 .unwrap_or_else(|| panic!("must be terminal: {rendered}"));
3005 assert_eq!(kind, expected_kind, "{rendered}");
3006 assert!(recovery.contains(expected_text), "{recovery}");
3007 }
3008
3009 for retryable in [
3013 InferenceError::Transient {
3014 status: Some(503),
3015 message: "try later".into(),
3016 },
3017 InferenceError::CredentialUnavailable {
3018 provider: "parslee".into(),
3019 model: "hosted/test".into(),
3020 reason: CredentialFailure::RaceRetryable,
3021 detail: "credential appeared on re-read".into(),
3022 },
3023 InferenceError::InferenceFailed("temporary decoder failure".into()),
3024 InferenceError::DownloadFailed("mirror timed out".into()),
3025 InferenceError::CatalogPreconditionMismatch {
3026 detail: "revision moved".into(),
3027 },
3028 InferenceError::ControlledTermination,
3029 InferenceError::ModelManagement(
3030 car_inference::model_management::ModelManagementError::MissingReceipt {
3031 model_id: "local/test".into(),
3032 },
3033 ),
3034 InferenceError::DeadlineExceeded {
3035 applied_ms: 1_000,
3036 elapsed_ms: 1_001,
3037 last_error: "still in flight".into(),
3038 },
3039 InferenceError::UnsupportedMode {
3040 mode: "video",
3041 backend: "mlx",
3042 reason: "not wired",
3043 },
3044 InferenceError::ContentRefused {
3045 provider: "provider".into(),
3046 kind: None,
3047 code: None,
3048 message: "refused on content grounds".into(),
3049 },
3050 InferenceError::TokenizationError("bad byte".into()),
3051 InferenceError::DeviceError("gpu hiccup".into()),
3052 InferenceError::Io(std::io::Error::other("socket reset")),
3053 ] {
3054 covered.push(inference_error_variant(&retryable));
3055 let rendered = format!("{retryable}");
3056 assert!(
3057 TurnGenerationError::from(retryable)
3058 .terminal_inference()
3059 .is_none(),
3060 "retryable failures keep the retry path: {rendered}"
3061 );
3062 }
3063
3064 covered.sort_unstable();
3065 covered.dedup();
3066 assert_eq!(
3067 covered, ALL_INFERENCE_ERROR_VARIANTS,
3068 "every InferenceError variant must appear in one of the two tables above"
3069 );
3070 }
3071
3072 fn contract_for_prompt_test() -> OutcomeContract {
3073 OutcomeContract {
3074 allow_credentials: false,
3075 description: "d".into(),
3076 checks: vec![],
3077 }
3078 }
3079
3080 #[test]
3092 fn the_coding_loop_withholds_tools_operator_policy_denies() {
3093 fn names(defs: &[Value]) -> std::collections::BTreeSet<String> {
3094 defs.iter()
3095 .filter_map(|d| d["name"].as_str().map(String::from))
3096 .collect()
3097 }
3098 let dir = tempfile::tempdir().unwrap();
3099
3100 let open = WorktreeExecutor::new(dir.path());
3101 let before = names(&coder_session_tool_defs(&open, false, false));
3102 assert!(
3103 before.contains("shell"),
3104 "control must offer the tool the next one denies: {before:?}"
3105 );
3106
3107 let denied = WorktreeExecutor::new(dir.path())
3108 .with_denied_tools(["shell".to_string()].into_iter().collect());
3109 let after = names(&coder_session_tool_defs(&denied, false, false));
3110
3111 assert_eq!(
3112 before.difference(&after).cloned().collect::<Vec<_>>(),
3113 vec!["shell".to_string()],
3114 "exactly the denied tool is withheld"
3115 );
3116 assert!(
3117 after.difference(&before).next().is_none(),
3118 "withholding must not ADD anything"
3119 );
3120 }
3121
3122 #[test]
3124 fn the_coding_loop_advertises_the_built_ins_not_the_delegate() {
3125 fn names(defs: &[Value]) -> Vec<String> {
3126 defs.iter()
3127 .filter_map(|d| d["name"].as_str().map(String::from))
3128 .collect()
3129 }
3130
3131 let advertised = names(&native_loop_tool_defs(false));
3132 assert_eq!(advertised, names(&WorktreeExecutor::tool_defs()));
3133 assert!(
3134 !advertised.iter().any(|n| n.starts_with("parslee_")),
3135 "delegate tools leaked into the coding loop: {advertised:?}"
3136 );
3137 assert!(advertised.iter().any(|n| n == "shell"));
3138
3139 let dir = tempfile::tempdir().unwrap();
3142 let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
3143 assert!(names(&exec.all_tool_defs())
3144 .iter()
3145 .any(|n| n.starts_with("parslee_")));
3146
3147 let with_ask = names(&native_loop_tool_defs(true));
3149 assert_eq!(with_ask.len(), advertised.len() + 1);
3150 assert_eq!(with_ask.last().unwrap(), ASK_USER_TOOL);
3151 }
3152
3153 #[test]
3157 fn the_network_pair_is_offered_only_once_the_operator_grants_it() {
3158 let dir = tempfile::tempdir().unwrap();
3159 let exec = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
3160
3161 assert!(
3162 network_tool_defs(&exec, false).is_empty(),
3163 "an ungranted session must not be shown a tool the gate will refuse"
3164 );
3165
3166 let granted: Vec<String> = network_tool_defs(&exec, true)
3167 .iter()
3168 .filter_map(|d| d["name"].as_str().map(String::from))
3169 .collect();
3170 assert_eq!(granted, vec!["http_request", "web_search"]);
3171 }
3172
3173 #[test]
3177 fn final_policy_filter_withholds_browser_tools_after_opt_in_assembly() {
3178 let dir = tempfile::tempdir().unwrap();
3179 let policy_dir = dir.path().join(".car/policies");
3180 std::fs::create_dir_all(&policy_dir).unwrap();
3181 std::fs::write(
3182 policy_dir.join("browser.toml"),
3183 "deny_tool = [\"browse_navigate\"]\n",
3184 )
3185 .unwrap();
3186 let executor = WorktreeExecutor::for_coder_session(dir.path())
3187 .unwrap()
3188 .with_browser_tools();
3189 let tools = coder_session_tool_defs(&executor, false, false);
3190 assert!(!tools.iter().any(|tool| tool["name"] == "browse_navigate"));
3191 assert!(tools.iter().any(|tool| tool["name"] == "browse_observe"));
3192 }
3193
3194 #[test]
3195 fn the_coding_loop_offers_browser_tools_only_after_opt_in() {
3196 let dir = tempfile::tempdir().unwrap();
3197 let plain = WorktreeExecutor::for_coder_session(dir.path()).unwrap();
3198 let plain_names: Vec<String> = coder_session_tool_defs(&plain, true, true)
3199 .iter()
3200 .filter_map(|d| d["name"].as_str().map(String::from))
3201 .collect();
3202 assert!(!plain_names.iter().any(|n| n.starts_with("browse_")));
3203 assert!(!plain_names.iter().any(|n| n.starts_with("browser_")));
3204
3205 let enabled = WorktreeExecutor::for_coder_session(dir.path())
3206 .unwrap()
3207 .with_browser_tools();
3208 assert!(!enabled.delegates_reachable());
3209 let enabled_names: Vec<String> = coder_session_tool_defs(&enabled, true, true)
3210 .iter()
3211 .filter_map(|d| d["name"].as_str().map(String::from))
3212 .collect();
3213 for required in [
3214 "browse_navigate",
3215 "browse_click",
3216 "browse_type",
3217 "browse_scroll",
3218 "browse_keypress",
3219 "browse_wait",
3220 "browse_observe",
3221 "browser_await_answer",
3222 "browser_await_signin",
3223 "browser_record_start",
3224 "browser_record_stop",
3225 ] {
3226 assert!(
3227 enabled_names.contains(&required.to_string()),
3228 "missing {required}"
3229 );
3230 }
3231 assert!(enabled.delegates_reachable());
3232 }
3233
3234 #[test]
3237 fn an_overlay_is_appended_to_the_prompt() {
3238 let contract = contract_for_prompt_test();
3239 let base = system_prompt_with_overlay(&contract, "env", None, None);
3240 let with =
3241 system_prompt_with_overlay(&contract, "env", None, Some("Prefer smaller diffs."));
3242
3243 assert!(with.contains("Prefer smaller diffs."));
3244 assert!(
3245 with.starts_with(&base),
3246 "the overlay must be strictly additive — the base prompt has to survive verbatim"
3247 );
3248 assert!(with.len() > base.len());
3249 }
3250
3251 #[test]
3256 fn a_repo_with_no_project_context_gets_an_unchanged_prompt() {
3257 let contract = contract_for_prompt_test();
3258 let bare = system_prompt(&contract, "env", None);
3259 assert_eq!(system_prompt(&contract, "env", Some("")), bare);
3260 assert_eq!(system_prompt(&contract, "env", Some(" \n ")), bare);
3261 }
3262
3263 #[test]
3266 fn project_instructions_reach_the_system_prompt() {
3267 let contract = contract_for_prompt_test();
3268 let dir = tempfile::tempdir().unwrap();
3271 std::fs::write(
3272 dir.path().join("CLAUDE.md"),
3273 "No cargo feature flags. Ever.",
3274 )
3275 .unwrap();
3276 let block = crate::coder::project_context::project_context(dir.path())
3277 .expect("a repo with CLAUDE.md yields a block");
3278
3279 let p = system_prompt(&contract, "env", Some(&block));
3280 assert!(p.contains("No cargo feature flags. Ever."));
3281 assert!(p.contains("does NOT check them"));
3284 assert!(p.contains("never weaken or edit a contract check"));
3285 let env_at = p.find("ENVIRONMENT:").expect("environment section");
3287 let rules_at = p.find("No cargo feature flags").expect("instructions");
3288 let how_at = p.find("How to work:").expect("how-to-work section");
3289 assert!(env_at < rules_at && rules_at < how_at);
3290 }
3291
3292 #[test]
3294 fn no_overlay_changes_nothing() {
3295 let contract = contract_for_prompt_test();
3296 let base = system_prompt(&contract, "env", None);
3297 assert_eq!(
3298 system_prompt_with_overlay(&contract, "env", None, None),
3299 base
3300 );
3301 assert_eq!(
3302 system_prompt_with_overlay(&contract, "env", None, Some("")),
3303 base
3304 );
3305 assert_eq!(
3306 system_prompt_with_overlay(&contract, "env", None, Some(" \n ")),
3307 base,
3308 "whitespace is not an overlay"
3309 );
3310 }
3311
3312 #[test]
3317 fn the_overlay_is_marked_subordinate_to_the_base_rules() {
3318 let contract = contract_for_prompt_test();
3319 let with = system_prompt_with_overlay(&contract, "env", None, Some("Commit when done."));
3320 let marker = with
3321 .find("ADDITIONAL GUIDANCE")
3322 .expect("the overlay must be delimited, not silently concatenated");
3323 assert!(
3324 with[marker..].contains("the rules above win"),
3325 "a conflicting overlay instruction must not read as authoritative"
3326 );
3327 assert!(
3328 with.find("Commit when done.").unwrap() > marker,
3329 "the overlay must come after its own header"
3330 );
3331 }
3332
3333 #[test]
3336 fn merge_harness_adopts_and_clears_the_overlay() {
3337 let mut cfg = NativeLoopConfig::default();
3338 cfg.merge_harness(&car_memgine::HarnessConfig {
3339 prompt_overlay: Some("evolved guidance".into()),
3340 ..Default::default()
3341 });
3342 assert_eq!(cfg.prompt_overlay.as_deref(), Some("evolved guidance"));
3343
3344 cfg.merge_harness(&car_memgine::HarnessConfig {
3345 prompt_overlay: None,
3346 ..Default::default()
3347 });
3348 assert_eq!(
3349 cfg.prompt_overlay, None,
3350 "a rollback must actually remove the overlay, not leave it latched"
3351 );
3352 }
3353
3354 #[test]
3355 fn merge_harness_raises_coder_budgets_only_upward() {
3356 let mut cfg = NativeLoopConfig {
3359 max_iterations: 8,
3360 max_turns_per_iteration: 24,
3361 ..Default::default()
3362 };
3363 cfg.merge_harness(&car_memgine::HarnessConfig {
3364 prompt_overlay: None,
3365 max_retries: 30,
3366 retry_backoff_ms: 0,
3367 planning_max_replans: 12, });
3369 assert_eq!(
3370 cfg.max_iterations, 13,
3371 "planning_max_replans+1 reaches the coder"
3372 );
3373 assert_eq!(
3374 cfg.max_turns_per_iteration, 30,
3375 "max_retries raises the turn floor"
3376 );
3377
3378 let mut base = NativeLoopConfig {
3380 max_iterations: 8,
3381 max_turns_per_iteration: 24,
3382 ..Default::default()
3383 };
3384 base.merge_harness(&car_memgine::HarnessConfig::default()); assert_eq!(base.max_iterations, 8, "never lowered below base");
3386 assert_eq!(base.max_turns_per_iteration, 24);
3387 }
3388
3389 struct Script {
3391 turns: Vec<InferenceResult>,
3392 cursor: AtomicUsize,
3393 seen: std::sync::Mutex<Vec<GenerateRequest>>,
3398 }
3399
3400 impl Script {
3401 fn new(turns: Vec<InferenceResult>) -> Self {
3402 Self {
3403 turns,
3404 cursor: AtomicUsize::new(0),
3405 seen: std::sync::Mutex::new(Vec::new()),
3406 }
3407 }
3408 fn prompt(&self, n: usize) -> String {
3410 let reqs = self.seen.lock().expect("seen poisoned");
3411 serde_json::to_string(&reqs[n].messages).unwrap_or_default()
3412 }
3413 fn prompts(&self) -> usize {
3414 self.seen.lock().expect("seen poisoned").len()
3415 }
3416 }
3417
3418 fn turn(text: &str, tool_calls: serde_json::Value) -> InferenceResult {
3419 serde_json::from_value(serde_json::json!({
3420 "text": text,
3421 "tool_calls": tool_calls,
3422 "trace_id": "t",
3423 "model_used": "scripted",
3424 "latency_ms": 0,
3425 }))
3426 .expect("scripted InferenceResult shape")
3427 }
3428
3429 fn turn_with_stop(
3430 text: &str,
3431 tool_calls: serde_json::Value,
3432 stop_reason: Option<&str>,
3433 ) -> InferenceResult {
3434 serde_json::from_value(serde_json::json!({
3435 "text": text,
3436 "tool_calls": tool_calls,
3437 "trace_id": "t",
3438 "model_used": "scripted",
3439 "latency_ms": 0,
3440 "stop_reason": stop_reason,
3441 }))
3442 .expect("scripted InferenceResult shape")
3443 }
3444
3445 #[async_trait]
3446 impl TurnGenerator for Script {
3447 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
3448 self.seen.lock().expect("seen poisoned").push(req);
3449 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
3450 self.turns
3451 .get(i)
3452 .cloned()
3453 .ok_or_else(|| "script exhausted".to_string())
3454 }
3455 }
3456
3457 #[test]
3463 fn the_constructors_cannot_produce_a_passed_run_that_also_failed() {
3464 let green = LoopOutcome::green(3, Vec::new());
3465 assert!(green.passed);
3466 assert_eq!(green.failure, None);
3467 assert_eq!(green.error, None);
3468
3469 let lost = LoopOutcome::lost(LoopFailure::Verification, None, 3, Vec::new());
3470 assert!(!lost.passed);
3471 assert_eq!(lost.failure, Some(LoopFailure::Verification));
3472
3473 let scraped = LoopOutcome::lost(
3476 LoopFailure::EngineUnavailable,
3477 Some("external agent 'codex' failed: no binary".into()),
3478 0,
3479 Vec::new(),
3480 );
3481 assert!(scraped.error.unwrap().starts_with("external agent '"));
3482 }
3483
3484 fn failed(name: &str, exit: i64, tail: &str) -> CheckResult {
3487 CheckResult {
3488 credentials_allowed: false,
3489 name: name.into(),
3490 passed: false,
3491 exit_code: Some(exit),
3492 output_tail: tail.into(),
3493 duration_ms: 1,
3494 timed_out: false,
3495 deadline_clamped: false,
3496 }
3497 }
3498
3499 #[test]
3505 fn a_changed_error_class_under_one_check_name_is_not_a_recurrence() {
3506 let mut seen = HashMap::new();
3507 let compile = primary_failure(&[failed("tests", 101, "error[E0433]: failed to resolve")]);
3508 let assertion = primary_failure(&[failed("tests", 1, "assertion `left == right` failed")]);
3509 assert_ne!(
3510 compile.as_ref().map(|s| s.key()),
3511 assertion.as_ref().map(|s| s.key()),
3512 "same check, different error class must be different signatures"
3513 );
3514 assert_eq!(record_recurrence(&mut seen, compile.as_ref()), 0);
3515 assert_eq!(
3516 record_recurrence(&mut seen, assertion.as_ref()),
3517 0,
3518 "progress must not read as a recurrence"
3519 );
3520 }
3521
3522 #[test]
3525 fn the_identical_failure_recurs_and_counts_up() {
3526 let mut seen = HashMap::new();
3527 let sig = primary_failure(&[failed("tests", 1, "assertion `left == right` failed")]);
3528 assert_eq!(record_recurrence(&mut seen, sig.as_ref()), 0);
3529 assert_eq!(record_recurrence(&mut seen, sig.as_ref()), 1);
3530 assert_eq!(record_recurrence(&mut seen, sig.as_ref()), 2);
3531 }
3532
3533 #[test]
3537 fn an_oscillating_failure_still_recurs() {
3538 let mut seen = HashMap::new();
3539 let a = primary_failure(&[failed("tests", 1, "assertion `left == right` failed")]);
3540 let b = primary_failure(&[failed("build", 101, "error[E0433]: failed to resolve")]);
3541 assert_eq!(record_recurrence(&mut seen, a.as_ref()), 0);
3542 assert_eq!(record_recurrence(&mut seen, b.as_ref()), 0);
3543 assert_eq!(
3544 record_recurrence(&mut seen, a.as_ref()),
3545 1,
3546 "A -> B -> A is going in circles, not progress"
3547 );
3548 }
3549
3550 #[test]
3552 fn a_green_evaluation_is_not_a_recurrence() {
3553 let mut seen = HashMap::new();
3554 assert_eq!(record_recurrence(&mut seen, None), 0);
3555 assert!(seen.is_empty());
3556 }
3557
3558 #[tokio::test]
3562 async fn an_exhausted_budget_denies_admission_before_any_turn() {
3563 let script = Script::new(vec![turn("should never run", serde_json::json!([]))]);
3564 let dir = tempfile::tempdir().unwrap();
3565 let executor = WorktreeExecutor::new(dir.path());
3566 let sink = Arc::new(EventSink::test_sink());
3567 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3568 let cfg = NativeLoopConfig {
3569 deadline: std::sync::Arc::new(SessionDeadline::new(Some(0))),
3571 ..Default::default()
3572 };
3573 let outcome = run_native_loop(
3574 &script,
3575 &executor,
3576 "x",
3577 &OutcomeContract {
3578 allow_credentials: false,
3579 description: "x".into(),
3580 checks: vec![ContractCheck {
3581 name: "gate".into(),
3582 command: "exit 1".into(),
3583 expect_exit_zero: true,
3584 output_contains: None,
3585 timeout_secs: 10,
3586 baseline: false,
3587 differential: None,
3588 }],
3589 },
3590 &sink,
3591 &cancel,
3592 &cfg,
3593 &RepairMemory::disabled(),
3594 None,
3595 )
3596 .await;
3597 assert_eq!(
3598 script.prompts(),
3599 0,
3600 "the budget gates before any model turn"
3601 );
3602 assert_eq!(outcome.failure, Some(LoopFailure::BudgetExhausted));
3603 assert_eq!(outcome.iterations, 0);
3604 assert!(outcome
3605 .error
3606 .expect("the reason must surface")
3607 .contains("budget exhausted"));
3608 }
3609
3610 struct NeverReturns;
3611
3612 #[async_trait]
3613 impl TurnGenerator for NeverReturns {
3614 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
3615 std::future::pending().await
3616 }
3617 }
3618
3619 #[tokio::test]
3625 async fn a_native_turn_timeout_is_typed_and_still_evaluates_the_contract() {
3626 let dir = tempfile::tempdir().unwrap();
3627 let executor = WorktreeExecutor::new(dir.path());
3628 let (sink, events) = EventSink::collecting("native-turn-timeout");
3629 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3630 let cfg = NativeLoopConfig {
3631 deadline: std::sync::Arc::new(SessionDeadline::from_duration(Some(
3632 std::time::Duration::from_secs(1),
3633 ))),
3634 ..Default::default()
3635 };
3636 let contract = OutcomeContract {
3637 allow_credentials: false,
3638 description: "timeout still gets judged".into(),
3639 checks: vec![ContractCheck {
3640 name: "judged".into(),
3641 command: "exit 1".into(),
3642 expect_exit_zero: true,
3643 output_contains: None,
3644 timeout_secs: 10,
3645 baseline: false,
3646 differential: None,
3647 }],
3648 };
3649
3650 let outcome = tokio::time::timeout(
3651 std::time::Duration::from_secs(2),
3652 run_native_loop(
3653 &NeverReturns,
3654 &executor,
3655 "x",
3656 &contract,
3657 &sink,
3658 &cancel,
3659 &cfg,
3660 &RepairMemory::disabled(),
3661 None,
3662 ),
3663 )
3664 .await
3665 .expect("the native turn must stop at the session deadline");
3666
3667 assert_eq!(outcome.failure, Some(LoopFailure::BudgetExhausted));
3668 let events = events.lock().expect("events poisoned");
3669 assert!(events
3670 .iter()
3671 .any(|event| matches!(event.kind, CoderEventKind::Error { .. })));
3672 assert!(events.iter().any(|event| matches!(
3673 event.kind,
3674 CoderEventKind::CheckCompleted { ref result } if result.name == "judged"
3675 )));
3676 }
3677
3678 #[tokio::test]
3685 async fn the_escalation_is_delivered_to_the_model_only_after_a_repeat() {
3686 let dir = tempfile::tempdir().unwrap();
3687 let executor = WorktreeExecutor::new(dir.path());
3688 let sink = Arc::new(EventSink::test_sink());
3689 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3690
3691 let edit = |n: u32| {
3694 turn(
3695 "editing",
3696 serde_json::json!([{
3697 "id": format!("c{n}"),
3698 "name": "write_file",
3699 "arguments": {"path": format!("f{n}.txt"), "content": "x"}
3700 }]),
3701 )
3702 };
3703 let script = Script::new(vec![
3704 edit(1),
3705 turn("done", serde_json::json!([])),
3706 edit(2),
3707 turn("done", serde_json::json!([])),
3708 edit(3),
3709 turn("done", serde_json::json!([])),
3710 ]);
3711 let contract = OutcomeContract {
3712 allow_credentials: false,
3713 description: "never green".into(),
3714 checks: vec![ContractCheck {
3715 name: "gate".into(),
3716 command: "exit 1".into(),
3717 expect_exit_zero: true,
3718 output_contains: None,
3719 timeout_secs: 10,
3720 baseline: false,
3721 differential: None,
3722 }],
3723 };
3724 let cfg = NativeLoopConfig {
3725 max_iterations: 3,
3726 ..Default::default()
3727 };
3728 let outcome = run_native_loop(
3729 &script,
3730 &executor,
3731 "x",
3732 &contract,
3733 &sink,
3734 &cancel,
3735 &cfg,
3736 &RepairMemory::disabled(),
3737 None,
3738 )
3739 .await;
3740 assert!(!outcome.passed);
3741
3742 assert!(
3744 !script.prompt(0).contains("failed the same way"),
3745 "escalated before anything repeated"
3746 );
3747 let last = script.prompt(script.prompts() - 1);
3750 assert!(
3751 last.contains("failed the same way"),
3752 "the escalation never reached the model: {last}"
3753 );
3754 }
3755
3756 fn dead_backbone() -> Script {
3759 Script {
3760 turns: vec![],
3761 cursor: AtomicUsize::new(0),
3762 seen: std::sync::Mutex::new(Vec::new()),
3763 }
3764 }
3765
3766 async fn run_against(script: &Script, check: &str) -> LoopOutcome {
3767 let dir = tempfile::tempdir().unwrap();
3768 let executor = WorktreeExecutor::new(dir.path());
3769 let sink = Arc::new(EventSink::test_sink());
3770 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3771 let contract = OutcomeContract {
3772 allow_credentials: false,
3773 description: "x".into(),
3774 checks: vec![ContractCheck {
3775 name: "gate".into(),
3776 command: check.into(),
3777 expect_exit_zero: true,
3778 output_contains: None,
3779 timeout_secs: 10,
3780 baseline: false,
3781 differential: None,
3782 }],
3783 };
3784 run_native_loop(
3785 script,
3786 &executor,
3787 "x",
3788 &contract,
3789 &sink,
3790 &cancel,
3791 &NativeLoopConfig::default(),
3792 &RepairMemory::disabled(),
3793 None,
3794 )
3795 .await
3796 }
3797
3798 struct AuthFlaky {
3801 remaining: AtomicUsize,
3802 inner: Script,
3803 }
3804
3805 #[async_trait]
3806 impl TurnGenerator for AuthFlaky {
3807 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
3808 if self.remaining.load(Ordering::SeqCst) > 0 {
3809 self.remaining.fetch_sub(1, Ordering::SeqCst);
3810 return Err("no credential for proprietary provider 'parslee': \
3811 set $PARSLEE_ACCESS_TOKEN or run `car auth login parslee`"
3812 .to_string());
3813 }
3814 self.inner.generate(req).await
3815 }
3816 }
3817
3818 #[derive(Debug)]
3820 struct SignsIn;
3821 #[async_trait]
3822 impl AuthGate for SignsIn {
3823 async fn is_authenticated(&self) -> bool {
3824 true
3825 }
3826 }
3827
3828 #[derive(Debug)]
3830 struct NeverSignsIn;
3831 #[async_trait]
3832 impl AuthGate for NeverSignsIn {
3833 async fn is_authenticated(&self) -> bool {
3834 false
3835 }
3836 }
3837
3838 async fn run_with_auth(
3839 gen: &dyn TurnGenerator,
3840 check: &str,
3841 gate: Arc<dyn AuthGate>,
3842 auth_wait: std::time::Duration,
3843 ) -> LoopOutcome {
3844 let dir = tempfile::tempdir().unwrap();
3845 let executor = WorktreeExecutor::new(dir.path());
3846 let sink = Arc::new(EventSink::test_sink());
3847 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
3848 let contract = OutcomeContract {
3849 allow_credentials: false,
3850 description: "x".into(),
3851 checks: vec![ContractCheck {
3852 name: "gate".into(),
3853 command: check.into(),
3854 expect_exit_zero: true,
3855 output_contains: None,
3856 timeout_secs: 10,
3857 baseline: false,
3858 differential: None,
3859 }],
3860 };
3861 let cfg = NativeLoopConfig {
3862 auth_gate: Some(gate),
3863 auth_wait,
3864 ..Default::default()
3865 };
3866 run_native_loop(
3867 gen,
3868 &executor,
3869 "x",
3870 &contract,
3871 &sink,
3872 &cancel,
3873 &cfg,
3874 &RepairMemory::disabled(),
3875 None,
3876 )
3877 .await
3878 }
3879
3880 #[tokio::test]
3886 async fn a_lapsed_credential_waits_for_sign_in_and_then_resumes() {
3887 let gen = AuthFlaky {
3888 remaining: AtomicUsize::new(5),
3891 inner: Script::new(vec![turn("done", serde_json::json!([]))]),
3892 };
3893 let outcome = run_with_auth(
3894 &gen,
3895 "exit 0",
3896 Arc::new(SignsIn),
3897 std::time::Duration::from_secs(5),
3898 )
3899 .await;
3900
3901 assert!(
3902 outcome.passed,
3903 "the session must resume after sign-in, not die: {:?}",
3904 outcome.error
3905 );
3906 assert_eq!(outcome.failure, None);
3907 }
3908
3909 #[tokio::test]
3913 async fn nobody_signs_in_reports_needs_auth_not_infrastructure() {
3914 let gen = AuthFlaky {
3915 remaining: AtomicUsize::new(99),
3916 inner: Script::new(vec![turn("done", serde_json::json!([]))]),
3917 };
3918 let outcome = run_with_auth(
3919 &gen,
3920 "exit 1",
3921 Arc::new(NeverSignsIn),
3922 std::time::Duration::ZERO,
3923 )
3924 .await;
3925
3926 assert!(!outcome.passed);
3927 assert_eq!(
3928 outcome.failure,
3929 Some(LoopFailure::NeedsAuth),
3930 "an unanswered sign-in must not masquerade as an outage"
3931 );
3932 }
3933
3934 #[test]
3941 fn enriched_credential_errors_still_classify_as_auth_failures() {
3942 for msg in [
3943 "no credential for proprietary provider 'parslee' (model parslee/reasoning): the \
3944 Parslee token expired at unix 1234 and could not be refreshed. Re-authenticate \
3945 with `car auth login`",
3946 "no credential for proprietary provider 'parslee' (model parslee/reasoning): the \
3947 credential store could not be read (code=152). This is not a sign-out",
3948 "no credential for proprietary provider 'parslee' (model parslee/reasoning): no \
3949 account is signed in. Run `car auth login`",
3950 ] {
3951 assert!(
3952 is_auth_failure(msg),
3953 "enriched credential error must still read as an auth failure: {msg}"
3954 );
3955 }
3956 }
3957
3958 #[test]
3963 fn missing_provider_keys_do_not_enter_the_parslee_sign_in_wait() {
3964 for message in [
3965 "inference failed: OpenRouter requires a key — run `car keys set openrouter` or connect your OpenRouter account in CarHost",
3966 "inference failed: no API keys available for endpoint http://127.0.0.1:9/a47-missing-key (checked env vars: [\"CAR_TEST_A47_GENERIC_PROVIDER_KEY_DO_NOT_SET\", \"CAR_TEST_A47_GENERIC_EXTRA_KEY_1_DO_NOT_SET\", \"CAR_TEST_A47_GENERIC_EXTRA_KEY_2_DO_NOT_SET\"])",
3967 ] {
3968 assert!(!is_auth_failure(message), "wrong sign-in wait: {message}");
3969 }
3970 }
3971
3972 #[tokio::test]
3983 async fn a_workspace_gap_ends_the_session_without_a_sign_in_wait() {
3984 struct NoWorkspace {
3985 calls: AtomicUsize,
3986 }
3987 #[async_trait]
3988 impl TurnGenerator for NoWorkspace {
3989 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
3990 panic!("native coder must use the typed generation seam")
3991 }
3992
3993 async fn generate_coder(
3994 &self,
3995 _req: GenerateRequest,
3996 ) -> Result<InferenceResult, TurnGenerationError> {
3997 self.calls.fetch_add(1, Ordering::SeqCst);
3998 Err(TurnGenerationError::from(
3999 InferenceError::WorkspaceRequired {
4000 provider: "Parslee".into(),
4001 detail: "finish setting up at https://parslee.ai, then try again".into(),
4002 },
4003 ))
4004 }
4005 }
4006
4007 let generator = NoWorkspace {
4008 calls: AtomicUsize::new(0),
4009 };
4010 let cfg = NativeLoopConfig {
4011 auth_gate: Some(Arc::new(SignsIn)),
4012 ..Default::default()
4013 };
4014 let (outcome, events) = run_collecting(&generator, "exit 1", cfg).await;
4015
4016 assert!(
4017 events
4018 .iter()
4019 .all(|event| !matches!(&event.kind, CoderEventKind::AuthRequired { .. })),
4020 "a signed-in operator must not be told to sign in"
4021 );
4022 assert_eq!(
4023 outcome.failure,
4024 Some(LoopFailure::Configuration),
4025 "the run ends as configuration — the same bucket `coder/rpc.rs` \
4026 gives this kind on the agent-builder path"
4027 );
4028 assert_ne!(
4029 outcome.failure,
4030 Some(LoopFailure::NeedsAuth),
4031 "the workspace step is not an auth repair"
4032 );
4033 assert_ne!(
4034 outcome.failure,
4035 Some(LoopFailure::Infrastructure),
4036 "no retry creates a workspace"
4037 );
4038 let error = outcome.error.expect("a terminal cause");
4039 assert!(error.contains("https://parslee.ai"), "{error}");
4040 assert_eq!(
4043 generator.calls.load(Ordering::SeqCst),
4044 3,
4045 "the loop must stop on its own budget, not loop on a satisfied gate"
4046 );
4047 }
4048
4049 #[test]
4052 fn auth_failures_are_distinguished_from_outages() {
4053 assert!(is_auth_failure(
4054 "no credential for proprietary provider 'parslee': run `car auth login parslee`"
4055 ));
4056 assert!(is_auth_failure(
4057 "your Parslee session has expired or was rejected"
4058 ));
4059 assert!(is_auth_failure(
4060 "car-auth: cannot read Parslee credentials (secret store error)"
4061 ));
4062 assert!(is_auth_failure(
4063 "Parslee credential store unreadable for `parslee/reasoning`"
4064 ));
4065 assert!(is_auth_failure(
4066 "openai credential environment variable missing: `OPENAI_API_KEY` for explicitly requested `openai/gpt-5.6`"
4067 ));
4068 assert!(is_auth_failure(
4072 "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: \
4073 Authentication required"
4074 ));
4075
4076 assert!(!is_auth_failure("connection reset by peer"));
4077 assert!(!is_auth_failure("503 Service Unavailable"));
4078 assert!(!is_auth_failure("script exhausted"));
4079 assert!(!is_auth_failure("model failed, trying next fallback"));
4080 }
4081
4082 struct AlwaysFails {
4085 message: String,
4086 }
4087
4088 #[async_trait]
4089 impl TurnGenerator for AlwaysFails {
4090 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
4091 Err(self.message.clone())
4092 }
4093 }
4094
4095 async fn run_collecting(
4098 gen: &dyn TurnGenerator,
4099 check: &str,
4100 cfg: NativeLoopConfig,
4101 ) -> (LoopOutcome, Vec<CoderEvent>) {
4102 let dir = tempfile::tempdir().unwrap();
4103 let executor = WorktreeExecutor::new(dir.path());
4104 let (sink, collected) = EventSink::collecting("coder-auth");
4105 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4106 let contract = OutcomeContract {
4107 allow_credentials: false,
4108 description: "x".into(),
4109 checks: vec![ContractCheck {
4110 name: "gate".into(),
4111 command: check.into(),
4112 expect_exit_zero: true,
4113 output_contains: None,
4114 timeout_secs: 10,
4115 baseline: false,
4116 differential: None,
4117 }],
4118 };
4119 let outcome = run_native_loop(
4120 gen,
4121 &executor,
4122 "x",
4123 &contract,
4124 &sink,
4125 &cancel,
4126 &cfg,
4127 &RepairMemory::disabled(),
4128 None,
4129 )
4130 .await;
4131 let events = collected.lock().expect("collector poisoned").clone();
4132 (outcome, events)
4133 }
4134
4135 #[tokio::test]
4148 async fn an_ungated_auth_failure_still_asks_for_sign_in_without_waiting() {
4149 let gen = AlwaysFails {
4150 message: "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: \
4151 Authentication required"
4152 .to_string(),
4153 };
4154 let (outcome, events) = run_collecting(&gen, "exit 1", NativeLoopConfig::default()).await;
4155
4156 let prompts: Vec<(u64, String)> = events
4157 .iter()
4158 .filter_map(|e| match &e.kind {
4159 CoderEventKind::AuthRequired { wait_secs, message } => {
4160 Some((*wait_secs, message.clone()))
4161 }
4162 _ => None,
4163 })
4164 .collect();
4165 let strikes = events
4169 .iter()
4170 .filter(|e| {
4171 matches!(&e.kind, CoderEventKind::Error { message }
4172 if message.contains("inference failed (turn"))
4173 })
4174 .count();
4175 assert_eq!(strikes, 3, "the run must have burned all three strikes");
4176 assert_eq!(
4177 prompts.len(),
4178 1,
4179 "an expired credential must ask for a sign-in exactly ONCE with no auth \
4180 gate — one prompt across all {strikes} strikes, not one per strike: {:?}",
4181 events.iter().map(|e| &e.kind).collect::<Vec<_>>()
4182 );
4183 for (wait_secs, message) in &prompts {
4184 assert_eq!(
4185 *wait_secs, 0,
4186 "an ungated loop is not waiting; saying it is would be a lie on the wire"
4187 );
4188 assert!(message.contains("401"), "the cause must survive: {message}");
4189 }
4190 assert_eq!(
4196 outcome.failure,
4197 Some(LoopFailure::NeedsAuth),
4198 "no gate means no wait, not that an expired login becomes infrastructure"
4199 );
4200 }
4201
4202 #[tokio::test]
4203 async fn mixed_auth_and_local_oom_is_classified_as_auth() {
4204 let gen = AlwaysFails {
4205 message: "inference failed: Parslee login expired for `parslee/reasoning` — \
4206 run `car auth login`; fallback then failed: This model needs about \
4207 9059 MB, beyond the configured 6553 MB local-model allocation"
4208 .to_string(),
4209 };
4210 let (outcome, events) = run_collecting(&gen, "exit 1", NativeLoopConfig::default()).await;
4211
4212 assert_eq!(outcome.failure, Some(LoopFailure::NeedsAuth));
4213 let auth_message = events
4214 .iter()
4215 .find_map(|event| match &event.kind {
4216 CoderEventKind::AuthRequired { message, .. } => Some(message.as_str()),
4217 _ => None,
4218 })
4219 .expect("mixed failure must emit auth_required");
4220 let auth_pos = auth_message
4221 .find("Parslee login expired")
4222 .expect("credential cause must be named");
4223 let oom_pos = auth_message
4224 .find("9059 MB")
4225 .expect("fallback OOM must remain as secondary detail");
4226 assert!(auth_pos < oom_pos, "credential cause must be named first");
4227 }
4228
4229 #[tokio::test]
4230 async fn genuine_local_oom_remains_inference_infrastructure() {
4231 let gen = AlwaysFails {
4232 message: "inference failed: This model needs about 9059 MB, beyond the configured \
4233 6553 MB local-model allocation"
4234 .to_string(),
4235 };
4236 let (outcome, events) = run_collecting(&gen, "exit 1", NativeLoopConfig::default()).await;
4237
4238 assert_eq!(outcome.failure, Some(LoopFailure::Infrastructure));
4239 assert!(events
4240 .iter()
4241 .all(|event| !matches!(&event.kind, CoderEventKind::AuthRequired { .. })));
4242 }
4243
4244 struct RejectedThenServes {
4250 remaining: AtomicUsize,
4251 message: String,
4252 inner: Script,
4253 }
4254
4255 #[async_trait]
4256 impl TurnGenerator for RejectedThenServes {
4257 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
4258 if self.remaining.load(Ordering::SeqCst) > 0 {
4259 self.remaining.fetch_sub(1, Ordering::SeqCst);
4260 return Err(self.message.clone());
4261 }
4262 self.inner.generate(req).await
4263 }
4264 }
4265
4266 #[derive(Debug, Default)]
4271 struct SignsInWhenAsked {
4272 polls: AtomicUsize,
4273 }
4274 #[async_trait]
4275 impl AuthGate for SignsInWhenAsked {
4276 async fn is_authenticated(&self) -> bool {
4277 self.polls.fetch_add(1, Ordering::SeqCst);
4278 true
4279 }
4280 }
4281
4282 #[tokio::test]
4290 async fn a_rejected_credential_with_a_gate_waits_and_then_resumes() {
4291 let gate = Arc::new(SignsInWhenAsked::default());
4292 let cfg = NativeLoopConfig {
4293 auth_gate: Some(gate.clone()),
4294 auth_wait: std::time::Duration::from_secs(5),
4297 ..Default::default()
4298 };
4299 let gen = RejectedThenServes {
4300 remaining: AtomicUsize::new(1),
4302 message: "inference failed: Parslee org lookup failed: HTTP 401 Unauthorized: \
4303 Authentication required"
4304 .to_string(),
4305 inner: Script::new(vec![turn("done", serde_json::json!([]))]),
4306 };
4307 let (outcome, events) = run_collecting(&gen, "exit 0", cfg.clone()).await;
4308
4309 let prompts: Vec<u64> = events
4310 .iter()
4311 .filter_map(|e| match &e.kind {
4312 CoderEventKind::AuthRequired { wait_secs, .. } => Some(*wait_secs),
4313 _ => None,
4314 })
4315 .collect();
4316 assert_eq!(
4317 prompts,
4318 vec![cfg.auth_wait.as_secs()],
4319 "a gated lapse must advertise the REAL wait window, not 0: {:?}",
4320 events.iter().map(|e| &e.kind).collect::<Vec<_>>()
4321 );
4322 assert!(
4323 cfg.auth_wait.as_secs() > 0,
4324 "the window must be non-zero or the assertion above proves nothing"
4325 );
4326 assert!(
4327 gate.polls.load(Ordering::SeqCst) >= 1,
4328 "the loop must have actually waited on the gate"
4329 );
4330
4331 assert!(
4334 outcome.passed,
4335 "the session must resume after sign-in, not die: {:?}",
4336 outcome.error
4337 );
4338 assert_eq!(
4339 outcome.failure, None,
4340 "a recovered lapse is neither NeedsAuth nor Infrastructure"
4341 );
4342 }
4343
4344 #[tokio::test]
4349 async fn a_degraded_lane_is_announced_once_per_run() {
4350 let degraded = |text: &str, tool_calls: serde_json::Value| {
4351 let mut t = turn(text, tool_calls);
4352 t.auth_fallback_from = Some("parslee/reasoning".to_string());
4353 t.model_used = "local/qwen3".to_string();
4354 t
4355 };
4356 let script = Script::new(vec![
4359 degraded(
4360 "editing",
4361 serde_json::json!([{
4362 "id": "c1",
4363 "name": "write_file",
4364 "arguments": {"path": "hello.txt", "content": "hello coder"}
4365 }]),
4366 ),
4367 degraded("done", serde_json::json!([])),
4368 ]);
4369 let (outcome, events) =
4370 run_collecting(&script, "exit 0", NativeLoopConfig::default()).await;
4371 assert!(outcome.passed, "outcome: {outcome:?}");
4372 assert_eq!(
4373 script.prompts(),
4374 2,
4375 "both degraded turns must actually have run"
4376 );
4377
4378 let announcements: Vec<(&str, &str)> = events
4379 .iter()
4380 .filter_map(|e| match &e.kind {
4381 CoderEventKind::ModelFallback { from, to, reason } => {
4382 assert!(
4383 reason.contains("car auth login"),
4384 "the reason must name the remedy: {reason}"
4385 );
4386 Some((from.as_str(), to.as_str()))
4387 }
4388 _ => None,
4389 })
4390 .collect();
4391 assert_eq!(
4392 announcements,
4393 vec![("parslee/reasoning", "local/qwen3")],
4394 "exactly one announcement, naming the dead lane and the model that answered"
4395 );
4396 }
4397
4398 #[tokio::test]
4404 async fn a_dead_backbone_over_green_checks_still_passes() {
4405 let outcome = run_against(&dead_backbone(), &crate::coder::test_cmds::touch("m.txt")).await;
4406 assert!(outcome.passed, "the contract decides: {outcome:?}");
4407 assert_eq!(outcome.failure, None);
4408 assert!(outcome.error.is_none());
4409 }
4410
4411 #[tokio::test]
4414 async fn a_dead_backbone_over_red_checks_is_infrastructure() {
4415 let outcome = run_against(&dead_backbone(), "exit 1").await;
4416 assert!(!outcome.passed);
4417 assert_eq!(outcome.failure, Some(LoopFailure::Infrastructure));
4418 assert!(outcome
4419 .error
4420 .expect("a dead backbone must surface")
4421 .contains("inference failed repeatedly"));
4422 }
4423
4424 #[tokio::test]
4425 async fn scripted_loop_edits_verifies_and_passes() {
4426 let dir = tempfile::tempdir().unwrap();
4427 let executor = WorktreeExecutor::new(dir.path());
4428 let (sink, collected) = EventSink::collecting("coder-native");
4429 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4430
4431 let script = Script {
4433 turns: vec![
4434 turn(
4435 "creating the file",
4436 serde_json::json!([{
4437 "id": "c1",
4438 "name": "write_file",
4439 "arguments": {"path": "hello.txt", "content": "hello coder"}
4440 }]),
4441 ),
4442 turn("done — file created", serde_json::json!([])),
4443 ],
4444 cursor: AtomicUsize::new(0),
4445 seen: std::sync::Mutex::new(Vec::new()),
4446 };
4447 let contract = OutcomeContract {
4448 allow_credentials: false,
4449 description: "hello.txt exists with content".into(),
4450 checks: vec![ContractCheck {
4451 name: "exists".into(),
4452 command: crate::coder::test_cmds::contains("coder", "hello.txt"),
4453 expect_exit_zero: true,
4454 output_contains: None,
4455 timeout_secs: 10,
4456 baseline: false,
4457 differential: None,
4458 }],
4459 };
4460
4461 let outcome = run_native_loop(
4462 &script,
4463 &executor,
4464 "create hello.txt containing 'hello coder'",
4465 &contract,
4466 &sink,
4467 &cancel,
4468 &NativeLoopConfig::default(),
4469 &RepairMemory::disabled(),
4470 None,
4471 )
4472 .await;
4473
4474 assert!(outcome.passed, "outcome: {outcome:?}");
4475 assert_eq!(outcome.iterations, 1);
4476 assert!(dir.path().join("hello.txt").exists());
4477
4478 let events = collected.lock().unwrap();
4480 let types: Vec<&str> = events
4481 .iter()
4482 .map(|e| match &e.kind {
4483 CoderEventKind::IterationStarted { .. } => "iteration",
4484 CoderEventKind::ToolCall { .. } => "tool_call",
4485 CoderEventKind::ToolResult { .. } => "tool_result",
4486 CoderEventKind::PlanText { .. } => "plan",
4487 CoderEventKind::CheckStarted { .. } => "check_started",
4488 CoderEventKind::CheckCompleted { .. } => "check_completed",
4489 _ => "other",
4490 })
4491 .collect();
4492 assert_eq!(
4493 types,
4494 vec![
4495 "iteration",
4496 "tool_call",
4497 "tool_result",
4498 "plan",
4499 "check_started",
4500 "check_completed"
4501 ]
4502 );
4503 }
4504
4505 #[tokio::test]
4509 async fn browser_policy_denial_is_recorded_as_tool_receipts() {
4510 let dir = tempfile::tempdir().unwrap();
4511 let policies = dir.path().join(".car").join("policies");
4512 std::fs::create_dir_all(&policies).unwrap();
4513 std::fs::write(
4514 policies.join("browser.toml"),
4515 "deny_tool = [\"browse_navigate\"]\n",
4516 )
4517 .unwrap();
4518 let executor = WorktreeExecutor::for_coder_session(dir.path())
4519 .unwrap()
4520 .with_browser_tools();
4521 let collected: Arc<std::sync::Mutex<Vec<crate::coder::CoderEvent>>> =
4522 Arc::new(std::sync::Mutex::new(Vec::new()));
4523 let collector = Arc::clone(&collected);
4524 let emitter: crate::coder::EventEmitter = Arc::new(move |event| {
4525 collector.lock().unwrap().push(event);
4526 });
4527 let journal = dir.path().join("browser-receipts.events.jsonl");
4528 let sink = EventSink::new("coder-browser", Some(emitter), Some(journal.clone()));
4529 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4530 let script = Script {
4531 turns: vec![
4532 turn(
4533 "checking the page",
4534 serde_json::json!([{
4535 "id": "browser-call",
4536 "name": "browse_navigate",
4537 "arguments": {"url": "https://example.com"}
4538 }]),
4539 ),
4540 turn("done", serde_json::json!([])),
4541 ],
4542 cursor: AtomicUsize::new(0),
4543 seen: std::sync::Mutex::new(Vec::new()),
4544 };
4545 let contract = OutcomeContract {
4546 allow_credentials: false,
4547 description: "receipt probe".into(),
4548 checks: vec![],
4549 };
4550
4551 let outcome = run_native_loop(
4552 &script,
4553 &executor,
4554 "inspect a page",
4555 &contract,
4556 &sink,
4557 &cancel,
4558 &NativeLoopConfig::default(),
4559 &RepairMemory::disabled(),
4560 None,
4561 )
4562 .await;
4563 assert!(outcome.passed, "outcome: {outcome:?}");
4564
4565 let events = collected.lock().unwrap();
4566 assert!(events.iter().any(|event| matches!(
4567 &event.kind,
4568 CoderEventKind::ToolCall { tool, .. } if tool == "browse_navigate"
4569 )));
4570 assert!(events.iter().any(|event| matches!(
4571 &event.kind,
4572 CoderEventKind::ToolResult { tool, ok: false, preview }
4573 if tool == "browse_navigate" && preview.contains("operator policy")
4574 )));
4575 drop(events);
4576 drop(sink);
4577
4578 let durable = car_eventlog::EventLog::load_read_only(&journal).unwrap();
4579 assert!(durable.events().iter().any(|event| {
4580 event.kind == car_eventlog::EventKind::ActionExecuting
4581 && event.action_id.as_deref() == Some("browse_navigate")
4582 }));
4583 assert!(durable.events().iter().any(|event| {
4584 event.kind == car_eventlog::EventKind::ActionFailed
4585 && event.action_id.as_deref() == Some("browse_navigate")
4586 }));
4587 }
4588
4589 fn identical_read_turn() -> InferenceResult {
4592 turn(
4593 "reading again",
4594 serde_json::json!([{
4595 "id": "c",
4596 "name": "read_file",
4597 "arguments": {"path": "src.py"}
4598 }]),
4599 )
4600 }
4601
4602 #[tokio::test]
4603 async fn native_loop_no_progress_bails_but_green_contract_still_passes() {
4604 let dir = tempfile::tempdir().unwrap();
4608 let executor = WorktreeExecutor::new(dir.path());
4609 let (sink, _collected) = EventSink::collecting("coder-native");
4610 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4611
4612 let script = Script {
4613 turns: (0..NO_PROGRESS_REPEAT_LIMIT + 2)
4614 .map(|_| identical_read_turn())
4615 .collect(),
4616 cursor: AtomicUsize::new(0),
4617 seen: std::sync::Mutex::new(Vec::new()),
4618 };
4619 let contract = OutcomeContract {
4621 allow_credentials: false,
4622 description: "already satisfied".into(),
4623 checks: vec![ContractCheck {
4624 name: "ok".into(),
4625 command: crate::coder::test_cmds::PASS.into(),
4629 expect_exit_zero: true,
4630 output_contains: None,
4631 timeout_secs: 10,
4632 baseline: false,
4633 differential: None,
4634 }],
4635 };
4636
4637 let outcome = run_native_loop(
4638 &script,
4639 &executor,
4640 "fix the bug",
4641 &contract,
4642 &sink,
4643 &cancel,
4644 &NativeLoopConfig::default(),
4645 &RepairMemory::disabled(),
4646 None,
4647 )
4648 .await;
4649
4650 assert!(
4651 outcome.passed,
4652 "green contract must pass despite the thrash: {outcome:?}"
4653 );
4654 assert_eq!(outcome.iterations, 1);
4655 }
4656
4657 #[tokio::test]
4658 async fn native_loop_repeated_failed_edits_verify_successful_work() {
4659 let dir = tempfile::tempdir().unwrap();
4663 let executor = WorktreeExecutor::new(dir.path());
4664 let (sink, _collected) = EventSink::collecting("coder-native");
4665 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4666
4667 let mut turns = vec![
4668 turn(
4669 "",
4670 serde_json::json!([{"id":"write", "name":"write_file", "arguments":{"path":"result.txt", "content":"done"}}]),
4671 ),
4672 turn(
4673 "",
4674 serde_json::json!([{"id":"read", "name":"read_file", "arguments":{"path":"result.txt"}}]),
4675 ),
4676 ];
4677 turns.extend((0..NO_PROGRESS_REPEAT_LIMIT + 2).map(|_| turn("", serde_json::json!([
4678 {"id":"stale", "name":"edit_file", "arguments":{"path":"result.txt", "old_text":"missing", "new_text":"done"}}
4679 ]))));
4680 let script = Script {
4681 turns,
4682 cursor: AtomicUsize::new(0),
4683 seen: std::sync::Mutex::new(Vec::new()),
4684 };
4685 let contract = OutcomeContract {
4687 allow_credentials: false,
4688 description: "already satisfied".into(),
4689 checks: vec![ContractCheck {
4690 name: "ok".into(),
4691 command: crate::coder::test_cmds::contains("done", "result.txt"),
4692 expect_exit_zero: true,
4693 output_contains: None,
4694 timeout_secs: 10,
4695 baseline: false,
4696 differential: None,
4697 }],
4698 };
4699
4700 let outcome = run_native_loop(
4701 &script,
4702 &executor,
4703 "fix the bug",
4704 &contract,
4705 &sink,
4706 &cancel,
4707 &NativeLoopConfig::default(),
4708 &RepairMemory::disabled(),
4709 None,
4710 )
4711 .await;
4712
4713 assert!(
4714 outcome.passed,
4715 "green contract must pass despite the thrash: {outcome:?}"
4716 );
4717 assert_eq!(outcome.iterations, 1);
4718 assert_eq!(
4719 script.cursor.load(Ordering::SeqCst),
4720 (NO_PROGRESS_REPEAT_LIMIT + 2) as usize
4721 );
4722 assert_eq!(
4723 std::fs::read_to_string(dir.path().join("result.txt")).unwrap(),
4724 "done"
4725 );
4726 }
4727
4728 #[tokio::test]
4729 async fn native_loop_aborts_after_two_no_progress_iterations() {
4730 let dir = tempfile::tempdir().unwrap();
4734 let executor = WorktreeExecutor::new(dir.path());
4735 let (sink, _collected) = EventSink::collecting("coder-native");
4736 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4737
4738 let script = Script {
4740 turns: (0..(NO_PROGRESS_REPEAT_LIMIT * 2 + 4))
4741 .map(|_| identical_read_turn())
4742 .collect(),
4743 cursor: AtomicUsize::new(0),
4744 seen: std::sync::Mutex::new(Vec::new()),
4745 };
4746 let contract = OutcomeContract {
4748 allow_credentials: false,
4749 description: "never satisfied".into(),
4750 checks: vec![ContractCheck {
4751 name: "never".into(),
4752 command: crate::coder::test_cmds::FAIL.to_string(),
4753 expect_exit_zero: true,
4754 output_contains: None,
4755 timeout_secs: 10,
4756 baseline: false,
4757 differential: None,
4758 }],
4759 };
4760
4761 let outcome = run_native_loop(
4762 &script,
4763 &executor,
4764 "fix the bug",
4765 &contract,
4766 &sink,
4767 &cancel,
4768 &NativeLoopConfig::default(),
4769 &RepairMemory::disabled(),
4770 None,
4771 )
4772 .await;
4773
4774 assert!(!outcome.passed, "outcome: {outcome:?}");
4775 let err = outcome.error.unwrap_or_default();
4776 assert!(err.contains("no-progress loop"), "error was: {err}");
4777 assert_eq!(outcome.iterations, 2);
4779 }
4780
4781 #[tokio::test]
4792 async fn a_dead_backbone_after_the_edit_still_records_the_author() {
4793 let dir = tempfile::tempdir().unwrap();
4794 let workspace = dir.path().join("workspace");
4795 std::fs::create_dir(&workspace).unwrap();
4796 let executor = WorktreeExecutor::new(&workspace);
4797 let journal = dir.path().join("journal").join("events.jsonl");
4798 let sink = EventSink::new("coder-dead-backbone", None, Some(journal.clone()));
4799 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4800
4801 let mut editing = turn(
4804 "creating the file",
4805 serde_json::json!([{
4806 "id": "c1",
4807 "name": "write_file",
4808 "arguments": {"path": "hello.txt", "content": "hello coder"}
4809 }]),
4810 );
4811 editing.model_used = "writer".to_string();
4812 let script = Script::new(vec![editing]);
4813
4814 let contract = OutcomeContract {
4815 allow_credentials: false,
4816 description: "hello.txt exists with content".into(),
4817 checks: vec![ContractCheck {
4818 name: "exists".into(),
4819 command: crate::coder::test_cmds::contains("coder", "hello.txt"),
4820 expect_exit_zero: true,
4821 output_contains: None,
4822 timeout_secs: 10,
4823 baseline: false,
4824 differential: None,
4825 }],
4826 };
4827
4828 let outcome = run_native_loop(
4829 &script,
4830 &executor,
4831 "create hello.txt containing 'hello coder'",
4832 &contract,
4833 &sink,
4834 &cancel,
4835 &NativeLoopConfig::default(),
4836 &RepairMemory::disabled(),
4837 None,
4838 )
4839 .await;
4840 assert!(outcome.passed, "outcome: {outcome:?}");
4842
4843 drop(sink);
4844 let log = car_eventlog::EventLog::load(&journal).unwrap();
4845 let ev = log
4846 .events()
4847 .iter()
4848 .find(|e| e.kind == car_eventlog::EventKind::TurnCompleted)
4849 .expect("the backbone-death exit must journal a terminal too");
4850 assert_eq!(
4851 ev.data.get("decision"),
4852 Some(&serde_json::json!("inference_failed"))
4853 );
4854 assert_eq!(
4855 ev.data.get("models_served"),
4856 Some(&serde_json::json!(["writer"])),
4857 "the model that landed the edit must be named: {:?}",
4858 ev.data
4859 );
4860 }
4861
4862 #[tokio::test]
4870 async fn native_loop_journals_every_model_that_served_an_iteration() {
4871 let dir = tempfile::tempdir().unwrap();
4872 let workspace = dir.path().join("workspace");
4873 std::fs::create_dir(&workspace).unwrap();
4874 let executor = WorktreeExecutor::new(&workspace);
4875 let journal = dir.path().join("journal").join("events.jsonl");
4876 let sink = EventSink::new("coder-models", None, Some(journal.clone()));
4877 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4878
4879 let mut editing = turn(
4882 "creating the file",
4883 serde_json::json!([{
4884 "id": "c1",
4885 "name": "write_file",
4886 "arguments": {"path": "hello.txt", "content": "hello coder"}
4887 }]),
4888 );
4889 editing.model_used = "writer".to_string();
4890 let mut closing = turn("done — file created", serde_json::json!([]));
4891 closing.model_used = "finisher".to_string();
4892
4893 let script = Script::new(vec![editing, closing]);
4894 let contract = OutcomeContract {
4895 allow_credentials: false,
4896 description: "hello.txt exists with content".into(),
4897 checks: vec![ContractCheck {
4898 name: "exists".into(),
4899 command: crate::coder::test_cmds::contains("coder", "hello.txt"),
4900 expect_exit_zero: true,
4901 output_contains: None,
4902 timeout_secs: 10,
4903 baseline: false,
4904 differential: None,
4905 }],
4906 };
4907
4908 let outcome = run_native_loop(
4909 &script,
4910 &executor,
4911 "create hello.txt containing 'hello coder'",
4912 &contract,
4913 &sink,
4914 &cancel,
4915 &NativeLoopConfig::default(),
4916 &RepairMemory::disabled(),
4917 None,
4918 )
4919 .await;
4920 assert!(outcome.passed, "outcome: {outcome:?}");
4921
4922 drop(sink);
4923 let log = car_eventlog::EventLog::load(&journal).unwrap();
4924 let ev = log
4925 .events()
4926 .iter()
4927 .find(|e| e.kind == car_eventlog::EventKind::TurnCompleted)
4928 .expect("a terminal was journaled");
4929
4930 assert_eq!(
4932 ev.data.get("model_id"),
4933 Some(&serde_json::json!("finisher"))
4934 );
4935 assert_eq!(
4937 ev.data.get("models_served"),
4938 Some(&serde_json::json!(["writer", "finisher"])),
4939 "the model that wrote the edit must be recorded: {:?}",
4940 ev.data
4941 );
4942 }
4943
4944 #[tokio::test]
4945 async fn native_loop_empty_tool_calls_journals_turn_completed() {
4946 let dir = tempfile::tempdir().unwrap();
4950 let workspace = dir.path().join("workspace");
4951 std::fs::create_dir(&workspace).unwrap();
4952 let executor = WorktreeExecutor::new(&workspace);
4953 let journal = dir.path().join("journal").join("events.jsonl");
4954 let sink = EventSink::new("coder-native", None, Some(journal.clone()));
4958 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
4959
4960 let script = Script {
4961 turns: vec![
4962 turn(
4963 "creating the file",
4964 serde_json::json!([{
4965 "id": "c1",
4966 "name": "write_file",
4967 "arguments": {"path": "hello.txt", "content": "hello coder"}
4968 }]),
4969 ),
4970 turn("done — file created", serde_json::json!([])),
4971 ],
4972 cursor: AtomicUsize::new(0),
4973 seen: std::sync::Mutex::new(Vec::new()),
4974 };
4975 let contract = OutcomeContract {
4976 allow_credentials: false,
4977 description: "hello.txt exists with content".into(),
4978 checks: vec![ContractCheck {
4979 name: "exists".into(),
4980 command: crate::coder::test_cmds::contains("coder", "hello.txt"),
4981 expect_exit_zero: true,
4982 output_contains: None,
4983 timeout_secs: 10,
4984 baseline: false,
4985 differential: None,
4986 }],
4987 };
4988
4989 let outcome = run_native_loop(
4990 &script,
4991 &executor,
4992 "create hello.txt containing 'hello coder'",
4993 &contract,
4994 &sink,
4995 &cancel,
4996 &NativeLoopConfig::default(),
4997 &RepairMemory::disabled(),
4998 None,
4999 )
5000 .await;
5001 assert!(outcome.passed, "outcome: {outcome:?}");
5002
5003 drop(sink);
5005 let log = car_eventlog::EventLog::load(&journal).unwrap();
5006 let terminals: Vec<_> = log
5007 .events()
5008 .iter()
5009 .filter(|e| e.kind == car_eventlog::EventKind::TurnCompleted)
5010 .collect();
5011 assert_eq!(
5012 terminals.len(),
5013 1,
5014 "exactly one empty-tool-calls terminal recorded"
5015 );
5016 let ev = terminals[0];
5017 assert_eq!(
5018 ev.data.get("decision"),
5019 Some(&serde_json::json!("empty_tool_calls"))
5020 );
5021 assert_eq!(
5022 ev.data.get("model_id"),
5023 Some(&serde_json::json!("scripted"))
5024 );
5025 assert_eq!(
5028 ev.data.get("model_tier"),
5029 Some(&serde_json::json!("unknown"))
5030 );
5031 }
5032
5033 #[tokio::test]
5034 async fn native_loop_injects_proactive_memory_from_journaled_failures() {
5035 use car_memgine::MemgineEngine;
5036 use std::sync::Mutex as StdMutex;
5037 use tokio::sync::Mutex as AsyncMutex;
5038
5039 struct CaptureContext {
5040 seen: Arc<StdMutex<Vec<Option<String>>>>,
5041 }
5042
5043 #[async_trait]
5044 impl TurnGenerator for CaptureContext {
5045 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5046 self.seen.lock().unwrap().push(req.context.clone());
5047 Ok(turn("done", serde_json::json!([])))
5048 }
5049 }
5050
5051 let dir = tempfile::tempdir().unwrap();
5052 let executor = WorktreeExecutor::new(dir.path());
5053 let journal = dir.path().join("events.jsonl");
5054 let sink = EventSink::new("coder-native", None, Some(journal.clone()));
5055 sink.emit(CoderEventKind::ToolResult {
5056 tool: "shell".into(),
5057 ok: false,
5058 preview: "pytest failed because fixture data is missing".into(),
5059 });
5060 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5061 let memory = RepairMemory::new(Some(Arc::new(AsyncMutex::new(MemgineEngine::new(None)))));
5062 let seen = Arc::new(StdMutex::new(Vec::new()));
5063 let capture = CaptureContext { seen: seen.clone() };
5064 let contract = OutcomeContract {
5065 allow_credentials: false,
5066 description: "noop".into(),
5067 checks: vec![],
5068 };
5069
5070 let outcome = run_native_loop(
5071 &capture,
5072 &executor,
5073 "fix the pytest failure",
5074 &contract,
5075 &sink,
5076 &cancel,
5077 &NativeLoopConfig::default(),
5078 &memory,
5079 None,
5080 )
5081 .await;
5082
5083 assert!(outcome.passed, "outcome: {outcome:?}");
5084 let contexts = seen.lock().unwrap();
5085 let context = contexts[0].as_deref().unwrap_or("");
5086 assert!(
5087 context.contains("## Proactive Memory"),
5088 "request context should carry proactive memory: {context}"
5089 );
5090 assert!(
5091 context.contains("Action shell in proposal session failed"),
5092 "journaled failure should be injected: {context}"
5093 );
5094 drop(sink);
5095 let log = car_eventlog::EventLog::load(&journal).unwrap();
5096 assert!(log
5097 .events()
5098 .iter()
5099 .any(|e| e.kind == car_eventlog::EventKind::ProactiveMemoryMaintained));
5100 assert!(log.events().iter().any(|e| {
5101 e.kind == car_eventlog::EventKind::ProactiveMemoryIntervention
5102 && e.data.get("decision") == Some(&serde_json::json!("inject"))
5103 }));
5104 }
5105
5106 #[tokio::test]
5107 async fn native_loop_turn_budget_exhaustion_journals_max_turns() {
5108 let dir = tempfile::tempdir().unwrap();
5113 let executor = WorktreeExecutor::new(dir.path());
5114 let journal = dir.path().join("events.jsonl");
5115 let sink = EventSink::new("coder-native", None, Some(journal.clone()));
5116 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5117
5118 let tool_turn = || {
5119 turn(
5120 "still working",
5121 serde_json::json!([{
5122 "id": "c",
5123 "name": "write_file",
5124 "arguments": {"path": "scratch.txt", "content": "x"}
5125 }]),
5126 )
5127 };
5128 let script = Script {
5129 turns: vec![tool_turn(), tool_turn()],
5130 cursor: AtomicUsize::new(0),
5131 seen: std::sync::Mutex::new(Vec::new()),
5132 };
5133 let contract = OutcomeContract {
5134 allow_credentials: false,
5135 description: "never satisfied".into(),
5136 checks: vec![ContractCheck {
5137 name: "exists".into(),
5138 command: crate::coder::test_cmds::contains("coder", "hello.txt"),
5139 expect_exit_zero: true,
5140 output_contains: None,
5141 timeout_secs: 10,
5142 baseline: false,
5143 differential: None,
5144 }],
5145 };
5146 let cfg = NativeLoopConfig {
5147 steering: None,
5148 prompt_overlay: None,
5149 model: None,
5150 exclude_models: Vec::new(),
5151 max_iterations: 1,
5152 max_turns_per_iteration: 2,
5153 max_tokens_per_turn: 4096,
5154 deadline: SessionDeadline::shared_default(),
5155 auth_gate: None,
5156 auth_wait: std::time::Duration::ZERO,
5157 can_adjudicate_no_change: false,
5158 baseline_captures: crate::coder::contract::BaselineCaptures::new(),
5159 };
5160
5161 let outcome = run_native_loop(
5162 &script,
5163 &executor,
5164 "keep writing forever",
5165 &contract,
5166 &sink,
5167 &cancel,
5168 &cfg,
5169 &RepairMemory::disabled(),
5170 None,
5171 )
5172 .await;
5173 assert!(!outcome.passed, "outcome: {outcome:?}");
5174
5175 drop(sink);
5176 let log = car_eventlog::EventLog::load(&journal).unwrap();
5177 let max_turns: Vec<_> = log
5178 .events()
5179 .iter()
5180 .filter(|e| {
5181 e.kind == car_eventlog::EventKind::TurnCompleted
5182 && e.data.get("decision") == Some(&serde_json::json!("max_turns"))
5183 })
5184 .collect();
5185 assert_eq!(
5186 max_turns.len(),
5187 1,
5188 "turn-budget exhaustion recorded once as max_turns"
5189 );
5190 assert_eq!(max_turns[0].data.get("turns"), Some(&serde_json::json!(2)));
5191 }
5192
5193 #[tokio::test]
5194 async fn native_loop_compacts_persistent_history_to_context_window() {
5195 use std::sync::Mutex;
5203
5204 struct RecordingGen {
5205 seen: Arc<Mutex<Vec<(usize, bool)>>>,
5207 turn_no: AtomicUsize,
5210 }
5211 #[async_trait]
5212 impl TurnGenerator for RecordingGen {
5213 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5214 let msgs = req.messages.as_ref().expect("coder always sets messages");
5215 let starts_with_system = matches!(msgs.first(), Some(Message::System { .. }));
5216 self.seen
5217 .lock()
5218 .unwrap()
5219 .push((msgs.len(), starts_with_system));
5220 let n = self.turn_no.fetch_add(1, Ordering::SeqCst);
5224 Ok(turn(
5225 &"x".repeat(8000),
5226 serde_json::json!([{
5227 "id": format!("c{n}"),
5228 "name": "write_file",
5229 "arguments": {"path": format!("big{n}.txt"), "content": "y"}
5230 }]),
5231 ))
5232 }
5233 fn context_window(&self, _model: &str) -> usize {
5234 200 }
5236 }
5237
5238 let dir = tempfile::tempdir().unwrap();
5239 let executor = WorktreeExecutor::new(dir.path());
5240 let (sink, _collected) = EventSink::collecting("compact-test");
5241 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5242 let seen: Arc<Mutex<Vec<(usize, bool)>>> = Arc::new(Mutex::new(Vec::new()));
5243 let gen = RecordingGen {
5244 seen: seen.clone(),
5245 turn_no: AtomicUsize::new(0),
5246 };
5247
5248 let cfg = NativeLoopConfig {
5249 steering: None,
5250 prompt_overlay: None,
5251 model: Some("scripted".into()),
5252 exclude_models: Vec::new(),
5253 max_iterations: 1,
5254 max_turns_per_iteration: 12,
5255 max_tokens_per_turn: 4096,
5256 deadline: SessionDeadline::shared_default(),
5257 auth_gate: None,
5258 auth_wait: std::time::Duration::ZERO,
5259 can_adjudicate_no_change: false,
5260 baseline_captures: crate::coder::contract::BaselineCaptures::new(),
5261 };
5262 let contract = OutcomeContract {
5263 allow_credentials: false,
5264 description: "never satisfied".into(),
5265 checks: vec![ContractCheck {
5266 name: "never".into(),
5267 command: crate::coder::test_cmds::FAIL.to_string(),
5268 expect_exit_zero: true,
5269 output_contains: None,
5270 timeout_secs: 10,
5271 baseline: false,
5272 differential: None,
5273 }],
5274 };
5275
5276 let _ = run_native_loop(
5277 &gen,
5278 &executor,
5279 "grow the thread",
5280 &contract,
5281 &sink,
5282 &cancel,
5283 &cfg,
5284 &RepairMemory::disabled(),
5285 None,
5286 )
5287 .await;
5288
5289 let seen = seen.lock().unwrap();
5290 assert_eq!(seen.len(), 12, "all 12 turns generated");
5291 assert!(
5294 seen.iter().all(|(_, sys)| *sys),
5295 "System prompt must stay pinned every turn"
5296 );
5297 let max_len = seen.iter().map(|(n, _)| *n).max().unwrap();
5300 assert!(
5301 max_len < 14,
5302 "persistent history not bounded — max messages/turn = {max_len}"
5303 );
5304 }
5305
5306 #[tokio::test]
5307 async fn native_loop_routes_high_stakes_and_excludes_review_models() {
5308 use std::sync::Mutex;
5314 struct CapturingGen {
5315 intents: Arc<Mutex<Vec<Option<car_inference::IntentHint>>>>,
5316 cursor: AtomicUsize,
5317 }
5318 #[async_trait]
5319 impl TurnGenerator for CapturingGen {
5320 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5321 self.intents.lock().unwrap().push(req.intent.clone());
5322 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5326 if i == 0 {
5327 Ok(turn(
5328 "",
5329 serde_json::json!([{
5330 "id": "c1", "name": "write_file",
5331 "arguments": {"path": "f.txt", "content": "x"}
5332 }]),
5333 ))
5334 } else {
5335 Ok(turn("done", serde_json::json!([])))
5336 }
5337 }
5338 }
5339
5340 let dir = tempfile::tempdir().unwrap();
5341 let executor = WorktreeExecutor::new(dir.path());
5342 let sink = EventSink::test_sink();
5343 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5344 let captured = Arc::new(Mutex::new(Vec::new()));
5345 let gen = CapturingGen {
5346 intents: captured.clone(),
5347 cursor: AtomicUsize::new(0),
5348 };
5349 let contract = OutcomeContract {
5350 allow_credentials: false,
5351 description: "noop".into(),
5352 checks: vec![],
5353 };
5354
5355 let _ = run_native_loop(
5356 &gen,
5357 &executor,
5358 "make a change",
5359 &contract,
5360 &sink,
5361 &cancel,
5362 &NativeLoopConfig {
5363 exclude_models: vec!["reviewer-a".into(), "reviewer-b".into()],
5364 ..Default::default()
5365 },
5366 &RepairMemory::disabled(),
5367 None,
5368 )
5369 .await;
5370
5371 {
5372 let intents = captured.lock().unwrap();
5373 assert!(
5374 intents.len() >= 2,
5375 "expected the loop to issue multiple inferences, got {}",
5376 intents.len()
5377 );
5378 for (n, intent) in intents.iter().enumerate() {
5379 let intent = intent
5380 .as_ref()
5381 .unwrap_or_else(|| panic!("turn {n} issued an inference with no IntentHint"));
5382 assert!(intent.high_stakes, "turn {n} must route high_stakes");
5383 assert_eq!(
5384 intent.task,
5385 Some(car_inference::TaskHint::Code),
5386 "turn {n} must keep the Code task hint"
5387 );
5388 assert_eq!(
5389 intent.exclude_models,
5390 ["reviewer-a", "reviewer-b"],
5391 "turn {n} must exclude every review-panel seat"
5392 );
5393 assert!(
5394 intent.strict_exclusions,
5395 "turn {n} must refuse instead of falling back to a review-panel seat"
5396 );
5397 }
5398 }
5399
5400 let pinned_captured = Arc::new(Mutex::new(Vec::new()));
5404 let pinned = CapturingGen {
5405 intents: pinned_captured.clone(),
5406 cursor: AtomicUsize::new(0),
5407 };
5408 let _ = run_native_loop(
5409 &pinned,
5410 &executor,
5411 "make another change",
5412 &contract,
5413 &EventSink::test_sink(),
5414 &Arc::new(std::sync::atomic::AtomicBool::new(false)),
5415 &NativeLoopConfig {
5416 model: Some("operator-pinned".into()),
5417 exclude_models: vec!["reviewer-a".into()],
5418 ..Default::default()
5419 },
5420 &RepairMemory::disabled(),
5421 None,
5422 )
5423 .await;
5424 assert!(pinned_captured.lock().unwrap().iter().all(|intent| {
5425 intent
5426 .as_ref()
5427 .is_some_and(|hint| hint.exclude_models.is_empty() && !hint.strict_exclusions)
5428 }));
5429 }
5430
5431 #[tokio::test]
5432 async fn no_independent_coder_stops_after_one_route_attempt_as_configuration() {
5433 struct NoEligible {
5434 calls: AtomicUsize,
5435 }
5436 #[async_trait]
5437 impl TurnGenerator for NoEligible {
5438 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5439 panic!("native coder must use the typed generation seam")
5440 }
5441
5442 async fn generate_coder(
5443 &self,
5444 _req: GenerateRequest,
5445 ) -> Result<InferenceResult, TurnGenerationError> {
5446 self.calls.fetch_add(1, Ordering::SeqCst);
5447 Err(TurnGenerationError::NoEligibleModel {
5448 excluded_models: "reviewer-a, reviewer-b".into(),
5449 })
5450 }
5451 }
5452
5453 let dir = tempfile::tempdir().unwrap();
5454 let generator = NoEligible {
5455 calls: AtomicUsize::new(0),
5456 };
5457 let outcome = run_native_loop(
5458 &generator,
5459 &WorktreeExecutor::new(dir.path()),
5460 "make a change",
5461 &OutcomeContract {
5462 allow_credentials: false,
5463 description: "must change".into(),
5464 checks: vec![ContractCheck {
5465 baseline: false,
5466 differential: None,
5467 name: "red baseline".into(),
5468 command: crate::coder::test_cmds::FAIL.into(),
5469 expect_exit_zero: true,
5470 output_contains: None,
5471 timeout_secs: 10,
5472 }],
5473 },
5474 &EventSink::test_sink(),
5475 &Arc::new(std::sync::atomic::AtomicBool::new(false)),
5476 &NativeLoopConfig {
5477 exclude_models: vec!["reviewer-a".into(), "reviewer-b".into()],
5478 ..Default::default()
5479 },
5480 &RepairMemory::disabled(),
5481 None,
5482 )
5483 .await;
5484
5485 assert_eq!(generator.calls.load(Ordering::SeqCst), 1);
5486 assert_eq!(outcome.failure, Some(LoopFailure::Configuration));
5487 let error = outcome.error.expect("configuration detail");
5488 assert!(error.contains("reviewer-a"), "{error}");
5489 assert!(error.contains("reviewer-b"), "{error}");
5490 assert!(error.contains("heal.toml"), "{error}");
5491 }
5492
5493 #[tokio::test]
5494 async fn scripted_loop_repairs_after_red_checks() {
5495 let dir = tempfile::tempdir().unwrap();
5496 let executor = WorktreeExecutor::new(dir.path());
5497 let sink = EventSink::test_sink();
5498 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5499
5500 let script = Script {
5503 turns: vec![
5504 turn(
5505 "",
5506 serde_json::json!([{
5507 "id": "c1", "name": "write_file",
5508 "arguments": {"path": "x.txt", "content": "wrong"}
5509 }]),
5510 ),
5511 turn("done", serde_json::json!([])),
5512 turn(
5513 "",
5514 serde_json::json!([{
5515 "id": "c2", "name": "write_file",
5516 "arguments": {"path": "x.txt", "content": "right"}
5517 }]),
5518 ),
5519 turn("fixed", serde_json::json!([])),
5520 ],
5521 cursor: AtomicUsize::new(0),
5522 seen: std::sync::Mutex::new(Vec::new()),
5523 };
5524 let contract = OutcomeContract {
5525 allow_credentials: false,
5526 description: "x.txt says right".into(),
5527 checks: vec![ContractCheck {
5528 name: "content".into(),
5529 command: crate::coder::test_cmds::contains_or_report("right", "x.txt"),
5530 expect_exit_zero: true,
5531 output_contains: None,
5532 timeout_secs: 10,
5533 baseline: false,
5534 differential: None,
5535 }],
5536 };
5537
5538 let outcome = run_native_loop(
5539 &script,
5540 &executor,
5541 "write right into x.txt",
5542 &contract,
5543 &sink,
5544 &cancel,
5545 &NativeLoopConfig::default(),
5546 &RepairMemory::disabled(),
5547 None,
5548 )
5549 .await;
5550 assert!(outcome.passed);
5551 assert_eq!(outcome.iterations, 2, "one repair round expected");
5552 }
5553
5554 #[tokio::test]
5560 async fn f2_iteration_two_carries_iteration_one_conversation() {
5561 use std::sync::Mutex as StdMutex;
5562
5563 struct MsgCapture {
5564 seen: Arc<StdMutex<Vec<String>>>,
5565 cursor: AtomicUsize,
5566 }
5567 #[async_trait]
5568 impl TurnGenerator for MsgCapture {
5569 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5570 self.seen
5571 .lock()
5572 .unwrap()
5573 .push(serde_json::to_string(&req.messages).unwrap_or_default());
5574 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5575 match i {
5576 0 => Ok(turn(
5577 "",
5578 serde_json::json!([{
5579 "id": "iter1call", "name": "write_file",
5580 "arguments": {"path": "x.txt", "content": "ITER1_WRONG"}
5581 }]),
5582 )),
5583 1 => Ok(turn("done", serde_json::json!([]))),
5584 2 => Ok(turn(
5585 "",
5586 serde_json::json!([{
5587 "id": "iter2call", "name": "write_file",
5588 "arguments": {"path": "x.txt", "content": "ITER2_right"}
5589 }]),
5590 )),
5591 _ => Ok(turn("fixed", serde_json::json!([]))),
5592 }
5593 }
5594 }
5595
5596 let dir = tempfile::tempdir().unwrap();
5597 let executor = WorktreeExecutor::new(dir.path());
5598 let sink = EventSink::test_sink();
5599 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5600 let seen = Arc::new(StdMutex::new(Vec::new()));
5601 let gen = MsgCapture {
5602 seen: seen.clone(),
5603 cursor: AtomicUsize::new(0),
5604 };
5605 let contract = OutcomeContract {
5606 allow_credentials: false,
5607 description: "x.txt says ITER2_right".into(),
5608 checks: vec![ContractCheck {
5609 name: "content".into(),
5610 command: crate::coder::test_cmds::contains("ITER2_right", "x.txt"),
5611 expect_exit_zero: true,
5612 output_contains: None,
5613 timeout_secs: 10,
5614 baseline: false,
5615 differential: None,
5616 }],
5617 };
5618
5619 let outcome = run_native_loop(
5620 &gen,
5621 &executor,
5622 "write ITER2_right into x.txt",
5623 &contract,
5624 &sink,
5625 &cancel,
5626 &NativeLoopConfig::default(),
5627 &RepairMemory::disabled(),
5628 None,
5629 )
5630 .await;
5631
5632 assert!(outcome.passed);
5633 assert_eq!(outcome.iterations, 2, "expected a repair round");
5634 let seen = seen.lock().unwrap();
5635 assert!(
5636 seen.len() >= 4,
5637 "expected >=4 inferences, got {}",
5638 seen.len()
5639 );
5640 assert!(
5643 seen[2].contains("ITER1_WRONG") || seen[2].contains("iter1call"),
5644 "F2: iteration 2 lost iteration 1's conversation:\n{}",
5645 seen[2]
5646 );
5647 }
5648
5649 #[tokio::test]
5657 async fn a_nomination_exits_the_loop_unjudged() {
5658 let dir = tempfile::tempdir().unwrap();
5659 let executor = WorktreeExecutor::new(dir.path());
5660 let sink = EventSink::test_sink();
5661 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5662
5663 let script = Script {
5664 turns: vec![
5665 turn(
5666 "this is already handled",
5667 serde_json::json!([{
5668 "id": "n1",
5669 "name": REPORT_NO_CHANGE_TOOL,
5670 "arguments": {
5671 "kind": "premise_wrong",
5672 "summary": "the handler already covers the empty case",
5673 "evidence": "read handler.rs:88 and ran the suite green"
5674 }
5675 }]),
5676 ),
5677 turn("should not be reached", serde_json::json!([])),
5679 ],
5680 cursor: AtomicUsize::new(0),
5681 seen: std::sync::Mutex::new(Vec::new()),
5682 };
5683 let contract = OutcomeContract {
5684 allow_credentials: false,
5685 description: "noop".into(),
5686 checks: vec![],
5687 };
5688 let cfg = NativeLoopConfig {
5689 can_adjudicate_no_change: true,
5690 ..Default::default()
5691 };
5692
5693 let outcome = run_native_loop(
5694 &script,
5695 &executor,
5696 "fix the empty case",
5697 &contract,
5698 &sink,
5699 &cancel,
5700 &cfg,
5701 &RepairMemory::disabled(),
5702 None,
5703 )
5704 .await;
5705
5706 let nomination = outcome
5707 .nomination
5708 .expect("the finding must survive out of the loop");
5709 assert_eq!(nomination.kind, NoChangeKind::PremiseWrong);
5710 assert!(nomination.summary.contains("already covers"));
5711 assert!(nomination.evidence.contains("handler.rs:88"));
5712 assert!(!outcome.passed, "no diff, so not green");
5713 assert!(
5714 outcome.failure.is_none(),
5715 "a nomination is not a loss — booking it as one is the whole defect"
5716 );
5717 assert_eq!(
5718 script.cursor.load(Ordering::SeqCst),
5719 1,
5720 "the loop kept going after the nomination"
5721 );
5722 }
5723
5724 #[tokio::test]
5728 async fn a_nomination_from_an_unprepared_caller_is_not_honoured() {
5729 let dir = tempfile::tempdir().unwrap();
5730 let executor = WorktreeExecutor::new(dir.path());
5731 let sink = EventSink::test_sink();
5732 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5733
5734 let script = Script {
5735 turns: vec![
5736 turn(
5737 "declaring no change",
5738 serde_json::json!([{
5739 "id": "n1",
5740 "name": REPORT_NO_CHANGE_TOOL,
5741 "arguments": {
5742 "kind": "premise_wrong",
5743 "summary": "s",
5744 "evidence": "e"
5745 }
5746 }]),
5747 ),
5748 turn("giving up", serde_json::json!([])),
5749 ],
5750 cursor: AtomicUsize::new(0),
5751 seen: std::sync::Mutex::new(Vec::new()),
5752 };
5753 let contract = OutcomeContract {
5754 allow_credentials: false,
5755 description: "noop".into(),
5756 checks: vec![],
5757 };
5758
5759 let outcome = run_native_loop(
5760 &script,
5761 &executor,
5762 "fix it",
5763 &contract,
5764 &sink,
5765 &cancel,
5766 &NativeLoopConfig::default(),
5768 &RepairMemory::disabled(),
5769 None,
5770 )
5771 .await;
5772
5773 assert!(
5774 outcome.nomination.is_none(),
5775 "a caller that cannot judge a nomination must never receive one"
5776 );
5777 }
5778
5779 #[tokio::test]
5784 async fn f3_truncated_turn_is_not_treated_as_done() {
5785 let dir = tempfile::tempdir().unwrap();
5786 let executor = WorktreeExecutor::new(dir.path());
5787 let sink = EventSink::test_sink();
5788 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5789
5790 let script = Script {
5791 turns: vec![
5792 turn_with_stop(
5794 "partial output that got cut o",
5795 serde_json::json!([]),
5796 Some("length"),
5797 ),
5798 turn_with_stop("done for real", serde_json::json!([]), Some("stop")),
5800 ],
5801 cursor: AtomicUsize::new(0),
5802 seen: std::sync::Mutex::new(Vec::new()),
5803 };
5804 let contract = OutcomeContract {
5805 allow_credentials: false,
5806 description: "noop".into(),
5807 checks: vec![],
5808 };
5809
5810 let _ = run_native_loop(
5811 &script,
5812 &executor,
5813 "do the thing",
5814 &contract,
5815 &sink,
5816 &cancel,
5817 &NativeLoopConfig::default(),
5818 &RepairMemory::disabled(),
5819 None,
5820 )
5821 .await;
5822
5823 assert_eq!(
5826 script.cursor.load(Ordering::SeqCst),
5827 2,
5828 "truncated turn was mistaken for completion — loop stopped early instead of continuing"
5829 );
5830 }
5831
5832 #[tokio::test]
5836 async fn repair_round_learns_and_recalls_across_sessions() {
5837 use crate::coder::skill_memory::FailureSignature;
5838 use car_memgine::MemgineEngine;
5839 use tokio::sync::Mutex as AsyncMutex;
5840
5841 let memory = RepairMemory::new(Some(Arc::new(AsyncMutex::new(MemgineEngine::new(None)))));
5843
5844 let contract = OutcomeContract {
5847 allow_credentials: false,
5848 description: "x.txt says right".into(),
5849 checks: vec![ContractCheck {
5850 name: "content".into(),
5851 command: crate::coder::test_cmds::contains_or_report("right", "x.txt"),
5852 expect_exit_zero: true,
5853 output_contains: None,
5854 timeout_secs: 10,
5855 baseline: false,
5856 differential: None,
5857 }],
5858 };
5859 let sig = FailureSignature {
5860 check: "content".into(),
5861 error_class: "test_failure".into(),
5862 };
5863
5864 let dir1 = tempfile::tempdir().unwrap();
5866 let exec1 = WorktreeExecutor::new(dir1.path());
5867 let sink = EventSink::test_sink();
5868 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5869 let script1 = Script {
5870 turns: vec![
5871 turn(
5872 "",
5873 serde_json::json!([{
5874 "id": "c1", "name": "write_file",
5875 "arguments": {"path": "x.txt", "content": "wrong"}
5876 }]),
5877 ),
5878 turn("nothing useful yet", serde_json::json!([])),
5879 turn(
5880 "",
5881 serde_json::json!([{
5882 "id": "c2", "name": "write_file",
5883 "arguments": {"path": "x.txt", "content": "right"}
5884 }]),
5885 ),
5886 turn(
5887 "wrote 'right' into x.txt to satisfy the grep",
5888 serde_json::json!([]),
5889 ),
5890 ],
5891 cursor: AtomicUsize::new(0),
5892 seen: std::sync::Mutex::new(Vec::new()),
5893 };
5894 let outcome1 = run_native_loop(
5895 &script1,
5896 &exec1,
5897 "write right into x.txt",
5898 &contract,
5899 &sink,
5900 &cancel,
5901 &NativeLoopConfig::default(),
5902 &memory,
5903 None,
5904 )
5905 .await;
5906 assert!(outcome1.passed);
5907 let recalled = memory
5909 .recall(&sig)
5910 .await
5911 .expect("session 1 should have learned");
5912 assert!(recalled.contains("right"), "approach captured: {recalled}");
5913
5914 let dir2 = tempfile::tempdir().unwrap();
5917 let exec2 = WorktreeExecutor::new(dir2.path());
5918 let (sink2, collected) = EventSink::collecting("coder-learn");
5919 let seen_hint = Arc::new(std::sync::atomic::AtomicBool::new(false));
5920
5921 struct HintWatcher {
5924 seen: Arc<std::sync::atomic::AtomicBool>,
5925 cursor: AtomicUsize,
5926 }
5927 #[async_trait]
5928 impl TurnGenerator for HintWatcher {
5929 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5930 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5931 let saw_hint = req
5932 .messages
5933 .as_ref()
5934 .map(|ms| {
5935 ms.iter().any(
5936 |m| matches!(m, Message::User { content } if content.contains("HINT")),
5937 )
5938 })
5939 .unwrap_or(false);
5940 if saw_hint {
5941 self.seen.store(true, Ordering::SeqCst);
5942 }
5943 Ok(match i {
5944 0 => turn("did nothing", serde_json::json!([])),
5946 1 => turn(
5948 "",
5949 serde_json::json!([{
5950 "id": "c1", "name": "write_file",
5951 "arguments": {"path": "x.txt", "content": "right"}
5952 }]),
5953 ),
5954 _ => turn("applied the recalled fix", serde_json::json!([])),
5955 })
5956 }
5957 }
5958
5959 let script2 = HintWatcher {
5960 seen: seen_hint.clone(),
5961 cursor: AtomicUsize::new(0),
5962 };
5963 let outcome2 = run_native_loop(
5964 &script2,
5965 &exec2,
5966 "write right into x.txt",
5967 &contract,
5968 &sink2,
5969 &cancel,
5970 &NativeLoopConfig::default(),
5971 &memory,
5972 None,
5973 )
5974 .await;
5975 assert!(outcome2.passed, "session 2 should pass: {outcome2:?}");
5976 assert!(
5977 seen_hint.load(Ordering::SeqCst),
5978 "the recalled hint must have been injected into the repair prompt"
5979 );
5980 drop(collected);
5981 }
5982
5983 #[tokio::test]
5987 async fn ask_user_tool_routes_to_handler_and_answer_reaches_model() {
5988 use std::sync::Mutex as StdMutex;
5989
5990 let dir = tempfile::tempdir().unwrap();
5991 let executor = WorktreeExecutor::new(dir.path());
5992 let (sink, collected) = EventSink::collecting("coder-ask");
5993 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
5994
5995 struct CannedAsker {
5998 seen_prompt: Arc<StdMutex<Option<String>>>,
5999 answer: String,
6000 }
6001 #[async_trait]
6002 impl AskUser for CannedAsker {
6003 async fn ask(&self, prompt: &str) -> Result<String, String> {
6004 *self.seen_prompt.lock().unwrap() = Some(prompt.to_string());
6005 Ok(self.answer.clone())
6006 }
6007 }
6008 let seen_prompt = Arc::new(StdMutex::new(None));
6009 let asker = CannedAsker {
6010 seen_prompt: seen_prompt.clone(),
6011 answer: "use port 8080".to_string(),
6012 };
6013
6014 struct AskThenWrite {
6018 cursor: AtomicUsize,
6019 }
6020 #[async_trait]
6021 impl TurnGenerator for AskThenWrite {
6022 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
6023 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
6024 match i {
6025 0 => Ok(turn(
6026 "",
6027 serde_json::json!([{
6028 "id": "a1", "name": "ask_user",
6029 "arguments": {"prompt": "which port?"}
6030 }]),
6031 )),
6032 1 => {
6033 let answer = req
6035 .messages
6036 .as_ref()
6037 .and_then(|ms| {
6038 ms.iter().rev().find_map(|m| match m {
6039 Message::ToolResult { content, .. } => Some(content.clone()),
6040 _ => None,
6041 })
6042 })
6043 .unwrap_or_default();
6044 Ok(turn(
6045 "",
6046 serde_json::json!([{
6047 "id": "w1", "name": "write_file",
6048 "arguments": {"path": "answer.txt", "content": answer}
6049 }]),
6050 ))
6051 }
6052 _ => Ok(turn("done", serde_json::json!([]))),
6053 }
6054 }
6055 }
6056
6057 let contract = OutcomeContract {
6058 allow_credentials: false,
6059 description: "answer.txt records the chosen port".into(),
6060 checks: vec![ContractCheck {
6061 name: "has_port".into(),
6062 command: crate::coder::test_cmds::contains("8080", "answer.txt"),
6063 expect_exit_zero: true,
6064 output_contains: None,
6065 timeout_secs: 10,
6066 baseline: false,
6067 differential: None,
6068 }],
6069 };
6070
6071 let outcome = run_native_loop(
6072 &AskThenWrite {
6073 cursor: AtomicUsize::new(0),
6074 },
6075 &executor,
6076 "pick a port and record it",
6077 &contract,
6078 &sink,
6079 &cancel,
6080 &NativeLoopConfig::default(),
6081 &RepairMemory::disabled(),
6082 Some(&asker),
6083 )
6084 .await;
6085
6086 assert!(outcome.passed, "outcome: {outcome:?}");
6087 assert_eq!(seen_prompt.lock().unwrap().as_deref(), Some("which port?"));
6089 assert_eq!(
6091 std::fs::read_to_string(dir.path().join("answer.txt")).unwrap(),
6092 "use port 8080"
6093 );
6094 let events = collected.lock().unwrap();
6098 assert!(events.iter().any(|e| matches!(
6099 &e.kind,
6100 CoderEventKind::ToolCall { tool, .. } if tool == ASK_USER_TOOL
6101 )));
6102 }
6103
6104 #[tokio::test]
6107 async fn ask_user_without_handler_is_a_recoverable_error() {
6108 let dir = tempfile::tempdir().unwrap();
6109 let executor = WorktreeExecutor::new(dir.path());
6110 let (sink, _collected) = EventSink::collecting("coder-noask");
6111 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
6112
6113 struct ToolPeek {
6115 offered: Arc<std::sync::atomic::AtomicBool>,
6116 }
6117 #[async_trait]
6118 impl TurnGenerator for ToolPeek {
6119 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
6120 let has_ask = req
6121 .tools
6122 .as_ref()
6123 .map(|ts| ts.iter().any(|t| t["name"] == ASK_USER_TOOL))
6124 .unwrap_or(false);
6125 self.offered.store(has_ask, Ordering::SeqCst);
6126 Ok(turn("done", serde_json::json!([])))
6127 }
6128 }
6129 let offered_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
6130 let contract = OutcomeContract {
6131 allow_credentials: false,
6132 description: "noop".into(),
6133 checks: vec![ContractCheck {
6134 name: "ok".into(),
6135 command: crate::coder::test_cmds::PASS.to_string(),
6136 expect_exit_zero: true,
6137 output_contains: None,
6138 timeout_secs: 10,
6139 baseline: false,
6140 differential: None,
6141 }],
6142 };
6143 let _ = run_native_loop(
6144 &ToolPeek {
6145 offered: offered_flag.clone(),
6146 },
6147 &executor,
6148 "x",
6149 &contract,
6150 &sink,
6151 &cancel,
6152 &NativeLoopConfig::default(),
6153 &RepairMemory::disabled(),
6154 None,
6155 )
6156 .await;
6157 assert!(
6158 !offered_flag.load(Ordering::SeqCst),
6159 "ask_user must not be offered when no handler is wired"
6160 );
6161 }
6162
6163 #[tokio::test]
6164 async fn cancellation_stops_the_loop() {
6165 let dir = tempfile::tempdir().unwrap();
6166 let executor = WorktreeExecutor::new(dir.path());
6167 let sink = EventSink::test_sink();
6168 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(true));
6169 let script = Script {
6170 turns: vec![],
6171 cursor: AtomicUsize::new(0),
6172 seen: std::sync::Mutex::new(Vec::new()),
6173 };
6174 let contract = OutcomeContract {
6175 allow_credentials: false,
6176 description: "d".into(),
6177 checks: vec![ContractCheck {
6178 name: "never".into(),
6179 command: crate::coder::test_cmds::PASS.to_string(),
6180 expect_exit_zero: true,
6181 output_contains: None,
6182 timeout_secs: 10,
6183 baseline: false,
6184 differential: None,
6185 }],
6186 };
6187 let outcome = run_native_loop(
6188 &script,
6189 &executor,
6190 "x",
6191 &contract,
6192 &sink,
6193 &cancel,
6194 &NativeLoopConfig::default(),
6195 &RepairMemory::disabled(),
6196 None,
6197 )
6198 .await;
6199 assert_eq!(outcome.error.as_deref(), Some("cancelled"));
6200 assert_eq!(outcome.iterations, 0);
6201 }
6202
6203 #[test]
6204 fn failure_feedback_lists_only_failures() {
6205 let results = vec![
6206 CheckResult {
6207 credentials_allowed: false,
6208 name: "good".into(),
6209 passed: true,
6210 exit_code: Some(0),
6211 output_tail: "ok".into(),
6212 duration_ms: 1,
6213 timed_out: false,
6214 deadline_clamped: false,
6215 },
6216 CheckResult {
6217 credentials_allowed: false,
6218 name: "bad".into(),
6219 passed: false,
6220 exit_code: Some(1),
6221 output_tail: "assertion failed".into(),
6222 duration_ms: 1,
6223 timed_out: false,
6224 deadline_clamped: false,
6225 },
6226 ];
6227 let fb = failure_feedback(&results, 0);
6228 assert!(fb.contains("FAILED bad"));
6229 assert!(fb.contains("assertion failed"));
6230 assert!(!fb.contains("FAILED good"));
6231 assert!(fb.contains("name the single cause"));
6233 assert!(!fb.contains("failed 2 times in a row"));
6234 }
6235
6236 #[test]
6237 fn failure_feedback_escalates_on_a_recurring_failure() {
6238 let results = vec![CheckResult {
6239 credentials_allowed: false,
6240 name: "run_tests".into(),
6241 passed: false,
6242 exit_code: Some(1),
6243 output_tail: "AttributeError: no attribute '_remove_slot_root'".into(),
6244 duration_ms: 1,
6245 timed_out: false,
6246 deadline_clamped: false,
6247 }];
6248 let fb = failure_feedback(&results, 1);
6251 assert!(fb.contains("failed the same way 2 times"), "{fb}");
6252 assert!(fb.contains("do NOT re-apply a variation"));
6253 assert!(fb.contains("IMPLEMENT it"));
6254 }
6255
6256 #[test]
6257 fn system_prompt_carries_the_contract() {
6258 let contract = OutcomeContract {
6259 allow_credentials: false,
6260 description: "make the tests pass".into(),
6261 checks: vec![super::super::contract::ContractCheck {
6262 name: "tests".into(),
6263 command: "cargo test -p demo".into(),
6264 expect_exit_zero: true,
6265 output_contains: None,
6266 timeout_secs: 300,
6267 baseline: false,
6268 differential: None,
6269 }],
6270 };
6271 let p = system_prompt(&contract, "Top-level entries: Cargo.toml, src", None);
6272 assert!(p.contains("cargo test -p demo"));
6273 assert!(p.contains("STOP calling tools"));
6274 assert!(p.contains("EXACT command(s) from the OUTCOME CONTRACT"));
6277 }
6278
6279 #[test]
6280 fn coder_prompt_contains_discipline_and_keeps_stop_contract() {
6281 let contract = OutcomeContract {
6282 allow_credentials: false,
6283 description: "make the tests pass".into(),
6284 checks: vec![ContractCheck {
6285 name: "tests".into(),
6286 command: "cargo test -p demo".into(),
6287 expect_exit_zero: true,
6288 output_contains: None,
6289 timeout_secs: 300,
6290 baseline: false,
6291 differential: None,
6292 }],
6293 };
6294 let env = "Top-level entries: Cargo.toml, src\nBuild systems detected: Rust (cargo)";
6295 let p = system_prompt(&contract, env, None);
6296
6297 assert!(p.contains("Inspect before you edit"), "inspect-first");
6299 assert!(
6300 p.contains("grep_files") && p.contains("find_files"),
6301 "search-before-read discipline"
6302 );
6303 assert!(p.contains("prefer edit_file"), "surgical-edit discipline");
6304 assert!(
6305 p.contains("Never fabricate file contents"),
6306 "anti-fabrication (files)"
6307 );
6308 assert!(
6309 p.contains("Never claim a check passed"),
6310 "anti-fabrication (results)"
6311 );
6312 assert!(
6313 p.contains("read the actual error output before retrying"),
6314 "read-the-error discipline"
6315 );
6316 assert!(
6318 p.contains("is NOT a task failure") && p.contains("blocked"),
6319 "blocked-verification-is-not-failure guidance"
6320 );
6321 assert!(
6327 p.contains("Trace the checks before you declare done"),
6328 "check-tracing guidance"
6329 );
6330 assert!(
6331 !p.contains("set()"),
6332 "no eval-specific correctness hints in the global prompt"
6333 );
6334 assert!(
6342 p.contains("copy the command string character-for-character"),
6343 "exact-command self-verify (no broad substitute)"
6344 );
6345 assert!(
6346 p.contains("The environment is not yours to fix") && p.contains("denied by policy"),
6347 "environment repair: judgment in the prompt, enforcement in policy"
6348 );
6349 assert!(
6350 !p.contains("STRICTLY FORBIDDEN"),
6351 "the enumerated prose blacklist moved to the inspector chain"
6352 );
6353
6354 assert!(p.contains("do not rely on it: verify the checks yourself first"));
6357
6358 assert!(
6360 p.contains("reply with a brief plain-text summary and STOP calling tools"),
6361 "the STOP-calling-tools loop-termination contract must survive verbatim"
6362 );
6363 assert!(p.contains("Do not git commit"), "policy: no git commit");
6367 assert!(
6372 p.contains("gh pr create") && p.contains("the runtime opens the"),
6373 "publication is denied by any route, and the runtime does the publishing"
6374 );
6375 assert!(
6376 p.contains("Read-only forge commands"),
6377 "the allowed half of the forge guard must be stated, not just the denied half"
6378 );
6379 assert!(p.contains("ENVIRONMENT:"));
6381 assert!(p.contains("Rust (cargo)"));
6382 assert!(p.contains("cargo test -p demo"));
6384 }
6385
6386 #[test]
6387 fn preview_truncates_on_char_boundary() {
6388 assert_eq!(preview("short", 10), "short");
6389 let long = "é".repeat(300);
6390 let p = preview(&long, 5);
6391 assert!(p.ends_with('…') && p.chars().count() <= 4);
6392 }
6393
6394 struct FirstUserCapture {
6398 captured: Arc<std::sync::Mutex<String>>,
6399 }
6400 #[async_trait]
6401 impl TurnGenerator for FirstUserCapture {
6402 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
6403 let first_user = req
6404 .messages
6405 .as_ref()
6406 .and_then(|ms| {
6407 ms.iter().find_map(|m| match m {
6408 Message::User { content } => Some(content.clone()),
6409 _ => None,
6410 })
6411 })
6412 .unwrap_or_default();
6413 *self.captured.lock().unwrap() = first_user;
6414 Ok(turn("done", serde_json::json!([])))
6415 }
6416 }
6417
6418 fn trivial_contract() -> OutcomeContract {
6419 OutcomeContract {
6420 allow_credentials: false,
6421 description: "trivial".into(),
6422 checks: vec![ContractCheck {
6423 name: "ok".into(),
6424 command: crate::coder::test_cmds::PASS.to_string(),
6425 expect_exit_zero: true,
6426 output_contains: None,
6427 timeout_secs: 10,
6428 baseline: false,
6429 differential: None,
6430 }],
6431 }
6432 }
6433
6434 #[tokio::test]
6435 async fn coder_first_message_carries_recall_when_facts_exist() {
6436 use crate::coder::skill_memory::FailureSignature;
6437 use car_memgine::MemgineEngine;
6438 use tokio::sync::Mutex as AsyncMutex;
6439
6440 let memory = RepairMemory::new(Some(Arc::new(AsyncMutex::new(MemgineEngine::new(None)))));
6443 let sig = FailureSignature {
6444 check: "tests".into(),
6445 error_class: "test_failure".into(),
6446 };
6447 memory
6448 .record_success(&sig, "add the missing import and re-run cargo test")
6449 .await;
6450
6451 let dir = tempfile::tempdir().unwrap();
6452 let executor = WorktreeExecutor::new(dir.path());
6453 let sink = EventSink::test_sink();
6454 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
6455 let captured = Arc::new(std::sync::Mutex::new(String::new()));
6456
6457 let outcome = run_native_loop(
6458 &FirstUserCapture {
6459 captured: captured.clone(),
6460 },
6461 &executor,
6462 "the tests are failing, please fix them",
6463 &trivial_contract(),
6464 &sink,
6465 &cancel,
6466 &NativeLoopConfig::default(),
6467 &memory,
6468 None,
6469 )
6470 .await;
6471 assert!(outcome.passed, "outcome: {outcome:?}");
6472
6473 let first_user = captured.lock().unwrap().clone();
6474 assert!(
6475 first_user.contains("Recall from prior sessions"),
6476 "the labelled session-start recall must be in the first user turn: {first_user}"
6477 );
6478 assert!(
6479 first_user.contains("missing import"),
6480 "the recalled approach content rides along: {first_user}"
6481 );
6482 }
6483
6484 #[tokio::test]
6485 async fn coder_first_message_recall_absent_when_empty() {
6486 use car_memgine::MemgineEngine;
6487 use tokio::sync::Mutex as AsyncMutex;
6488
6489 let memory = RepairMemory::new(Some(Arc::new(AsyncMutex::new(MemgineEngine::new(None)))));
6492
6493 let dir = tempfile::tempdir().unwrap();
6494 let executor = WorktreeExecutor::new(dir.path());
6495 let sink = EventSink::test_sink();
6496 let cancel: CancelFlag = Arc::new(std::sync::atomic::AtomicBool::new(false));
6497 let captured = Arc::new(std::sync::Mutex::new(String::new()));
6498
6499 let outcome = run_native_loop(
6500 &FirstUserCapture {
6501 captured: captured.clone(),
6502 },
6503 &executor,
6504 "the tests are failing, please fix them",
6505 &trivial_contract(),
6506 &sink,
6507 &cancel,
6508 &NativeLoopConfig::default(),
6509 &memory,
6510 None,
6511 )
6512 .await;
6513 assert!(outcome.passed, "outcome: {outcome:?}");
6514
6515 let first_user = captured.lock().unwrap().clone();
6516 assert!(
6517 !first_user.contains("Recall from prior sessions"),
6518 "no recall section when the engine has nothing relevant: {first_user}"
6519 );
6520 }
6521}