Skip to main content

agentos_client/
session.rs

1//! Agent sessions (ACP) methods + supporting types.
2//!
3//! Ported from `packages/core/src/agent-os.ts` (session methods) and `agent-session-types.ts`
4//! (session/mode/config/capability/permission types). Agent types are resolved dynamically
5//! from the configured `/opt/agentos` package manifests (keyed by manifest `name`), exactly
6//! as the TS client does — there is no hardcoded agent registry.
7//!
8//! ACP = JSON-RPC 2.0 over stdio. Sessions are referenced by string ID and return JSON-serializable
9//! data only. JSON-RPC errors are NOT Rust `Err`; methods that issue requests return a
10//! [`JsonRpcResponse`] whose `error` field may be set.
11
12use std::collections::{BTreeMap, BTreeSet};
13use std::pin::Pin;
14use std::sync::atomic::Ordering;
15
16use anyhow::Result;
17use futures::Stream;
18use serde::{Deserialize, Serialize};
19use serde_json::{json, Value};
20
21use agentos_protocol::generated::v1::{
22    AcpCloseSessionRequest, AcpCreateSessionRequest, AcpGetSessionStateRequest,
23    AcpListAgentsRequest, AcpRequest, AcpResponse, AcpResumeSessionRequest, AcpRuntimeKind,
24    AcpSessionCreatedResponse, AcpSessionRequest, AcpSessionStateResponse,
25};
26use agentos_protocol::ACP_EXTENSION_NAMESPACE;
27use agentos_sidecar_client::wire;
28
29use crate::agent_os::{AgentOs, SessionEntry};
30use crate::config::ToolKit;
31use crate::error::ClientError;
32use crate::json_rpc::{JsonRpcError, JsonRpcId, JsonRpcNotification, JsonRpcResponse};
33use crate::stream::Subscription;
34use crate::{CLOSED_SESSION_ID_RETENTION_LIMIT, PERMISSION_TIMEOUT_MS};
35
36/// ACP method name for legacy permission requests/responses.
37const LEGACY_PERMISSION_METHOD: &str = "request/permission";
38
39/// ACP method name for permission requests issued by the agent to the host (TS
40/// `ACP_PERMISSION_METHOD`). Used by the host-request ACP dispatcher in `agent_os.rs`.
41pub(crate) const ACP_PERMISSION_METHOD: &str = "session/request_permission";
42
43/// Maximum in-flight session RPC requests per session.
44const SESSION_PENDING_REQUEST_LIMIT: usize = 1024;
45
46pub(crate) struct PermissionRouteRequest {
47    pub(crate) session_id: String,
48    pub(crate) permission_id: String,
49    pub(crate) params: Value,
50}
51
52pub(crate) struct PermissionRouteResult {
53    pub(crate) reply: Option<String>,
54}
55
56struct SessionCreatedResponse {
57    session_id: String,
58    modes: Option<Value>,
59    config_options: Vec<Value>,
60    agent_capabilities: Option<Value>,
61    agent_info: Option<Value>,
62}
63
64pub(crate) struct SessionStateResponse {
65    modes: Option<Value>,
66    config_options: Vec<Value>,
67    agent_capabilities: Option<Value>,
68    agent_info: Option<Value>,
69}
70
71/// Maximum bytes accumulated into `PromptResult.text`.
72const PROMPT_TEXT_CAPTURE_LIMIT_BYTES: usize = 16 * 1024 * 1024;
73
74/// Maximum agent-message chunks tracked per prompt call.
75const PROMPT_DELIVERED_CHUNK_LIMIT: usize = 262_144;
76
77pub type SessionEventStream = Pin<Box<dyn Stream<Item = JsonRpcNotification> + Send>>;
78pub type SessionEventSubscription = (SessionEventStream, Subscription);
79pub type PermissionRequestStream = Pin<Box<dyn Stream<Item = PermissionRequest> + Send>>;
80pub type PermissionRequestSubscription = (PermissionRequestStream, Subscription);
81pub type AgentExitStream = Pin<Box<dyn Stream<Item = AgentExitEvent> + Send>>;
82pub type AgentExitSubscription = (AgentExitStream, Subscription);
83
84/// An unexpected ACP adapter process exit — a crash from the host's
85/// perspective (any spontaneous exit without `close_session`, including exit
86/// code 0) — plus the sidecar's bounded auto-restart outcome. Mirrors the wire
87/// `AcpAgentExitedEvent` and the TS `AgentExitEvent`.
88///
89/// `restart` is one of `"restarted"` (adapter respawned and the session
90/// natively re-attached under the same id; still usable), `"unsupported"`
91/// (adapter lacks `loadSession`/`resume`; session evicted), `"failed"`
92/// (respawn/re-attach errored; evicted), or `"exhausted"` (restart budget
93/// spent; evicted).
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct AgentExitEvent {
96    #[serde(rename = "sessionId")]
97    pub session_id: String,
98    #[serde(rename = "agentType")]
99    pub agent_type: String,
100    #[serde(rename = "processId")]
101    pub process_id: String,
102    /// Adapter exit code; `None` when the exit was observed indirectly.
103    #[serde(rename = "exitCode")]
104    pub exit_code: Option<i32>,
105    pub restart: String,
106    #[serde(rename = "restartCount")]
107    pub restart_count: u32,
108    #[serde(rename = "maxRestarts")]
109    pub max_restarts: u32,
110}
111
112// ---------------------------------------------------------------------------
113// Supporting types
114// ---------------------------------------------------------------------------
115
116/// In-memory session registry entry summary.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct SessionInfo {
119    #[serde(rename = "sessionId")]
120    pub session_id: String,
121    #[serde(rename = "agentType")]
122    pub agent_type: String,
123}
124
125/// A registry agent entry from `list_agents`. Mirrors the TS `AgentRegistryEntry`.
126/// The client is npm-agnostic and parses no manifests: `list_agents` is a sidecar
127/// ACP RPC that enumerates the projected `/opt/agentos` packages. The entry is just
128/// the agent `id`; `installed` is always `true` (the package is materialized into
129/// the VM at boot).
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct AgentRegistryEntry {
132    pub id: String,
133    pub installed: bool,
134}
135
136/// MCP server config used by `create_session`.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(tag = "type", rename_all = "lowercase")]
139pub enum McpServerConfig {
140    Local {
141        command: String,
142        #[serde(default, skip_serializing_if = "Vec::is_empty")]
143        args: Vec<String>,
144        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
145        env: BTreeMap<String, String>,
146    },
147    Remote {
148        url: String,
149        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
150        headers: BTreeMap<String, String>,
151    },
152}
153
154/// Options for `create_session`.
155#[derive(Debug, Clone, PartialEq, Eq, Default)]
156pub struct CreateSessionOptions {
157    /// Default `"/workspace"`.
158    pub cwd: Option<String>,
159    pub env: BTreeMap<String, String>,
160    /// Default `[]`.
161    pub mcp_servers: Vec<McpServerConfig>,
162    /// Default false.
163    pub skip_os_instructions: bool,
164    pub additional_instructions: Option<String>,
165}
166
167/// The id returned by `create_session`.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct SessionId {
170    #[serde(rename = "sessionId")]
171    pub session_id: String,
172}
173
174/// Result of `resume_session`. `session_id` is the live ACP session id in the
175/// fresh VM: equal to the requested id for native loads, or a freshly assigned id
176/// for the fallback tier — the caller (e.g. the actor) remaps `external -> live`.
177/// `mode` is `"native"` or `"fallback"`.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct ResumeSessionResult {
180    #[serde(rename = "sessionId")]
181    pub session_id: String,
182    pub mode: String,
183}
184
185/// Options for `resume_session`. Mirrors the durability-dependent fields the
186/// sidecar fallback tier needs to re-launch the adapter, plus the transcript
187/// pointer.
188#[derive(Debug, Clone, PartialEq, Eq, Default)]
189pub struct ResumeSessionOptions {
190    /// Guest-readable path to the reconstructed transcript. When present, the
191    /// fallback tier arms a continuation preamble pointing the agent at it.
192    pub transcript_path: Option<String>,
193    /// Default `"/workspace"`.
194    pub cwd: Option<String>,
195    pub env: BTreeMap<String, String>,
196}
197
198/// Result of `prompt`.
199#[derive(Debug, Clone, PartialEq)]
200pub struct PromptResult {
201    pub response: JsonRpcResponse,
202    pub text: String,
203}
204
205/// A single session mode (`{ id; name?; label?; description?; [k]: unknown }`).
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207pub struct SessionMode {
208    pub id: String,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub name: Option<String>,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub label: Option<String>,
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub description: Option<String>,
215    /// Additional unmodeled fields.
216    #[serde(flatten)]
217    pub extra: BTreeMap<String, Value>,
218}
219
220/// Session mode state (`{ currentModeId; availableModes }`).
221///
222/// `currentModeId` and `availableModes` default so a loosely-shaped modes object (one missing either
223/// field) still deserializes and is stored. Mirrors TS `toSessionModes`, which returns ANY non-array
224/// object as `SessionModeState` with no field check.
225#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
226pub struct SessionModeState {
227    #[serde(default, rename = "currentModeId")]
228    pub current_mode_id: String,
229    #[serde(default, rename = "availableModes")]
230    pub available_modes: Vec<SessionMode>,
231}
232
233/// An allowed value for a config option.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct ConfigAllowedValue {
236    pub id: String,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub label: Option<String>,
239}
240
241/// A session config option.
242///
243/// `id` defaults so a partial entry missing `id` still deserializes and is kept (rather than dropped),
244/// narrowing the gap with TS `toSessionConfigOptions`, which casts the whole array verbatim. Truly
245/// non-object entries still cannot be stored in this typed Vec; see the parity audit minor note.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct SessionConfigOption {
248    #[serde(default)]
249    pub id: String,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub category: Option<String>,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub label: Option<String>,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub description: Option<String>,
256    #[serde(
257        default,
258        rename = "currentValue",
259        skip_serializing_if = "Option::is_none"
260    )]
261    pub current_value: Option<String>,
262    #[serde(
263        default,
264        rename = "allowedValues",
265        skip_serializing_if = "Option::is_none"
266    )]
267    pub allowed_values: Option<Vec<ConfigAllowedValue>>,
268    #[serde(default, rename = "readOnly", skip_serializing_if = "Option::is_none")]
269    pub read_only: Option<bool>,
270}
271
272/// Prompt capabilities sub-object.
273#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
274pub struct PromptCapabilities {
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub audio: Option<bool>,
277    #[serde(
278        default,
279        rename = "embeddedContext",
280        skip_serializing_if = "Option::is_none"
281    )]
282    pub embedded_context: Option<bool>,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub image: Option<bool>,
285    #[serde(flatten)]
286    pub extra: BTreeMap<String, Value>,
287}
288
289/// Agent capabilities (all optional booleans + prompt capabilities + extras).
290#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
291pub struct AgentCapabilities {
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub permissions: Option<bool>,
294    #[serde(default, rename = "plan_mode", skip_serializing_if = "Option::is_none")]
295    pub plan_mode: Option<bool>,
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub questions: Option<bool>,
298    #[serde(
299        default,
300        rename = "tool_calls",
301        skip_serializing_if = "Option::is_none"
302    )]
303    pub tool_calls: Option<bool>,
304    #[serde(
305        default,
306        rename = "text_messages",
307        skip_serializing_if = "Option::is_none"
308    )]
309    pub text_messages: Option<bool>,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub images: Option<bool>,
312    #[serde(
313        default,
314        rename = "file_attachments",
315        skip_serializing_if = "Option::is_none"
316    )]
317    pub file_attachments: Option<bool>,
318    #[serde(
319        default,
320        rename = "session_lifecycle",
321        skip_serializing_if = "Option::is_none"
322    )]
323    pub session_lifecycle: Option<bool>,
324    #[serde(
325        default,
326        rename = "error_events",
327        skip_serializing_if = "Option::is_none"
328    )]
329    pub error_events: Option<bool>,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub reasoning: Option<bool>,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub status: Option<bool>,
334    #[serde(
335        default,
336        rename = "streaming_deltas",
337        skip_serializing_if = "Option::is_none"
338    )]
339    pub streaming_deltas: Option<bool>,
340    #[serde(default, rename = "mcp_tools", skip_serializing_if = "Option::is_none")]
341    pub mcp_tools: Option<bool>,
342    #[serde(
343        default,
344        rename = "promptCapabilities",
345        skip_serializing_if = "Option::is_none"
346    )]
347    pub prompt_capabilities: Option<PromptCapabilities>,
348    #[serde(flatten)]
349    pub extra: BTreeMap<String, Value>,
350}
351
352/// Agent info (`{ name; title?; version?; [k]: unknown }`).
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354pub struct AgentInfo {
355    pub name: String,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub title: Option<String>,
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub version: Option<String>,
360    #[serde(flatten)]
361    pub extra: BTreeMap<String, Value>,
362}
363
364/// Initial hydration data for a session.
365#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
366pub struct SessionInitData {
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub modes: Option<SessionModeState>,
369    #[serde(
370        default,
371        rename = "configOptions",
372        skip_serializing_if = "Option::is_none"
373    )]
374    pub config_options: Option<Vec<SessionConfigOption>>,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub capabilities: Option<AgentCapabilities>,
377    #[serde(default, rename = "agentInfo", skip_serializing_if = "Option::is_none")]
378    pub agent_info: Option<AgentInfo>,
379}
380
381/// A Clone-able one-shot responder for a permission request.
382///
383/// [`PermissionRequest`] is delivered over a [`tokio::sync::broadcast`] channel, which requires the
384/// item to be `Clone`. A raw `oneshot::Sender` is not `Clone`, so the sender is held behind a shared
385/// `Arc<Mutex<Option<..>>>`; the first [`PermissionResponder::respond`] call takes the sender out
386/// and resolves it. Subsequent calls (or other broadcast clones) are no-ops.
387#[derive(Clone)]
388pub struct PermissionResponder {
389    inner:
390        std::sync::Arc<parking_lot::Mutex<Option<tokio::sync::oneshot::Sender<PermissionReply>>>>,
391}
392
393impl PermissionResponder {
394    /// Create a responder paired with the receiving end.
395    pub fn new() -> (Self, tokio::sync::oneshot::Receiver<PermissionReply>) {
396        let (tx, rx) = tokio::sync::oneshot::channel();
397        (
398            Self {
399                inner: std::sync::Arc::new(parking_lot::Mutex::new(Some(tx))),
400            },
401            rx,
402        )
403    }
404
405    /// Resolve the request with `reply`. The first call wins; later calls are no-ops.
406    pub fn respond(&self, reply: PermissionReply) {
407        if let Some(tx) = self.inner.lock().take() {
408            let _ = tx.send(reply);
409        }
410    }
411}
412
413/// A permission request delivered to a subscriber. Carries a Clone-able one-shot responder.
414///
415/// Requests are delivered by the sidecar permission-request path
416/// ([`AgentOs::deliver_sidecar_permission_request`]). The subscriber resolves the request via
417/// [`PermissionResponder::respond`] or [`AgentOs::respond_permission`]; the
418/// [`crate::PERMISSION_TIMEOUT_MS`] timeout and the no-subscriber path auto-reject.
419#[derive(Clone)]
420pub struct PermissionRequest {
421    pub permission_id: String,
422    pub description: Option<String>,
423    pub params: Value,
424    pub responder: PermissionResponder,
425}
426
427impl std::fmt::Debug for PermissionRequest {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        f.debug_struct("PermissionRequest")
430            .field("permission_id", &self.permission_id)
431            .field("description", &self.description)
432            .field("params", &self.params)
433            .finish_non_exhaustive()
434    }
435}
436
437/// A permission reply.
438#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
439#[serde(rename_all = "lowercase")]
440pub enum PermissionReply {
441    Once,
442    Always,
443    Reject,
444}
445
446/// The wire string for a [`PermissionReply`] (`"once"` / `"always"` / `"reject"`), matching the
447/// serde `lowercase` rename and the TS `PermissionReply` union.
448fn permission_reply_wire(reply: PermissionReply) -> &'static str {
449    match reply {
450        PermissionReply::Once => "once",
451        PermissionReply::Always => "always",
452        PermissionReply::Reject => "reject",
453    }
454}
455
456// ---------------------------------------------------------------------------
457// Local-state helpers (operate on a `SessionEntry`; mirror the TS private helpers)
458// ---------------------------------------------------------------------------
459
460/// Whether a cached [`AgentCapabilities`] is empty in the TS sense (`Object.keys(caps).length === 0`):
461/// every modeled field is `None` and there are no extra keys. `toAgentCapabilities` stores `{}` for
462/// any non-object/empty state, and `getSessionCapabilities` returns `null` for that empty object.
463fn agent_capabilities_is_empty(caps: &AgentCapabilities) -> bool {
464    caps.permissions.is_none()
465        && caps.plan_mode.is_none()
466        && caps.questions.is_none()
467        && caps.tool_calls.is_none()
468        && caps.text_messages.is_none()
469        && caps.images.is_none()
470        && caps.file_attachments.is_none()
471        && caps.session_lifecycle.is_none()
472        && caps.error_events.is_none()
473        && caps.reasoning.is_none()
474        && caps.status.is_none()
475        && caps.streaming_deltas.is_none()
476        && caps.mcp_tools.is_none()
477        && caps.prompt_capabilities.is_none()
478        && caps.extra.is_empty()
479}
480
481/// Whether a notification should be delivered to `on_session_event` subscribers (`session/update`
482/// only). Mirrors `shouldDispatchToSessionEventHandlers`.
483fn should_dispatch_to_session_event_handlers(notification: &JsonRpcNotification) -> bool {
484    notification.method == "session/update"
485}
486
487pub(crate) fn record_live_session_event(entry: &SessionEntry, notification: JsonRpcNotification) {
488    apply_session_update(entry, &notification);
489    if should_dispatch_to_session_event_handlers(&notification) {
490        let _ = entry.event_tx.send(notification);
491    }
492}
493
494fn apply_session_update(entry: &SessionEntry, notification: &JsonRpcNotification) {
495    if notification.method != "session/update" {
496        return;
497    }
498    let Some(params) = notification.params.as_ref().and_then(Value::as_object) else {
499        return;
500    };
501    let update = params
502        .get("update")
503        .and_then(Value::as_object)
504        .unwrap_or(params);
505    match update.get("sessionUpdate").and_then(Value::as_str) {
506        Some("current_mode_update") => {
507            let Some(mode_id) = update.get("currentModeId").and_then(Value::as_str) else {
508                return;
509            };
510            let mut modes = entry.modes.lock();
511            if let Some(modes) = modes.as_mut() {
512                modes.current_mode_id = mode_id.to_string();
513            }
514        }
515        Some("config_option_update") | Some("config_options_update") => {
516            let Some(options) = update.get("configOptions").and_then(Value::as_array) else {
517                return;
518            };
519            let parsed = options
520                .iter()
521                .filter_map(|value| serde_json::from_value(value.clone()).ok())
522                .collect();
523            *entry.config_options.lock() = parsed;
524            apply_synthetic_config_overrides(entry);
525        }
526        Some("agent_message_chunk") | None | Some(_) => {}
527    }
528}
529
530fn accumulate_agent_message_chunk(
531    notification: &JsonRpcNotification,
532    delivered_chunks: &mut usize,
533    agent_text: &mut String,
534) -> std::result::Result<(), ClientError> {
535    let params = notification.params.clone().unwrap_or(Value::Null);
536    let update = params.get("update").cloned().unwrap_or(Value::Null);
537    if update.get("sessionUpdate").and_then(Value::as_str) != Some("agent_message_chunk") {
538        return Ok(());
539    }
540    if let Some(chunk) = update
541        .get("content")
542        .and_then(|content| content.get("text"))
543        .and_then(Value::as_str)
544    {
545        if *delivered_chunks >= PROMPT_DELIVERED_CHUNK_LIMIT {
546            return Err(prompt_chunk_limit_error());
547        }
548        let next_len = agent_text
549            .len()
550            .checked_add(chunk.len())
551            .ok_or_else(|| prompt_text_limit_error(usize::MAX))?;
552        if next_len > PROMPT_TEXT_CAPTURE_LIMIT_BYTES {
553            return Err(prompt_text_limit_error(next_len));
554        }
555        agent_text.push_str(chunk);
556        *delivered_chunks += 1;
557    }
558    Ok(())
559}
560
561fn pending_session_request_count(entry: &SessionEntry) -> usize {
562    let mut count = 0;
563    entry.pending_prompt_resolvers.scan(|_, _| {
564        count += 1;
565    });
566    count
567}
568
569fn prompt_text_limit_error(size: usize) -> ClientError {
570    ClientError::Sidecar(format!(
571        "prompt text capture is {size} bytes, limit is {PROMPT_TEXT_CAPTURE_LIMIT_BYTES}"
572    ))
573}
574
575fn prompt_chunk_limit_error() -> ClientError {
576    ClientError::Sidecar(format!(
577        "prompt chunk tracking limit exceeded: at most {PROMPT_DELIVERED_CHUNK_LIMIT} chunks can be captured per prompt"
578    ))
579}
580
581struct PendingSessionRequestGuard<'a> {
582    os: &'a AgentOs,
583    session_id: &'a str,
584    resolver_id: i64,
585    active: bool,
586}
587
588impl<'a> PendingSessionRequestGuard<'a> {
589    fn new(os: &'a AgentOs, session_id: &'a str, resolver_id: i64) -> Self {
590        Self {
591            os,
592            session_id,
593            resolver_id,
594            active: true,
595        }
596    }
597
598    fn cleanup(&mut self) {
599        if self.active {
600            self.os
601                .cleanup_pending_resolver(self.session_id, self.resolver_id);
602            self.active = false;
603        }
604    }
605}
606
607impl Drop for PendingSessionRequestGuard<'_> {
608    fn drop(&mut self) {
609        self.cleanup();
610    }
611}
612
613/// Re-apply synthetic config overrides onto the cached config options. Mirrors
614/// `_applySyntheticConfigOverrides`.
615fn apply_synthetic_config_overrides(entry: &SessionEntry) {
616    let overrides = entry.config_overrides.lock().clone();
617    if overrides.is_empty() {
618        return;
619    }
620    let mut options = entry.config_options.lock();
621    for option in options.iter_mut() {
622        // Skip internal pending-request method markers (see `send_session_request`); they share the
623        // override map but are never real config option ids/categories.
624        let override_value = overrides
625            .get(&option.id)
626            .filter(|_| !option.id.starts_with(PENDING_METHOD_PREFIX))
627            .cloned()
628            .or_else(|| {
629                option
630                    .category
631                    .as_ref()
632                    .and_then(|category| overrides.get(category).cloned())
633            });
634        if let Some(value) = override_value {
635            option.current_value = Some(value);
636        }
637    }
638}
639
640/// Prefix for the internal per-resolver method markers stored in `config_overrides` (so cancel can
641/// distinguish `session/prompt` resolvers without an extra `SessionEntry` field).
642const PENDING_METHOD_PREFIX: &str = "__pending_method::";
643
644/// Apply the local cache mutations of `_syncSessionState`: modes, config options, capabilities,
645/// and agent info from a sidecar [`SessionStateResponse`].
646fn sync_session_state(entry: &SessionEntry, state: &SessionStateResponse) {
647    *entry.modes.lock() = state
648        .modes
649        .as_ref()
650        .filter(|value| value.is_object())
651        .and_then(|value| serde_json::from_value(value.clone()).ok());
652
653    *entry.config_options.lock() = state
654        .config_options
655        .iter()
656        .filter_map(|value| serde_json::from_value(value.clone()).ok())
657        .collect();
658
659    apply_synthetic_config_overrides(entry);
660
661    *entry.capabilities.lock() = state
662        .agent_capabilities
663        .as_ref()
664        .filter(|value| value.is_object())
665        .and_then(|value| serde_json::from_value(value.clone()).ok());
666
667    *entry.agent_info.lock() = state
668        .agent_info
669        .as_ref()
670        .filter(|value| value.is_object())
671        .and_then(|value| serde_json::from_value(value.clone()).ok());
672}
673
674/// Synthesize the unsupported-config JSON-RPC error response (`-32601`). Mirrors
675/// `_unsupportedConfigResponse`.
676fn unsupported_config_response(agent_type: &str, category: &str) -> JsonRpcResponse {
677    let message = if agent_type == "opencode" && category == "model" {
678        "OpenCode reports available models, but model switching must be configured before createSession() because ACP session/set_config_option is not implemented.".to_string()
679    } else {
680        format!("The {category} config option is read-only for {agent_type} sessions.")
681    };
682    JsonRpcResponse {
683        jsonrpc: "2.0".to_string(),
684        id: Some(JsonRpcId::Null),
685        result: None,
686        error: Some(JsonRpcError {
687            code: -32601,
688            message,
689            data: None,
690        }),
691    }
692}
693
694/// Build the closed-session abort response (`-32000`). Mirrors `_abortPendingSessionRequests`.
695fn session_closed_response(session_id: &str) -> JsonRpcResponse {
696    JsonRpcResponse {
697        jsonrpc: "2.0".to_string(),
698        id: Some(JsonRpcId::Null),
699        result: None,
700        error: Some(JsonRpcError {
701            code: -32000,
702            message: format!("Session closed: {session_id}"),
703            data: None,
704        }),
705    }
706}
707
708fn session_created_from_acp(
709    response: AcpSessionCreatedResponse,
710) -> std::result::Result<SessionCreatedResponse, ClientError> {
711    Ok(SessionCreatedResponse {
712        session_id: response.session_id,
713        modes: parse_optional_json(response.modes, "modes")?,
714        config_options: parse_json_vec(response.config_options, "configOptions")?,
715        agent_capabilities: parse_optional_json(response.agent_capabilities, "agentCapabilities")?,
716        agent_info: parse_optional_json(response.agent_info, "agentInfo")?,
717    })
718}
719
720fn session_state_from_acp(
721    response: AcpSessionStateResponse,
722) -> std::result::Result<SessionStateResponse, ClientError> {
723    Ok(SessionStateResponse {
724        modes: parse_optional_json(response.modes, "modes")?,
725        config_options: parse_json_vec(response.config_options, "configOptions")?,
726        agent_capabilities: parse_optional_json(response.agent_capabilities, "agentCapabilities")?,
727        agent_info: parse_optional_json(response.agent_info, "agentInfo")?,
728    })
729}
730
731fn parse_optional_json(
732    value: Option<String>,
733    label: &str,
734) -> std::result::Result<Option<Value>, ClientError> {
735    value
736        .map(|value| {
737            serde_json::from_str(&value).map_err(|error| {
738                ClientError::Sidecar(format!("malformed ACP {label} JSON: {error}"))
739            })
740        })
741        .transpose()
742}
743
744fn parse_json_vec(
745    values: Vec<String>,
746    label: &str,
747) -> std::result::Result<Vec<Value>, ClientError> {
748    values
749        .into_iter()
750        .map(|value| {
751            serde_json::from_str(&value).map_err(|error| {
752                ClientError::Sidecar(format!("malformed ACP {label} JSON: {error}"))
753            })
754        })
755        .collect()
756}
757
758fn unexpected_acp_response(operation: &str, response: AcpResponse) -> ClientError {
759    ClientError::Sidecar(format!("unexpected response to {operation}: {response:?}"))
760}
761
762fn combine_instructions(additional: Option<&str>, tool_reference: &str) -> Option<String> {
763    let mut parts = Vec::new();
764    if let Some(additional) = additional.map(str::trim).filter(|value| !value.is_empty()) {
765        parts.push(additional.to_string());
766    }
767    let tool_reference = tool_reference.trim();
768    if !tool_reference.is_empty() {
769        parts.push(tool_reference.to_string());
770    }
771    if parts.is_empty() {
772        None
773    } else {
774        Some(parts.join("\n\n"))
775    }
776}
777
778fn build_host_tool_reference(tool_kits: &[ToolKit]) -> String {
779    if tool_kits.is_empty() {
780        return String::new();
781    }
782
783    let mut lines = vec![
784        String::from("## Available Host Tools"),
785        String::new(),
786        String::from("Run `agentos list-tools` to see all available tools."),
787        String::new(),
788    ];
789
790    for kit in tool_kits {
791        lines.push(format!("### {}", kit.name));
792        lines.push(String::new());
793        lines.push(kit.description.clone());
794        lines.push(String::new());
795        for tool in &kit.tools {
796            let signature = build_tool_flag_signature(&tool.input_schema);
797            let suffix = if signature.is_empty() {
798                String::new()
799            } else {
800                format!(" {signature}")
801            };
802            lines.push(format!(
803                "- `agentos-{} {}{}` — {}",
804                kit.name, tool.name, suffix, tool.description
805            ));
806        }
807        lines.push(String::new());
808        lines.push(format!(
809            "Run `agentos-{} <tool> --help` for details.",
810            kit.name
811        ));
812        lines.push(String::new());
813    }
814
815    lines.join("\n")
816}
817
818fn build_tool_flag_signature(schema: &Value) -> String {
819    describe_tool_flags(schema)
820        .into_iter()
821        .map(|flag| {
822            if flag.required {
823                format!("{} <{}>", flag.name, flag.value_type)
824            } else {
825                format!("[{} <{}>]", flag.name, flag.value_type)
826            }
827        })
828        .collect::<Vec<_>>()
829        .join(" ")
830}
831
832struct ToolFlagDescription {
833    name: String,
834    value_type: String,
835    required: bool,
836}
837
838fn describe_tool_flags(schema: &Value) -> Vec<ToolFlagDescription> {
839    let properties = schema
840        .get("properties")
841        .and_then(Value::as_object)
842        .cloned()
843        .unwrap_or_default();
844    let required = schema
845        .get("required")
846        .and_then(Value::as_array)
847        .map(|items| {
848            items
849                .iter()
850                .filter_map(Value::as_str)
851                .map(str::to_owned)
852                .collect::<BTreeSet<_>>()
853        })
854        .unwrap_or_default();
855
856    properties
857        .into_iter()
858        .map(|(field_name, field_schema)| ToolFlagDescription {
859            name: format!("--{}", camel_to_kebab(&field_name)),
860            value_type: describe_tool_flag_type(&field_schema),
861            required: required.contains(&field_name),
862        })
863        .collect()
864}
865
866fn describe_tool_flag_type(schema: &Value) -> String {
867    match json_schema_type(schema) {
868        Some("array") => {
869            let item_type = schema
870                .get("items")
871                .and_then(json_schema_type)
872                .unwrap_or("string");
873            format!("{item_type}[]")
874        }
875        Some("string") => schema
876            .get("enum")
877            .and_then(Value::as_array)
878            .map(|values| values.iter().filter_map(Value::as_str).collect::<Vec<_>>())
879            .filter(|values| !values.is_empty())
880            .map(|values| values.join("|"))
881            .unwrap_or_else(|| String::from("string")),
882        Some(other) => other.to_string(),
883        None => String::from("string"),
884    }
885}
886
887fn json_schema_type(schema: &Value) -> Option<&str> {
888    schema.get("type").and_then(Value::as_str)
889}
890
891fn camel_to_kebab(value: &str) -> String {
892    let mut output = String::new();
893    for (index, ch) in value.chars().enumerate() {
894        if ch.is_ascii_uppercase() && index > 0 {
895            output.push('-');
896        }
897        output.push(ch.to_ascii_lowercase());
898    }
899    output
900}
901
902// ---------------------------------------------------------------------------
903// Methods
904// ---------------------------------------------------------------------------
905
906impl AgentOs {
907    /// VM-scoped ownership for session RPCs.
908    fn session_ownership(&self) -> wire::OwnershipScope {
909        wire::OwnershipScope::VmOwnership(wire::VmOwnership {
910            connection_id: self.connection_id().to_string(),
911            session_id: self.wire_session_id().to_string(),
912            vm_id: self.vm_id().to_string(),
913        })
914    }
915
916    /// Look up a session entry or return [`ClientError::SessionNotFound`]. Mirrors `_requireSession`.
917    fn require_session<R>(
918        &self,
919        session_id: &str,
920        f: impl FnOnce(&SessionEntry) -> R,
921    ) -> std::result::Result<R, ClientError> {
922        self.inner()
923            .sessions
924            .read(session_id, |_, entry| f(entry))
925            .ok_or_else(|| ClientError::SessionNotFound(session_id.to_string()))
926    }
927
928    /// Re-hydrate cached session state from the sidecar `AcpGetSessionStateRequest` snapshot.
929    /// Mirrors `_hydrateSessionState`.
930    async fn hydrate_session_state(
931        &self,
932        session_id: &str,
933    ) -> std::result::Result<(), ClientError> {
934        self.require_session(session_id, |_| ())?;
935        let response = self
936            .send_acp_request(AcpRequest::AcpGetSessionStateRequest(
937                AcpGetSessionStateRequest {
938                    session_id: session_id.to_string(),
939                },
940            ))
941            .await?;
942        let AcpResponse::AcpSessionStateResponse(state) = response else {
943            return Err(unexpected_acp_response(
944                "AcpGetSessionStateRequest",
945                response,
946            ));
947        };
948        let state = session_state_from_acp(state)?;
949
950        self.require_session(session_id, |entry| sync_session_state(entry, &state))?;
951        Ok(())
952    }
953
954    /// Core request helper: every session request routes through this. Tracks pending resolvers per
955    /// session (cancel prompt-fallback + abort-on-close), calls the sidecar, re-hydrates state, and
956    /// applies local cache updates for `set_mode` / `set_config_option`.
957    pub(crate) async fn send_session_request(
958        &self,
959        session_id: &str,
960        method: &str,
961        params: Option<Value>,
962    ) -> std::result::Result<JsonRpcResponse, ClientError> {
963        let request_params = params;
964
965        // Register a pending-resolver slot so cancel/close can resolve this request locally. The
966        // resolver carries the intended [`JsonRpcResponse`] (close -> `-32000 Session closed`,
967        // cancel -> `{stopReason: cancelled}`); whichever completes first wins. Mirrors the TS
968        // resolver `{ method, resolve: (response) => void }`.
969        let resolver_id = self.inner().request_counter.fetch_add(1, Ordering::SeqCst);
970        let (resolve_tx, resolve_rx) = tokio::sync::oneshot::channel::<JsonRpcResponse>();
971        self.require_session(session_id, |entry| {
972            let _guard = entry.pending_session_request_lock.lock();
973            if pending_session_request_count(entry) >= SESSION_PENDING_REQUEST_LIMIT {
974                return Err(ClientError::Sidecar(format!(
975                    "session pending request limit exceeded: at most {SESSION_PENDING_REQUEST_LIMIT} requests can be in flight per session"
976                )));
977            }
978            let _ = entry
979                .pending_prompt_resolvers
980                .insert(resolver_id, resolve_tx);
981            // Track the method so prompt-fallback can target only `session/prompt` resolvers.
982            entry
983                .config_overrides
984                .lock()
985                .entry(format!("{PENDING_METHOD_PREFIX}{resolver_id}"))
986                .or_insert_with(|| method.to_string());
987            Ok(())
988        })??;
989        let mut pending_request_guard =
990            PendingSessionRequestGuard::new(self, session_id, resolver_id);
991
992        let rpc = self.send_acp_request(AcpRequest::AcpSessionRequest(AcpSessionRequest {
993            session_id: session_id.to_string(),
994            method: method.to_string(),
995            params: request_params
996                .clone()
997                .map(|params| serde_json::to_string(&params))
998                .transpose()
999                .map_err(|error| {
1000                    ClientError::Sidecar(format!("failed to encode session params: {error}"))
1001                })?,
1002        }));
1003        tokio::pin!(rpc);
1004
1005        let response = tokio::select! {
1006            biased;
1007            resolved = resolve_rx => {
1008                // A cancel/close resolved this request locally before the sidecar replied. The
1009                // resolver carries the intended response (cancel vs close), set at the abort/cancel
1010                // site, so it is returned verbatim rather than re-derived from the method.
1011                pending_request_guard.cleanup();
1012                match resolved {
1013                    Ok(response) => return Ok(response),
1014                    Err(_) => return Ok(session_closed_response(session_id)),
1015                }
1016            }
1017            result = &mut rpc => {
1018                pending_request_guard.cleanup();
1019                result?
1020            }
1021        };
1022
1023        let response = match response {
1024            AcpResponse::AcpSessionRpcResponse(rpc) => {
1025                serde_json::from_str::<JsonRpcResponse>(&rpc.response).map_err(|err| {
1026                    ClientError::Sidecar(format!("malformed session rpc response: {err}"))
1027                })?
1028            }
1029            other => return Err(unexpected_acp_response("AcpSessionRequest", other)),
1030        };
1031
1032        // Re-hydrate state regardless of outcome (best-effort; ignore errors).
1033        let _ = self.hydrate_session_state(session_id).await;
1034
1035        if response.error.is_none() {
1036            self.apply_post_send_cache_updates(session_id, method, request_params.as_ref())?;
1037        }
1038
1039        Ok(response)
1040    }
1041
1042    /// Drop a pending-resolver slot and its tracked method marker.
1043    fn cleanup_pending_resolver(&self, session_id: &str, resolver_id: i64) {
1044        let _ = self.require_session(session_id, |entry| {
1045            let _ = entry.pending_prompt_resolvers.remove(&resolver_id);
1046            entry
1047                .config_overrides
1048                .lock()
1049                .remove(&format!("{PENDING_METHOD_PREFIX}{resolver_id}"));
1050        });
1051    }
1052
1053    /// Apply local cache updates for successful `session/set_mode` / `session/set_config_option`.
1054    fn apply_post_send_cache_updates(
1055        &self,
1056        session_id: &str,
1057        method: &str,
1058        params: Option<&Value>,
1059    ) -> std::result::Result<(), ClientError> {
1060        self.require_session(session_id, |entry| {
1061            if method == "session/set_mode" {
1062                if let Some(mode_id) = params.and_then(|p| p.get("modeId")).and_then(Value::as_str)
1063                {
1064                    let mut modes = entry.modes.lock();
1065                    if let Some(modes) = modes.as_mut() {
1066                        modes.current_mode_id = mode_id.to_string();
1067                    }
1068                }
1069            }
1070            if method == "session/set_config_option" {
1071                let config_id = params
1072                    .and_then(|p| p.get("configId"))
1073                    .and_then(Value::as_str);
1074                let value = params.and_then(|p| p.get("value")).and_then(Value::as_str);
1075                if let (Some(config_id), Some(value)) = (config_id, value) {
1076                    let mut options = entry.config_options.lock();
1077                    for option in options.iter_mut() {
1078                        if option.id == config_id {
1079                            option.current_value = Some(value.to_string());
1080                        }
1081                    }
1082                }
1083            }
1084        })
1085    }
1086
1087    /// Set a config option by its category (model/thought_level). Mirrors
1088    /// `_setSessionConfigByCategory`: readonly -> error response.
1089    async fn set_session_config_by_category(
1090        &self,
1091        session_id: &str,
1092        category: &str,
1093        value: &str,
1094    ) -> std::result::Result<JsonRpcResponse, ClientError> {
1095        let (read_only, config_id, agent_type) = self.require_session(session_id, |entry| {
1096            let options = entry.config_options.lock();
1097            let option = options
1098                .iter()
1099                .find(|option| option.category.as_deref() == Some(category));
1100            (
1101                option.and_then(|option| option.read_only).unwrap_or(false),
1102                option.map(|option| option.id.clone()),
1103                entry.agent_type.clone(),
1104            )
1105        })?;
1106
1107        if read_only {
1108            return Ok(unsupported_config_response(&agent_type, category));
1109        }
1110
1111        let config_id = config_id.unwrap_or_else(|| category.to_string());
1112        let response = self
1113            .send_session_request(
1114                session_id,
1115                "session/set_config_option",
1116                Some(json!({ "configId": config_id, "value": value })),
1117            )
1118            .await?;
1119
1120        Ok(response)
1121    }
1122
1123    /// List in-memory sessions.
1124    pub fn list_sessions(&self) -> Vec<SessionInfo> {
1125        let mut sessions = Vec::new();
1126        self.inner().sessions.scan(|session_id, entry| {
1127            sessions.push(SessionInfo {
1128                session_id: session_id.clone(),
1129                agent_type: entry.agent_type.clone(),
1130            });
1131        });
1132        sessions
1133    }
1134
1135    /// List available agents. A thin forwarder: sends `AcpListAgentsRequest` and
1136    /// maps the sidecar's response. The sidecar enumerates the projected
1137    /// `/opt/agentos` packages (client parses no manifests). Every such agent is a
1138    /// package materialized into the VM at boot, so `installed` is always `true`.
1139    pub async fn list_agents(&self) -> Result<Vec<AgentRegistryEntry>> {
1140        let response = self
1141            .send_acp_request(AcpRequest::AcpListAgentsRequest(AcpListAgentsRequest {
1142                reserved: false,
1143            }))
1144            .await?;
1145        let AcpResponse::AcpListAgentsResponse(listed) = response else {
1146            return Err(unexpected_acp_response("AcpListAgentsRequest", response).into());
1147        };
1148        Ok(listed
1149            .agents
1150            .into_iter()
1151            .map(|agent| AgentRegistryEntry {
1152                id: agent.id,
1153                installed: agent.installed,
1154            })
1155            .collect())
1156    }
1157
1158    /// Create an ACP session. Resolves the agent config, merges env (user wins), creates the session
1159    /// via the sidecar (`runtime: java_script`, protocol v1, default client caps), and hydrates
1160    /// state. Agent OS owns dynamic tool-reference instructions and forwards them as additional
1161    /// instructions; the sidecar owns final base-prompt assembly and agent-specific injection. On
1162    /// hydration failure the session is removed and the error rethrown. Returns the session id only.
1163    pub async fn create_session(
1164        &self,
1165        agent_type: &str,
1166        options: CreateSessionOptions,
1167    ) -> Result<SessionId> {
1168        // The client is npm-agnostic: it sends only the agent name. The sidecar
1169        // resolves the name -> package -> entrypoint/env/launchArgs from the
1170        // projected `/opt/agentos/<name>/current/agentos-package.json` and spawns.
1171        let env: BTreeMap<String, String> = options.env.clone();
1172
1173        let cwd = options
1174            .cwd
1175            .clone()
1176            .unwrap_or_else(|| "/workspace".to_string());
1177        let mcp_servers: Vec<Value> = options
1178            .mcp_servers
1179            .iter()
1180            .filter_map(|server| serde_json::to_value(server).ok())
1181            .collect();
1182        let client_capabilities = json!({
1183            "fs": { "readTextFile": true, "writeTextFile": true },
1184            "terminal": true,
1185        });
1186        let tool_reference = build_host_tool_reference(&self.config().tool_kits);
1187        let additional_instructions =
1188            combine_instructions(options.additional_instructions.as_deref(), &tool_reference);
1189
1190        let response = self
1191            .send_acp_request(AcpRequest::AcpCreateSessionRequest(
1192                AcpCreateSessionRequest {
1193                    agent_type: agent_type.to_string(),
1194                    runtime: AcpRuntimeKind::JavaScript,
1195                    args: Vec::new(),
1196                    env: env.into_iter().collect(),
1197                    cwd,
1198                    mcp_servers: serde_json::to_string(&mcp_servers).map_err(|error| {
1199                        ClientError::Sidecar(format!("failed to encode MCP servers: {error}"))
1200                    })?,
1201                    protocol_version: crate::ACP_PROTOCOL_VERSION as i32,
1202                    client_capabilities: serde_json::to_string(&client_capabilities).map_err(
1203                        |error| {
1204                            ClientError::Sidecar(format!(
1205                                "failed to encode client capabilities: {error}"
1206                            ))
1207                        },
1208                    )?,
1209                    additional_instructions,
1210                    skip_os_instructions: options.skip_os_instructions,
1211                },
1212            ))
1213            .await?;
1214        let AcpResponse::AcpSessionCreatedResponse(created) = response else {
1215            return Err(unexpected_acp_response("AcpCreateSessionRequest", response).into());
1216        };
1217        let created = session_created_from_acp(created)?;
1218
1219        // Seed local state from the create response, then register + hydrate from the authoritative
1220        // sidecar state.
1221        let state = SessionStateResponse {
1222            modes: created.modes,
1223            config_options: created.config_options,
1224            agent_capabilities: created.agent_capabilities,
1225            agent_info: created.agent_info,
1226        };
1227        self.register_session(&created.session_id, agent_type, &state)
1228            .await?;
1229
1230        Ok(SessionId {
1231            session_id: created.session_id,
1232        })
1233    }
1234
1235    /// Register a freshly created session entry and hydrate it. Used by the create path once
1236    /// agent-config resolution exists; exposed so the create flow stays a 1:1 port of the local
1237    /// registration + hydrate + on-failure-remove behavior.
1238    pub(crate) async fn register_session(
1239        &self,
1240        session_id: &str,
1241        agent_type: &str,
1242        state: &SessionStateResponse,
1243    ) -> std::result::Result<(), ClientError> {
1244        {
1245            let mut closed = self.inner().closed_session_ids.lock();
1246            closed.retain(|id| id != session_id);
1247        }
1248
1249        let (event_tx, _) = tokio::sync::broadcast::channel(1024);
1250        let (permission_tx, _) = tokio::sync::broadcast::channel(64);
1251        let (agent_exit_tx, _) = tokio::sync::broadcast::channel(16);
1252        let entry = SessionEntry {
1253            agent_type: agent_type.to_string(),
1254            modes: parking_lot::Mutex::new(None),
1255            config_options: parking_lot::Mutex::new(Vec::new()),
1256            capabilities: parking_lot::Mutex::new(None),
1257            agent_info: parking_lot::Mutex::new(None),
1258            config_overrides: parking_lot::Mutex::new(BTreeMap::new()),
1259            event_tx,
1260            permission_tx,
1261            agent_exit_tx,
1262            pending_permission_replies: scc::HashMap::new(),
1263            pending_session_request_lock: parking_lot::Mutex::new(()),
1264            pending_prompt_resolvers: scc::HashMap::new(),
1265        };
1266        sync_session_state(&entry, state);
1267        let _ = self.inner().sessions.insert(session_id.to_string(), entry);
1268
1269        match self.hydrate_session_state(session_id).await {
1270            Ok(()) => Ok(()),
1271            Err(error) => {
1272                let _ = self.inner().sessions.remove(session_id);
1273                Err(error)
1274            }
1275        }
1276    }
1277
1278    /// Resume a session that exists in durable storage but is not live in this VM
1279    /// (e.g. after a Rivet actor slept and woke with a fresh VM). Thin forwarder:
1280    /// resolves the agent config + adapter entrypoint exactly as `create_session`
1281    /// does, then forwards a single [`AcpResumeSessionRequest`] to the sidecar,
1282    /// which owns the resume state machine (native `session/load` when supported,
1283    /// else `session/new` + transcript-continuation preamble). The returned
1284    /// `session_id` is the live id in this VM (equal to `session_id` for native
1285    /// loads, freshly assigned for the fallback); the caller remaps
1286    /// `external -> live`. The new live session is registered + hydrated locally so
1287    /// subsequent prompts route to it.
1288    ///
1289    /// Resume depends on a durable root; on a non-durable (default in-memory) root
1290    /// there is no surviving store and the fallback tier always runs.
1291    pub async fn resume_session(
1292        &self,
1293        session_id: &str,
1294        agent_type: &str,
1295        options: ResumeSessionOptions,
1296    ) -> Result<ResumeSessionResult> {
1297        // The client is npm-agnostic: it sends only the agent name. The sidecar
1298        // resolves the name -> package -> entrypoint/env/launchArgs from the
1299        // projected manifest, exactly as `create_session` does.
1300        let env: BTreeMap<String, String> = options.env.clone();
1301
1302        let cwd = options
1303            .cwd
1304            .clone()
1305            .unwrap_or_else(|| "/workspace".to_string());
1306
1307        let response = self
1308            .send_acp_request(AcpRequest::AcpResumeSessionRequest(
1309                AcpResumeSessionRequest {
1310                    session_id: session_id.to_string(),
1311                    agent_type: agent_type.to_string(),
1312                    transcript_path: options.transcript_path.clone(),
1313                    cwd,
1314                    env: env.into_iter().collect(),
1315                },
1316            ))
1317            .await?;
1318        let AcpResponse::AcpSessionResumedResponse(resumed) = response else {
1319            return Err(unexpected_acp_response("AcpResumeSessionRequest", response).into());
1320        };
1321
1322        // Register + hydrate the live session so subsequent prompts route to it.
1323        let empty_state = SessionStateResponse {
1324            modes: None,
1325            config_options: Vec::new(),
1326            agent_capabilities: None,
1327            agent_info: None,
1328        };
1329        self.register_session(&resumed.session_id, agent_type, &empty_state)
1330            .await?;
1331
1332        Ok(ResumeSessionResult {
1333            session_id: resumed.session_id,
1334            mode: resumed.mode,
1335        })
1336    }
1337
1338    /// Destroy a session. Best-effort `cancel_session` then internal close.
1339    pub async fn destroy_session(&self, session_id: &str) -> Result<()> {
1340        self.require_session(session_id, |_| ())?;
1341        let _ = self.cancel_session(session_id).await;
1342        self.close_session_internal(session_id).await?;
1343        Ok(())
1344    }
1345
1346    /// Prompt a session. Subscribes to live `session/update` events, accumulates
1347    /// `agent_message_chunk` text, sends `session/prompt`, and unsubscribes by dropping the
1348    /// receiver. The `response` may itself be an error.
1349    pub async fn prompt(&self, session_id: &str, text: &str) -> Result<PromptResult> {
1350        let mut rx = self.require_session(session_id, |entry| entry.event_tx.subscribe())?;
1351
1352        let mut agent_text = String::new();
1353        let mut delivered_chunks = 0;
1354        let mut prompt_text_error: Option<ClientError> = None;
1355
1356        let request = self.send_session_request(
1357            session_id,
1358            "session/prompt",
1359            Some(json!({ "prompt": [{ "type": "text", "text": text }] })),
1360        );
1361        tokio::pin!(request);
1362
1363        // Drive the request to completion while concurrently draining broadcast chunks, so the
1364        // bounded broadcast buffer never lags during a long prompt.
1365        let response = loop {
1366            tokio::select! {
1367                biased;
1368                result = &mut request => break result,
1369                event = rx.recv() => {
1370                    match event {
1371                        Ok(event) => accumulate_agent_message_chunk(
1372                            &event,
1373                            &mut delivered_chunks,
1374                            &mut agent_text,
1375                        )
1376                        .unwrap_or_else(|error| {
1377                            prompt_text_error.get_or_insert(error);
1378                        }),
1379                        Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
1380                        Err(tokio::sync::broadcast::error::RecvError::Closed) => {
1381                            // Channel closed; finish the request without further chunks.
1382                            break (&mut request).await;
1383                        }
1384                    }
1385                }
1386            }
1387        };
1388
1389        // Drain already-buffered live events before unsubscribing.
1390        loop {
1391            match rx.try_recv() {
1392                Ok(event) => {
1393                    accumulate_agent_message_chunk(&event, &mut delivered_chunks, &mut agent_text)
1394                        .unwrap_or_else(|error| {
1395                            prompt_text_error.get_or_insert(error);
1396                        })
1397                }
1398                Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
1399                Err(tokio::sync::broadcast::error::TryRecvError::Empty)
1400                | Err(tokio::sync::broadcast::error::TryRecvError::Closed) => break,
1401            }
1402        }
1403        drop(rx);
1404
1405        let response = response?;
1406        if let Some(error) = prompt_text_error {
1407            return Err(error.into());
1408        }
1409
1410        Ok(PromptResult {
1411            response,
1412            text: agent_text,
1413        })
1414    }
1415
1416    /// Cancel a session. If prompt requests are pending, resolves locally + background
1417    /// `session/cancel` and returns a synthetic `{ via: "prompt-fallback" }`; else real
1418    /// `session/cancel`.
1419    pub async fn cancel_session(&self, session_id: &str) -> Result<JsonRpcResponse> {
1420        self.require_session(session_id, |_| ())?;
1421        let cancelled_pending_prompt = self.cancel_pending_prompt_requests(session_id)?;
1422        if cancelled_pending_prompt {
1423            // Forward the real cancel in the background (best effort); return the synthetic
1424            // prompt-fallback response immediately.
1425            let this = self.clone();
1426            let session_id_owned = session_id.to_string();
1427            tokio::spawn(async move {
1428                let _ = this
1429                    .send_session_request(&session_id_owned, "session/cancel", None)
1430                    .await;
1431            });
1432            return Ok(JsonRpcResponse {
1433                jsonrpc: "2.0".to_string(),
1434                id: Some(JsonRpcId::Null),
1435                result: Some(json!({
1436                    "cancelled": true,
1437                    "requested": true,
1438                    "via": "prompt-fallback",
1439                })),
1440                error: None,
1441            });
1442        }
1443        Ok(self
1444            .send_session_request(session_id, "session/cancel", None)
1445            .await?)
1446    }
1447
1448    /// Resolve any pending `session/prompt` resolvers with a synthetic `stopReason: cancelled`
1449    /// result. Returns whether a prompt was cancelled. Mirrors `_cancelPendingPromptRequests`.
1450    fn cancel_pending_prompt_requests(
1451        &self,
1452        session_id: &str,
1453    ) -> std::result::Result<bool, ClientError> {
1454        self.require_session(session_id, |entry| {
1455            let mut prompt_resolver_ids = Vec::new();
1456            {
1457                let overrides = entry.config_overrides.lock();
1458                for (key, method) in overrides.iter() {
1459                    if let Some(id) = key.strip_prefix(PENDING_METHOD_PREFIX) {
1460                        if method == "session/prompt" {
1461                            if let Ok(id) = id.parse::<i64>() {
1462                                prompt_resolver_ids.push(id);
1463                            }
1464                        }
1465                    }
1466                }
1467            }
1468            let mut cancelled = false;
1469            for id in prompt_resolver_ids {
1470                if let Some((_, resolver)) = entry.pending_prompt_resolvers.remove(&id) {
1471                    // Mirrors `_cancelPendingPromptRequests`: resolve prompt resolvers with the
1472                    // synthetic `{ result: { stopReason: "cancelled" } }` response.
1473                    let _ = resolver.send(JsonRpcResponse {
1474                        jsonrpc: "2.0".to_string(),
1475                        id: Some(JsonRpcId::Null),
1476                        result: Some(json!({ "stopReason": "cancelled" })),
1477                        error: None,
1478                    });
1479                    cancelled = true;
1480                }
1481                entry
1482                    .config_overrides
1483                    .lock()
1484                    .remove(&format!("{PENDING_METHOD_PREFIX}{id}"));
1485            }
1486            cancelled
1487        })
1488    }
1489
1490    /// Abort all pending session requests with a `-32000 Session closed` response. Mirrors
1491    /// `_abortPendingSessionRequests`.
1492    fn abort_pending_session_requests(&self, session_id: &str) {
1493        let _ = self.require_session(session_id, |entry| {
1494            let mut ids = Vec::new();
1495            entry.pending_prompt_resolvers.scan(|id, _| ids.push(*id));
1496            for id in ids {
1497                if let Some((_, resolver)) = entry.pending_prompt_resolvers.remove(&id) {
1498                    // Mirrors `_abortPendingSessionRequests`: resolve EVERY pending resolver
1499                    // (prompt or otherwise) with the `-32000` `Session closed: <id>` error.
1500                    let _ = resolver.send(session_closed_response(session_id));
1501                }
1502                entry
1503                    .config_overrides
1504                    .lock()
1505                    .remove(&format!("{PENDING_METHOD_PREFIX}{id}"));
1506            }
1507        });
1508    }
1509
1510    /// Reject all pending permission replies. The TS path clears their 120s timers and rejects them;
1511    /// here dropping the responder side closes the awaiting channel. Mirrors
1512    /// `_rejectPendingPermissionReplies`.
1513    fn reject_pending_permission_replies(&self, session_id: &str) {
1514        let _ = self.require_session(session_id, |entry| {
1515            let mut ids = Vec::new();
1516            entry
1517                .pending_permission_replies
1518                .scan(|id, _| ids.push(id.clone()));
1519            for id in ids {
1520                let _ = entry.pending_permission_replies.remove(&id);
1521            }
1522        });
1523    }
1524
1525    /// Close a session. SYNC fire-and-forget. Errors only if unknown across sessions / closed-ids /
1526    /// in-flight closes. Aborts pending, rejects pending permissions, records the closed id (bounded
1527    /// 2048). Mirrors `closeSession`, whose known-check spans `_sessions`, `_closedSessionIds`, and
1528    /// `_sessionClosePromises`.
1529    pub fn close_session(&self, session_id: &str) -> std::result::Result<(), ClientError> {
1530        let known = self.inner().sessions.contains(session_id)
1531            || self.inner().closing_session_ids.contains(session_id)
1532            || self
1533                .inner()
1534                .closed_session_ids
1535                .lock()
1536                .iter()
1537                .any(|id| id == session_id);
1538        if !known {
1539            return Err(ClientError::SessionNotFound(session_id.to_string()));
1540        }
1541
1542        // Synchronously mark the close in-flight (mirrors setting `_sessionClosePromises`) so a
1543        // second `close_session` / close-after-destroy issued during the detached close still sees
1544        // the id as known.
1545        let _ = self
1546            .inner()
1547            .closing_session_ids
1548            .insert(session_id.to_string());
1549
1550        let this = self.clone();
1551        let session_id_owned = session_id.to_string();
1552        tokio::spawn(async move {
1553            let _ = this.close_session_internal(&session_id_owned).await;
1554            let _ = this.inner().closing_session_ids.remove(&session_id_owned);
1555        });
1556        Ok(())
1557    }
1558
1559    /// Internal close: abort pending requests, reject pending permissions, deregister the session,
1560    /// record the closed id (bounded), and best-effort `AcpCloseSessionRequest`. Mirrors
1561    /// `_closeSessionInternal`.
1562    pub(crate) async fn close_session_internal(
1563        &self,
1564        session_id: &str,
1565    ) -> std::result::Result<(), ClientError> {
1566        if self
1567            .inner()
1568            .closed_session_ids
1569            .lock()
1570            .iter()
1571            .any(|id| id == session_id)
1572        {
1573            return Ok(());
1574        }
1575
1576        self.abort_pending_session_requests(session_id);
1577        self.reject_pending_permission_replies(session_id);
1578
1579        // Require existence before removal, matching `_requireSession` in `_closeSessionInternal`.
1580        if !self.inner().sessions.contains(session_id) {
1581            return Err(ClientError::SessionNotFound(session_id.to_string()));
1582        }
1583        let _ = self.inner().sessions.remove(session_id);
1584        {
1585            let mut closed = self.inner().closed_session_ids.lock();
1586            closed.push_back(session_id.to_string());
1587            while closed.len() > CLOSED_SESSION_ID_RETENTION_LIMIT {
1588                closed.pop_front();
1589            }
1590        }
1591
1592        // Session processes live entirely inside the VM, so the only safe teardown is the ACP close
1593        // request, which targets the guest process by its in-VM session/process handle.
1594        //
1595        // NEVER fall back to a host `kill()` here. A session/process pid is a guest/kernel display
1596        // PID, not a host PID. Passing it to the host signal API would SIGKILL whatever unrelated
1597        // host process happens to share that number -- and a negative PID kills the entire host
1598        // process *group* with that id. In the TypeScript client that has in practice killed the host
1599        // tmux session, the test launcher, and even the user systemd manager. This client holds no
1600        // host handle for guest processes, so there is nothing host-side to signal; the ACP close
1601        // request remains the authoritative teardown path.
1602        let response = self
1603            .send_acp_request(AcpRequest::AcpCloseSessionRequest(AcpCloseSessionRequest {
1604                session_id: session_id.to_string(),
1605            }))
1606            .await?;
1607        match response {
1608            AcpResponse::AcpSessionClosedResponse(_) => Ok(()),
1609            other => Err(unexpected_acp_response("AcpCloseSessionRequest", other)),
1610        }
1611    }
1612
1613    async fn send_acp_request(
1614        &self,
1615        request: AcpRequest,
1616    ) -> std::result::Result<AcpResponse, ClientError> {
1617        let payload = serde_bare::to_vec(&request).map_err(|error| {
1618            ClientError::Sidecar(format!("failed to encode ACP request: {error}"))
1619        })?;
1620        let response = self
1621            .transport()
1622            .request_wire(
1623                self.session_ownership(),
1624                wire::RequestPayload::ExtEnvelope(wire::ExtEnvelope {
1625                    namespace: ACP_EXTENSION_NAMESPACE.to_string(),
1626                    payload,
1627                }),
1628            )
1629            .await?;
1630        let envelope = match response {
1631            wire::ResponsePayload::ExtEnvelope(envelope) => envelope,
1632            wire::ResponsePayload::RejectedResponse(rejected) => {
1633                return Err(ClientError::Kernel {
1634                    code: rejected.code,
1635                    message: rejected.message,
1636                });
1637            }
1638            other => {
1639                return Err(ClientError::Sidecar(format!(
1640                    "unexpected ACP Ext response: {other:?}"
1641                )));
1642            }
1643        };
1644        if envelope.namespace != ACP_EXTENSION_NAMESPACE {
1645            return Err(ClientError::Sidecar(format!(
1646                "unexpected ACP Ext namespace: {}",
1647                envelope.namespace
1648            )));
1649        }
1650        let response: AcpResponse = serde_bare::from_slice(&envelope.payload).map_err(|error| {
1651            ClientError::Sidecar(format!("failed to decode ACP response: {error}"))
1652        })?;
1653        match response {
1654            AcpResponse::AcpErrorResponse(error) => Err(ClientError::Kernel {
1655                code: error.code,
1656                message: error.message,
1657            }),
1658            response => Ok(response),
1659        }
1660    }
1661
1662    /// Respond to a permission request. If a pending reply slot exists, resolves it and returns a
1663    /// synthetic `{ via: "sidecar-request" }`; else the legacy `request/permission` RPC. Mirrors
1664    /// `respondPermission`.
1665    pub async fn respond_permission(
1666        &self,
1667        session_id: &str,
1668        permission_id: &str,
1669        reply: PermissionReply,
1670    ) -> Result<JsonRpcResponse> {
1671        let pending = self.require_session(session_id, |entry| {
1672            entry
1673                .pending_permission_replies
1674                .remove(permission_id)
1675                .map(|(_, responder)| responder)
1676        })?;
1677
1678        if let Some(responder) = pending {
1679            let _ = responder.send(reply);
1680            return Ok(JsonRpcResponse {
1681                jsonrpc: "2.0".to_string(),
1682                id: Some(JsonRpcId::Null),
1683                result: Some(json!({
1684                    "permissionId": permission_id,
1685                    "reply": reply,
1686                    "via": "sidecar-request",
1687                })),
1688                error: None,
1689            });
1690        }
1691
1692        Ok(self
1693            .send_session_request(
1694                session_id,
1695                LEGACY_PERMISSION_METHOD,
1696                Some(json!({ "permissionId": permission_id, "reply": reply })),
1697            )
1698            .await?)
1699    }
1700
1701    /// Set the session mode (`session/set_mode`). Updates cached `current_mode_id` on success.
1702    pub async fn set_session_mode(
1703        &self,
1704        session_id: &str,
1705        mode_id: &str,
1706    ) -> Result<JsonRpcResponse> {
1707        Ok(self
1708            .send_session_request(
1709                session_id,
1710                "session/set_mode",
1711                Some(json!({ "modeId": mode_id })),
1712            )
1713            .await?)
1714    }
1715
1716    /// Get cached session mode state.
1717    pub fn get_session_modes(&self, session_id: &str) -> Option<SessionModeState> {
1718        self.require_session(session_id, |entry| entry.modes.lock().clone())
1719            .ok()
1720            .flatten()
1721    }
1722
1723    /// Set the session model. Uses `set_config_option` with category `model`; readonly -> error
1724    /// response.
1725    pub async fn set_session_model(
1726        &self,
1727        session_id: &str,
1728        model: &str,
1729    ) -> Result<JsonRpcResponse> {
1730        Ok(self
1731            .set_session_config_by_category(session_id, "model", model)
1732            .await?)
1733    }
1734
1735    /// Set the session thought level. Same as model with category `thought_level`.
1736    pub async fn set_session_thought_level(
1737        &self,
1738        session_id: &str,
1739        level: &str,
1740    ) -> Result<JsonRpcResponse> {
1741        Ok(self
1742            .set_session_config_by_category(session_id, "thought_level", level)
1743            .await?)
1744    }
1745
1746    /// Get cached config options (shallow copy).
1747    pub fn get_session_config_options(&self, session_id: &str) -> Vec<SessionConfigOption> {
1748        self.require_session(session_id, |entry| entry.config_options.lock().clone())
1749            .unwrap_or_default()
1750    }
1751
1752    /// Get cached capabilities. Mirrors `getSessionCapabilities`: returns `null` (`None`) when the
1753    /// stored capabilities object has no keys (`Object.keys(caps).length === 0`).
1754    pub fn get_session_capabilities(&self, session_id: &str) -> Option<AgentCapabilities> {
1755        self.require_session(session_id, |entry| entry.capabilities.lock().clone())
1756            .ok()
1757            .flatten()
1758            .filter(|caps| !agent_capabilities_is_empty(caps))
1759    }
1760
1761    /// Get cached agent info.
1762    pub fn get_session_agent_info(&self, session_id: &str) -> Option<AgentInfo> {
1763        self.require_session(session_id, |entry| entry.agent_info.lock().clone())
1764            .ok()
1765            .flatten()
1766    }
1767
1768    /// Raw passthrough to `send_session_request` (which already re-hydrates + applies set_mode /
1769    /// set_config_option cache updates). Mirrors `rawSessionSend`.
1770    pub async fn raw_session_send(
1771        &self,
1772        session_id: &str,
1773        method: &str,
1774        params: Option<Value>,
1775    ) -> Result<JsonRpcResponse> {
1776        Ok(self
1777            .send_session_request(session_id, method, params)
1778            .await?)
1779    }
1780
1781    /// Thin alias for `raw_session_send`.
1782    pub async fn raw_send(
1783        &self,
1784        session_id: &str,
1785        method: &str,
1786        params: Option<Value>,
1787    ) -> Result<JsonRpcResponse> {
1788        self.raw_session_send(session_id, method, params).await
1789    }
1790
1791    /// Subscribe to live `session/update` events. Only events emitted after subscription are
1792    /// delivered.
1793    pub fn on_session_event(
1794        &self,
1795        session_id: &str,
1796    ) -> std::result::Result<SessionEventSubscription, ClientError> {
1797        let rx = self.require_session(session_id, |entry| entry.event_tx.subscribe())?;
1798        let stream = futures::stream::unfold(rx, move |mut rx| async move {
1799            loop {
1800                match rx.recv().await {
1801                    Ok(notification) => return Some((notification, rx)),
1802                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
1803                    Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
1804                }
1805            }
1806        });
1807        Ok((Box::pin(stream), Subscription::noop()))
1808    }
1809
1810    /// Subscribe to permission requests raised by the session's guest agent. Requests originate
1811    /// from the sidecar `permission_request` callback (the sidecar normalizes both the legacy
1812    /// `request/permission` and ACP `session/request_permission` method names before invoking the
1813    /// host). With no subscribers a request auto-rejects; subscribers reply via the carried
1814    /// [`PermissionResponder`] or [`AgentOs::respond_permission`], bounded by the
1815    /// [`crate::PERMISSION_TIMEOUT_MS`] timeout.
1816    pub fn on_permission_request(
1817        &self,
1818        session_id: &str,
1819    ) -> std::result::Result<PermissionRequestSubscription, ClientError> {
1820        let rx = self.require_session(session_id, |entry| entry.permission_tx.subscribe())?;
1821
1822        // Pass broadcast items straight through. Each item carries a cloneable
1823        // [`PermissionResponder`] that resolves the pending reply slot registered by
1824        // `deliver_sidecar_permission_request`.
1825        let stream = futures::stream::unfold(rx, move |mut rx| async move {
1826            loop {
1827                match rx.recv().await {
1828                    Ok(request) => return Some((request, rx)),
1829                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
1830                    Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
1831                }
1832            }
1833        });
1834
1835        Ok((Box::pin(stream), Subscription::noop()))
1836    }
1837
1838    /// Subscribe to unexpected adapter process exits (crashes) for a session,
1839    /// including the sidecar's bounded auto-restart outcome. Only events
1840    /// emitted after subscription are delivered; only `restart == "restarted"`
1841    /// leaves the session usable. Mirrors the TS `onAgentExit` option.
1842    pub fn on_agent_exit(
1843        &self,
1844        session_id: &str,
1845    ) -> std::result::Result<AgentExitSubscription, ClientError> {
1846        let rx = self.require_session(session_id, |entry| entry.agent_exit_tx.subscribe())?;
1847        let stream = futures::stream::unfold(rx, move |mut rx| async move {
1848            loop {
1849                match rx.recv().await {
1850                    Ok(event) => return Some((event, rx)),
1851                    Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
1852                    Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
1853                }
1854            }
1855        });
1856        Ok((Box::pin(stream), Subscription::noop()))
1857    }
1858
1859    /// Answer an ACP permission callback by fanning a [`PermissionRequest`] out to
1860    /// `on_permission_request` subscribers and waiting for the reply. Mirrors TS
1861    /// `_handlePermissionSidecarRequest`:
1862    /// - unknown session -> `error: "Session not found: <id>"`
1863    /// - no subscribers -> `reply: "reject"`
1864    /// - otherwise registers the `pending_permission_replies` slot, delivers the request, and waits
1865    ///   up to [`crate::PERMISSION_TIMEOUT_MS`] for `respond_permission` / the responder; timeout
1866    ///   removes the slot and returns `error: "Timed out waiting for permission reply: <id>"`.
1867    pub(crate) async fn deliver_sidecar_permission_request(
1868        &self,
1869        request: PermissionRouteRequest,
1870    ) -> PermissionRouteResult {
1871        let PermissionRouteRequest {
1872            session_id,
1873            permission_id,
1874            params,
1875        } = request;
1876
1877        let (slot_tx, slot_rx) = tokio::sync::oneshot::channel::<PermissionReply>();
1878        let (responder, responder_rx) = PermissionResponder::new();
1879        let description = params
1880            .get("description")
1881            .and_then(Value::as_str)
1882            .map(str::to_string);
1883        let delivered = PermissionRequest {
1884            permission_id: permission_id.clone(),
1885            description,
1886            params,
1887            responder,
1888        };
1889
1890        // Register the reply slot and broadcast under the same session lookup. No subscribers ->
1891        // auto-reject (mirrors `permissionHandlers.size === 0`).
1892        let registered = self.require_session(&session_id, |entry| {
1893            if entry.permission_tx.receiver_count() == 0 {
1894                return false;
1895            }
1896            let _ = entry
1897                .pending_permission_replies
1898                .insert(permission_id.clone(), slot_tx);
1899            let _ = entry.permission_tx.send(delivered);
1900            true
1901        });
1902        match registered {
1903            Ok(true) => {}
1904            Ok(false) => {
1905                return PermissionRouteResult {
1906                    reply: Some(permission_reply_wire(PermissionReply::Reject).to_string()),
1907                };
1908            }
1909            Err(_) => {
1910                return PermissionRouteResult { reply: None };
1911            }
1912        }
1913
1914        // Bridge the subscriber's `responder.respond(..)` into the same reply slot.
1915        let this = self.clone();
1916        let bridge_session_id = session_id.clone();
1917        let bridge_permission_id = permission_id.clone();
1918        tokio::spawn(async move {
1919            if let Ok(reply) = responder_rx.await {
1920                let _ = this
1921                    .respond_permission(&bridge_session_id, &bridge_permission_id, reply)
1922                    .await;
1923            }
1924        });
1925
1926        let timeout = tokio::time::sleep(std::time::Duration::from_millis(PERMISSION_TIMEOUT_MS));
1927        tokio::pin!(timeout);
1928        tokio::select! {
1929            reply = slot_rx => match reply {
1930                Ok(reply) => PermissionRouteResult {
1931                    reply: Some(permission_reply_wire(reply).to_string()),
1932                },
1933                // The slot sender dropped without a reply (session closed / replies rejected).
1934                Err(_) => PermissionRouteResult {
1935                    reply: Some(permission_reply_wire(PermissionReply::Reject).to_string()),
1936                },
1937            },
1938            _ = &mut timeout => {
1939                let _ = self.require_session(&session_id, |entry| {
1940                    let _ = entry.pending_permission_replies.remove(&permission_id);
1941                });
1942                PermissionRouteResult {
1943                    reply: None,
1944                }
1945            }
1946        }
1947    }
1948}
1949
1950// Private accumulator coverage stays inline because integration tests cannot construct the missed
1951// broadcast plus hydrated-ring ordering without exposing client internals.
1952#[cfg(test)]
1953mod prompt_accumulation_tests {
1954    use super::*;
1955
1956    fn notification(update: Value) -> JsonRpcNotification {
1957        JsonRpcNotification {
1958            jsonrpc: "2.0".to_string(),
1959            method: "session/update".to_string(),
1960            params: Some(json!({ "update": update })),
1961        }
1962    }
1963
1964    #[test]
1965    fn non_chunk_events_do_not_affect_prompt_text() {
1966        let chunk = notification(json!({
1967            "sessionUpdate": "agent_message_chunk",
1968            "content": { "text": "hello" },
1969        }));
1970        let non_chunk = notification(json!({
1971            "sessionUpdate": "current_mode_update",
1972            "currentModeId": "default",
1973        }));
1974
1975        let mut delivered_chunks = 0;
1976        let mut text = String::new();
1977        accumulate_agent_message_chunk(&non_chunk, &mut delivered_chunks, &mut text)
1978            .expect("non-chunk");
1979        accumulate_agent_message_chunk(&chunk, &mut delivered_chunks, &mut text).expect("chunk");
1980
1981        assert_eq!(text, "hello");
1982    }
1983
1984    #[test]
1985    fn prompt_text_capture_limit_rejects_overflowing_chunk() {
1986        let chunk = notification(json!({
1987            "sessionUpdate": "agent_message_chunk",
1988            "content": { "text": "abcd" },
1989        }));
1990        let mut delivered_chunks = 0;
1991        let mut text = "x".repeat(PROMPT_TEXT_CAPTURE_LIMIT_BYTES - 3);
1992        let error = accumulate_agent_message_chunk(&chunk, &mut delivered_chunks, &mut text)
1993            .expect_err("chunk should exceed prompt text cap");
1994        assert!(
1995            error.to_string().contains("prompt text capture is"),
1996            "unexpected error: {error}"
1997        );
1998        assert_eq!(text.len(), PROMPT_TEXT_CAPTURE_LIMIT_BYTES - 3);
1999    }
2000
2001    #[test]
2002    fn prompt_chunk_limit_rejects_more_tracked_chunks() {
2003        let chunk = notification(json!({
2004            "sessionUpdate": "agent_message_chunk",
2005            "content": { "text": "x" },
2006        }));
2007        let mut delivered_chunks = PROMPT_DELIVERED_CHUNK_LIMIT;
2008        let mut text = String::new();
2009        let error = accumulate_agent_message_chunk(&chunk, &mut delivered_chunks, &mut text)
2010            .expect_err("chunk should exceed chunk tracking cap");
2011        assert!(
2012            error
2013                .to_string()
2014                .contains("prompt chunk tracking limit exceeded"),
2015            "unexpected error: {error}"
2016        );
2017        assert!(text.is_empty());
2018    }
2019
2020    #[test]
2021    fn pending_session_request_count_tracks_registered_resolvers() {
2022        let (event_tx, _) = tokio::sync::broadcast::channel(1);
2023        let (permission_tx, _) = tokio::sync::broadcast::channel(1);
2024        let (agent_exit_tx, _) = tokio::sync::broadcast::channel(1);
2025        let entry = SessionEntry {
2026            agent_type: "pi".to_string(),
2027            modes: parking_lot::Mutex::new(None),
2028            config_options: parking_lot::Mutex::new(Vec::new()),
2029            capabilities: parking_lot::Mutex::new(None),
2030            agent_info: parking_lot::Mutex::new(None),
2031            config_overrides: parking_lot::Mutex::new(BTreeMap::new()),
2032            event_tx,
2033            permission_tx,
2034            agent_exit_tx,
2035            pending_permission_replies: scc::HashMap::new(),
2036            pending_session_request_lock: parking_lot::Mutex::new(()),
2037            pending_prompt_resolvers: scc::HashMap::new(),
2038        };
2039        let (first_tx, _first_rx) = tokio::sync::oneshot::channel();
2040        let (second_tx, _second_rx) = tokio::sync::oneshot::channel();
2041        let _ = entry.pending_prompt_resolvers.insert(1, first_tx);
2042        let _ = entry.pending_prompt_resolvers.insert(2, second_tx);
2043
2044        assert_eq!(pending_session_request_count(&entry), 2);
2045    }
2046}