Skip to main content

harn_vm/
bridge.rs

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