1use std::collections::HashMap;
17use std::future::Future;
18use std::path::PathBuf;
19use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
20use std::sync::{Arc, Mutex as StdMutex};
21use std::time::Duration;
22
23use car_engine::Runtime;
24use car_inference::tasks::generate::{ContentBlock, Message, Provenance, ToolCall};
25use car_ir::{ActionProposal, ActionStatus};
26use serde_json::{json, Value};
27use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex};
28
29use super::agent_loop::{
30 run_assistant_goal_loop_in_session_durable, run_assistant_loop_cancellable_in_session_durable,
31 ApprovalDecision, ApprovalGate, AssistantEvent, GoalLoopResult,
32};
33use super::governance::AssistantDurability;
34use super::AssistantConfig;
35use crate::coder::native_loop::TurnGenerator;
36
37const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
39
40#[derive(Clone, Debug)]
42pub struct ChatGoal {
43 pub check: String,
44 pub max_iterations: u32,
45}
46
47fn shell_check_proposal(command: &str) -> car_ir::ActionProposal {
50 serde_json::from_value(json!({
51 "source": "chat-goal-check",
52 "actions": [{
53 "id": "goal_check",
54 "type": "tool_call",
55 "tool": "shell",
56 "parameters": { "command": command },
57 }],
58 }))
59 .expect("static shell-check proposal shape")
60}
61
62async fn run_shell_check_with_approval(
63 runtime: &Runtime,
64 cfg: &AssistantConfig,
65 approval: Option<&dyn ApprovalGate>,
66 command: &str,
67) -> i32 {
68 if cfg.gated_tools.iter().any(|tool| tool == "shell") {
69 let params = json!({ "command": command, "purpose": "goal_check" });
70 match approval {
71 Some(gate) => match gate.request("shell", ¶ms).await {
72 ApprovalDecision::Approved => {}
73 ApprovalDecision::Denied(_) => return 1,
74 },
75 None => return 1,
76 }
77 }
78
79 let exec = runtime.execute(&shell_check_proposal(command)).await;
80 exec.results
81 .first()
82 .and_then(|r| r.output.as_ref())
83 .and_then(|o| o.get("exit_code"))
84 .and_then(|v| v.as_i64())
85 .unwrap_or(1) as i32
86}
87
88fn goal_turn_terminal_event(result: &GoalLoopResult, session_id: &str) -> Value {
116 use car_verify::goal::{GoalHalt, GoalStatus};
117
118 match &result.run.status {
119 GoalStatus::Achieved => {
120 json!({ "kind": "done", "text": result.outcome.summary, "session_id": session_id })
121 }
122 GoalStatus::Halted {
123 halt: GoalHalt::EvaluationTimeout,
124 } => json!({
125 "kind": "done",
126 "text": format!(
127 "{}\n\n[goal check] not verified — {}",
128 result.outcome.summary, result.run.last_reason
129 ),
130 "finish_reason": "goal check unevaluated (timed out) — reply delivered unverified",
137 "goal_unevaluated": true,
141 "session_id": session_id,
142 }),
143 GoalStatus::Halted { halt } => json!({
144 "kind": "error",
145 "error": format!(
146 "goal not reached: {} after {} iteration(s); last check: {}",
147 halt.as_str(),
148 result.run.iterations,
149 result.run.last_reason
150 ),
151 "session_id": session_id,
152 }),
153 }
154}
155
156pub struct AssistantService {
158 generator: Arc<dyn TurnGenerator>,
159 runtime: Arc<Runtime>,
160 cfg: AssistantConfig,
161 system: String,
163 threads: AsyncMutex<HashMap<String, Vec<Message>>>,
165 cancels: StdMutex<HashMap<String, Arc<AtomicBool>>>,
167 runtime_sessions: AsyncMutex<HashMap<String, String>>,
171 approvals: Arc<StdMutex<HashMap<String, oneshot::Sender<bool>>>>,
174 durability: Option<Arc<dyn AssistantDurability>>,
177 repository_root: Option<PathBuf>,
178}
179
180impl AssistantService {
181 pub fn new(
182 generator: Arc<dyn TurnGenerator>,
183 runtime: Arc<Runtime>,
184 cfg: AssistantConfig,
185 system: String,
186 ) -> Self {
187 Self {
188 generator,
189 runtime,
190 cfg,
191 system,
192 threads: AsyncMutex::new(HashMap::new()),
193 cancels: StdMutex::new(HashMap::new()),
194 runtime_sessions: AsyncMutex::new(HashMap::new()),
195 approvals: Arc::new(StdMutex::new(HashMap::new())),
196 durability: None,
197 repository_root: None,
198 }
199 }
200
201 pub fn new_durable(
202 generator: Arc<dyn TurnGenerator>,
203 runtime: Arc<Runtime>,
204 cfg: AssistantConfig,
205 system: String,
206 durability: Arc<dyn AssistantDurability>,
207 repository_root: PathBuf,
208 ) -> Self {
209 let mut service = Self::new(generator, runtime, cfg, system);
210 service.durability = Some(durability);
211 service.repository_root = Some(repository_root);
212 service
213 }
214
215 fn config_for_model(&self, model: Option<&str>) -> AssistantConfig {
216 let mut cfg = self.cfg.clone();
217 if let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) {
218 cfg.model = Some(model.to_string());
219 cfg.strict_model = true;
220 }
221 cfg
222 }
223
224 async fn runtime_session_for(&self, session_id: &str) -> String {
225 let mut sessions = self.runtime_sessions.lock().await;
226 if let Some(runtime_session) = sessions.get(session_id) {
227 return runtime_session.clone();
228 }
229 let runtime_session = self.runtime.open_session().await;
230 sessions.insert(session_id.to_string(), runtime_session.clone());
231 runtime_session
232 }
233
234 async fn reconcile_dangling_actions(
239 &self,
240 session_id: &str,
241 runtime_session: &str,
242 messages: &mut Vec<Message>,
243 ) -> Result<(), String> {
244 let Some(store) = &self.durability else {
245 return Ok(());
246 };
247 let answered: std::collections::HashSet<String> = messages
248 .iter()
249 .filter_map(|message| match message {
250 Message::ToolResult { tool_use_id, .. } => Some(tool_use_id.clone()),
251 _ => None,
252 })
253 .collect();
254 let calls: Vec<ToolCall> = messages
255 .iter()
256 .flat_map(|message| match message {
257 Message::Assistant { tool_calls, .. } => tool_calls.clone(),
258 _ => Vec::new(),
259 })
260 .filter(|call| call.id.as_ref().is_some_and(|id| !answered.contains(id)))
261 .collect();
262 for call in calls {
263 let call_id = call.id.clone().expect("filtered to calls with ids");
264 let params = serde_json::to_value(&call.arguments).unwrap_or(Value::Null);
265 let Some(scope) = action_scope(self.repository_root.as_ref(), &call.name, ¶ms)
266 else {
267 messages.push(Message::ToolResult {
268 tool_use_id: call_id,
269 content: json!({"error": "tool call was interrupted before a durable action scope existed; not replayed"}).to_string(),
270 provenance: Provenance::Internal,
271 });
272 continue;
273 };
274 let candidate =
275 super::governance::SupervisedActionRecord::propose(session_id, &call_id, scope);
276 let Some(mut record) = store.load_action(&candidate.id).await? else {
277 messages.push(Message::ToolResult {
278 tool_use_id: call_id,
279 content:
280 json!({"error": "tool call was interrupted before approval; not replayed"})
281 .to_string(),
282 provenance: Provenance::Internal,
283 });
284 continue;
285 };
286 let content = match record.state {
287 super::governance::ActionState::Approved => {
288 record.transition(super::governance::ActionState::Dispatched, None)?;
289 store.record_action(&record).await?;
290 let proposal: ActionProposal = serde_json::from_value(json!({
291 "source": "durable-resume",
292 "actions": [{
293 "id": call_id,
294 "type": "tool_call",
295 "tool": call.name,
296 "parameters": params,
297 }],
298 }))
299 .map_err(|e| format!("cannot rebuild approved action on resume: {e}"))?;
300 let exec = self
301 .runtime
302 .execute_with_session(&proposal, runtime_session)
303 .await;
304 let result = exec.results.first();
305 let ok = result.is_some_and(|result| {
306 matches!(result.status, ActionStatus::Succeeded)
307 && (call.name != "shell"
308 || result
309 .output
310 .as_ref()
311 .and_then(|output| output.get("exit_code"))
312 .and_then(Value::as_i64)
313 == Some(0))
314 });
315 let receipt = json!({
316 "ok": ok,
317 "action_id": result.map(|result| result.action_id.clone()),
318 "output": result.and_then(|result| result.output.clone()),
319 });
320 record.transition(
321 if ok {
322 super::governance::ActionState::Completed
323 } else {
324 super::governance::ActionState::Failed
325 },
326 Some(receipt.clone()),
327 )?;
328 store.record_action(&record).await?;
329 receipt.to_string()
330 }
331 super::governance::ActionState::Dispatched => {
332 record.transition(
333 super::governance::ActionState::Indeterminate,
334 Some(json!({"reason": "process restarted after dispatch without a terminal receipt"})),
335 )?;
336 store.record_action(&record).await?;
337 json!({"error": "action outcome is indeterminate after restart; it was not replayed"}).to_string()
338 }
339 super::governance::ActionState::Completed
340 | super::governance::ActionState::Failed => record
341 .receipt
342 .clone()
343 .unwrap_or_else(|| json!({"status": format!("{:?}", record.state)}))
344 .to_string(),
345 super::governance::ActionState::Proposed => {
346 json!({"error": "approval was interrupted; action was not dispatched"})
347 .to_string()
348 }
349 super::governance::ActionState::Denied
350 | super::governance::ActionState::Indeterminate => {
351 json!({"error": format!("durable action is {:?}; not replayed", record.state)})
352 .to_string()
353 }
354 };
355 messages.push(Message::ToolResult {
356 tool_use_id: call_id,
357 content,
358 provenance: Provenance::Internal,
359 });
360 }
361 Ok(())
362 }
363
364 pub fn cancel(&self, session_id: &str) {
366 if let Ok(g) = self.cancels.lock() {
367 if let Some(flag) = g.get(session_id) {
368 flag.store(true, Ordering::Relaxed);
369 }
370 }
371 }
372
373 pub fn resolve_approval(&self, approval_id: &str, approved: bool) -> bool {
376 let tx = self
377 .approvals
378 .lock()
379 .ok()
380 .and_then(|mut g| g.remove(approval_id));
381 match tx {
382 Some(tx) => tx.send(approved).is_ok(),
383 None => false,
384 }
385 }
386
387 pub async fn handle_turn<E, Fut>(
394 &self,
395 session_id: &str,
396 prompt: &str,
397 attachments: Option<Vec<Value>>,
398 emit: E,
399 ) where
400 E: Fn(Value) -> Fut + Send + Sync + 'static,
401 Fut: Future<Output = ()> + Send + 'static,
402 {
403 self.handle_turn_with_model(session_id, prompt, attachments, None, emit)
404 .await;
405 }
406
407 pub async fn handle_turn_with_model<E, Fut>(
412 &self,
413 session_id: &str,
414 prompt: &str,
415 attachments: Option<Vec<Value>>,
416 model: Option<&str>,
417 emit: E,
418 ) where
419 E: Fn(Value) -> Fut + Send + Sync + 'static,
420 Fut: Future<Output = ()> + Send + 'static,
421 {
422 let cfg = self.config_for_model(model);
423 let runtime_session = self.runtime_session_for(session_id).await;
424 let images: Vec<ContentBlock> = attachments
427 .unwrap_or_default()
428 .into_iter()
429 .filter_map(|a| serde_json::from_value::<ContentBlock>(a).ok())
430 .filter(|c| {
431 matches!(
432 c,
433 ContentBlock::ImageBase64 { .. } | ContentBlock::ImageUrl { .. }
434 )
435 })
436 .collect();
437
438 let cancel = Arc::new(AtomicBool::new(false));
440 if let Ok(mut g) = self.cancels.lock() {
441 g.insert(session_id.to_string(), cancel.clone());
442 }
443
444 let cached = self.threads.lock().await.get(session_id).cloned();
447 let mut messages = match cached {
448 Some(existing) => existing,
449 None => {
450 let restored = match &self.durability {
451 Some(store) => match store.load_checkpoint(session_id).await {
452 Ok(checkpoint) => checkpoint.map(|checkpoint| checkpoint.messages),
453 Err(e) => {
454 emit(json!({
455 "kind": "error",
456 "error": format!("durable transcript resume failed: {e}"),
457 "session_id": session_id,
458 }))
459 .await;
460 if let Ok(mut g) = self.cancels.lock() {
461 g.remove(session_id);
462 }
463 return;
464 }
465 },
466 None => None,
467 };
468 let seeded = restored.unwrap_or_else(|| {
469 vec![Message::System {
470 content: self.system.clone(),
471 }]
472 });
473 self.threads
474 .lock()
475 .await
476 .insert(session_id.to_string(), seeded.clone());
477 seeded
478 }
479 };
480 if let Err(e) = self
481 .reconcile_dangling_actions(session_id, &runtime_session, &mut messages)
482 .await
483 {
484 emit(json!({
485 "kind": "error",
486 "error": format!("durable action reconciliation failed: {e}"),
487 "session_id": session_id,
488 }))
489 .await;
490 return;
491 }
492 messages.push(Message::User {
493 content: prompt.to_string(),
494 });
495 if let Some(store) = &self.durability {
496 if let Err(e) = store
497 .checkpoint(session_id, &messages, "user_turn", None)
498 .await
499 {
500 emit(json!({
501 "kind": "error",
502 "error": format!("durable checkpoint failed before inference: {e}"),
503 "session_id": session_id,
504 }))
505 .await;
506 return;
507 }
508 }
509
510 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
513 let drain = tokio::spawn(async move {
514 while let Some(v) = rx.recv().await {
515 emit(v).await;
516 }
517 });
518
519 let sid = session_id.to_string();
520 let tx_term = tx.clone();
524 let gate = ChatApprovalGate {
525 session_id: sid.clone(),
526 tx: tx.clone(),
527 approvals: self.approvals.clone(),
528 counter: Arc::new(AtomicU64::new(0)),
529 durability: self.durability.clone(),
530 repository_root: self.repository_root.clone(),
531 };
532 let outcome = run_assistant_loop_cancellable_in_session_durable(
533 &*self.generator,
534 &self.runtime,
535 &cfg,
536 &mut messages,
537 &cancel,
538 Some(&gate),
539 if images.is_empty() {
540 None
541 } else {
542 Some(images.as_slice())
543 },
544 Some(&runtime_session),
545 Some(session_id),
546 self.durability.as_deref(),
547 true,
548 {
549 let sid = sid.clone();
550 move |ev| {
551 if matches!(ev, AssistantEvent::Done { .. } | AssistantEvent::Error(_)) {
555 return;
556 }
557 if let Some(mut payload) = event_to_wire(ev) {
558 payload["session_id"] = json!(sid);
559 let _ = tx.send(payload);
560 }
561 }
562 },
563 )
564 .await;
565 drop(gate); let completion = super::governance::completion_matrix_from_messages(&messages);
568 let ungrounded_claims =
569 super::agent_loop::ungrounded_summary_claims(&outcome.summary, &outcome.tool_receipts);
570 let _ = tx_term.send(json!({
571 "kind": "receipt_report",
572 "completion": completion,
573 "ungrounded_claims": ungrounded_claims,
574 "session_id": sid,
575 }));
576 let terminal_summary = super::agent_loop::annotate_summary_with_claim_note(
577 &outcome.summary,
578 &ungrounded_claims,
579 );
580 let terminal = match outcome.status {
581 "cancelled" | "error" => {
582 json!({ "kind": "error", "error": terminal_summary, "session_id": sid })
583 }
584 _ => json!({ "kind": "done", "text": terminal_summary, "session_id": sid }),
585 };
586 let _ = tx_term.send(terminal);
587
588 drop(tx_term);
589 let _ = drain.await;
590
591 if outcome.status != "cancelled" {
594 let mut g = self.threads.lock().await;
595 g.insert(session_id.to_string(), messages);
596 }
597 if let Ok(mut g) = self.cancels.lock() {
598 g.remove(session_id);
599 }
600 }
601
602 pub async fn handle_goal_turn<E, Fut>(
606 &self,
607 session_id: &str,
608 prompt: &str,
609 _attachments: Option<Vec<Value>>,
610 goal: ChatGoal,
611 emit: E,
612 ) where
613 E: Fn(Value) -> Fut + Send + Sync + 'static,
614 Fut: Future<Output = ()> + Send + 'static,
615 {
616 self.handle_goal_turn_with_model(session_id, prompt, _attachments, goal, None, emit)
617 .await;
618 }
619
620 pub async fn handle_goal_turn_with_model<E, Fut>(
622 &self,
623 session_id: &str,
624 prompt: &str,
625 _attachments: Option<Vec<Value>>,
626 goal: ChatGoal,
627 model: Option<&str>,
628 emit: E,
629 ) where
630 E: Fn(Value) -> Fut + Send + Sync + 'static,
631 Fut: Future<Output = ()> + Send + 'static,
632 {
633 let cfg = self.config_for_model(model);
634 use car_verify::goal::{GoalCondition, GoalGovernor, GoalSpec, GoalStatus};
635
636 let runtime_session = self.runtime_session_for(session_id).await;
637
638 let cancel = Arc::new(AtomicBool::new(false));
639 if let Ok(mut g) = self.cancels.lock() {
640 g.insert(session_id.to_string(), cancel.clone());
641 }
642
643 let mut messages = match &self.durability {
647 Some(store) => match store.load_checkpoint(session_id).await {
648 Ok(Some(checkpoint)) => checkpoint.messages,
649 Ok(None) => vec![Message::System {
650 content: format!(
651 "{}\n\nYou are working toward a goal. Completion is verified \
652 deterministically by running this shell command:\n {}\nIt is \
653 done only when that command exits 0. Keep working until it does.",
654 self.system, goal.check
655 ),
656 }],
657 Err(e) => {
658 emit(json!({
659 "kind": "error",
660 "error": format!("durable goal resume failed: {e}"),
661 "session_id": session_id,
662 }))
663 .await;
664 return;
665 }
666 },
667 None => vec![Message::System {
668 content: format!(
669 "{}\n\nYou are working toward a goal. Completion is verified \
670 deterministically by running this shell command:\n {}\nIt is \
671 done only when that command exits 0. Keep working until it does.",
672 self.system, goal.check
673 ),
674 }],
675 };
676 if let Err(e) = self
677 .reconcile_dangling_actions(session_id, &runtime_session, &mut messages)
678 .await
679 {
680 emit(json!({
681 "kind": "error",
682 "error": format!("durable goal action reconciliation failed: {e}"),
683 "session_id": session_id,
684 }))
685 .await;
686 return;
687 }
688
689 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
690 let drain = tokio::spawn(async move {
691 while let Some(v) = rx.recv().await {
692 emit(v).await;
693 }
694 });
695
696 let sid = session_id.to_string();
697 let tx_term = tx.clone();
698 let gate = ChatApprovalGate {
699 session_id: sid.clone(),
700 tx: tx.clone(),
701 approvals: self.approvals.clone(),
702 counter: Arc::new(AtomicU64::new(0)),
703 durability: self.durability.clone(),
704 repository_root: self.repository_root.clone(),
705 };
706 let check_gate = gate.clone();
707 let spec = GoalSpec {
708 goal: prompt.to_string(),
709 condition: GoalCondition::Command {
710 id: "goal_check".into(),
711 expect_exit: 0,
712 },
713 governor: GoalGovernor {
714 max_turns: Some(goal.max_iterations.max(1)),
715 ..Default::default()
716 },
717 };
718 let check = goal.check.clone();
719 let check_cfg = cfg.clone();
720 let result = run_assistant_goal_loop_in_session_durable(
721 &*self.generator,
722 &self.runtime,
723 &cfg,
724 &mut messages,
725 &cancel,
726 Some(&gate),
727 &spec,
728 Some(&runtime_session),
729 Some(session_id),
730 self.durability.as_deref(),
731 move |_outcome| {
732 let cmd = check.clone();
733 let check_gate = check_gate.clone();
734 let check_cfg = check_cfg.clone();
735 async move {
736 let exit = run_shell_check_with_approval(
737 &self.runtime,
738 &check_cfg,
739 Some(&check_gate),
740 &cmd,
741 )
742 .await;
743 let mut g = car_engine::GoalGather::default();
744 g.command_exits.insert("goal_check".into(), exit);
745 g
746 }
747 },
748 {
749 let sid = sid.clone();
750 move |ev| {
751 if matches!(ev, AssistantEvent::Done { .. } | AssistantEvent::Error(_)) {
755 return;
756 }
757 if let Some(mut payload) = event_to_wire(ev) {
758 payload["session_id"] = json!(sid);
759 let _ = tx.send(payload);
760 }
761 }
762 },
763 )
764 .await;
765 drop(gate);
766
767 let completion = super::governance::completion_matrix_from_messages(&messages);
768 let _ = tx_term.send(json!({
769 "kind": "receipt_report",
770 "completion": completion,
771 "session_id": sid,
772 }));
773 let terminal = goal_turn_terminal_event(&result, &sid);
774 let _ = tx_term.send(terminal);
775
776 drop(tx_term);
777 let _ = drain.await;
778
779 if !matches!(result.run.status, GoalStatus::Halted { .. }) {
780 let mut g = self.threads.lock().await;
781 g.insert(session_id.to_string(), messages);
782 }
783 if let Ok(mut g) = self.cancels.lock() {
784 g.remove(session_id);
785 }
786 }
787}
788
789fn action_scope(
793 repository_root: Option<&PathBuf>,
794 tool: &str,
795 params: &Value,
796) -> Option<super::governance::ActionScope> {
797 let repository_root = repository_root?.clone();
798 let command = params
799 .get("command")
800 .and_then(Value::as_str)
801 .unwrap_or_default();
802 let target = params
803 .get("target")
804 .or_else(|| params.get("url"))
805 .or_else(|| params.get("path"))
806 .and_then(Value::as_str)
807 .unwrap_or(command)
808 .to_string();
809 let environment = params
810 .get("environment")
811 .and_then(Value::as_str)
812 .unwrap_or("unspecified")
813 .to_string();
814 let lower = format!("{tool} {command}").to_ascii_lowercase();
815 let mut capabilities = Vec::new();
816 if lower.contains("git push") {
817 capabilities.push(super::governance::CredentialCapability(
818 "git:configured-remote".into(),
819 ));
820 }
821 if lower.contains("az ") || lower.contains("azure") {
822 capabilities.push(super::governance::CredentialCapability(
823 "azure:active-account".into(),
824 ));
825 }
826 if lower.contains("sql") || lower.contains("database") || lower.contains("migration") {
827 capabilities.push(super::governance::CredentialCapability(
828 "database:project-configured".into(),
829 ));
830 }
831 Some(super::governance::ActionScope {
832 tool: tool.to_string(),
833 parameters: params.clone(),
834 repository_root,
835 target,
836 environment,
837 credential_capabilities: capabilities,
838 })
839}
840
841#[derive(Clone)]
842struct ChatApprovalGate {
843 session_id: String,
844 tx: mpsc::UnboundedSender<Value>,
845 approvals: Arc<StdMutex<HashMap<String, oneshot::Sender<bool>>>>,
846 counter: Arc<AtomicU64>,
847 durability: Option<Arc<dyn AssistantDurability>>,
848 repository_root: Option<PathBuf>,
849}
850
851impl ChatApprovalGate {
852 fn action_scope(&self, tool: &str, params: &Value) -> Option<super::governance::ActionScope> {
853 action_scope(self.repository_root.as_ref(), tool, params)
854 }
855
856 async fn durable_action(
857 &self,
858 call_id: &str,
859 tool: &str,
860 params: &Value,
861 ) -> Result<Option<super::governance::SupervisedActionRecord>, String> {
862 let (Some(store), Some(scope)) = (&self.durability, self.action_scope(tool, params)) else {
863 return Ok(None);
864 };
865 let action =
866 super::governance::SupervisedActionRecord::propose(&self.session_id, call_id, scope);
867 Ok(store.load_action(&action.id).await?.or(Some(action)))
868 }
869}
870
871#[async_trait::async_trait]
872impl ApprovalGate for ChatApprovalGate {
873 async fn request(&self, tool: &str, params: &Value) -> ApprovalDecision {
874 let n = self.counter.fetch_add(1, Ordering::Relaxed);
875 self.request_action(&format!("unbound-{n}"), tool, params)
876 .await
877 }
878
879 async fn request_action(&self, call_id: &str, tool: &str, params: &Value) -> ApprovalDecision {
880 let mut action = match self.durable_action(call_id, tool, params).await {
881 Ok(action) => action,
882 Err(e) => {
883 return ApprovalDecision::Denied(format!("cannot persist approval scope: {e}"))
884 }
885 };
886 if let Some(existing) = &action {
887 match existing.state {
888 super::governance::ActionState::Approved => return ApprovalDecision::Approved,
889 super::governance::ActionState::Dispatched => {
890 let mut indeterminate = existing.clone();
891 let _ = indeterminate.transition(
892 super::governance::ActionState::Indeterminate,
893 Some(json!({"reason": "resumed after dispatch without terminal receipt"})),
894 );
895 if let Some(store) = &self.durability {
896 let _ = store.record_action(&indeterminate).await;
897 }
898 return ApprovalDecision::Denied(
899 "action was dispatched before restart and is indeterminate; reconcile it before retrying".into(),
900 );
901 }
902 super::governance::ActionState::Completed
903 | super::governance::ActionState::Failed
904 | super::governance::ActionState::Denied
905 | super::governance::ActionState::Indeterminate => {
906 return ApprovalDecision::Denied(
907 "this durable action identity is terminal and cannot be replayed".into(),
908 );
909 }
910 super::governance::ActionState::Proposed => {}
911 }
912 }
913 if let (Some(store), Some(proposed)) = (&self.durability, &action) {
914 if store
915 .load_action(&proposed.id)
916 .await
917 .ok()
918 .flatten()
919 .is_none()
920 {
921 if let Err(e) = store.record_action(proposed).await {
922 return ApprovalDecision::Denied(format!("cannot record action proposal: {e}"));
923 }
924 }
925 }
926 let n = self.counter.fetch_add(1, Ordering::Relaxed);
927 let approval_id = format!("{}-appr-{n}", self.session_id);
928 let (otx, orx) = oneshot::channel();
929 if let Ok(mut g) = self.approvals.lock() {
930 g.insert(approval_id.clone(), otx);
931 }
932 let _ = self.tx.send(json!({
933 "kind": "approval_pending",
934 "approval_id": approval_id,
935 "tool": tool,
936 "params": params,
937 "session_id": self.session_id,
938 "action_id": action.as_ref().map(|record| record.id.clone()),
939 "scope": action.as_ref().map(|record| record.scope.clone()),
940 }));
941 let decision = match tokio::time::timeout(APPROVAL_TIMEOUT, orx).await {
942 Ok(Ok(true)) => ApprovalDecision::Approved,
943 Ok(Ok(false)) => ApprovalDecision::Denied("declined by user".into()),
944 _ => {
945 if let Ok(mut g) = self.approvals.lock() {
947 g.remove(&approval_id);
948 }
949 ApprovalDecision::Denied("approval timed out".into())
950 }
951 };
952 if let (Some(store), Some(record)) = (&self.durability, action.as_mut()) {
953 let next = match &decision {
954 ApprovalDecision::Approved => super::governance::ActionState::Approved,
955 ApprovalDecision::Denied(_) => super::governance::ActionState::Denied,
956 };
957 if let Err(e) = record.transition(next, Some(json!({ "approval_id": approval_id }))) {
958 return ApprovalDecision::Denied(format!("cannot durably record approval: {e}"));
959 }
960 if let Err(e) = store.record_action(record).await {
961 return ApprovalDecision::Denied(format!("cannot durably record approval: {e}"));
962 }
963 }
964 decision
965 }
966
967 async fn before_dispatch(
968 &self,
969 call_id: &str,
970 tool: &str,
971 params: &Value,
972 ) -> Result<(), String> {
973 let Some(mut record) = self.durable_action(call_id, tool, params).await? else {
974 return Ok(());
975 };
976 if record.state != super::governance::ActionState::Approved {
977 return Err(format!(
978 "action {} is {:?}, not approved",
979 record.id, record.state
980 ));
981 }
982 record.transition(super::governance::ActionState::Dispatched, None)?;
983 self.durability
984 .as_ref()
985 .expect("durable action has a store")
986 .record_action(&record)
987 .await
988 }
989
990 async fn after_dispatch(
991 &self,
992 call_id: &str,
993 tool: &str,
994 params: &Value,
995 ok: bool,
996 receipt: &Value,
997 ) -> Result<(), String> {
998 let Some(mut record) = self.durable_action(call_id, tool, params).await? else {
999 return Ok(());
1000 };
1001 record.transition(
1002 if ok {
1003 super::governance::ActionState::Completed
1004 } else {
1005 super::governance::ActionState::Failed
1006 },
1007 Some(receipt.clone()),
1008 )?;
1009 self.durability
1010 .as_ref()
1011 .expect("durable action has a store")
1012 .record_action(&record)
1013 .await
1014 }
1015}
1016
1017fn event_to_wire(ev: AssistantEvent) -> Option<Value> {
1021 match ev {
1022 AssistantEvent::ModelServed {
1023 model_id,
1024 local_last_resort,
1025 } => Some(json!({
1026 "kind": "model_served",
1027 "model_id": model_id,
1028 "local_last_resort": local_last_resort,
1029 })),
1030 AssistantEvent::Text(t) => Some(json!({ "kind": "token", "delta": t })),
1031 AssistantEvent::ToolCall { name, params } => {
1032 Some(json!({ "kind": "tool_call", "tool": name, "params": params }))
1033 }
1034 AssistantEvent::ToolResult { .. } => None,
1035 AssistantEvent::Done { text } => Some(json!({ "kind": "done", "text": text })),
1036 AssistantEvent::Error(e) => Some(json!({ "kind": "error", "error": e })),
1037 AssistantEvent::GoalEvaluated {
1038 iteration,
1039 met,
1040 grounded,
1041 reason,
1042 } => Some(json!({
1043 "kind": "goal_evaluated",
1044 "iteration": iteration,
1045 "met": met,
1046 "grounded": grounded,
1047 "reason": reason,
1048 })),
1049 }
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054 use super::*;
1055 use crate::assistant::executor::GeneralExecutor;
1056 use async_trait::async_trait;
1057 use car_engine::{LocalSubstrate, Substrate, ToolExecutor};
1058 use car_inference::{GenerateRequest, InferenceEngine, InferenceResult};
1059 use std::sync::atomic::AtomicUsize;
1060
1061 fn turn(text: &str, tool_calls: Value) -> InferenceResult {
1062 serde_json::from_value(json!({
1063 "text": text, "tool_calls": tool_calls,
1064 "trace_id": "t", "model_used": "scripted", "latency_ms": 0,
1065 }))
1066 .unwrap()
1067 }
1068
1069 struct Script {
1070 turns: Vec<InferenceResult>,
1071 cursor: AtomicUsize,
1072 }
1073 #[async_trait]
1074 impl TurnGenerator for Script {
1075 async fn generate(&self, _r: GenerateRequest) -> Result<InferenceResult, String> {
1076 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
1077 self.turns.get(i).cloned().ok_or("exhausted".into())
1078 }
1079 }
1080
1081 struct RecordingScript {
1082 requests: Arc<StdMutex<Vec<GenerateRequest>>>,
1083 }
1084
1085 #[async_trait]
1086 impl TurnGenerator for RecordingScript {
1087 async fn generate(&self, request: GenerateRequest) -> Result<InferenceResult, String> {
1088 self.requests.lock().unwrap().push(request);
1089 Ok(turn("done", json!([])))
1090 }
1091 }
1092
1093 async fn runtime(dir: &std::path::Path) -> Arc<Runtime> {
1094 let substrate: Arc<dyn Substrate> = Arc::new(LocalSubstrate::new());
1095 let exec: Arc<dyn ToolExecutor> =
1096 Arc::new(GeneralExecutor::new(substrate.clone(), dir, true));
1097 let rt = Runtime::new()
1098 .with_inference(Arc::new(InferenceEngine::new(Default::default())))
1099 .with_executor(exec)
1100 .with_substrate(substrate);
1101 rt.register_agent_basics().await;
1102 rt.register_tool_entry(
1103 car_engine::ToolEntry::builtin(car_ir::builtins::shell()).with_side_effects(true),
1104 )
1105 .await;
1106 Arc::new(rt)
1107 }
1108
1109 struct FixedApproval(bool);
1110
1111 #[async_trait]
1112 impl ApprovalGate for FixedApproval {
1113 async fn request(&self, _tool: &str, _params: &Value) -> ApprovalDecision {
1114 if self.0 {
1115 ApprovalDecision::Approved
1116 } else {
1117 ApprovalDecision::Denied("denied".into())
1118 }
1119 }
1120 }
1121
1122 #[derive(Default)]
1123 struct MemoryDurability {
1124 actions: AsyncMutex<HashMap<String, super::super::governance::SupervisedActionRecord>>,
1125 checkpoints: AsyncMutex<HashMap<String, super::super::governance::AssistantCheckpoint>>,
1126 }
1127
1128 #[async_trait]
1129 impl AssistantDurability for MemoryDurability {
1130 async fn load_checkpoint(
1131 &self,
1132 session_id: &str,
1133 ) -> Result<Option<super::super::governance::AssistantCheckpoint>, String> {
1134 Ok(self.checkpoints.lock().await.get(session_id).cloned())
1135 }
1136
1137 async fn checkpoint(
1138 &self,
1139 session_id: &str,
1140 messages: &[Message],
1141 reason: &str,
1142 goal: Option<Value>,
1143 ) -> Result<(), String> {
1144 let mut checkpoints = self.checkpoints.lock().await;
1145 let revision = checkpoints
1146 .get(session_id)
1147 .map(|checkpoint| checkpoint.revision + 1)
1148 .unwrap_or(1);
1149 checkpoints.insert(
1150 session_id.to_string(),
1151 super::super::governance::AssistantCheckpoint {
1152 id: session_id.to_string(),
1153 session_id: session_id.to_string(),
1154 revision,
1155 repository_root: PathBuf::from("/fixture/repo"),
1156 messages: messages.to_vec(),
1157 goal,
1158 compaction: Some(json!({ "reason": reason })),
1159 completion: super::super::governance::completion_matrix_from_messages(messages),
1160 },
1161 );
1162 Ok(())
1163 }
1164
1165 async fn load_action(
1166 &self,
1167 action_id: &str,
1168 ) -> Result<Option<super::super::governance::SupervisedActionRecord>, String> {
1169 Ok(self.actions.lock().await.get(action_id).cloned())
1170 }
1171
1172 async fn record_action(
1173 &self,
1174 record: &super::super::governance::SupervisedActionRecord,
1175 ) -> Result<(), String> {
1176 self.actions
1177 .lock()
1178 .await
1179 .insert(record.id.clone(), record.clone());
1180 Ok(())
1181 }
1182 }
1183
1184 #[tokio::test]
1185 async fn restarted_service_resumes_by_stable_host_session_id() {
1186 let dir = tempfile::tempdir().unwrap();
1187 let durability = Arc::new(MemoryDurability::default());
1188 let cfg = AssistantConfig {
1189 model: Some("scripted".into()),
1190 strict_model: false,
1191 max_turns: 2,
1192 tools: GeneralExecutor::tool_defs(),
1193 gated_tools: Vec::new(),
1194 approval_policy: None,
1195 proactive_memory: None,
1196 tool_memory: None,
1197 tool_labels: None,
1198 todos: None,
1199 value_store_previews: false,
1200 response_format: None,
1201 context_window_override: None,
1202 refuse_unadvertised_tools: false,
1203 response_format_validator: None,
1204 delegate_budget: None,
1205 };
1206
1207 let mut first_turn = turn("phase-one-evidence", json!([]));
1208 first_turn.model_identity.resolved_model_id = "mlx/qwen3-4b:4bit".into();
1209 first_turn.local_last_resort = true;
1210 let first = AssistantService::new_durable(
1211 Arc::new(Script {
1212 turns: vec![first_turn],
1213 cursor: AtomicUsize::new(0),
1214 }),
1215 runtime(dir.path()).await,
1216 cfg.clone(),
1217 "sys".into(),
1218 durability.clone(),
1219 dir.path().to_path_buf(),
1220 );
1221 first
1222 .handle_turn("stable-host-session", "investigate", None, |_| async {})
1223 .await;
1224 drop(first);
1225
1226 let second = AssistantService::new_durable(
1227 Arc::new(Script {
1228 turns: vec![turn("continuity-confirmed", json!([]))],
1229 cursor: AtomicUsize::new(0),
1230 }),
1231 runtime(dir.path()).await,
1232 cfg,
1233 "sys".into(),
1234 durability.clone(),
1235 dir.path().to_path_buf(),
1236 );
1237 second
1238 .handle_turn(
1239 "stable-host-session",
1240 "continue without repeating",
1241 None,
1242 |_| async {},
1243 )
1244 .await;
1245
1246 let checkpoints = durability.checkpoints.lock().await;
1247 assert_eq!(
1248 checkpoints.len(),
1249 1,
1250 "runtime UUIDs must not become checkpoint keys"
1251 );
1252 let resumed = checkpoints
1253 .get("stable-host-session")
1254 .expect("stable session checkpoint");
1255 let transcript = serde_json::to_string(&resumed.messages).unwrap();
1256 assert!(transcript.contains("phase-one-evidence"));
1257 assert!(transcript.contains("continue without repeating"));
1258 assert!(transcript.contains("continuity-confirmed"));
1259 assert!(
1260 transcript.contains(r#""model_id":"mlx/qwen3-4b:4bit","local_last_resort":true"#),
1261 "the durable transcript must identify an on-device last-resort turn: {transcript}"
1262 );
1263 assert!(
1264 transcript.contains(r#""model_id":"scripted","local_last_resort":false"#),
1265 "the next ordinary turn must keep its own attribution: {transcript}"
1266 );
1267 }
1268
1269 #[tokio::test]
1270 async fn unsupported_final_claim_is_redriven_before_done() {
1271 let dir = tempfile::tempdir().unwrap();
1272 let script = Arc::new(Script {
1273 turns: vec![
1274 turn("The repository is clean.", json!([])),
1275 turn(
1276 "No git status receipt is available, so repository state remains unknown.",
1277 json!([]),
1278 ),
1279 ],
1280 cursor: AtomicUsize::new(0),
1281 });
1282 let cfg = AssistantConfig {
1283 model: Some("scripted".into()),
1284 strict_model: false,
1285 max_turns: 3,
1286 tools: GeneralExecutor::tool_defs(),
1287 gated_tools: Vec::new(),
1288 approval_policy: None,
1289 proactive_memory: None,
1290 tool_memory: None,
1291 tool_labels: None,
1292 todos: None,
1293 value_store_previews: false,
1294 response_format: None,
1295 context_window_override: None,
1296 refuse_unadvertised_tools: false,
1297 response_format_validator: None,
1298 delegate_budget: None,
1299 };
1300 let service =
1301 AssistantService::new(script.clone(), runtime(dir.path()).await, cfg, "sys".into());
1302 let events = Arc::new(StdMutex::new(Vec::<Value>::new()));
1303 let captured = events.clone();
1304 service
1305 .handle_turn("claims", "inspect", None, move |event| {
1306 let captured = captured.clone();
1307 async move { captured.lock().unwrap().push(event) }
1308 })
1309 .await;
1310
1311 assert_eq!(script.cursor.load(Ordering::SeqCst), 2);
1312 let events = events.lock().unwrap();
1313 let done = events.iter().find(|event| event["kind"] == "done").unwrap();
1314 assert!(done["text"].as_str().unwrap().contains("remains unknown"));
1315 assert!(!done["text"].as_str().unwrap().contains("[claim check]"));
1316 }
1317
1318 #[tokio::test]
1319 async fn scoped_approval_is_durable_exact_and_auditable() {
1320 let repo = tempfile::tempdir().unwrap();
1321 std::fs::create_dir(repo.path().join(".git")).unwrap();
1322 let durability = Arc::new(MemoryDurability::default());
1323 let approvals = Arc::new(StdMutex::new(HashMap::new()));
1324 let (tx, mut rx) = mpsc::unbounded_channel();
1325 let gate = ChatApprovalGate {
1326 session_id: "s1".into(),
1327 tx,
1328 approvals: approvals.clone(),
1329 counter: Arc::new(AtomicU64::new(0)),
1330 durability: Some(durability.clone()),
1331 repository_root: Some(repo.path().to_path_buf()),
1332 };
1333 let params = json!({
1334 "command": "git push origin HEAD:main",
1335 "target": "origin/main",
1336 "environment": "fixture"
1337 });
1338 let pending_gate = gate.clone();
1339 let pending_params = params.clone();
1340 let pending = tokio::spawn(async move {
1341 pending_gate
1342 .request_action("call-1", "shell", &pending_params)
1343 .await
1344 });
1345 let event = rx.recv().await.expect("approval event");
1346 assert_eq!(event["kind"], "approval_pending");
1347 assert_eq!(event["scope"]["target"], "origin/main");
1348 assert_eq!(event["scope"]["environment"], "fixture");
1349 assert_eq!(
1350 event["scope"]["credential_capabilities"][0],
1351 "git:configured-remote"
1352 );
1353 let approval_id = event["approval_id"].as_str().unwrap();
1354 approvals
1355 .lock()
1356 .unwrap()
1357 .remove(approval_id)
1358 .unwrap()
1359 .send(true)
1360 .unwrap();
1361 assert!(matches!(pending.await.unwrap(), ApprovalDecision::Approved));
1362
1363 gate.before_dispatch("call-1", "shell", ¶ms)
1364 .await
1365 .unwrap();
1366 gate.after_dispatch(
1367 "call-1",
1368 "shell",
1369 ¶ms,
1370 true,
1371 &json!({"remote_sha": "abc"}),
1372 )
1373 .await
1374 .unwrap();
1375 let action_id = event["action_id"].as_str().unwrap();
1376 let action = durability.load_action(action_id).await.unwrap().unwrap();
1377 assert_eq!(
1378 action.state,
1379 super::super::governance::ActionState::Completed
1380 );
1381
1382 let changed = json!({
1383 "command": "git push origin HEAD:other",
1384 "target": "origin/other",
1385 "environment": "fixture"
1386 });
1387 assert!(gate
1388 .before_dispatch("call-1", "shell", &changed)
1389 .await
1390 .is_err());
1391
1392 let denied_gate = gate.clone();
1393 let denied_params = changed.clone();
1394 let denied = tokio::spawn(async move {
1395 denied_gate
1396 .request_action("call-2", "shell", &denied_params)
1397 .await
1398 });
1399 let denied_event = rx.recv().await.expect("denial approval event");
1400 let denied_id = denied_event["approval_id"].as_str().unwrap();
1401 approvals
1402 .lock()
1403 .unwrap()
1404 .remove(denied_id)
1405 .unwrap()
1406 .send(false)
1407 .unwrap();
1408 assert!(matches!(denied.await.unwrap(), ApprovalDecision::Denied(_)));
1409 let denied_action = durability
1410 .load_action(denied_event["action_id"].as_str().unwrap())
1411 .await
1412 .unwrap()
1413 .unwrap();
1414 assert_eq!(
1415 denied_action.state,
1416 super::super::governance::ActionState::Denied
1417 );
1418 assert!(denied_action.receipt.is_some(), "denial must be auditable");
1419 assert!(gate
1420 .before_dispatch("call-2", "shell", &changed)
1421 .await
1422 .is_err());
1423 }
1424
1425 fn dangling_shell(call_id: &str, command: &str) -> Vec<Message> {
1426 vec![
1427 Message::System {
1428 content: "sys".into(),
1429 },
1430 Message::User {
1431 content: "do it".into(),
1432 },
1433 Message::Assistant {
1434 content: String::new(),
1435 tool_calls: vec![serde_json::from_value(json!({
1436 "id": call_id,
1437 "name": "shell",
1438 "arguments": {"command": command},
1439 }))
1440 .unwrap()],
1441 thinking: vec![],
1442 model_id: None,
1443 local_last_resort: false,
1444 },
1445 ]
1446 }
1447
1448 #[tokio::test]
1449 async fn restart_before_dispatch_runs_approved_action_once() {
1450 let repo = tempfile::tempdir().unwrap();
1451 std::fs::create_dir(repo.path().join(".git")).unwrap();
1452 let rt = runtime(repo.path()).await;
1453 let durability = Arc::new(MemoryDurability::default());
1454 let generator: Arc<dyn TurnGenerator> = Arc::new(Script {
1455 turns: vec![],
1456 cursor: AtomicUsize::new(0),
1457 });
1458 let service = AssistantService::new_durable(
1459 generator,
1460 rt,
1461 test_cfg_with_gated_shell(),
1462 "sys".into(),
1463 durability.clone(),
1464 repo.path().to_path_buf(),
1465 );
1466 let command = &crate::coder::test_cmds::append_line("x", "effect.txt");
1467 let params = json!({"command": command});
1468 let scope = action_scope(Some(&repo.path().to_path_buf()), "shell", ¶ms).unwrap();
1469 let mut action = super::super::governance::SupervisedActionRecord::propose(
1470 "restart-before",
1471 "call-1",
1472 scope,
1473 );
1474 action
1475 .transition(super::super::governance::ActionState::Approved, None)
1476 .unwrap();
1477 durability.record_action(&action).await.unwrap();
1478 let mut messages = dangling_shell("call-1", command);
1479 let runtime_session = service.runtime_session_for("restart-before").await;
1480 service
1481 .reconcile_dangling_actions("restart-before", &runtime_session, &mut messages)
1482 .await
1483 .unwrap();
1484 service
1485 .reconcile_dangling_actions("restart-before", &runtime_session, &mut messages)
1486 .await
1487 .unwrap();
1488 assert_eq!(
1491 std::fs::read_to_string(repo.path().join("effect.txt"))
1492 .unwrap()
1493 .trim(),
1494 "x",
1495 "the approved effect must execute exactly once"
1496 );
1497 let recovered = durability.load_action(&action.id).await.unwrap().unwrap();
1498 assert_eq!(
1499 recovered.state,
1500 super::super::governance::ActionState::Completed
1501 );
1502 }
1503
1504 #[tokio::test]
1505 async fn restart_after_dispatch_marks_indeterminate_without_replay() {
1506 let repo = tempfile::tempdir().unwrap();
1507 std::fs::create_dir(repo.path().join(".git")).unwrap();
1508 let rt = runtime(repo.path()).await;
1509 let durability = Arc::new(MemoryDurability::default());
1510 let generator: Arc<dyn TurnGenerator> = Arc::new(Script {
1511 turns: vec![],
1512 cursor: AtomicUsize::new(0),
1513 });
1514 let service = AssistantService::new_durable(
1515 generator,
1516 rt,
1517 test_cfg_with_gated_shell(),
1518 "sys".into(),
1519 durability.clone(),
1520 repo.path().to_path_buf(),
1521 );
1522 let command = &crate::coder::test_cmds::append_line("x", "must-not-exist.txt");
1523 let params = json!({"command": command});
1524 let scope = action_scope(Some(&repo.path().to_path_buf()), "shell", ¶ms).unwrap();
1525 let mut action = super::super::governance::SupervisedActionRecord::propose(
1526 "restart-after",
1527 "call-2",
1528 scope,
1529 );
1530 action
1531 .transition(super::super::governance::ActionState::Approved, None)
1532 .unwrap();
1533 action
1534 .transition(super::super::governance::ActionState::Dispatched, None)
1535 .unwrap();
1536 durability.record_action(&action).await.unwrap();
1537 let mut messages = dangling_shell("call-2", command);
1538 let runtime_session = service.runtime_session_for("restart-after").await;
1539 service
1540 .reconcile_dangling_actions("restart-after", &runtime_session, &mut messages)
1541 .await
1542 .unwrap();
1543 assert!(!repo.path().join("must-not-exist.txt").exists());
1544 let recovered = durability.load_action(&action.id).await.unwrap().unwrap();
1545 assert_eq!(
1546 recovered.state,
1547 super::super::governance::ActionState::Indeterminate
1548 );
1549 }
1550
1551 fn test_cfg_with_gated_shell() -> AssistantConfig {
1552 AssistantConfig {
1553 model: Some("scripted".into()),
1554 strict_model: false,
1555 max_turns: 4,
1556 tools: GeneralExecutor::tool_defs(),
1557 gated_tools: vec!["shell".into()],
1558 approval_policy: None,
1559 proactive_memory: None,
1560 tool_memory: None,
1561 tool_labels: None,
1565 todos: None,
1566 value_store_previews: false,
1567 response_format: None,
1568 context_window_override: None,
1569 refuse_unadvertised_tools: false,
1570 response_format_validator: None,
1571 delegate_budget: None,
1572 }
1573 }
1574
1575 #[tokio::test]
1576 async fn goal_shell_check_does_not_run_without_required_approval() {
1577 let dir = tempfile::tempdir().unwrap();
1578 let rt = runtime(dir.path()).await;
1579 let cfg = test_cfg_with_gated_shell();
1580 let target = dir.path().join("should-not-exist");
1581
1582 let exit = run_shell_check_with_approval(
1583 &rt,
1584 &cfg,
1585 None,
1586 &crate::coder::test_cmds::touch("should-not-exist"),
1587 )
1588 .await;
1589
1590 assert_eq!(exit, 1);
1591 assert!(
1592 !target.exists(),
1593 "gated goal verifier command must not run without approval"
1594 );
1595 }
1596
1597 #[tokio::test]
1598 async fn goal_shell_check_runs_after_required_approval() {
1599 let dir = tempfile::tempdir().unwrap();
1600 let rt = runtime(dir.path()).await;
1601 let cfg = test_cfg_with_gated_shell();
1602 let target = dir.path().join("approved-check");
1603
1604 let exit = run_shell_check_with_approval(
1605 &rt,
1606 &cfg,
1607 Some(&FixedApproval(true)),
1608 &crate::coder::test_cmds::touch("approved-check"),
1609 )
1610 .await;
1611
1612 assert_eq!(exit, 0);
1613 assert!(target.exists(), "approved verifier command should run");
1614 }
1615
1616 #[tokio::test]
1617 async fn chat_turn_streams_tokens_and_done() {
1618 let dir = tempfile::tempdir().unwrap();
1619 let rt = runtime(dir.path()).await;
1620 let generator: Arc<dyn TurnGenerator> = Arc::new(Script {
1621 turns: vec![
1622 turn(
1623 "let me compute",
1624 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "2+2" } }]),
1625 ),
1626 turn("It's 4.", json!([])),
1627 ],
1628 cursor: AtomicUsize::new(0),
1629 });
1630 let cfg = AssistantConfig {
1631 model: Some("scripted".into()),
1632 strict_model: false,
1633 max_turns: 4,
1634 tools: GeneralExecutor::tool_defs(),
1635 gated_tools: Vec::new(),
1636 approval_policy: None,
1637 proactive_memory: None,
1638 tool_memory: None,
1639 tool_labels: None,
1643 todos: None,
1644 value_store_previews: false,
1645 response_format: None,
1646 context_window_override: None,
1647 refuse_unadvertised_tools: false,
1648 response_format_validator: None,
1649 delegate_budget: None,
1650 };
1651 let svc = AssistantService::new(generator, rt, cfg, "sys".into());
1652
1653 let events = Arc::new(StdMutex::new(Vec::<Value>::new()));
1654 let ev2 = events.clone();
1655 svc.handle_turn("s1", "what is 2+2?", None, move |v| {
1656 let ev = ev2.clone();
1657 async move {
1658 ev.lock().unwrap().push(v);
1659 }
1660 })
1661 .await;
1662
1663 let got = events.lock().unwrap().clone();
1664 assert!(got.iter().all(|e| e["session_id"] == "s1"));
1666 assert!(got
1668 .iter()
1669 .any(|e| e["kind"] == "tool_call" && e["tool"] == "calculate"));
1670 let last = got.last().unwrap();
1672 assert_eq!(last["kind"], "done");
1673 assert_eq!(last["text"], "It's 4.");
1674
1675 let thread_len = svc.threads.lock().await.get("s1").map(|m| m.len()).unwrap();
1678 assert!(thread_len >= 3, "thread should persist across the turn");
1679 }
1680
1681 #[tokio::test]
1687 async fn every_chat_event_is_forwardable_by_the_daemon() {
1688 let dir = tempfile::tempdir().unwrap();
1689 let rt = runtime(dir.path()).await;
1690 let generator: Arc<dyn TurnGenerator> = Arc::new(Script {
1691 turns: vec![
1692 turn(
1693 "let me compute",
1694 json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "1+1" } }]),
1695 ),
1696 turn("It's 2.", json!([])),
1697 ],
1698 cursor: AtomicUsize::new(0),
1699 });
1700 let cfg = AssistantConfig {
1701 model: Some("scripted".into()),
1702 strict_model: false,
1703 max_turns: 4,
1704 tools: GeneralExecutor::tool_defs(),
1705 gated_tools: Vec::new(),
1706 approval_policy: None,
1707 proactive_memory: None,
1708 tool_memory: None,
1709 tool_labels: None,
1713 todos: None,
1714 value_store_previews: false,
1715 response_format: None,
1716 context_window_override: None,
1717 refuse_unadvertised_tools: false,
1718 response_format_validator: None,
1719 delegate_budget: None,
1720 };
1721 let svc = AssistantService::new(generator, rt, cfg, "sys".into());
1722 let events = Arc::new(StdMutex::new(Vec::<Value>::new()));
1723 let ev2 = events.clone();
1724 svc.handle_turn("sess-42", "1+1?", None, move |v| {
1725 let ev = ev2.clone();
1726 async move {
1727 ev.lock().unwrap().push(v);
1728 }
1729 })
1730 .await;
1731
1732 const KNOWN_KINDS: [&str; 8] = [
1733 "model_served",
1734 "token",
1735 "tool_call",
1736 "approval_pending",
1737 "goal_evaluated",
1738 "receipt_report",
1739 "done",
1740 "error",
1741 ];
1742 let got = events.lock().unwrap().clone();
1743 assert!(!got.is_empty());
1744 for e in &got {
1745 assert_eq!(
1747 e.get("session_id").and_then(Value::as_str),
1748 Some("sess-42"),
1749 "every event must carry its session_id: {e}"
1750 );
1751 let kind = e.get("kind").and_then(Value::as_str).unwrap_or("");
1753 assert!(KNOWN_KINDS.contains(&kind), "unknown event kind: {e}");
1754 }
1755 assert_eq!(got.last().unwrap()["kind"], "done");
1757 }
1758
1759 #[tokio::test]
1760 async fn explicit_chat_model_reaches_inference_and_unset_preserves_agent_default() {
1761 let dir = tempfile::tempdir().unwrap();
1762 let rt = runtime(dir.path()).await;
1763 let requests = Arc::new(StdMutex::new(Vec::new()));
1764 let generator: Arc<dyn TurnGenerator> = Arc::new(RecordingScript {
1765 requests: requests.clone(),
1766 });
1767 let cfg = AssistantConfig {
1768 model: Some("agent/default".into()),
1769 strict_model: false,
1770 max_turns: 2,
1771 tools: Vec::new(),
1772 gated_tools: Vec::new(),
1773 approval_policy: None,
1774 proactive_memory: None,
1775 tool_memory: None,
1776 tool_labels: None,
1780 todos: None,
1781 value_store_previews: false,
1782 response_format: None,
1783 context_window_override: None,
1784 refuse_unadvertised_tools: false,
1785 response_format_validator: None,
1786 delegate_budget: None,
1787 };
1788 let svc = AssistantService::new(generator, rt, cfg, "sys".into());
1789
1790 svc.handle_turn_with_model(
1791 "selected",
1792 "hello",
1793 None,
1794 Some("openrouter/deepseek/deepseek-v3.2"),
1795 |_| async {},
1796 )
1797 .await;
1798 svc.handle_turn("adaptive", "hello", None, |_| async {})
1799 .await;
1800
1801 let got = requests.lock().unwrap();
1802 assert_eq!(got.len(), 2);
1803 assert_eq!(
1804 got[0].model.as_deref(),
1805 Some("openrouter/deepseek/deepseek-v3.2")
1806 );
1807 assert!(
1808 got[0].params.strict_model,
1809 "a selected native model must not silently fall back"
1810 );
1811 assert_eq!(got[1].model.as_deref(), Some("agent/default"));
1812 assert!(
1813 !got[1].params.strict_model,
1814 "an unset native preference preserves the agent's routing policy"
1815 );
1816 }
1817
1818 #[test]
1819 fn model_served_event_has_host_wire_shape() {
1820 let wire = event_to_wire(AssistantEvent::ModelServed {
1821 model_id: "mlx/qwen3-4b:4bit".into(),
1822 local_last_resort: true,
1823 })
1824 .expect("model attribution should be surfaced to hosts");
1825
1826 assert_eq!(wire["kind"], "model_served");
1827 assert_eq!(wire["model_id"], "mlx/qwen3-4b:4bit");
1828 assert_eq!(wire["local_last_resort"], true);
1829 }
1830
1831 #[test]
1832 fn goal_evaluated_event_has_host_wire_shape() {
1833 let wire = event_to_wire(AssistantEvent::GoalEvaluated {
1834 iteration: 2,
1835 met: false,
1836 grounded: true,
1837 reason: "command goal_check exited 1".into(),
1838 })
1839 .expect("goal verifier events should be surfaced to hosts");
1840
1841 assert_eq!(wire["kind"], "goal_evaluated");
1842 assert_eq!(wire["iteration"], 2);
1843 assert_eq!(wire["met"], false);
1844 assert_eq!(wire["grounded"], true);
1845 assert_eq!(wire["reason"], "command goal_check exited 1");
1846 }
1847
1848 fn goal_loop_result(
1849 halt_status: car_verify::goal::GoalStatus,
1850 iterations: u32,
1851 summary: &str,
1852 last_reason: &str,
1853 ) -> GoalLoopResult {
1854 GoalLoopResult {
1855 outcome: super::super::agent_loop::AssistantOutcome {
1856 status: "goal_pending",
1857 summary: summary.to_string(),
1858 turns: iterations,
1859 tools_called: Vec::new(),
1860 tool_receipts: Vec::new(),
1861 models_served: Vec::new(),
1862 model_used: "mlx/qwen3-8b".into(),
1863 },
1864 run: car_verify::goal::GoalRun {
1865 status: halt_status,
1866 iterations,
1867 grounded: true,
1868 cost_usd: 0.0,
1869 last_reason: last_reason.to_string(),
1870 evidence: Vec::new(),
1871 },
1872 }
1873 }
1874
1875 #[test]
1879 fn goal_turn_terminal_event_fails_open_on_evaluation_timeout() {
1880 use car_verify::goal::{GoalHalt, GoalStatus};
1881
1882 let result = goal_loop_result(
1883 GoalStatus::Halted {
1884 halt: GoalHalt::EvaluationTimeout,
1885 },
1886 1,
1887 "Here is your answer.",
1888 "goal check did not complete within 120s — treating this turn's reply as \
1889 unevaluated rather than blocking on it",
1890 );
1891
1892 let wire = goal_turn_terminal_event(&result, "s1");
1893
1894 assert_eq!(
1895 wire["kind"], "done",
1896 "an unevaluated check must fail open, not be reported as an error: {wire}"
1897 );
1898 assert_eq!(wire["session_id"], "s1");
1899 let text = wire["text"].as_str().unwrap();
1900 assert!(
1901 text.starts_with("Here is your answer."),
1902 "the primary reply must be delivered verbatim: {text}"
1903 );
1904 assert!(
1905 text.contains("[goal check] not verified"),
1906 "the unverified state must still be visible in the text: {text}"
1907 );
1908 assert_eq!(
1913 wire["goal_unevaluated"], true,
1914 "the fail-open path must carry a machine-readable marker, not just \
1915 prose in finish_reason, or the durable status record will still \
1916 claim \"met\": {wire}"
1917 );
1918 }
1919
1920 #[test]
1925 fn goal_turn_terminal_event_still_errors_on_a_genuine_goal_failure() {
1926 use car_verify::goal::{GoalHalt, GoalStatus};
1927
1928 let result = goal_loop_result(
1929 GoalStatus::Halted {
1930 halt: GoalHalt::TurnBudget,
1931 },
1932 3,
1933 "I tried, but the tests still fail.",
1934 "command 'goal_check' exited 1",
1935 );
1936
1937 let wire = goal_turn_terminal_event(&result, "s2");
1938
1939 assert_eq!(
1940 wire["kind"], "error",
1941 "a check that ran and genuinely failed keeps today's error semantics: {wire}"
1942 );
1943 assert!(wire["error"]
1944 .as_str()
1945 .unwrap()
1946 .contains("turn budget exhausted"));
1947 }
1948
1949 #[test]
1950 fn goal_turn_terminal_event_reports_done_on_achieved() {
1951 use car_verify::goal::GoalStatus;
1952
1953 let result = goal_loop_result(GoalStatus::Achieved, 2, "All set.", "");
1954
1955 let wire = goal_turn_terminal_event(&result, "s3");
1956
1957 assert_eq!(wire["kind"], "done");
1958 assert_eq!(wire["text"], "All set.");
1959 assert!(
1964 wire.get("goal_unevaluated").is_none(),
1965 "an achieved goal must not be marked unevaluated: {wire}"
1966 );
1967 }
1968
1969 #[tokio::test]
1970 async fn goal_turn_streams_verifier_events_and_one_terminal() {
1971 let dir = tempfile::tempdir().unwrap();
1972 let rt = runtime(dir.path()).await;
1973 let create = crate::coder::test_cmds::touch("goal.done");
1974 let generator: Arc<dyn TurnGenerator> = Arc::new(Script {
1975 turns: vec![
1976 turn("starting", json!([])),
1977 turn(
1978 "creating sentinel",
1979 json!([{ "id": "s1", "name": "shell", "arguments": { "command": create } }]),
1980 ),
1981 turn("done", json!([])),
1982 ],
1983 cursor: AtomicUsize::new(0),
1984 });
1985 let cfg = AssistantConfig {
1986 model: Some("scripted".into()),
1987 strict_model: false,
1988 max_turns: 4,
1989 tools: GeneralExecutor::tool_defs(),
1990 gated_tools: Vec::new(),
1991 approval_policy: None,
1992 proactive_memory: None,
1993 tool_memory: None,
1994 tool_labels: None,
1998 todos: None,
1999 value_store_previews: false,
2000 response_format: None,
2001 context_window_override: None,
2002 refuse_unadvertised_tools: false,
2003 response_format_validator: None,
2004 delegate_budget: None,
2005 };
2006 let svc = AssistantService::new(generator, rt, cfg, "sys".into());
2007
2008 let events = Arc::new(StdMutex::new(Vec::<Value>::new()));
2009 let ev2 = events.clone();
2010 svc.handle_goal_turn(
2011 "goal-s1",
2012 "create goal.done",
2013 None,
2014 ChatGoal {
2015 check: crate::coder::test_cmds::file_exists("goal.done"),
2016 max_iterations: 4,
2017 },
2018 move |v| {
2019 let ev = ev2.clone();
2020 async move {
2021 ev.lock().unwrap().push(v);
2022 }
2023 },
2024 )
2025 .await;
2026
2027 let got = events.lock().unwrap().clone();
2028 let verifier: Vec<_> = got
2029 .iter()
2030 .filter(|e| e["kind"] == "goal_evaluated")
2031 .collect();
2032 assert_eq!(verifier.len(), 2, "one verifier event per goal iteration");
2033 assert_eq!(verifier[0]["met"], false);
2034 assert_eq!(verifier[1]["met"], true);
2035 assert_eq!(verifier[1]["grounded"], true);
2036 assert_eq!(
2037 got.iter().filter(|e| e["kind"] == "done").count(),
2038 1,
2039 "iteration-local done events must not leak as terminal chat events"
2040 );
2041 assert_eq!(got.last().unwrap()["kind"], "done");
2042 assert_eq!(
2043 std::fs::read_to_string(dir.path().join("goal.done")).unwrap_or_default(),
2044 ""
2045 );
2046 }
2047
2048 #[tokio::test]
2049 async fn chat_gated_write_emits_approval_and_resumes_on_approve() {
2050 let dir = tempfile::tempdir().unwrap();
2051 let rt = runtime(dir.path()).await;
2052 let generator: Arc<dyn TurnGenerator> = Arc::new(Script {
2053 turns: vec![
2054 turn(
2055 "",
2056 json!([{ "id": "w1", "name": "write_file", "arguments": { "path": "z.txt", "content": "zephyr" } }]),
2057 ),
2058 turn("done", json!([])),
2059 ],
2060 cursor: AtomicUsize::new(0),
2061 });
2062 let cfg = AssistantConfig {
2063 model: Some("scripted".into()),
2064 strict_model: false,
2065 max_turns: 4,
2066 tools: GeneralExecutor::tool_defs(),
2067 gated_tools: vec!["write_file".into()],
2068 approval_policy: None,
2069 proactive_memory: None,
2070 tool_memory: None,
2071 tool_labels: None,
2075 todos: None,
2076 value_store_previews: false,
2077 response_format: None,
2078 context_window_override: None,
2079 refuse_unadvertised_tools: false,
2080 response_format_validator: None,
2081 delegate_budget: None,
2082 };
2083 let svc = Arc::new(AssistantService::new(generator, rt, cfg, "sys".into()));
2084
2085 let events = Arc::new(StdMutex::new(Vec::<Value>::new()));
2086 let ev2 = events.clone();
2087
2088 let svc_run = svc.clone();
2090 let turn_task = tokio::spawn(async move {
2091 svc_run
2092 .handle_turn("s1", "write z.txt", None, move |v| {
2093 let ev = ev2.clone();
2094 async move {
2095 ev.lock().unwrap().push(v);
2096 }
2097 })
2098 .await;
2099 });
2100
2101 let approved = {
2103 let mut ok = false;
2104 for _ in 0..200 {
2105 let id = events
2106 .lock()
2107 .unwrap()
2108 .iter()
2109 .find(|e| e["kind"] == "approval_pending")
2110 .and_then(|e| e["approval_id"].as_str().map(String::from));
2111 if let Some(id) = id {
2112 assert!(svc.resolve_approval(&id, true));
2113 ok = true;
2114 break;
2115 }
2116 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2117 }
2118 ok
2119 };
2120 assert!(
2121 approved,
2122 "an approval_pending event should have been emitted"
2123 );
2124 turn_task.await.unwrap();
2125
2126 assert_eq!(
2128 std::fs::read_to_string(dir.path().join("z.txt")).unwrap(),
2129 "zephyr"
2130 );
2131 }
2132}