Skip to main content

harn_vm/
bridge.rs

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