Skip to main content

harn_vm/
bridge.rs

1//! JSON-RPC 2.0 host bridge for VM effects when `harn run --bridge` is active.
2
3mod authority;
4mod control;
5pub use authority::leading_authority_param_count;
6pub use authority::{inject_leading_authorities, inject_leading_authority};
7pub use control::HostBridgeControlState;
8use control::{handle_cancel_tool_call_notification, DaemonIdleState};
9use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
10use std::future::Future;
11use std::io::Write;
12use std::path::{Path, PathBuf};
13use std::pin::Pin;
14use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
15use std::sync::Arc;
16use std::time::Duration;
17
18use tokio::io::AsyncBufReadExt;
19use tokio::sync::{oneshot, Mutex, Notify};
20
21use harn_parser::diagnostic_codes::Code;
22
23use crate::orchestration::MutationSessionRecord;
24use crate::value::{ErrorCategory, VmClosure, VmError, VmValue};
25use crate::visible_text::VisibleTextState;
26use crate::vm::Vm;
27
28/// Default timeout for non-interactive bridge calls (5 minutes).
29const DEFAULT_TIMEOUT: Duration = Duration::from_mins(5);
30
31fn bridge_call_timeout(method: &str) -> Option<Duration> {
32    match method {
33        // A human approval is intentionally open-ended. Cancellation and host
34        // disconnects still wake the waiter, but a slow approver must not be
35        // converted into a terminal denial by the transport watchdog.
36        crate::llm::acp_permission::METHOD_REQUEST_PERMISSION => None,
37        _ => Some(DEFAULT_TIMEOUT),
38    }
39}
40
41async fn wait_for_bridge_call_timeout(method: &str) -> Duration {
42    if let Some(timeout) = bridge_call_timeout(method) {
43        tokio::time::sleep(timeout).await;
44        timeout
45    } else {
46        std::future::pending::<Duration>().await
47    }
48}
49
50pub type HostBridgeWriter = Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync>;
51
52fn stdout_writer(stdout_lock: Arc<std::sync::Mutex<()>>) -> HostBridgeWriter {
53    Arc::new(move |line: &str| {
54        let _guard = stdout_lock.lock().unwrap_or_else(|e| e.into_inner());
55        let mut stdout = std::io::stdout().lock();
56        stdout
57            .write_all(line.as_bytes())
58            .map_err(|e| format!("Bridge write error: {e}"))?;
59        stdout
60            .write_all(b"\n")
61            .map_err(|e| format!("Bridge write error: {e}"))?;
62        stdout
63            .flush()
64            .map_err(|e| format!("Bridge flush error: {e}"))?;
65        Ok(())
66    })
67}
68
69/// A JSON-RPC 2.0 bridge to a host process over stdin/stdout.
70///
71/// The bridge sends requests to the host on stdout and receives responses
72/// on stdin. A background task reads stdin and dispatches responses to
73/// waiting callers by request ID. All stdout writes are serialized through
74/// a mutex to prevent interleaving.
75pub struct HostBridge {
76    next_id: AtomicU64,
77    /// Pending request waiters, keyed by JSON-RPC id.
78    pending: Arc<Mutex<HashMap<u64, oneshot::Sender<serde_json::Value>>>>,
79    /// Whether the host has sent a cancel notification.
80    cancelled: Arc<AtomicBool>,
81    /// Tool calls routed through this bridge. The bridge owns the registry so
82    /// its transport task and every VM it drives share one explicit address
83    /// space even when they run on different executor tasks or OS threads.
84    tool_call_cancellations: Arc<crate::tool_call_cancellations::CancellationRegistry>,
85    /// Wakes pending host calls when cancellation arrives.
86    cancel_notify: Arc<Notify>,
87    /// Whether the host transport has closed. Checked while holding `pending`
88    /// so a request cannot register after the reader's final clear.
89    disconnected: Arc<AtomicBool>,
90    /// Transport writer used to send JSON-RPC to the host.
91    writer: HostBridgeWriter,
92    /// ACP session ID (set in ACP mode for session-scoped notifications).
93    session_id: std::sync::Mutex<String>,
94    /// Name of the currently executing Harn script (without .harn suffix).
95    script_name: std::sync::Mutex<String>,
96    /// Transcript injections queued by the host while a run is active.
97    queued_transcript_injections: HostBridgeInjectionState,
98    /// Host-triggered resume signal for daemon agents.
99    resume_requested: Arc<AtomicBool>,
100    /// Host-triggered skill-registry invalidation signal. Set when the
101    /// host sends a `skills/update` notification; consumed by the CLI
102    /// between runs (watch mode, long-running agents) to rebuild the
103    /// layered skill catalog from its current filesystem + host state.
104    skills_reload_requested: Arc<AtomicBool>,
105    /// State and waiters for the daemon loop's idle boundary.
106    daemon_idle: DaemonIdleState,
107    /// Canonical ACP stop reason and producer-owned terminal outcome recorded
108    /// by the most recent `agent_loop` finalize during this prompt.
109    prompt_outcome: std::sync::Mutex<Option<(String, crate::agent_events::AgentTerminalOutcome)>>,
110    /// Per-call visible assistant text state for call_progress notifications.
111    visible_call_states: std::sync::Mutex<HashMap<String, VisibleTextState>>,
112    /// Whether an LLM call's deltas should be exposed to end users while streaming.
113    visible_call_streams: std::sync::Mutex<HashMap<String, bool>>,
114    /// Optional in-process host-module backend used by `harn playground`.
115    in_process: Option<InProcessHost>,
116}
117
118struct InProcessHost {
119    module_path: PathBuf,
120    exported_functions: BTreeMap<String, Arc<VmClosure>>,
121    vm: Vm,
122}
123
124impl InProcessHost {
125    /// Box-pin'd to break the static recursion between the VM's hot dispatch
126    /// loop and the bridge: a bridge-backed builtin spawns a child VM that
127    /// calls back into the dispatch loop via `call_closure_pub`. Indirecting
128    /// at this slow-path boundary keeps the recursion satisfied without
129    /// allocating per call in the hot per-callback path.
130    fn dispatch<'a>(
131        &'a self,
132        method: &'a str,
133        params: serde_json::Value,
134    ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, VmError>> + Send + 'a>> {
135        Box::pin(async move {
136            match method {
137                "builtin_call" => {
138                    let name = params
139                        .get("name")
140                        .and_then(|value| value.as_str())
141                        .unwrap_or_default();
142                    let args = params
143                        .get("args")
144                        .and_then(|value| value.as_array())
145                        .cloned()
146                        .unwrap_or_default()
147                        .into_iter()
148                        .map(|value| json_result_to_vm_value(&value))
149                        .collect::<Vec<_>>();
150                    self.invoke_export(name, &args).await
151                }
152                "host/tools/list" => self
153                    .invoke_optional_export("host_tools_list", &[])
154                    .await
155                    .map(|value| value.unwrap_or_else(|| serde_json::json!({ "tools": [] }))),
156                crate::llm::acp_permission::METHOD_REQUEST_PERMISSION => {
157                    self.request_permission(params).await
158                }
159                other => Err(VmError::Runtime(format!(
160                    "playground host backend does not implement bridge method '{other}'"
161                ))),
162            }
163        })
164    }
165
166    async fn invoke_export(
167        &self,
168        name: &str,
169        args: &[VmValue],
170    ) -> Result<serde_json::Value, VmError> {
171        let Some(closure) = self.exported_functions.get(name) else {
172            return Err(VmError::Runtime(format!(
173                "Playground host is missing capability '{name}'. Define `pub fn {name}(...)` in {}",
174                self.module_path.display()
175            )));
176        };
177
178        let mut vm = self.vm.child_vm_for_host();
179        let call_args = authority::inject_export_authority(&vm, closure, args, name)?;
180        let result = vm.call_closure_pub(closure, &call_args).await?;
181        Ok(crate::llm::vm_value_to_json(&result))
182    }
183
184    async fn invoke_optional_export(
185        &self,
186        name: &str,
187        args: &[VmValue],
188    ) -> Result<Option<serde_json::Value>, VmError> {
189        if !self.exported_functions.contains_key(name) {
190            return Ok(None);
191        }
192        self.invoke_export(name, args).await.map(Some)
193    }
194
195    async fn request_permission(
196        &self,
197        params: serde_json::Value,
198    ) -> Result<serde_json::Value, VmError> {
199        // No exported `request_permission` means the playground host has no
200        // approval policy, so it grants through the canonical ACP option.
201        let Some(closure) = self.exported_functions.get("request_permission") else {
202            return Ok(crate::llm::acp_permission::allow_response());
203        };
204
205        let tool_call = params.get("toolCall");
206        let tool_name = tool_call
207            .and_then(|tool_call| tool_call.pointer("/_meta/harn/toolName"))
208            .or_else(|| tool_call.and_then(|tool_call| tool_call.get("toolName")))
209            .or_else(|| tool_call.and_then(|tool_call| tool_call.get("title")))
210            .and_then(|value| value.as_str())
211            .unwrap_or_default();
212        let tool_args = tool_call
213            .and_then(|tool_call| tool_call.get("rawInput"))
214            .map(json_result_to_vm_value)
215            .unwrap_or(VmValue::Nil);
216        let full_payload = json_result_to_vm_value(&params);
217
218        let arg_count = closure.func.params.len();
219        let args = if arg_count >= 3 {
220            vec![
221                VmValue::String(arcstr::ArcStr::from(tool_name.to_string())),
222                tool_args,
223                full_payload,
224            ]
225        } else if arg_count == 2 {
226            vec![
227                VmValue::String(arcstr::ArcStr::from(tool_name.to_string())),
228                tool_args,
229            ]
230        } else if arg_count == 1 {
231            vec![full_payload]
232        } else {
233            Vec::new()
234        };
235
236        let mut vm = self.vm.child_vm_for_host();
237        let result = vm.call_closure_pub(closure, &args).await?;
238        // Translate the script's verdict into a canonical ACP response
239        // (`{ outcome: { outcome: "selected" | "cancelled", optionId? } }`).
240        // The script API stays ergonomic — bool / string-reason / dict — but
241        // the wire shape is canonical.
242        let payload = match result {
243            VmValue::Bool(granted) => {
244                if granted {
245                    crate::llm::acp_permission::allow_response()
246                } else {
247                    crate::llm::acp_permission::reject_response(None)
248                }
249            }
250            VmValue::String(reason) if !reason.is_empty() => {
251                crate::llm::acp_permission::reject_response(Some(reason.to_string()))
252            }
253            other => {
254                let json = crate::llm::vm_value_to_json(&other);
255                if let Some(granted) = json.get("granted").and_then(|value| value.as_bool()) {
256                    if granted {
257                        crate::llm::acp_permission::allow_response()
258                    } else {
259                        crate::llm::acp_permission::reject_response(
260                            json.get("reason")
261                                .and_then(|value| value.as_str())
262                                .map(str::to_string),
263                        )
264                    }
265                } else if json.get("outcome").is_some() {
266                    // The script already returned a canonical-shaped outcome.
267                    json
268                } else if other.is_truthy() {
269                    crate::llm::acp_permission::allow_response()
270                } else {
271                    crate::llm::acp_permission::reject_response(None)
272                }
273            }
274        };
275        Ok(payload)
276    }
277}
278
279/// How a queued bridge injection is delivered into the agent loop.
280///
281/// `AuditOnly` injections drain at `loop_exit`, *after* the last LLM call has
282/// returned, so they land in the transcript audit but are **never rendered into
283/// a model prompt**.
284/// Hosts that want the model to react to the reminder on its final
285/// iteration should use `FinishStep` instead, which drains at every
286/// `iteration_start` / `post_tool_dispatch` / `iteration_end` checkpoint.
287#[derive(Clone, Copy, Debug, PartialEq, Eq)]
288pub enum QueuedUserMessageMode {
289    InterruptImmediate,
290    FinishStep,
291    AuditOnly,
292}
293
294#[derive(Clone, Copy, Debug, PartialEq, Eq)]
295pub enum DeliveryCheckpoint {
296    InterruptImmediate,
297    AfterCurrentOperation,
298    EndOfInteraction,
299}
300
301impl QueuedUserMessageMode {
302    fn from_str(value: &str) -> Self {
303        match value {
304            "interrupt_immediate" | "interrupt" => Self::InterruptImmediate,
305            // `steer` is the ACP `session/inject` alias for mid-turn
306            // user-message delivery at the next tool boundary; it maps to
307            // the same `FinishStep` checkpoint as `finish_step`.
308            "finish_step" | "after_current_operation" | "steer" => Self::FinishStep,
309            // `queue` is the explicit ACP alias for the audit-only path.
310            "queue" => Self::AuditOnly,
311            // Unknown / missing modes fall through to the safest option:
312            // record for audit, do not preempt the loop. Pre-#2212 hosts
313            // that send `wait_for_completion` are caught by this arm —
314            // the canonical name is `audit_only` going forward.
315            _ => Self::AuditOnly,
316        }
317    }
318
319    fn as_str(self) -> &'static str {
320        match self {
321            Self::InterruptImmediate => "interrupt_immediate",
322            Self::FinishStep => "finish_step",
323            Self::AuditOnly => "audit_only",
324        }
325    }
326}
327
328#[derive(Clone, Debug, PartialEq, Eq)]
329pub struct QueuedUserMessage {
330    pub message_id: String,
331    pub content: String,
332    pub transcript_content: serde_json::Value,
333    pub mode: QueuedUserMessageMode,
334}
335
336#[derive(Clone, Debug, PartialEq, Eq)]
337pub struct QueuedReminder {
338    pub reminder: crate::llm::helpers::SystemReminder,
339    pub mode: QueuedUserMessageMode,
340}
341
342#[derive(Clone, Debug, PartialEq, Eq)]
343pub enum QueuedTranscriptInjection {
344    User(QueuedUserMessage),
345    Reminder(QueuedReminder),
346}
347
348#[derive(Debug, Default)]
349struct QueuedTranscriptInjections {
350    queue: VecDeque<QueuedTranscriptInjection>,
351    revoked_user_message_ids: HashSet<String>,
352    delivered_user_message_ids: HashSet<String>,
353    revoked_reminder_ids: HashSet<String>,
354    delivered_reminder_ids: HashSet<String>,
355}
356
357#[derive(Clone, Debug, Default)]
358pub struct HostBridgeInjectionState {
359    inner: Arc<Mutex<QueuedTranscriptInjections>>,
360}
361
362#[derive(Clone, Copy, Debug, PartialEq, Eq)]
363pub enum PendingUserMessageMutationResult {
364    Mutated,
365    AlreadyRevoked,
366    AlreadyDelivered,
367    UnknownMessageId,
368}
369
370impl QueuedTranscriptInjection {
371    fn mode(&self) -> QueuedUserMessageMode {
372        match self {
373            Self::User(message) => message.mode,
374            Self::Reminder(reminder) => reminder.mode,
375        }
376    }
377
378    fn pending_json(&self, position: usize) -> serde_json::Value {
379        match self {
380            Self::User(message) => serde_json::json!({
381                "kind": "user",
382                "id": message.message_id,
383                "messageId": message.message_id,
384                "mode": message.mode.as_str(),
385                "position": position,
386                "content": message.transcript_content,
387            }),
388            Self::Reminder(reminder) => serde_json::json!({
389                "kind": "reminder",
390                "id": reminder.reminder.id,
391                "reminderId": reminder.reminder.id,
392                "mode": reminder.mode.as_str(),
393                "position": position,
394                "body": reminder.reminder.body,
395                "tags": reminder.reminder.tags,
396                "dedupeKey": reminder.reminder.dedupe_key,
397                "ttlTurns": reminder.reminder.ttl_turns,
398                "preserveOnCompact": reminder.reminder.preserve_on_compact,
399                "propagate": reminder.reminder.propagate.as_str(),
400                "roleHint": reminder.reminder.role_hint.as_str(),
401                "source": reminder.reminder.source.as_str(),
402                "firedAtTurn": reminder.reminder.fired_at_turn,
403                "originatingAgentId": reminder.reminder.originating_agent_id,
404            }),
405        }
406    }
407}
408
409#[derive(Clone, Copy, Debug, PartialEq, Eq)]
410pub enum PendingReminderMutationResult {
411    Mutated,
412    AlreadyRevoked,
413    AlreadyDelivered,
414    UnknownReminderId,
415}
416
417fn new_inject_message_id() -> String {
418    format!("msg_inj_{}", uuid::Uuid::now_v7().simple())
419}
420
421impl HostBridgeInjectionState {
422    pub fn new() -> Self {
423        Self::default()
424    }
425
426    pub async fn push_pending_user_message(
427        &self,
428        content: String,
429        transcript_content: serde_json::Value,
430        mode: &str,
431    ) -> String {
432        let message_id = new_inject_message_id();
433        self.inner
434            .lock()
435            .await
436            .queue
437            .push_back(QueuedTranscriptInjection::User(QueuedUserMessage {
438                message_id: message_id.clone(),
439                content,
440                transcript_content,
441                mode: QueuedUserMessageMode::from_str(mode),
442            }));
443        message_id
444    }
445
446    pub async fn revoke_pending_user_message(
447        &self,
448        message_id: &str,
449    ) -> PendingUserMessageMutationResult {
450        let mut state = self.inner.lock().await;
451        let mut retained = VecDeque::new();
452        let mut revoked = false;
453        while let Some(injection) = state.queue.pop_front() {
454            match &injection {
455                QueuedTranscriptInjection::User(message) if message.message_id == message_id => {
456                    revoked = true;
457                }
458                _ => retained.push_back(injection),
459            }
460        }
461        state.queue = retained;
462        if revoked {
463            state
464                .revoked_user_message_ids
465                .insert(message_id.to_string());
466            return PendingUserMessageMutationResult::Mutated;
467        }
468        if state.revoked_user_message_ids.contains(message_id) {
469            PendingUserMessageMutationResult::AlreadyRevoked
470        } else if state.delivered_user_message_ids.contains(message_id) {
471            PendingUserMessageMutationResult::AlreadyDelivered
472        } else {
473            PendingUserMessageMutationResult::UnknownMessageId
474        }
475    }
476
477    pub async fn revoke_pending_reminder(
478        &self,
479        reminder_id: &str,
480    ) -> PendingReminderMutationResult {
481        let mut state = self.inner.lock().await;
482        let mut retained = VecDeque::new();
483        let mut revoked = false;
484        while let Some(injection) = state.queue.pop_front() {
485            match &injection {
486                QueuedTranscriptInjection::Reminder(reminder)
487                    if reminder.reminder.id == reminder_id =>
488                {
489                    revoked = true;
490                }
491                _ => retained.push_back(injection),
492            }
493        }
494        state.queue = retained;
495        if revoked {
496            state.revoked_reminder_ids.insert(reminder_id.to_string());
497            return PendingReminderMutationResult::Mutated;
498        }
499        if state.revoked_reminder_ids.contains(reminder_id) {
500            PendingReminderMutationResult::AlreadyRevoked
501        } else if state.delivered_reminder_ids.contains(reminder_id) {
502            PendingReminderMutationResult::AlreadyDelivered
503        } else {
504            PendingReminderMutationResult::UnknownReminderId
505        }
506    }
507
508    pub async fn replace_pending_user_message(
509        &self,
510        message_id: &str,
511        content: String,
512        transcript_content: serde_json::Value,
513    ) -> PendingUserMessageMutationResult {
514        let mut state = self.inner.lock().await;
515        for injection in &mut state.queue {
516            if let QueuedTranscriptInjection::User(message) = injection {
517                if message.message_id == message_id {
518                    message.content = content;
519                    message.transcript_content = transcript_content;
520                    return PendingUserMessageMutationResult::Mutated;
521                }
522            }
523        }
524        if state.revoked_user_message_ids.contains(message_id) {
525            PendingUserMessageMutationResult::AlreadyRevoked
526        } else if state.delivered_user_message_ids.contains(message_id) {
527            PendingUserMessageMutationResult::AlreadyDelivered
528        } else {
529            PendingUserMessageMutationResult::UnknownMessageId
530        }
531    }
532
533    async fn push_session_reminder(&self, reminder: QueuedReminder) {
534        self.inner
535            .lock()
536            .await
537            .queue
538            .push_back(QueuedTranscriptInjection::Reminder(reminder));
539    }
540
541    pub async fn pending_injections_json(&self) -> serde_json::Value {
542        let state = self.inner.lock().await;
543        let injections = state
544            .queue
545            .iter()
546            .enumerate()
547            .map(|(position, injection)| injection.pending_json(position))
548            .collect::<Vec<_>>();
549        serde_json::json!({
550            "pendingCount": injections.len(),
551            "injections": injections,
552        })
553    }
554}
555
556fn reminder_unknown_option_error(message: impl AsRef<str>) -> String {
557    format!(
558        "{}: {}",
559        Code::ReminderUnknownOption.as_str(),
560        message.as_ref()
561    )
562}
563
564fn session_remind_shape_error(message: impl AsRef<str>) -> String {
565    format!(
566        "{}: {}",
567        Code::ReminderInvalidShape.as_str(),
568        message.as_ref()
569    )
570}
571
572fn reminder_unknown_propagate_error(message: impl AsRef<str>) -> String {
573    format!(
574        "{}: {}",
575        Code::ReminderUnknownPropagate.as_str(),
576        message.as_ref()
577    )
578}
579
580fn string_field(
581    map: &serde_json::Map<String, serde_json::Value>,
582    key: &str,
583    required: bool,
584) -> Result<Option<String>, String> {
585    match map.get(key) {
586        None | Some(serde_json::Value::Null) if required => Err(session_remind_shape_error(
587            format!("`{key}` must be a non-empty string"),
588        )),
589        None | Some(serde_json::Value::Null) => Ok(None),
590        Some(serde_json::Value::String(value)) if required && value.trim().is_empty() => Err(
591            session_remind_shape_error(format!("`{key}` must be a non-empty string")),
592        ),
593        Some(serde_json::Value::String(value)) => {
594            let trimmed = value.trim();
595            if trimmed.is_empty() {
596                Ok(None)
597            } else {
598                Ok(Some(trimmed.to_string()))
599            }
600        }
601        Some(other) => Err(session_remind_shape_error(format!(
602            "`{key}` must be a string, got {other}"
603        ))),
604    }
605}
606
607fn bool_field(
608    map: &serde_json::Map<String, serde_json::Value>,
609    key: &str,
610) -> Result<Option<bool>, String> {
611    match map.get(key) {
612        None | Some(serde_json::Value::Null) => Ok(None),
613        Some(serde_json::Value::Bool(value)) => Ok(Some(*value)),
614        Some(other) => Err(session_remind_shape_error(format!(
615            "`{key}` must be a bool, got {other}"
616        ))),
617    }
618}
619
620fn int_field(
621    map: &serde_json::Map<String, serde_json::Value>,
622    key: &str,
623) -> Result<Option<i64>, String> {
624    match map.get(key) {
625        None | Some(serde_json::Value::Null) => Ok(None),
626        Some(serde_json::Value::Number(value)) => {
627            let Some(value) = value.as_i64() else {
628                return Err(session_remind_shape_error(format!(
629                    "`{key}` must be an integer"
630                )));
631            };
632            Ok(Some(value))
633        }
634        Some(other) => Err(session_remind_shape_error(format!(
635            "`{key}` must be an int, got {other}"
636        ))),
637    }
638}
639
640fn tags_field(map: &serde_json::Map<String, serde_json::Value>) -> Result<Vec<String>, String> {
641    let Some(value) = map.get("tags") else {
642        return Ok(Vec::new());
643    };
644    if value.is_null() {
645        return Ok(Vec::new());
646    }
647    let Some(values) = value.as_array() else {
648        return Err(session_remind_shape_error("`tags` must be a list"));
649    };
650    let mut tags = Vec::new();
651    for value in values {
652        let Some(tag) = value.as_str() else {
653            return Err(session_remind_shape_error(format!(
654                "`tags` entries must be strings, got {value}"
655            )));
656        };
657        let tag = tag.trim();
658        if tag.is_empty() {
659            return Err(session_remind_shape_error(
660                "`tags` entries must be non-empty strings",
661            ));
662        }
663        if !tags.iter().any(|existing| existing == tag) {
664            tags.push(tag.to_string());
665        }
666    }
667    Ok(tags)
668}
669
670fn session_remind_payload_from_value(
671    value: &serde_json::Value,
672) -> Result<crate::llm::helpers::SystemReminder, String> {
673    let Some(map) = value.as_object() else {
674        return Err(session_remind_shape_error(
675            "session/remind payload must be a reminder object",
676        ));
677    };
678    const ALLOWED: &[&str] = &[
679        "_meta",
680        "body",
681        "dedupe_key",
682        "fired_at_turn",
683        "id",
684        "preserve_on_compact",
685        "propagate",
686        "role_hint",
687        "authority",
688        "source",
689        "tags",
690        "ttl_turns",
691    ];
692    let unknown = map
693        .keys()
694        .filter(|key| !ALLOWED.contains(&key.as_str()))
695        .map(String::as_str)
696        .collect::<Vec<_>>();
697    if !unknown.is_empty() {
698        if unknown.contains(&"content") {
699            return Err(session_remind_shape_error(
700                "session/remind expects reminder `body`, not user-message `content`",
701            ));
702        }
703        return Err(reminder_unknown_option_error(format!(
704            "unknown reminder option(s): {}",
705            unknown.join(", ")
706        )));
707    }
708    if let Some(meta) = map.get("_meta") {
709        if !meta.is_null() && !meta.is_object() {
710            return Err(session_remind_shape_error("`_meta` must be an object"));
711        }
712    }
713    let ttl_turns = int_field(map, "ttl_turns")?;
714    if let Some(value) = ttl_turns {
715        if value <= 0 {
716            return Err(session_remind_shape_error("`ttl_turns` must be > 0"));
717        }
718    }
719    let fired_at_turn = int_field(map, "fired_at_turn")?.unwrap_or(0);
720    if fired_at_turn < 0 {
721        return Err(session_remind_shape_error(
722            "`fired_at_turn` must be >= 0 when provided",
723        ));
724    }
725    match string_field(map, "source", false)?.as_deref() {
726        None | Some("bridge") => {}
727        Some(_) => {
728            return Err(session_remind_shape_error(
729                "`source` for session/remind must be bridge when provided",
730            ))
731        }
732    }
733    let propagate = match string_field(map, "propagate", false)?.as_deref() {
734        None => crate::llm::helpers::ReminderPropagate::Session,
735        Some("all") => crate::llm::helpers::ReminderPropagate::All,
736        Some("session") => crate::llm::helpers::ReminderPropagate::Session,
737        Some("none") => crate::llm::helpers::ReminderPropagate::None,
738        Some(_) => {
739            return Err(reminder_unknown_propagate_error(
740                "`propagate` must be one of all, session, or none",
741            ))
742        }
743    };
744    let role_hint =
745        authority::reminder_role_hint(string_field(map, "role_hint", false)?.as_deref())
746            .map_err(session_remind_shape_error)?;
747    let authority =
748        authority::directive_authority(string_field(map, "authority", false)?.as_deref())
749            .map_err(session_remind_shape_error)?;
750    Ok(crate::llm::helpers::SystemReminder {
751        id: string_field(map, "id", false)?.unwrap_or_else(|| uuid::Uuid::now_v7().to_string()),
752        tags: tags_field(map)?,
753        dedupe_key: string_field(map, "dedupe_key", false)?,
754        ttl_turns,
755        preserve_on_compact: bool_field(map, "preserve_on_compact")?.unwrap_or(false),
756        propagate,
757        role_hint,
758        authority,
759        source: crate::llm::helpers::ReminderSource::Bridge,
760        body: string_field(map, "body", true)?.unwrap_or_default(),
761        fired_at_turn,
762        originating_agent_id: None,
763    })
764}
765
766fn queued_session_remind_from_params(params: &serde_json::Value) -> Result<QueuedReminder, String> {
767    let mode = QueuedUserMessageMode::from_str(
768        params
769            .get("mode")
770            .and_then(|value| value.as_str())
771            .unwrap_or("audit_only"),
772    );
773    let reminder_value = if let Some(reminder) = params.get("reminder") {
774        reminder.clone()
775    } else {
776        let Some(params) = params.as_object() else {
777            return Err(session_remind_shape_error(
778                "session/remind params must be an object",
779            ));
780        };
781        let mut reminder = params.clone();
782        reminder.remove("mode");
783        reminder.remove("sessionId");
784        reminder.remove("session_id");
785        serde_json::Value::Object(reminder)
786    };
787    Ok(QueuedReminder {
788        reminder: session_remind_payload_from_value(&reminder_value)?,
789        mode,
790    })
791}
792
793// Default doesn't apply — new() spawns async tasks requiring a tokio LocalSet.
794#[allow(clippy::new_without_default)]
795impl HostBridge {
796    /// Create a new bridge and spawn the stdin reader task.
797    ///
798    /// Must be called within a tokio LocalSet (uses spawn_local for the
799    /// stdin reader since it's single-threaded).
800    pub fn new() -> Self {
801        let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<serde_json::Value>>>> =
802            Arc::new(Mutex::new(HashMap::new()));
803        let cancelled = Arc::new(AtomicBool::new(false));
804        let tool_call_cancellations = crate::tool_call_cancellations::fresh_registry();
805        let cancel_notify = Arc::new(Notify::new());
806        let disconnected = Arc::new(AtomicBool::new(false));
807        let queued_transcript_injections = HostBridgeInjectionState::default();
808        let resume_requested = Arc::new(AtomicBool::new(false));
809        let skills_reload_requested = Arc::new(AtomicBool::new(false));
810        let daemon_idle = DaemonIdleState::default();
811
812        // Stdin reader: reads JSON-RPC lines and dispatches responses
813        let pending_clone = pending.clone();
814        let cancelled_clone = cancelled.clone();
815        let cancel_notify_clone = cancel_notify.clone();
816        let disconnected_clone = disconnected.clone();
817        let queued_clone = queued_transcript_injections.clone();
818        let resume_clone = resume_requested.clone();
819        let skills_reload_clone = skills_reload_requested.clone();
820        let tool_call_cancellations_clone = tool_call_cancellations.clone();
821        tokio::task::spawn_local(async move {
822            let stdin = tokio::io::stdin();
823            let reader = tokio::io::BufReader::new(stdin);
824            let mut lines = reader.lines();
825
826            while let Ok(Some(line)) = lines.next_line().await {
827                let line = line.trim().to_string();
828                if line.is_empty() {
829                    continue;
830                }
831
832                let msg: serde_json::Value = match serde_json::from_str(&line) {
833                    Ok(v) => v,
834                    Err(_) => continue,
835                };
836
837                // Notifications have no id; responses have one.
838                if msg.get("id").is_none() {
839                    if let Some(method) = msg["method"].as_str() {
840                        if method == "cancel" {
841                            cancelled_clone.store(true, Ordering::SeqCst);
842                            cancel_notify_clone.notify_waiters();
843                        } else if method == "agent/resume" {
844                            resume_clone.store(true, Ordering::SeqCst);
845                        } else if method == "skills/update" {
846                            skills_reload_clone.store(true, Ordering::SeqCst);
847                        } else if method == "session/remind" {
848                            let params = &msg["params"];
849                            if let Ok(reminder) = queued_session_remind_from_params(params) {
850                                queued_clone.push_session_reminder(reminder).await;
851                            }
852                        } else if method == "session/cancel_tool_call" {
853                            handle_cancel_tool_call_notification(
854                                &tool_call_cancellations_clone,
855                                &msg["params"],
856                            );
857                        }
858                    }
859                    continue;
860                }
861
862                if let Some(id) = msg["id"].as_u64() {
863                    let mut pending = pending_clone.lock().await;
864                    if let Some(sender) = pending.remove(&id) {
865                        let _ = sender.send(msg);
866                    }
867                }
868            }
869
870            // Publish disconnect before taking the map lock. New callers check
871            // this state while holding the same lock, so none can register
872            // after the final clear and wait forever.
873            disconnected_clone.store(true, Ordering::SeqCst);
874            let mut pending = pending_clone.lock().await;
875            pending.clear();
876        });
877
878        Self {
879            next_id: AtomicU64::new(1),
880            pending,
881            cancelled,
882            tool_call_cancellations,
883            cancel_notify,
884            disconnected,
885            writer: stdout_writer(Arc::new(std::sync::Mutex::new(()))),
886            session_id: std::sync::Mutex::new(String::new()),
887            script_name: std::sync::Mutex::new(String::new()),
888            queued_transcript_injections,
889            resume_requested,
890            skills_reload_requested,
891            daemon_idle,
892            prompt_outcome: std::sync::Mutex::new(None),
893            visible_call_states: std::sync::Mutex::new(HashMap::new()),
894            visible_call_streams: std::sync::Mutex::new(HashMap::new()),
895            in_process: None,
896        }
897    }
898
899    /// Create a bridge from pre-existing shared state.
900    ///
901    /// Unlike `new()`, does **not** spawn a stdin reader — the caller is
902    /// responsible for dispatching responses into `pending`.  This is used
903    /// by ACP mode which already has its own stdin reader.
904    pub fn from_parts(
905        pending: Arc<Mutex<HashMap<u64, oneshot::Sender<serde_json::Value>>>>,
906        cancelled: Arc<AtomicBool>,
907        stdout_lock: Arc<std::sync::Mutex<()>>,
908        start_id: u64,
909    ) -> Self {
910        Self::from_parts_with_writer(pending, cancelled, stdout_writer(stdout_lock), start_id)
911    }
912
913    pub fn from_parts_with_writer(
914        pending: Arc<Mutex<HashMap<u64, oneshot::Sender<serde_json::Value>>>>,
915        cancelled: Arc<AtomicBool>,
916        writer: HostBridgeWriter,
917        start_id: u64,
918    ) -> Self {
919        Self::from_parts_with_writer_and_control(
920            pending,
921            writer,
922            start_id,
923            HostBridgeControlState::isolated(cancelled),
924        )
925    }
926
927    pub fn from_parts_with_writer_and_control(
928        pending: Arc<Mutex<HashMap<u64, oneshot::Sender<serde_json::Value>>>>,
929        writer: HostBridgeWriter,
930        start_id: u64,
931        control: HostBridgeControlState,
932    ) -> Self {
933        Self {
934            next_id: AtomicU64::new(start_id),
935            pending,
936            cancelled: control.cancelled,
937            tool_call_cancellations: control.tool_call_cancellations,
938            cancel_notify: control.cancel_notify,
939            disconnected: Arc::new(AtomicBool::new(false)),
940            writer,
941            session_id: std::sync::Mutex::new(String::new()),
942            script_name: std::sync::Mutex::new(String::new()),
943            queued_transcript_injections: control.queued_transcript_injections,
944            resume_requested: Arc::new(AtomicBool::new(false)),
945            skills_reload_requested: Arc::new(AtomicBool::new(false)),
946            daemon_idle: DaemonIdleState::default(),
947            prompt_outcome: std::sync::Mutex::new(None),
948            visible_call_states: std::sync::Mutex::new(HashMap::new()),
949            visible_call_streams: std::sync::Mutex::new(HashMap::new()),
950            in_process: None,
951        }
952    }
953
954    /// Create an in-process host bridge backed by exported functions from a
955    /// Harn module. Used by `harn playground` to avoid JSON-RPC boilerplate.
956    pub async fn from_harn_module(mut vm: Vm, module_path: &Path) -> Result<Self, VmError> {
957        let exported_functions = vm.load_module_exports(module_path).await?;
958        Ok(Self {
959            next_id: AtomicU64::new(1),
960            pending: Arc::new(Mutex::new(HashMap::new())),
961            cancelled: Arc::new(AtomicBool::new(false)),
962            tool_call_cancellations: crate::tool_call_cancellations::fresh_registry(),
963            cancel_notify: Arc::new(Notify::new()),
964            disconnected: Arc::new(AtomicBool::new(false)),
965            writer: stdout_writer(Arc::new(std::sync::Mutex::new(()))),
966            session_id: std::sync::Mutex::new(String::new()),
967            script_name: std::sync::Mutex::new(String::new()),
968            queued_transcript_injections: HostBridgeInjectionState::default(),
969            resume_requested: Arc::new(AtomicBool::new(false)),
970            skills_reload_requested: Arc::new(AtomicBool::new(false)),
971            daemon_idle: DaemonIdleState::default(),
972            prompt_outcome: std::sync::Mutex::new(None),
973            visible_call_states: std::sync::Mutex::new(HashMap::new()),
974            visible_call_streams: std::sync::Mutex::new(HashMap::new()),
975            in_process: Some(InProcessHost {
976                module_path: module_path.to_path_buf(),
977                exported_functions,
978                vm,
979            }),
980        })
981    }
982
983    /// Set the ACP session ID for session-scoped notifications.
984    pub fn set_session_id(&self, id: &str) {
985        *self.session_id.lock().unwrap_or_else(|e| e.into_inner()) = id.to_string();
986    }
987
988    /// Set the currently executing script name (without .harn suffix).
989    pub fn set_script_name(&self, name: &str) {
990        *self.script_name.lock().unwrap_or_else(|e| e.into_inner()) = name.to_string();
991    }
992
993    /// Get the current script name.
994    fn get_script_name(&self) -> String {
995        self.script_name
996            .lock()
997            .unwrap_or_else(|e| e.into_inner())
998            .clone()
999    }
1000
1001    /// Get the session ID.
1002    pub fn get_session_id(&self) -> String {
1003        self.session_id
1004            .lock()
1005            .unwrap_or_else(|e| e.into_inner())
1006            .clone()
1007    }
1008
1009    pub fn tool_call_cancellation_registry(
1010        &self,
1011    ) -> Arc<crate::tool_call_cancellations::CancellationRegistry> {
1012        self.tool_call_cancellations.clone()
1013    }
1014
1015    /// Write a complete JSON-RPC line to stdout, serialized through a mutex.
1016    fn write_line(&self, line: &str) -> Result<(), VmError> {
1017        (self.writer)(line).map_err(VmError::Runtime)
1018    }
1019
1020    /// Send a JSON-RPC request to the host and wait for the response.
1021    /// Non-interactive calls time out after 5 minutes to prevent deadlocks.
1022    /// Interactive permission requests remain pending until the host answers,
1023    /// cancels the run, or disconnects.
1024    pub async fn call(
1025        &self,
1026        method: &str,
1027        params: serde_json::Value,
1028    ) -> Result<serde_json::Value, VmError> {
1029        if let Some(in_process) = &self.in_process {
1030            return in_process.dispatch(method, params).await;
1031        }
1032
1033        if self.is_cancelled() {
1034            return Err(VmError::Runtime("Bridge: operation cancelled".into()));
1035        }
1036
1037        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
1038        let cancel_wait = self.cancel_notify.notified();
1039        tokio::pin!(cancel_wait);
1040        // `notify_waiters` does not retain a permit for a waiter that has not
1041        // been polled. Register before the final atomic cancellation check so
1042        // cancellation cannot land in the check/select gap and be lost.
1043        cancel_wait.as_mut().enable();
1044
1045        let request = crate::jsonrpc::request(id, method, params);
1046
1047        let (tx, rx) = oneshot::channel();
1048        {
1049            let mut pending = self.pending.lock().await;
1050            if self.disconnected.load(Ordering::SeqCst) {
1051                return Err(VmError::Runtime(
1052                    "Bridge: host connection is already closed".into(),
1053                ));
1054            }
1055            pending.insert(id, tx);
1056        }
1057
1058        let line = serde_json::to_string(&request)
1059            .map_err(|e| VmError::Runtime(format!("Bridge serialization error: {e}")))?;
1060        if let Err(e) = self.write_line(&line) {
1061            let mut pending = self.pending.lock().await;
1062            pending.remove(&id);
1063            return Err(e);
1064        }
1065
1066        if self.is_cancelled() {
1067            let mut pending = self.pending.lock().await;
1068            pending.remove(&id);
1069            return Err(VmError::Runtime("Bridge: operation cancelled".into()));
1070        }
1071
1072        let timeout_wait = wait_for_bridge_call_timeout(method);
1073        tokio::pin!(timeout_wait);
1074
1075        let response = tokio::select! {
1076            result = rx => match result {
1077                Ok(msg) => msg,
1078                Err(_) => {
1079                    // Sender dropped: host closed or stdin reader exited.
1080                    return Err(VmError::Runtime(
1081                        "Bridge: host closed connection before responding".into(),
1082                    ));
1083                }
1084            },
1085            _ = &mut cancel_wait => {
1086                let mut pending = self.pending.lock().await;
1087                pending.remove(&id);
1088                return Err(VmError::Runtime("Bridge: operation cancelled".into()));
1089            }
1090            timeout = &mut timeout_wait => {
1091                let mut pending = self.pending.lock().await;
1092                pending.remove(&id);
1093                return Err(VmError::Runtime(format!(
1094                    "Bridge: host did not respond to '{method}' within {}s",
1095                    timeout.as_secs()
1096                )));
1097            }
1098        };
1099
1100        if let Some(error) = response.get("error") {
1101            let message = error["message"].as_str().unwrap_or("Unknown host error");
1102            let code = error["code"].as_i64().unwrap_or(-1);
1103            // JSON-RPC -32001 signals the host rejected the tool (not permitted / not in allowlist).
1104            if code == -32001 {
1105                return Err(VmError::CategorizedError {
1106                    message: message.to_string(),
1107                    category: ErrorCategory::ToolRejected,
1108                });
1109            }
1110            return Err(VmError::Runtime(format!("Host error ({code}): {message}")));
1111        }
1112
1113        Ok(response["result"].clone())
1114    }
1115
1116    /// Send a JSON-RPC notification to the host (no response expected).
1117    /// Serialized through the stdout mutex to prevent interleaving.
1118    pub fn notify(&self, method: &str, params: serde_json::Value) {
1119        let notification = crate::jsonrpc::notification(method, params);
1120        if self.in_process.is_some() {
1121            return;
1122        }
1123        if let Ok(line) = serde_json::to_string(&notification) {
1124            let _ = self.write_line(&line);
1125        }
1126    }
1127
1128    /// Host cancel notification; `cancelled_flag` is the Arc for VM cancel tokens.
1129    pub fn is_cancelled(&self) -> bool {
1130        self.cancelled.load(Ordering::SeqCst)
1131    }
1132    pub fn cancelled_flag(&self) -> Arc<AtomicBool> {
1133        self.cancelled.clone()
1134    }
1135    pub fn take_resume_signal(&self) -> bool {
1136        self.resume_requested.swap(false, Ordering::SeqCst)
1137    }
1138    pub fn signal_resume(&self) {
1139        self.resume_requested.store(true, Ordering::SeqCst);
1140    }
1141    /// Record the current prompt's canonical ACP stop reason and typed terminal
1142    /// outcome atomically. The last writer wins because the outer `agent_loop`
1143    /// finalizes after any inner loops it spawned.
1144    pub fn set_prompt_outcome(
1145        &self,
1146        stop_reason: &str,
1147        terminal: &crate::agent_events::AgentTerminalOutcome,
1148    ) {
1149        *self
1150            .prompt_outcome
1151            .lock()
1152            .unwrap_or_else(|e| e.into_inner()) = Some((stop_reason.to_string(), terminal.clone()));
1153    }
1154
1155    /// Consume the prompt outcome after the pipeline returns. Pipelines that
1156    /// did not run an `agent_loop` leave this absent.
1157    pub fn take_prompt_outcome(
1158        &self,
1159    ) -> Option<(String, crate::agent_events::AgentTerminalOutcome)> {
1160        self.prompt_outcome
1161            .lock()
1162            .unwrap_or_else(|e| e.into_inner())
1163            .take()
1164    }
1165
1166    /// Consume any pending `skills/update` signal the host has sent.
1167    /// Returns `true` exactly once per notification, letting callers
1168    /// trigger a layered-discovery rebuild without polling false
1169    /// positives. See issue #73 for the hot-reload contract.
1170    pub fn take_skills_reload_signal(&self) -> bool {
1171        self.skills_reload_requested.swap(false, Ordering::SeqCst)
1172    }
1173
1174    /// Manually mark the skill catalog as stale. Used by tests and by
1175    /// the CLI when an internal event (e.g. `harn install`) should
1176    /// trigger the same rebuild a `skills/update` notification would.
1177    pub fn signal_skills_reload(&self) {
1178        self.skills_reload_requested.store(true, Ordering::SeqCst);
1179    }
1180
1181    /// Call the host's `skills/list` RPC and return the raw JSON array
1182    /// it responded with. Shape:
1183    /// `[{ "id": "...", "name": "...", "description": "...", "source": "..." }, ...]`.
1184    /// The CLI adapter converts each entry into a
1185    /// [`crate::skills::SkillManifestRef`].
1186    pub async fn list_host_skills(&self) -> Result<Vec<serde_json::Value>, VmError> {
1187        let result = self.call("skills/list", serde_json::json!({})).await?;
1188        match result {
1189            serde_json::Value::Array(items) => Ok(items),
1190            serde_json::Value::Object(map) => match map.get("skills") {
1191                Some(serde_json::Value::Array(items)) => Ok(items.clone()),
1192                _ => Err(VmError::Runtime(
1193                    "skills/list: host response must be an array or { skills: [...] }".into(),
1194                )),
1195            },
1196            _ => Err(VmError::Runtime(
1197                "skills/list: unexpected response shape".into(),
1198            )),
1199        }
1200    }
1201
1202    /// Call the host's `host/tools/list` RPC and return normalized tool
1203    /// descriptors. Shape:
1204    /// `[{ "name": "...", "description": "...", "schema": {...}, "deprecated": false }, ...]`.
1205    /// The bridge also accepts `{ "tools": [...] }` and
1206    /// `{ "result": { "tools": [...] } }` wrappers for lenient hosts.
1207    pub async fn list_host_tools(&self) -> Result<Vec<serde_json::Value>, VmError> {
1208        let result = self.call("host/tools/list", serde_json::json!({})).await?;
1209        parse_host_tools_list_response(result)
1210    }
1211
1212    /// Call the host's `skills/fetch` RPC for one skill id. Returns the
1213    /// raw JSON body so the CLI can inspect both the frontmatter fields
1214    /// and the skill markdown body in whatever shape the host sends.
1215    pub async fn fetch_host_skill(&self, id: &str) -> Result<serde_json::Value, VmError> {
1216        self.call("skills/fetch", serde_json::json!({ "id": id }))
1217            .await
1218    }
1219
1220    pub fn injection_state(&self) -> HostBridgeInjectionState {
1221        self.queued_transcript_injections.clone()
1222    }
1223
1224    pub async fn push_pending_user_message(
1225        &self,
1226        content: String,
1227        transcript_content: serde_json::Value,
1228        mode: &str,
1229    ) -> String {
1230        self.queued_transcript_injections
1231            .push_pending_user_message(content, transcript_content, mode)
1232            .await
1233    }
1234
1235    pub async fn push_queued_user_message(&self, content: String, mode: &str) -> String {
1236        self.push_pending_user_message(content.clone(), serde_json::Value::String(content), mode)
1237            .await
1238    }
1239
1240    pub async fn revoke_pending_user_message(
1241        &self,
1242        message_id: &str,
1243    ) -> PendingUserMessageMutationResult {
1244        self.queued_transcript_injections
1245            .revoke_pending_user_message(message_id)
1246            .await
1247    }
1248
1249    pub async fn revoke_pending_reminder(
1250        &self,
1251        reminder_id: &str,
1252    ) -> PendingReminderMutationResult {
1253        self.queued_transcript_injections
1254            .revoke_pending_reminder(reminder_id)
1255            .await
1256    }
1257
1258    pub async fn pending_injections_json(&self) -> serde_json::Value {
1259        self.queued_transcript_injections
1260            .pending_injections_json()
1261            .await
1262    }
1263
1264    pub async fn replace_pending_user_message(
1265        &self,
1266        message_id: &str,
1267        content: String,
1268        transcript_content: serde_json::Value,
1269    ) -> PendingUserMessageMutationResult {
1270        self.queued_transcript_injections
1271            .replace_pending_user_message(message_id, content, transcript_content)
1272            .await
1273    }
1274
1275    pub async fn push_queued_session_remind_from_params(
1276        &self,
1277        params: &serde_json::Value,
1278    ) -> Result<String, String> {
1279        let reminder = queued_session_remind_from_params(params)?;
1280        let reminder_id = reminder.reminder.id.clone();
1281        self.queued_transcript_injections
1282            .push_session_reminder(reminder)
1283            .await;
1284        Ok(reminder_id)
1285    }
1286
1287    pub async fn take_queued_user_messages(
1288        &self,
1289        include_interrupt_immediate: bool,
1290        include_finish_step: bool,
1291        include_audit_only: bool,
1292    ) -> Vec<QueuedUserMessage> {
1293        let mut state = self.queued_transcript_injections.inner.lock().await;
1294        let mut selected = Vec::new();
1295        let mut retained = VecDeque::new();
1296        while let Some(injection) = state.queue.pop_front() {
1297            let should_take = match injection.mode() {
1298                QueuedUserMessageMode::InterruptImmediate => include_interrupt_immediate,
1299                QueuedUserMessageMode::FinishStep => include_finish_step,
1300                QueuedUserMessageMode::AuditOnly => include_audit_only,
1301            };
1302            match (should_take, injection) {
1303                (true, QueuedTranscriptInjection::User(message)) => {
1304                    state
1305                        .delivered_user_message_ids
1306                        .insert(message.message_id.clone());
1307                    selected.push(message);
1308                }
1309                (_, injection) => retained.push_back(injection),
1310            }
1311        }
1312        state.queue = retained;
1313        selected
1314    }
1315
1316    pub async fn take_queued_transcript_injections(
1317        &self,
1318        include_interrupt_immediate: bool,
1319        include_finish_step: bool,
1320        include_audit_only: bool,
1321    ) -> Vec<QueuedTranscriptInjection> {
1322        let mut state = self.queued_transcript_injections.inner.lock().await;
1323        let mut selected = Vec::new();
1324        let mut retained = VecDeque::new();
1325        while let Some(injection) = state.queue.pop_front() {
1326            let should_take = match injection.mode() {
1327                QueuedUserMessageMode::InterruptImmediate => include_interrupt_immediate,
1328                QueuedUserMessageMode::FinishStep => include_finish_step,
1329                QueuedUserMessageMode::AuditOnly => include_audit_only,
1330            };
1331            if should_take {
1332                match &injection {
1333                    QueuedTranscriptInjection::User(message) => {
1334                        state
1335                            .delivered_user_message_ids
1336                            .insert(message.message_id.clone());
1337                    }
1338                    QueuedTranscriptInjection::Reminder(reminder) => {
1339                        state
1340                            .delivered_reminder_ids
1341                            .insert(reminder.reminder.id.clone());
1342                    }
1343                }
1344                selected.push(injection);
1345            } else {
1346                retained.push_back(injection);
1347            }
1348        }
1349        state.queue = retained;
1350        selected
1351    }
1352
1353    pub async fn take_queued_user_messages_for(
1354        &self,
1355        checkpoint: DeliveryCheckpoint,
1356    ) -> Vec<QueuedUserMessage> {
1357        match checkpoint {
1358            DeliveryCheckpoint::InterruptImmediate => {
1359                self.take_queued_user_messages(true, false, false).await
1360            }
1361            DeliveryCheckpoint::AfterCurrentOperation => {
1362                self.take_queued_user_messages(false, true, false).await
1363            }
1364            DeliveryCheckpoint::EndOfInteraction => {
1365                self.take_queued_user_messages(false, false, true).await
1366            }
1367        }
1368    }
1369
1370    pub async fn take_queued_transcript_injections_for(
1371        &self,
1372        checkpoint: DeliveryCheckpoint,
1373    ) -> Vec<QueuedTranscriptInjection> {
1374        match checkpoint {
1375            DeliveryCheckpoint::InterruptImmediate => {
1376                self.take_queued_transcript_injections(true, false, false)
1377                    .await
1378            }
1379            DeliveryCheckpoint::AfterCurrentOperation => {
1380                self.take_queued_transcript_injections(false, true, false)
1381                    .await
1382            }
1383            DeliveryCheckpoint::EndOfInteraction => {
1384                self.take_queued_transcript_injections(false, false, true)
1385                    .await
1386            }
1387        }
1388    }
1389
1390    /// Send an output notification (for log/print in bridge mode).
1391    pub fn send_output(&self, text: &str) {
1392        self.notify("output", serde_json::json!({"text": text}));
1393    }
1394
1395    /// Send a progress notification with optional numeric progress and structured data.
1396    pub fn send_progress(
1397        &self,
1398        phase: &str,
1399        message: &str,
1400        progress: Option<i64>,
1401        total: Option<i64>,
1402        data: Option<serde_json::Value>,
1403    ) {
1404        let mut payload = serde_json::json!({"phase": phase, "message": message});
1405        if let Some(p) = progress {
1406            payload["progress"] = serde_json::json!(p);
1407        }
1408        if let Some(t) = total {
1409            payload["total"] = serde_json::json!(t);
1410        }
1411        if let Some(d) = data {
1412            payload["data"] = d;
1413        }
1414        self.notify("progress", payload);
1415    }
1416
1417    /// Send a structured log notification.
1418    pub fn send_log(&self, level: &str, message: &str, fields: Option<serde_json::Value>) {
1419        let mut payload = serde_json::json!({"level": level, "message": message});
1420        if let Some(f) = fields {
1421            payload["fields"] = f;
1422        }
1423        self.notify("log", payload);
1424    }
1425
1426    /// Send a `session/update` with `call_start` — signals the beginning of
1427    /// an LLM call, tool call, or builtin call for observability.
1428    pub fn send_call_start(
1429        &self,
1430        call_id: &str,
1431        call_type: &str,
1432        name: &str,
1433        metadata: serde_json::Value,
1434    ) {
1435        let session_id = self.get_session_id();
1436        let script = self.get_script_name();
1437        let stream_publicly = metadata
1438            .get("stream_publicly")
1439            .and_then(|value| value.as_bool())
1440            .unwrap_or(true);
1441        self.visible_call_streams
1442            .lock()
1443            .unwrap_or_else(|e| e.into_inner())
1444            .insert(call_id.to_string(), stream_publicly);
1445        self.notify(
1446            "session/update",
1447            serde_json::json!({
1448                "sessionId": session_id,
1449                "update": {
1450                    "sessionUpdate": "call_start",
1451                    "content": {
1452                        "toolCallId": call_id,
1453                        "call_type": call_type,
1454                        "name": name,
1455                        "script": script,
1456                        "metadata": metadata,
1457                    },
1458                },
1459            }),
1460        );
1461    }
1462
1463    /// Send a `session/update` with `call_progress` — a streaming token delta
1464    /// from an in-flight LLM call.
1465    pub fn send_call_progress(
1466        &self,
1467        call_id: &str,
1468        delta: &str,
1469        accumulated_tokens: u64,
1470        user_visible: bool,
1471    ) {
1472        let session_id = self.get_session_id();
1473        let (visible_text, visible_delta) = {
1474            let stream_publicly = self
1475                .visible_call_streams
1476                .lock()
1477                .unwrap_or_else(|e| e.into_inner())
1478                .get(call_id)
1479                .copied()
1480                .unwrap_or(true);
1481            if !user_visible || !stream_publicly {
1482                (String::new(), String::new())
1483            } else {
1484                let mut states = self
1485                    .visible_call_states
1486                    .lock()
1487                    .unwrap_or_else(|e| e.into_inner());
1488                let state = states.entry(call_id.to_string()).or_default();
1489                state.push(delta, true)
1490            }
1491        };
1492        self.notify(
1493            "session/update",
1494            serde_json::json!({
1495                "sessionId": session_id,
1496                "update": {
1497                    "sessionUpdate": "call_progress",
1498                    "content": {
1499                        "toolCallId": call_id,
1500                        "delta": delta,
1501                        "accumulated_tokens": accumulated_tokens,
1502                        "visible_text": visible_text,
1503                        "visible_delta": visible_delta,
1504                        "user_visible": user_visible,
1505                    },
1506                },
1507            }),
1508        );
1509    }
1510
1511    /// Send a `session/update` with `call_end` — signals completion of a call.
1512    pub fn send_call_end(
1513        &self,
1514        call_id: &str,
1515        call_type: &str,
1516        name: &str,
1517        duration_ms: u64,
1518        status: &str,
1519        metadata: serde_json::Value,
1520    ) {
1521        let session_id = self.get_session_id();
1522        let script = self.get_script_name();
1523        self.visible_call_states
1524            .lock()
1525            .unwrap_or_else(|e| e.into_inner())
1526            .remove(call_id);
1527        self.visible_call_streams
1528            .lock()
1529            .unwrap_or_else(|e| e.into_inner())
1530            .remove(call_id);
1531        self.notify(
1532            "session/update",
1533            serde_json::json!({
1534                "sessionId": session_id,
1535                "update": {
1536                    "sessionUpdate": "call_end",
1537                    "content": {
1538                        "toolCallId": call_id,
1539                        "call_type": call_type,
1540                        "name": name,
1541                        "script": script,
1542                        "duration_ms": duration_ms,
1543                        "status": status,
1544                        "metadata": metadata,
1545                    },
1546                },
1547            }),
1548        );
1549    }
1550
1551    /// Send a worker lifecycle update for delegated/background execution.
1552    pub fn send_worker_update(
1553        &self,
1554        worker_id: &str,
1555        worker_name: &str,
1556        status: &str,
1557        metadata: serde_json::Value,
1558        audit: Option<&MutationSessionRecord>,
1559    ) {
1560        let session_id = self.get_session_id();
1561        let script = self.get_script_name();
1562        let started_at = metadata.get("started_at").cloned().unwrap_or_default();
1563        let finished_at = metadata.get("finished_at").cloned().unwrap_or_default();
1564        let snapshot_path = metadata.get("snapshot_path").cloned().unwrap_or_default();
1565        let run_id = metadata.get("child_run_id").cloned().unwrap_or_default();
1566        let run_path = metadata.get("child_run_path").cloned().unwrap_or_default();
1567        let lifecycle = serde_json::json!({
1568            "event": status,
1569            "worker_id": worker_id,
1570            "worker_name": worker_name,
1571            "started_at": started_at,
1572            "finished_at": finished_at,
1573        });
1574        self.notify(
1575            "session/update",
1576            serde_json::json!({
1577                "sessionId": session_id,
1578                "update": {
1579                    "sessionUpdate": "worker_update",
1580                    "content": {
1581                        "worker_id": worker_id,
1582                        "worker_name": worker_name,
1583                        "status": status,
1584                        "script": script,
1585                        "started_at": started_at,
1586                        "finished_at": finished_at,
1587                        "snapshot_path": snapshot_path,
1588                        "run_id": run_id,
1589                        "run_path": run_path,
1590                        "lifecycle": lifecycle,
1591                        "audit": audit,
1592                        "metadata": metadata,
1593                    },
1594                },
1595            }),
1596        );
1597    }
1598}
1599
1600/// Convert a serde_json::Value to a VmValue.
1601pub fn json_result_to_vm_value(val: &serde_json::Value) -> VmValue {
1602    crate::stdlib::json_to_vm_value(val)
1603}
1604
1605fn parse_host_tools_list_response(
1606    result: serde_json::Value,
1607) -> Result<Vec<serde_json::Value>, VmError> {
1608    let tools = match result {
1609        serde_json::Value::Array(items) => items,
1610        serde_json::Value::Object(map) => match map.get("tools").cloned().or_else(|| {
1611            map.get("result")
1612                .and_then(|value| value.get("tools"))
1613                .cloned()
1614        }) {
1615            Some(serde_json::Value::Array(items)) => items,
1616            _ => {
1617                return Err(VmError::Runtime(
1618                    "host/tools/list: host response must be an array or { tools: [...] }".into(),
1619                ));
1620            }
1621        },
1622        _ => {
1623            return Err(VmError::Runtime(
1624                "host/tools/list: unexpected response shape".into(),
1625            ));
1626        }
1627    };
1628
1629    let mut normalized = Vec::with_capacity(tools.len());
1630    for tool in tools {
1631        let serde_json::Value::Object(map) = tool else {
1632            return Err(VmError::Runtime(
1633                "host/tools/list: every tool must be an object".into(),
1634            ));
1635        };
1636        let Some(name) = map.get("name").and_then(|value| value.as_str()) else {
1637            return Err(VmError::Runtime(
1638                "host/tools/list: every tool must include a string `name`".into(),
1639            ));
1640        };
1641        let description = map
1642            .get("description")
1643            .and_then(|value| value.as_str())
1644            .or_else(|| {
1645                map.get("short_description")
1646                    .and_then(|value| value.as_str())
1647            })
1648            .unwrap_or_default();
1649        let schema = map
1650            .get("schema")
1651            .cloned()
1652            .or_else(|| map.get("parameters").cloned())
1653            .or_else(|| map.get("input_schema").cloned())
1654            .unwrap_or(serde_json::Value::Null);
1655        let deprecated = map
1656            .get("deprecated")
1657            .and_then(|value| value.as_bool())
1658            .unwrap_or(false);
1659        normalized.push(serde_json::json!({
1660            "name": name,
1661            "description": description,
1662            "schema": schema,
1663            "deprecated": deprecated,
1664        }));
1665    }
1666    Ok(normalized)
1667}
1668
1669#[cfg(test)]
1670mod tests {
1671    use super::*;
1672
1673    fn test_bridge() -> HostBridge {
1674        HostBridge::from_parts(
1675            Arc::new(Mutex::new(HashMap::new())),
1676            Arc::new(AtomicBool::new(false)),
1677            Arc::new(std::sync::Mutex::new(())),
1678            1,
1679        )
1680    }
1681
1682    fn test_bridge_sharing_injection_state(owner: &HostBridge) -> HostBridge {
1683        HostBridge::from_parts_with_writer_and_control(
1684            Arc::new(Mutex::new(HashMap::new())),
1685            Arc::new(|_| Ok(())),
1686            100,
1687            HostBridgeControlState::new(
1688                Arc::new(AtomicBool::new(false)),
1689                Arc::new(Notify::new()),
1690                owner.injection_state(),
1691                crate::tool_call_cancellations::fresh_registry(),
1692            ),
1693        )
1694    }
1695
1696    #[test]
1697    fn test_json_rpc_request_format() {
1698        let request = crate::jsonrpc::request(
1699            1,
1700            "llm_call",
1701            serde_json::json!({
1702                "prompt": "Hello",
1703                "system": "Be helpful",
1704            }),
1705        );
1706        let s = serde_json::to_string(&request).unwrap();
1707        assert!(s.contains("\"jsonrpc\":\"2.0\""));
1708        assert!(s.contains("\"id\":1"));
1709        assert!(s.contains("\"method\":\"llm_call\""));
1710    }
1711
1712    #[test]
1713    fn test_json_rpc_notification_format() {
1714        let notification =
1715            crate::jsonrpc::notification("output", serde_json::json!({"text": "[harn] hello\n"}));
1716        let s = serde_json::to_string(&notification).unwrap();
1717        assert!(s.contains("\"method\":\"output\""));
1718        assert!(!s.contains("\"id\""));
1719    }
1720
1721    #[test]
1722    fn test_json_rpc_error_response_parsing() {
1723        let response = crate::jsonrpc::error_response(1, -32600, "Invalid request");
1724        assert!(response.get("error").is_some());
1725        assert_eq!(
1726            response["error"]["message"].as_str().unwrap(),
1727            "Invalid request"
1728        );
1729    }
1730
1731    #[test]
1732    fn test_json_rpc_success_response_parsing() {
1733        let response = crate::jsonrpc::response(
1734            1,
1735            serde_json::json!({
1736                "text": "Hello world",
1737                "input_tokens": 10,
1738                "output_tokens": 5,
1739            }),
1740        );
1741        assert!(response.get("result").is_some());
1742        assert_eq!(response["result"]["text"].as_str().unwrap(), "Hello world");
1743    }
1744
1745    #[test]
1746    fn test_cancelled_flag() {
1747        let cancelled = Arc::new(AtomicBool::new(false));
1748        assert!(!cancelled.load(Ordering::SeqCst));
1749        cancelled.store(true, Ordering::SeqCst);
1750        assert!(cancelled.load(Ordering::SeqCst));
1751    }
1752
1753    #[tokio::test(flavor = "current_thread", start_paused = true)]
1754    async fn pending_permission_calls_return_when_cancellation_arrives() {
1755        let pending = Arc::new(Mutex::new(HashMap::new()));
1756        let cancelled = Arc::new(AtomicBool::new(false));
1757        let bridge = HostBridge::from_parts_with_writer(
1758            pending.clone(),
1759            cancelled.clone(),
1760            Arc::new(|_| Ok(())),
1761            1,
1762        );
1763
1764        let call = bridge.call(
1765            crate::llm::acp_permission::METHOD_REQUEST_PERMISSION,
1766            serde_json::json!({}),
1767        );
1768        tokio::pin!(call);
1769        wait_for_pending(&pending, 1, call.as_mut()).await;
1770
1771        cancelled.store(true, Ordering::SeqCst);
1772        bridge.cancel_notify.notify_waiters();
1773
1774        let result = tokio::time::timeout(Duration::from_secs(1), call)
1775            .await
1776            .expect("pending permission call should observe cancellation");
1777        assert!(matches!(
1778            result,
1779            Err(VmError::Runtime(message)) if message.contains("cancelled")
1780        ));
1781        assert!(pending.lock().await.is_empty());
1782    }
1783
1784    #[tokio::test(flavor = "current_thread", start_paused = true)]
1785    async fn registered_cancel_wait_survives_notification_before_first_poll() {
1786        let notify = Notify::new();
1787        let wait = notify.notified();
1788        tokio::pin!(wait);
1789        wait.as_mut().enable();
1790
1791        notify.notify_waiters();
1792
1793        tokio::select! {
1794            () = &mut wait => {}
1795            _ = tokio::task::yield_now() => panic!("registered cancellation notification was lost"),
1796        }
1797    }
1798
1799    #[tokio::test(flavor = "current_thread", start_paused = true)]
1800    async fn bridge_call_cannot_register_after_disconnect_clear() {
1801        let pending = Arc::new(Mutex::new(HashMap::new()));
1802        let bridge = HostBridge::from_parts_with_writer(
1803            pending.clone(),
1804            Arc::new(AtomicBool::new(false)),
1805            Arc::new(|_| Ok(())),
1806            1,
1807        );
1808        let guard = pending.lock().await;
1809        let call = bridge.call(
1810            crate::llm::acp_permission::METHOD_REQUEST_PERMISSION,
1811            serde_json::json!({}),
1812        );
1813        tokio::pin!(call);
1814        tokio::select! {
1815            result = &mut call => panic!("call bypassed pending lock: {result:?}"),
1816            _ = tokio::task::yield_now() => {}
1817        }
1818
1819        bridge.disconnected.store(true, Ordering::SeqCst);
1820        drop(guard);
1821
1822        let result = call.await;
1823        assert!(matches!(
1824            result,
1825            Err(VmError::Runtime(message)) if message.contains("already closed")
1826        ));
1827        assert!(pending.lock().await.is_empty());
1828    }
1829
1830    #[test]
1831    fn call_progress_hides_non_user_visible_deltas() {
1832        let lines = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1833        let captured = lines.clone();
1834        let bridge = HostBridge::from_parts_with_writer(
1835            Arc::new(Mutex::new(HashMap::new())),
1836            Arc::new(AtomicBool::new(false)),
1837            Arc::new(move |line| {
1838                captured
1839                    .lock()
1840                    .unwrap_or_else(|e| e.into_inner())
1841                    .push(line.to_string());
1842                Ok(())
1843            }),
1844            1,
1845        );
1846
1847        bridge.send_call_start(
1848            "call-1",
1849            "llm",
1850            "llm_call",
1851            serde_json::json!({"stream_publicly": true}),
1852        );
1853        bridge.send_call_progress(
1854            "call-1",
1855            r#"{"verdict":"done","reasoning":"internal"}"#,
1856            1,
1857            false,
1858        );
1859
1860        let lines = lines.lock().unwrap_or_else(|e| e.into_inner());
1861        let progress: serde_json::Value =
1862            serde_json::from_str(&lines[1]).expect("call_progress notification json");
1863        let content = &progress["params"]["update"]["content"];
1864        assert_eq!(
1865            content["delta"],
1866            r#"{"verdict":"done","reasoning":"internal"}"#
1867        );
1868        assert_eq!(content["user_visible"], false);
1869        assert_eq!(content["visible_text"], "");
1870        assert_eq!(content["visible_delta"], "");
1871    }
1872
1873    #[test]
1874    fn call_progress_hides_non_public_streams() {
1875        let lines = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1876        let captured = lines.clone();
1877        let bridge = HostBridge::from_parts_with_writer(
1878            Arc::new(Mutex::new(HashMap::new())),
1879            Arc::new(AtomicBool::new(false)),
1880            Arc::new(move |line| {
1881                captured
1882                    .lock()
1883                    .unwrap_or_else(|e| e.into_inner())
1884                    .push(line.to_string());
1885                Ok(())
1886            }),
1887            1,
1888        );
1889
1890        bridge.send_call_start(
1891            "call-1",
1892            "llm",
1893            "llm_call",
1894            serde_json::json!({"stream_publicly": false}),
1895        );
1896        bridge.send_call_progress("call-1", "secret schema bytes", 1, true);
1897
1898        let lines = lines.lock().unwrap_or_else(|e| e.into_inner());
1899        let progress: serde_json::Value =
1900            serde_json::from_str(&lines[1]).expect("call_progress notification json");
1901        let content = &progress["params"]["update"]["content"];
1902        assert_eq!(content["delta"], "secret schema bytes");
1903        assert_eq!(content["user_visible"], true);
1904        assert_eq!(content["visible_text"], "");
1905        assert_eq!(content["visible_delta"], "");
1906    }
1907
1908    #[test]
1909    fn queued_messages_are_filtered_by_delivery_mode() {
1910        let runtime = tokio::runtime::Builder::new_current_thread()
1911            .enable_all()
1912            .build()
1913            .unwrap();
1914        runtime.block_on(async {
1915            let bridge = test_bridge();
1916            bridge
1917                .push_queued_user_message("first".to_string(), "finish_step")
1918                .await;
1919            bridge
1920                .push_queued_user_message("second".to_string(), "audit_only")
1921                .await;
1922
1923            let finish_step = bridge.take_queued_user_messages(false, true, false).await;
1924            assert_eq!(finish_step.len(), 1);
1925            assert_eq!(finish_step[0].content, "first");
1926
1927            let audit_only = bridge.take_queued_user_messages(false, false, true).await;
1928            assert_eq!(audit_only.len(), 1);
1929            assert_eq!(audit_only[0].content, "second");
1930        });
1931    }
1932
1933    #[test]
1934    fn pending_user_messages_support_revoke_replace_and_delivery_states() {
1935        let runtime = tokio::runtime::Builder::new_current_thread()
1936            .enable_all()
1937            .build()
1938            .unwrap();
1939        runtime.block_on(async {
1940            let bridge = test_bridge();
1941            let first_id = bridge
1942                .push_pending_user_message(
1943                    "first".to_string(),
1944                    serde_json::json!("first"),
1945                    "audit_only",
1946                )
1947                .await;
1948            let second_id = bridge
1949                .push_pending_user_message(
1950                    "second".to_string(),
1951                    serde_json::json!("second"),
1952                    "audit_only",
1953                )
1954                .await;
1955
1956            assert_eq!(
1957                bridge
1958                    .replace_pending_user_message(
1959                        &second_id,
1960                        "second edited".to_string(),
1961                        serde_json::json!("second edited"),
1962                    )
1963                    .await,
1964                PendingUserMessageMutationResult::Mutated
1965            );
1966            assert_eq!(
1967                bridge.revoke_pending_user_message(&first_id).await,
1968                PendingUserMessageMutationResult::Mutated
1969            );
1970            assert_eq!(
1971                bridge.revoke_pending_user_message(&first_id).await,
1972                PendingUserMessageMutationResult::AlreadyRevoked
1973            );
1974
1975            let delivered = bridge
1976                .take_queued_user_messages_for(DeliveryCheckpoint::EndOfInteraction)
1977                .await;
1978            assert_eq!(delivered.len(), 1);
1979            assert_eq!(delivered[0].message_id, second_id);
1980            assert_eq!(delivered[0].content, "second edited");
1981
1982            assert_eq!(
1983                bridge.revoke_pending_user_message(&second_id).await,
1984                PendingUserMessageMutationResult::AlreadyDelivered
1985            );
1986            assert_eq!(
1987                bridge
1988                    .replace_pending_user_message(
1989                        &second_id,
1990                        "too late".to_string(),
1991                        serde_json::json!("too late"),
1992                    )
1993                    .await,
1994                PendingUserMessageMutationResult::AlreadyDelivered
1995            );
1996            assert_eq!(
1997                bridge.revoke_pending_user_message("missing").await,
1998                PendingUserMessageMutationResult::UnknownMessageId
1999            );
2000        });
2001    }
2002
2003    #[test]
2004    fn pending_user_message_replace_preserves_fifo_position_and_mode() {
2005        let runtime = tokio::runtime::Builder::new_current_thread()
2006            .enable_all()
2007            .build()
2008            .unwrap();
2009        runtime.block_on(async {
2010            let bridge = test_bridge();
2011            let first_id = bridge
2012                .push_pending_user_message(
2013                    "first".to_string(),
2014                    serde_json::json!("first"),
2015                    "finish_step",
2016                )
2017                .await;
2018            let second_id = bridge
2019                .push_pending_user_message(
2020                    "second".to_string(),
2021                    serde_json::json!("second"),
2022                    "finish_step",
2023                )
2024                .await;
2025            assert_eq!(
2026                bridge
2027                    .replace_pending_user_message(
2028                        &first_id,
2029                        "first edited".to_string(),
2030                        serde_json::json!("first edited"),
2031                    )
2032                    .await,
2033                PendingUserMessageMutationResult::Mutated
2034            );
2035
2036            let delivered = bridge
2037                .take_queued_user_messages_for(DeliveryCheckpoint::AfterCurrentOperation)
2038                .await;
2039            assert_eq!(
2040                delivered
2041                    .iter()
2042                    .map(|message| (&message.message_id, message.content.as_str(), message.mode))
2043                    .collect::<Vec<_>>(),
2044                vec![
2045                    (&first_id, "first edited", QueuedUserMessageMode::FinishStep,),
2046                    (&second_id, "second", QueuedUserMessageMode::FinishStep),
2047                ]
2048            );
2049        });
2050    }
2051
2052    #[test]
2053    fn pending_user_message_state_survives_bridge_replacement() {
2054        let runtime = tokio::runtime::Builder::new_current_thread()
2055            .enable_all()
2056            .build()
2057            .unwrap();
2058        runtime.block_on(async {
2059            let bridge = test_bridge();
2060            let revoked_id = bridge
2061                .push_pending_user_message(
2062                    "revoke me".to_string(),
2063                    serde_json::json!("revoke me"),
2064                    "audit_only",
2065                )
2066                .await;
2067            let delivered_id = bridge
2068                .push_pending_user_message(
2069                    "deliver me".to_string(),
2070                    serde_json::json!("deliver me"),
2071                    "audit_only",
2072                )
2073                .await;
2074            assert_eq!(
2075                bridge.revoke_pending_user_message(&revoked_id).await,
2076                PendingUserMessageMutationResult::Mutated
2077            );
2078            bridge.cancelled.store(true, Ordering::SeqCst);
2079
2080            let replacement_bridge = test_bridge_sharing_injection_state(&bridge);
2081            assert_eq!(
2082                replacement_bridge
2083                    .revoke_pending_user_message(&revoked_id)
2084                    .await,
2085                PendingUserMessageMutationResult::AlreadyRevoked
2086            );
2087            let delivered = replacement_bridge
2088                .take_queued_user_messages_for(DeliveryCheckpoint::EndOfInteraction)
2089                .await;
2090            assert_eq!(delivered.len(), 1);
2091            assert_eq!(delivered[0].message_id, delivered_id);
2092            assert_eq!(delivered[0].content, "deliver me");
2093            assert_eq!(
2094                bridge.revoke_pending_user_message(&delivered_id).await,
2095                PendingUserMessageMutationResult::AlreadyDelivered
2096            );
2097        });
2098    }
2099
2100    #[test]
2101    fn queued_transcript_injections_preserve_user_reminder_separation() {
2102        let runtime = tokio::runtime::Builder::new_current_thread()
2103            .enable_all()
2104            .build()
2105            .unwrap();
2106        runtime.block_on(async {
2107            let bridge = test_bridge();
2108            bridge
2109                .push_queued_user_message("human follow-up".to_string(), "finish_step")
2110                .await;
2111            let reminder_id = bridge
2112                .push_queued_session_remind_from_params(&serde_json::json!({
2113                    "body": "Host-provided ambient context.",
2114                    "tags": ["host"],
2115                    "dedupe_key": "host-context",
2116                    "ttl_turns": 2,
2117                    "mode": "audit_only",
2118                    "_meta": {"harn": {"source": "test"}},
2119                }))
2120                .await
2121                .expect("valid reminder");
2122
2123            let finish_step = bridge.take_queued_user_messages(false, true, false).await;
2124            assert_eq!(finish_step.len(), 1);
2125            assert_eq!(finish_step[0].content, "human follow-up");
2126
2127            let no_user_messages = bridge.take_queued_user_messages(false, false, true).await;
2128            assert!(no_user_messages.is_empty());
2129
2130            let injections = bridge
2131                .take_queued_transcript_injections_for(DeliveryCheckpoint::EndOfInteraction)
2132                .await;
2133            assert_eq!(injections.len(), 1);
2134            let QueuedTranscriptInjection::Reminder(reminder) = &injections[0] else {
2135                panic!("expected queued reminder");
2136            };
2137            assert_eq!(reminder.reminder.id, reminder_id);
2138            assert_eq!(reminder.reminder.body, "Host-provided ambient context.");
2139            assert_eq!(reminder.reminder.tags, vec!["host".to_string()]);
2140            assert_eq!(
2141                reminder.reminder.dedupe_key.as_deref(),
2142                Some("host-context")
2143            );
2144            assert_eq!(reminder.reminder.ttl_turns, Some(2));
2145            assert_eq!(
2146                reminder.reminder.source,
2147                crate::llm::helpers::ReminderSource::Bridge
2148            );
2149        });
2150    }
2151
2152    #[test]
2153    fn pending_injections_list_user_messages_and_reminders_in_fifo_order() {
2154        let runtime = tokio::runtime::Builder::new_current_thread()
2155            .enable_all()
2156            .build()
2157            .unwrap();
2158        runtime.block_on(async {
2159            let bridge = test_bridge();
2160            let message_id = bridge
2161                .push_pending_user_message(
2162                    "human follow-up".to_string(),
2163                    serde_json::json!([{"type": "text", "text": "human follow-up"}]),
2164                    "finish_step",
2165                )
2166                .await;
2167            let reminder_id = bridge
2168                .push_queued_session_remind_from_params(&serde_json::json!({
2169                    "id": "rem-test",
2170                    "body": "Host reminder",
2171                    "tags": ["host"],
2172                    "dedupe_key": "host-reminder",
2173                    "ttl_turns": 2,
2174                    "mode": "interrupt_immediate",
2175                }))
2176                .await
2177                .expect("valid session/remind payload");
2178
2179            let pending = bridge.pending_injections_json().await;
2180            assert_eq!(pending["pendingCount"], 2);
2181            assert_eq!(pending["injections"][0]["kind"], "user");
2182            assert_eq!(pending["injections"][0]["id"], message_id);
2183            assert_eq!(pending["injections"][0]["messageId"], message_id);
2184            assert_eq!(pending["injections"][0]["mode"], "finish_step");
2185            assert_eq!(pending["injections"][0]["position"], 0);
2186            assert_eq!(pending["injections"][1]["kind"], "reminder");
2187            assert_eq!(pending["injections"][1]["id"], reminder_id);
2188            assert_eq!(pending["injections"][1]["reminderId"], "rem-test");
2189            assert_eq!(pending["injections"][1]["mode"], "interrupt_immediate");
2190            assert_eq!(pending["injections"][1]["body"], "Host reminder");
2191            assert_eq!(pending["injections"][1]["dedupeKey"], "host-reminder");
2192            assert_eq!(pending["injections"][1]["ttlTurns"], 2);
2193            assert_eq!(pending["injections"][1]["position"], 1);
2194        });
2195    }
2196
2197    #[test]
2198    fn pending_reminders_support_revoke_and_delivery_states() {
2199        let runtime = tokio::runtime::Builder::new_current_thread()
2200            .enable_all()
2201            .build()
2202            .unwrap();
2203        runtime.block_on(async {
2204            let bridge = test_bridge();
2205            let revoked_id = bridge
2206                .push_queued_session_remind_from_params(&serde_json::json!({
2207                    "id": "rem-revoke",
2208                    "body": "remove me",
2209                    "mode": "finish_step",
2210                }))
2211                .await
2212                .expect("valid session/remind payload");
2213            let delivered_id = bridge
2214                .push_queued_session_remind_from_params(&serde_json::json!({
2215                    "id": "rem-deliver",
2216                    "body": "deliver me",
2217                    "mode": "finish_step",
2218                }))
2219                .await
2220                .expect("valid session/remind payload");
2221
2222            assert_eq!(
2223                bridge.revoke_pending_reminder(&revoked_id).await,
2224                PendingReminderMutationResult::Mutated
2225            );
2226            assert_eq!(
2227                bridge.revoke_pending_reminder(&revoked_id).await,
2228                PendingReminderMutationResult::AlreadyRevoked
2229            );
2230
2231            let pending = bridge.pending_injections_json().await;
2232            assert_eq!(pending["pendingCount"], 1);
2233            assert_eq!(pending["injections"][0]["reminderId"], delivered_id);
2234
2235            let delivered = bridge
2236                .take_queued_transcript_injections_for(DeliveryCheckpoint::AfterCurrentOperation)
2237                .await;
2238            assert_eq!(delivered.len(), 1);
2239            let QueuedTranscriptInjection::Reminder(reminder) = &delivered[0] else {
2240                panic!("expected delivered reminder");
2241            };
2242            assert_eq!(reminder.reminder.id, delivered_id);
2243
2244            assert_eq!(
2245                bridge.revoke_pending_reminder(&delivered_id).await,
2246                PendingReminderMutationResult::AlreadyDelivered
2247            );
2248            assert_eq!(
2249                bridge.revoke_pending_reminder("missing").await,
2250                PendingReminderMutationResult::UnknownReminderId
2251            );
2252        });
2253    }
2254
2255    #[test]
2256    fn bridge_remind_modes_honor_delivery_checkpoints() {
2257        let runtime = tokio::runtime::Builder::new_current_thread()
2258            .enable_all()
2259            .build()
2260            .unwrap();
2261        runtime.block_on(async {
2262            let cases = [
2263                (
2264                    "interrupt_immediate",
2265                    DeliveryCheckpoint::InterruptImmediate,
2266                    DeliveryCheckpoint::AfterCurrentOperation,
2267                ),
2268                (
2269                    "finish_step",
2270                    DeliveryCheckpoint::AfterCurrentOperation,
2271                    DeliveryCheckpoint::EndOfInteraction,
2272                ),
2273                (
2274                    "audit_only",
2275                    DeliveryCheckpoint::EndOfInteraction,
2276                    DeliveryCheckpoint::InterruptImmediate,
2277                ),
2278            ];
2279
2280            for (mode, expected_checkpoint, wrong_checkpoint) in cases {
2281                let bridge = test_bridge();
2282                bridge
2283                    .push_queued_session_remind_from_params(&serde_json::json!({
2284                        "body": format!("Reminder for {mode}"),
2285                        "mode": mode,
2286                    }))
2287                    .await
2288                    .expect("valid session/remind payload");
2289
2290                let premature = bridge
2291                    .take_queued_transcript_injections_for(wrong_checkpoint)
2292                    .await;
2293                assert!(
2294                    premature.is_empty(),
2295                    "{mode} reminder must not be delivered at {wrong_checkpoint:?}"
2296                );
2297
2298                let delivered = bridge
2299                    .take_queued_transcript_injections_for(expected_checkpoint)
2300                    .await;
2301                assert_eq!(delivered.len(), 1, "{mode} reminder was not delivered");
2302                let QueuedTranscriptInjection::Reminder(reminder) = &delivered[0] else {
2303                    panic!("expected reminder for {mode}");
2304                };
2305                assert_eq!(reminder.reminder.body, format!("Reminder for {mode}"));
2306            }
2307        });
2308    }
2309
2310    #[test]
2311    fn session_remind_validation_rejects_user_message_shape() {
2312        let err = queued_session_remind_from_params(&serde_json::json!({
2313            "content": "this is still a user message",
2314            "mode": "interrupt_immediate",
2315        }))
2316        .expect_err("session/remind must require a reminder body");
2317        assert!(err.contains(Code::ReminderInvalidShape.as_str()));
2318        assert!(err.contains("body"));
2319    }
2320
2321    #[test]
2322    fn session_remind_validation_rejects_unknown_options_separately() {
2323        let err = queued_session_remind_from_params(&serde_json::json!({
2324            "body": "valid body",
2325            "unknown_host_field": true,
2326        }))
2327        .expect_err("session/remind must reject unknown top-level fields");
2328        assert!(err.contains(Code::ReminderUnknownOption.as_str()));
2329        assert!(err.contains("unknown_host_field"));
2330    }
2331
2332    #[test]
2333    fn session_remind_validation_rejects_unknown_propagate_with_specific_code() {
2334        let err = queued_session_remind_from_params(&serde_json::json!({
2335            "body": "valid body",
2336            "propagate": "workspace",
2337        }))
2338        .expect_err("session/remind must reject unknown propagate values");
2339        assert!(err.contains(Code::ReminderUnknownPropagate.as_str()));
2340        assert!(err.contains("propagate"));
2341    }
2342
2343    #[test]
2344    fn test_json_result_to_vm_value_string() {
2345        let val = serde_json::json!("hello");
2346        let vm_val = json_result_to_vm_value(&val);
2347        assert_eq!(vm_val.display(), "hello");
2348    }
2349
2350    #[test]
2351    fn test_json_result_to_vm_value_dict() {
2352        let val = serde_json::json!({"name": "test", "count": 42});
2353        let vm_val = json_result_to_vm_value(&val);
2354        let VmValue::Dict(d) = &vm_val else {
2355            unreachable!("Expected Dict, got {:?}", vm_val);
2356        };
2357        assert_eq!(d.get("name").unwrap().display(), "test");
2358        assert_eq!(d.get("count").unwrap().display(), "42");
2359    }
2360
2361    #[test]
2362    fn test_json_result_to_vm_value_null() {
2363        let val = serde_json::json!(null);
2364        let vm_val = json_result_to_vm_value(&val);
2365        assert!(matches!(vm_val, VmValue::Nil));
2366    }
2367
2368    #[test]
2369    fn test_json_result_to_vm_value_nested() {
2370        let val = serde_json::json!({
2371            "text": "response",
2372            "tool_calls": [
2373                {"id": "tc_1", "name": "read_file", "arguments": {"path": "foo.rs"}}
2374            ],
2375            "input_tokens": 100,
2376            "output_tokens": 50,
2377        });
2378        let vm_val = json_result_to_vm_value(&val);
2379        let VmValue::Dict(d) = &vm_val else {
2380            unreachable!("Expected Dict, got {:?}", vm_val);
2381        };
2382        assert_eq!(d.get("text").unwrap().display(), "response");
2383        let VmValue::List(list) = d.get("tool_calls").unwrap() else {
2384            unreachable!("Expected List for tool_calls");
2385        };
2386        assert_eq!(list.len(), 1);
2387    }
2388
2389    #[test]
2390    fn parse_host_tools_list_accepts_object_wrapper() {
2391        let tools = parse_host_tools_list_response(serde_json::json!({
2392            "tools": [
2393                {
2394                    "name": "Read",
2395                    "description": "Read a file",
2396                    "schema": {"type": "object"},
2397                }
2398            ]
2399        }))
2400        .expect("tool list");
2401
2402        assert_eq!(tools.len(), 1);
2403        assert_eq!(tools[0]["name"], "Read");
2404        assert_eq!(tools[0]["deprecated"], false);
2405    }
2406
2407    #[test]
2408    fn parse_host_tools_list_accepts_compat_fields() {
2409        let tools = parse_host_tools_list_response(serde_json::json!({
2410            "result": {
2411                "tools": [
2412                    {
2413                        "name": "Edit",
2414                        "short_description": "Apply an edit",
2415                        "input_schema": {"type": "object"},
2416                        "deprecated": true,
2417                    }
2418                ]
2419            }
2420        }))
2421        .expect("tool list");
2422
2423        assert_eq!(tools[0]["description"], "Apply an edit");
2424        assert_eq!(tools[0]["schema"]["type"], "object");
2425        assert_eq!(tools[0]["deprecated"], true);
2426    }
2427
2428    #[test]
2429    fn parse_host_tools_list_requires_tool_names() {
2430        let err = parse_host_tools_list_response(serde_json::json!({
2431            "tools": [
2432                {"description": "missing name"}
2433            ]
2434        }))
2435        .expect_err("expected error");
2436        assert!(err
2437            .to_string()
2438            .contains("host/tools/list: every tool must include a string `name`"));
2439    }
2440
2441    #[test]
2442    fn test_timeout_duration() {
2443        assert_eq!(bridge_call_timeout("host/work"), Some(DEFAULT_TIMEOUT));
2444        assert_eq!(DEFAULT_TIMEOUT.as_secs(), 300);
2445    }
2446
2447    #[test]
2448    fn interactive_permission_requests_have_no_bridge_timeout() {
2449        assert_eq!(
2450            bridge_call_timeout(crate::llm::acp_permission::METHOD_REQUEST_PERMISSION),
2451            None
2452        );
2453    }
2454
2455    #[tokio::test(flavor = "current_thread", start_paused = true)]
2456    async fn non_interactive_bridge_calls_timeout_under_paused_time() {
2457        let pending = Arc::new(Mutex::new(HashMap::new()));
2458        let bridge = HostBridge::from_parts_with_writer(
2459            pending.clone(),
2460            Arc::new(AtomicBool::new(false)),
2461            Arc::new(|_| Ok(())),
2462            1,
2463        );
2464
2465        let call = bridge.call("host/work", serde_json::json!({}));
2466        tokio::pin!(call);
2467        wait_for_pending(&pending, 1, call.as_mut()).await;
2468
2469        tokio::time::advance(DEFAULT_TIMEOUT).await;
2470        let result = call.await;
2471        assert!(matches!(
2472            result,
2473            Err(VmError::Runtime(message)) if message.contains("host/work")
2474                && message.contains("within 300s")
2475        ));
2476        assert!(pending.lock().await.is_empty());
2477    }
2478
2479    #[tokio::test(flavor = "current_thread", start_paused = true)]
2480    async fn permission_bridge_calls_survive_timeout_window_under_paused_time() {
2481        let pending = Arc::new(Mutex::new(HashMap::new()));
2482        let bridge = HostBridge::from_parts_with_writer(
2483            pending.clone(),
2484            Arc::new(AtomicBool::new(false)),
2485            Arc::new(|_| Ok(())),
2486            1,
2487        );
2488
2489        let call = bridge.call(
2490            crate::llm::acp_permission::METHOD_REQUEST_PERMISSION,
2491            serde_json::json!({}),
2492        );
2493        tokio::pin!(call);
2494        wait_for_pending(&pending, 1, call.as_mut()).await;
2495
2496        tokio::time::advance(DEFAULT_TIMEOUT + Duration::from_secs(1)).await;
2497        tokio::select! {
2498            result = &mut call => panic!("permission request timed out: {result:?}"),
2499            _ = tokio::task::yield_now() => {}
2500        }
2501        assert!(pending.lock().await.contains_key(&1));
2502
2503        let sender = pending
2504            .lock()
2505            .await
2506            .remove(&1)
2507            .expect("pending permission sender");
2508        sender
2509            .send(serde_json::json!({
2510                "result": crate::llm::acp_permission::allow_response()
2511            }))
2512            .expect("send permission response");
2513
2514        let response = call.await.expect("permission response");
2515        assert!(matches!(
2516            crate::llm::acp_permission::parse_response(&response),
2517            crate::llm::acp_permission::WireOutcome::Allowed { .. }
2518        ));
2519    }
2520
2521    async fn wait_for_pending<F>(
2522        pending: &Arc<Mutex<HashMap<u64, oneshot::Sender<serde_json::Value>>>>,
2523        id: u64,
2524        mut call: Pin<&mut F>,
2525    ) where
2526        F: Future<Output = Result<serde_json::Value, VmError>>,
2527    {
2528        for _ in 0..8 {
2529            tokio::select! {
2530                result = call.as_mut() => panic!("call completed before entering pending map: {result:?}"),
2531                _ = tokio::task::yield_now() => {}
2532            }
2533            if pending.lock().await.contains_key(&id) {
2534                return;
2535            }
2536        }
2537        panic!("bridge call {id} did not enter the pending map after bounded polling");
2538    }
2539}