Skip to main content

car_server_core/assistant/
chat.rs

1//! The `agent.chat` harness — makes the assistant a real conversational agent.
2//!
3//! [`AssistantService`] holds one runtime + a per-`session_id` conversation
4//! thread and turns each `agent.chat` request into a loop run, streaming
5//! `agent.chat.event` payloads as they're produced. It is transport-agnostic:
6//! the caller supplies an async `emit` sink (the `--serve` path forwards it to
7//! `DaemonClient::notify`), so the same service is unit-testable without a live
8//! daemon. This is the reusable piece that closes the harness gap — until now
9//! only bespoke agents (Milo) implemented the agent side of `agent.chat`.
10//!
11//! Streaming discipline (per `docs/host-protocol.md`): the loop's synchronous
12//! `emit` writes into a bounded channel drained by a dedicated task that awaits
13//! `emit`, so a slow downstream never blocks the loop and the ack is never
14//! delayed behind token production.
15
16use 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
37/// How long a chat turn waits for a host approval before treating it as declined.
38const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
39
40/// Deterministic completion check for a goal-driven chat turn.
41#[derive(Clone, Debug)]
42pub struct ChatGoal {
43    pub check: String,
44    pub max_iterations: u32,
45}
46
47/// A single-action `shell` proposal used to run a chat goal's completion check
48/// on the bound substrate, audited through the runtime like any other tool.
49fn 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", &params).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
88/// Shape the terminal `agent.chat.event` for a finished goal turn.
89///
90/// `Achieved` and an evaluated-but-unmet halt (turn/cost/wall-clock budget,
91/// no-progress, cancel) keep `car do`'s existing goal-mode semantics: `done`
92/// with the reply, or `error` describing why the condition was never met.
93///
94/// `GoalHalt::EvaluationTimeout` (car#1112) is neither of those — the check
95/// never got the chance to run (a stuck approval wait, a wedged subprocess, an
96/// unreachable inference route), so there is no verdict on the condition
97/// either way. The model's reply already exists in `result.outcome.summary`
98/// by this point (`gather` only runs after an iteration completes); failing
99/// open and delivering it, with the unverified state noted in the text, beats
100/// discarding a good answer behind an opaque error because an unrelated
101/// verification pass stalled. The `[goal check]` note mirrors the existing
102/// `[claim check]` annotation convention (see `annotate_summary_with_claim_note`
103/// in `agent_loop.rs`).
104///
105/// `goal_unevaluated: true` (car#1113 review) IS a new wire field, added
106/// deliberately: `update_chat_goal_from_event` (handler.rs) used to map every
107/// `kind: "done"` straight to the durable `ChatGoalState.status = "met"`,
108/// unconditionally — so this fail-open path, whose entire point is "there is
109/// no verdict to trust either way", was persisting exactly the verdict it
110/// refuses to claim. `finish_reason` alone didn't fix that: it lands in the
111/// secondary `terminal_message` prose field, while `status` — the field
112/// `goal.status` documents as what a client actually reads — kept saying
113/// "met". This boolean is what `handler.rs` branches on to write
114/// `status: "unevaluated"` instead.
115fn 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            // `update_chat_goal_from_event` (handler.rs) reads `finish_reason`
131            // off a "done" event into the standing `ChatGoalState`'s
132            // `terminal_message` — set it so a client polling `goal.status`
133            // can still tell the check went unevaluated, rather than reading
134            // an empty message next to a `status: "met"` it would otherwise
135            // misread as verified.
136            "finish_reason": "goal check unevaluated (timed out) — reply delivered unverified",
137            // Machine-readable twin of the above: `handler.rs` reads this to
138            // keep the durable `status` field from claiming "met" on a goal
139            // that was never actually checked (see this fn's doc comment).
140            "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
156/// A conversational assistant over one runtime, multiplexed by `session_id`.
157pub struct AssistantService {
158    generator: Arc<dyn TurnGenerator>,
159    runtime: Arc<Runtime>,
160    cfg: AssistantConfig,
161    /// System prompt seeded as the first message of every new thread.
162    system: String,
163    /// Per-session conversation threads (multi-turn continuity).
164    threads: AsyncMutex<HashMap<String, Vec<Message>>>,
165    /// Per-session cancellation flags, set by [`Self::cancel`].
166    cancels: StdMutex<HashMap<String, Arc<AtomicBool>>>,
167    /// Runtime session ids paired with externally visible chat session ids. The
168    /// runtime uses these to keep stateful tool safety guards isolated while
169    /// this service multiplexes conversations through one shared executor.
170    runtime_sessions: AsyncMutex<HashMap<String, String>>,
171    /// Pending approvals awaiting a host decision, keyed by approval id. Resolved
172    /// by [`Self::resolve_approval`] (driven by the `agent.chat.approve` call).
173    approvals: Arc<StdMutex<HashMap<String, oneshot::Sender<bool>>>>,
174    /// Oplog-backed exact transcript/action persistence. `None` only for
175    /// one-shot/tests that do not opt into supervised durability.
176    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    /// Close any tool-call exchange interrupted by a process restart. An
235    /// approved action is dispatched once from its durable scope; a dispatched
236    /// action is marked indeterminate and never replayed; a terminal action is
237    /// represented by a synthetic tool result so provider history stays valid.
238    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, &params)
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    /// Signal the session's in-flight turn to stop before its next model call.
365    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    /// Resolve a pending approval (from an `agent.chat.approve` reverse-call).
374    /// Returns true if an approval by that id was waiting.
375    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    /// Run one chat turn for `session_id`, streaming `agent.chat.event` payloads
388    /// (each already stamped with `session_id`) through `emit`. Returns when the
389    /// turn reaches a terminal state. `attachments` are image `ContentBlock`s
390    /// (`image_base64`/`image_url`) forwarded to a vision model on the first
391    /// model call. The caller should have already acked the `agent.chat` request
392    /// and spawned this on its own task.
393    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    /// Run one chat turn with an optional host-selected CAR model. A missing
408    /// selector preserves the supervised agent's configured model; a supplied
409    /// selector is strict so the explicit native choice cannot silently fall
410    /// back to a different model.
411    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        // Image attachments → ContentBlocks for the vision path. The daemon
425        // already validated the shape; keep only image blocks.
426        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        // Fresh cancel flag per turn.
439        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        // Load (or seed) the thread; append the user turn. Clone out so the loop
445        // doesn't hold the threads lock across its awaits.
446        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        // Stream events through a channel drained by a dedicated task, so the
511        // loop's synchronous emit never blocks on the async sink.
512        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        // `tx_term` streams the synthesized terminal event after the loop; the
521        // loop's emit closure and the gate each hold their own clone. Dropping
522        // all three closes the channel so the drain task ends.
523        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                    // Emit the receipt matrix immediately before one terminal
552                    // event below, so hosts never see evidence arrive after
553                    // `done` and mistake an earlier proxy for completion.
554                    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); // release the gate's channel clone
566
567        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        // Persist the thread (unless cancelled mid-turn, where the partial
592        // assistant/tool messages would leave a dangling exchange).
593        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    /// Run one chat turn as a deterministic goal loop. The user prompt is the
603    /// pinned objective; completion is decided by `goal.check` exiting 0 through
604    /// the runtime, and every verifier pass streams `goal_evaluated`.
605    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    /// Goal-turn counterpart to [`Self::handle_turn_with_model`].
621    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        // Goal-loop turns are anchored to the objective every iteration. On a
644        // process restart, resume the exact checkpoint instead of silently
645        // seeding a fresh goal conversation.
646        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                    // Inner loop `done`/`error` events are iteration-local; the
752                    // goal loop sends one terminal event below after the verifier
753                    // achieves or halts.
754                    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
789/// The chat-surface approval gate: emit an `approval_pending` event and park on
790/// a oneshot resolved by [`AssistantService::resolve_approval`] (driven by the
791/// host's `agent.chat.approve` reverse-call). Times out to "declined".
792fn 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                // Timed out or the sender dropped — clean up and treat as denied.
946                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
1017/// Translate a loop event into an `agent.chat.event` wire payload. Tool results
1018/// are internal to the loop and not surfaced as their own kind (the model's
1019/// subsequent text conveys them).
1020fn 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", &params)
1364            .await
1365            .unwrap();
1366        gate.after_dispatch(
1367            "call-1",
1368            "shell",
1369            &params,
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", &params).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        // Trimmed: cmd's `echo` always terminates the line (with CRLF). A
1489        // second execution appends a second line, so this still fails on twice.
1490        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", &params).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            // None => built-in labels, which cover the network-reaching
1562            // commodity tools. A caller that loads .car/tool-labels.json
1563            // should pass the merged map (car#723).
1564            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            // None => built-in labels, which cover the network-reaching
1640            // commodity tools. A caller that loads .car/tool-labels.json
1641            // should pass the merged map (car#723).
1642            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        // Every event carries the session id.
1665        assert!(got.iter().all(|e| e["session_id"] == "s1"));
1666        // A tool_call for calculate was streamed.
1667        assert!(got
1668            .iter()
1669            .any(|e| e["kind"] == "tool_call" && e["tool"] == "calculate"));
1670        // The last event is the terminal done with the final text.
1671        let last = got.last().unwrap();
1672        assert_eq!(last["kind"], "done");
1673        assert_eq!(last["text"], "It's 4.");
1674
1675        // Second turn on the same session continues the thread (3 messages
1676        // seeded: system+user+assistant... at least the thread persisted).
1677        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    /// The daemon's `try_forward_agent_chat_event` forwards a chat event iff it
1682    /// is a notification carrying `params.session_id`; the host then dispatches
1683    /// on `kind`. This asserts every event we emit across a full turn (text,
1684    /// tool_call, terminal) satisfies that contract — the wire compatibility the
1685    /// live `agents.chat` → `agent.chat.event` path depends on.
1686    #[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            // None => built-in labels, which cover the network-reaching
1710            // commodity tools. A caller that loads .car/tool-labels.json
1711            // should pass the merged map (car#723).
1712            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            // Forwarding precondition: session_id present and correct.
1746            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            // Host-dispatchable: a known kind.
1752            let kind = e.get("kind").and_then(Value::as_str).unwrap_or("");
1753            assert!(KNOWN_KINDS.contains(&kind), "unknown event kind: {e}");
1754        }
1755        // The stream ends in a terminal `done`.
1756        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            // None => built-in labels, which cover the network-reaching
1777            // commodity tools. A caller that loads .car/tool-labels.json
1778            // should pass the merged map (car#723).
1779            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    /// car#1112: a goal check that never got to run must not swallow a good
1876    /// reply behind an opaque error — see `goal_turn_terminal_event`'s doc
1877    /// comment for the full "unevaluated, not failed" reasoning.
1878    #[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        // car#1113 review: `handler::update_chat_goal_from_event` reads this
1909        // to keep the durable `ChatGoalState.status` from claiming "met" on
1910        // a goal that was never actually checked — see that fn and this
1911        // fn's doc comment.
1912        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    /// The counterpart: a check that actually ran and genuinely failed (the
1921    /// pre-existing, still-correct goal-mode behavior) must keep reporting
1922    /// `error` — this fix narrows to the *unevaluated* case only, it does not
1923    /// turn every goal-mode failure into a silent `done`.
1924    #[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        // The `goal_unevaluated` marker is specific to the unevaluated-halt
1960        // case above — a real Achieved completion must not carry it, or
1961        // `update_chat_goal_from_event` would wrongly record `"unevaluated"`
1962        // for a goal that genuinely passed.
1963        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            // None => built-in labels, which cover the network-reaching
1995            // commodity tools. A caller that loads .car/tool-labels.json
1996            // should pass the merged map (car#723).
1997            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            // None => built-in labels, which cover the network-reaching
2072            // commodity tools. A caller that loads .car/tool-labels.json
2073            // should pass the merged map (car#723).
2074            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        // Drive the turn and, concurrently, approve the first pending request.
2089        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        // Poll for the approval_pending event, then approve it.
2102        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        // The write ran after approval.
2127        assert_eq!(
2128            std::fs::read_to_string(dir.path().join("z.txt")).unwrap(),
2129            "zephyr"
2130        );
2131    }
2132}